접근성있는 Windows 데스크톱 앱 만들기 (Windows UIA를 중심으로) 3: Win32에서 UIA Provider 시작하기
들어가며
안녕하세요. 엔비전스 입니다.
2부 에서 우리는 WinUI 3를 사용하여 접근성있는 애플리케이션을 구현하는 방법을 살펴보았습니다. AutomationProperties로 레이블을 지정하고, AutomationPeer를 구현하여 커스텀 컨트롤의 접근성을 제공했습니다. WinUI 3는 많은 부분을 자동으로 처리해주었기 때문에, 비교적 적은 코드로 완전한 접근성을 구현할 수 있었습니다.
이번 3부와 다음 4부에서는 한 단계 더 깊이 들어가 Win32 API를 사용하여 UIA Provider를 직접 구현해보겠습니다. Win32에서는 WinUI 3가 자동으로 처리해주던 모든 것을 개발자가 직접 구현해야 합니다. 이 과정을 통해 UIA가 실제로 어떻게 동작하는지 본질적인 이해를 얻을 수 있습니다.
이번 글에서는 다음과 같은 내용을 다룹니다.
- 첫째, WinUI 3와 Win32의 UIA 구현 방식을 비교하여 두 접근법의 차이점과 공통점을 이해합니다.
- 둘째, Win32에서 UIA Provider가 동작하는 아키텍처를 살펴봅니다.
- 셋째, IRawElementProviderSimple 인터페이스를 구현하여 커스텀 버튼에 기본적인 접근성을 제공합니다.
- 넷째, IInvokeProvider를 구현하여 버튼 클릭 동작을 보조 기술에 노출합니다.
WinUI 3와 Win32의 UIA 구현 비교
본격적인 Win32 구현에 앞서, 2부에서 사용한 WinUI 3 방식과 Win32 방식이 어떻게 다른지 비교해보겠습니다. 이 비교를 통해 Win32를 배우는 목적이 명확해지고, 두 방식 사이의 대응 관계를 이해할 수 있습니다.
왜 Win32 UIA를 알아야 하는가
Win32에서 Windows UIA를 사용하는 방법을 아는 것은 중요합니다. 몇 가지 중요한 이유가 있는데요.
기존 코드베이스 유지보수 : 많은 기업용 애플리케이션이 여전히 Win32 기반입니다. Visual Studio, Microsoft Office의 일부 구성 요소, 금융권 트레이딩 시스템, 산업용 제어 소프트웨어 등이 Win32로 작성되어 있습니다. 이런 애플리케이션에 접근성을 추가하려면 Win32 UIA를 알아야 합니다.
깊은 이해 : WinUI 3의 AutomationPeer가 내부적으로 무엇을 하는지 이해하면, 문제가 발생했을 때 더 효과적으로 디버깅할 수 있습니다. 추상화된 API만 사용하면 "마법처럼" 동작하지만, 그 마법이 깨졌을 때 원인을 찾기 어렵습니다.
프레임워크 독립성 : UIA의 본질을 이해하면 WinUI 3뿐만 아니라 MFC, Qt, 또는 완전히 커스텀한 UI 프레임워크에서도 접근성을 구현할 수 있습니다.
추상화 수준 비교
WinUI 3와 Win32는 같은 목표(UIA를 통한 접근성 제공)를 다른 추상화 수준에서 달성합니다. 아래 표는 주요 기능별로 두 방식을 비교한 것입니다.
| 기능 | WinUI 3 | Win32 |
|---|---|---|
| Provider 구현 | AutomationPeer 클래스 상속 | IRawElementProviderSimple COM 인터페이스 직접 구현 |
| 속성 노출 | GetNameCore() 등 메서드 오버라이드 | GetPropertyValue()에서 VARIANT로 반환 |
| Pattern 지원 | IRangeValueProvider 등 인터페이스 구현 | GetPatternProvider()에서 IUnknown* 반환 |
| 이벤트 발생 | RaisePropertyChangedEvent() 호출 | UiaRaiseAutomationEvent() 호출 |
| Provider 연결 | OnCreateAutomationPeer() 오버라이드 | WM_GETOBJECT 메시지 처리 |
| 메모리 관리 | C++/WinRT가 자동 처리 | COM 참조 카운팅 직접 구현 |
코드 비교: 동일한 기능의 다른 구현
같은 기능을 WinUI 3와 Win32에서 어떻게 다르게 구현하는지 코드로 비교해보겠습니다.
Name 속성 제공 - WinUI 3
// WinUI 3: AutomationPeer 메서드 오버라이드
hstring MyControlAutomationPeer::GetNameCore()
{
return L"내 컨트롤";
}
Name 속성 제공 - Win32
// Win32: VARIANT 타입으로 속성 값 반환
HRESULT MyControlProvider::GetPropertyValue(PROPERTYID propertyId, VARIANT* pRetVal)
{
pRetVal->vt = VT_EMPTY;
if (propertyId == UIA_NamePropertyId)
{
pRetVal->vt = VT_BSTR;
pRetVal->bstrVal = SysAllocString(L"내 컨트롤");
}
return S_OK;
}
WinUI 3에서는 단순히 문자열을 반환하면 되지만, Win32에서는 COM의 VARIANT 타입을 사용하여 값을 반환해야 합니다. 이것이 추상화 수준의 차이입니다.
Control Type 지정 - WinUI 3
AutomationControlType MyControlAutomationPeer::GetAutomationControlTypeCore()
{
return AutomationControlType::Button;
}
Control Type 지정 - Win32
// GetPropertyValue 내부에서 처리
case UIA_ControlTypePropertyId:
pRetVal->vt = VT_I4;
pRetVal->lVal = UIA_ButtonControlTypeId;
break;
WinUI 3의 AutomationPeer가 내부적으로 하는 일
WinUI 3에서 AutomationPeer를 만들 때, 프레임워크는 내부적으로 다음 작업들을 자동으로 수행합니다.
- IRawElementProviderSimple 구현체 생성 : AutomationPeer는 내부적으로 IRawElementProviderSimple을 구현하는 객체를 생성합니다.
- WM_GETOBJECT 처리 : XAML 프레임워크가 Window Procedure에서 WM_GETOBJECT 메시지를 받아 적절한 Provider를 반환합니다.
- 속성 매핑 : GetNameCore()의 반환값을 UIA_NamePropertyId에 대한 VARIANT 응답으로 변환합니다.
- COM 참조 카운팅 : C++/WinRT의 스마트 포인터가 AddRef/Release를 자동으로 호출합니다.
- 이벤트 전달 : RaisePropertyChangedEvent() 호출을 UiaRaiseAutomationPropertyChangedEvent()로 변환합니다.
결론적으로, WinUI 3에서 AutomationPeer를 만들 때 작성한 코드는 Win32에서 IRawElementProviderSimple을 구현하는 것과 본질적으로 같은 일을 합니다. 단지 WinUI 3가 많은 부분을 추상화해주었을 뿐입니다.
Win32 UIA Provider 아키텍처 이해
Win32에서 UIA Provider를 구현하기 전에, Provider가 어떻게 동작하는지 아키텍처를 이해해야 합니다.
Server-Side Provider vs Client-Side Provider
UIA에는 두 종류의 Provider가 있습니다.
Server-Side Provider 는 애플리케이션이 직접 구현하는 Provider입니다. 애플리케이션 개발자가 자신의 컨트롤에 대한 접근성 정보를 제공하기 위해 만듭니다. 이 글에서 구현하는 것이 바로 Server-Side Provider입니다.
Client-Side Provider (Proxy) 는 UIA가 기본으로 제공하는 대체 구현입니다. 애플리케이션이 Server-Side Provider를 제공하지 않을 때, UIA는 표준 Win32 컨트롤(버튼, 텍스트박스 등)에 대해 기본적인 접근성 정보를 제공하는 Proxy를 사용합니다.
표준 Win32 컨트롤을 사용한다면 Proxy 덕분에 기본적인 접근성이 제공됩니다. 하지만 커스텀 컨트롤을 만들었다면, UIA는 그 컨트롤이 무엇인지 알 수 없으므로 Server-Side Provider를 직접 구현해야 합니다.
UIA와 애플리케이션의 통신 흐름
스크린 리더가 애플리케이션의 UI 정보를 요청할 때 어떤 일이 일어나는지 살펴보겠습니다.
- 스크린 리더가 UIA Core에 요청 : 사용자가 Tab 키를 눌러 포커스를 이동하면, 스크린 리더는 UIA Core에 현재 포커스된 요소의 정보를 요청합니다.
- UIA Core가 WM_GETOBJECT 전송 : UIA Core는 해당 창에 WM_GETOBJECT 메시지를 보냅니다. lParam에는 UiaRootObjectId 값이 전달됩니다.
- 애플리케이션이 Provider 반환 : 애플리케이션의 Window Procedure는 WM_GETOBJECT를 받으면 IRawElementProviderSimple 인터페이스를 구현한 객체를 UiaReturnRawElementProvider 함수를 통해 반환합니다.
- UIA Core가 속성 요청 : UIA Core는 반환받은 Provider의 GetPropertyValue 메서드를 호출하여 Name, ControlType 등의 속성을 가져옵니다.
- 스크린 리더가 정보 출력 : 스크린 리더는 전달받은 정보를 음성으로 출력합니다.
WinUI 3에서는 2번과 3번 단계를 프레임워크가 자동으로 처리했습니다. Win32에서는 이 부분을 직접 구현해야 합니다.
개발 환경 설정
Win32 UIA Provider 개발에 필요한 환경을 설정합니다. 2부에서 Visual Studio 2022를 이미 설치했다면 추가 설치는 필요하지 않습니다.
필수 구성 요소
Visual Studio 2022
"C++를 사용한 데스크톱 개발" 워크로드가 설치되어 있어야 합니다. 2부에서 WinUI 3 개발을 위해 이미 설치했다면 그대로 사용할 수 있습니다.
Windows SDK
Windows 10 SDK 이상이 필요합니다. Visual Studio 설치 시 기본으로 포함됩니다.
프로젝트 설정
Visual Studio에서 "Windows 데스크톱 애플리케이션" 또는 "빈 프로젝트"를 생성합니다. 프로젝트 생성 후 다음 헤더와 라이브러리를 포함해야 합니다.
// pch.h 또는 소스 파일 상단에 추가
#include <windows.h>
#include <UIAutomation.h>
// 링크할 라이브러리
#pragma comment(lib, "UIAutomationCore.lib")
UIAutomation.h는 UIA 관련 모든 인터페이스, 상수, 그리고 API 함수들을 포함하는 통합 헤더입니다. 이 헤더는 내부적으로 UIAutomationCore.h(COM 인터페이스 정의), UIAutomationClient.h(클라이언트 API), UIAutomationCoreApi.h(UiaReturnRawElementProvider 등 핵심 함수) 등을 포함합니다.
IRawElementProviderSimple 인터페이스 이해
IRawElementProviderSimple은 모든 UIA Provider가 구현해야 하는 기본 인터페이스입니다. 이 인터페이스를 통해 컨트롤의 기본 속성과 지원하는 패턴을 노출합니다.
인터페이스 구조
IRawElementProviderSimple은 COM 인터페이스이므로 IUnknown을 상속받습니다. 따라서 AddRef, Release, QueryInterface 메서드를 구현해야 합니다. 여기에 더해 4개의 UIA 관련 멤버를 구현해야 합니다.
interface IRawElementProviderSimple : IUnknown
{
// Provider 유형 반환
HRESULT get_ProviderOptions(ProviderOptions* pRetVal);
// 지원하는 Control Pattern 반환
HRESULT GetPatternProvider(PATTERNID patternId, IUnknown** pRetVal);
// 속성 값 반환
HRESULT GetPropertyValue(PROPERTYID propertyId, VARIANT* pRetVal);
// 호스트 창의 기본 Provider 반환
HRESULT get_HostRawElementProvider(IRawElementProviderSimple** pRetVal);
};
각 메서드의 역할과 WinUI 3 대응
각 메서드가 어떤 역할을 하는지, WinUI 3에서는 어떻게 대응되는지 살펴보겠습니다.
get_ProviderOptions
Provider의 유형을 반환합니다. Server-Side Provider인지 Client-Side Provider인지, 그리고 추가 옵션을 지정합니다. WinUI 3에서는 프레임워크가 자동으로 처리합니다.
// 일반적인 구현
HRESULT get_ProviderOptions(ProviderOptions* pRetVal)
{
*pRetVal = ProviderOptions_ServerSideProvider;
return S_OK;
}
GetPatternProvider
컨트롤이 지원하는 Control Pattern의 Provider를 반환합니다. 예를 들어 버튼은 Invoke Pattern을, 슬라이더는 RangeValue Pattern을 지원합니다. WinUI 3에서는 GetPatternCore() 메서드가 이 역할을 합니다.
GetPropertyValue
컨트롤의 속성 값을 반환합니다. Name, ControlType, AutomationId 등 다양한 속성을 VARIANT 타입으로 반환합니다. WinUI 3에서는 GetNameCore(), GetAutomationControlTypeCore() 등 개별 메서드로 분리되어 있습니다.
get_HostRawElementProvider
호스트 창의 기본 Provider를 반환합니다. HWND를 가진 컨트롤은 UiaHostProviderFromHwnd 함수를 사용하여 기본 Provider를 가져올 수 있습니다. WinUI 3에서는 프레임워크가 자동으로 처리합니다.
필수 속성 목록
모든 UIA Provider는 최소한 다음 속성들을 제공해야 합니다. 이 속성들은 2부에서 다룬 AutomationProperties와 대응됩니다.
| Property ID | 설명 | WinUI 3 대응 |
|---|---|---|
| UIA_NamePropertyId | 접근 가능한 이름 | AutomationProperties.Name |
| UIA_ControlTypePropertyId | 컨트롤 유형 (버튼, 텍스트박스 등) | GetAutomationControlTypeCore() |
| UIA_AutomationIdPropertyId | 자동화 식별자 | AutomationProperties.AutomationId |
| UIA_IsKeyboardFocusablePropertyId | 키보드 포커스 가능 여부 | IsTabStop |
| UIA_HasKeyboardFocusPropertyId | 현재 키보드 포커스 상태 | FocusState |
| UIA_IsEnabledPropertyId | 활성화 상태 | IsEnabled |
커스텀 버튼 Provider 구현
이제 실제로 커스텀 버튼을 위한 UIA Provider를 구현해보겠습니다. 이 버튼은 표준 Win32 버튼이 아닌, 직접 그린 커스텀 컨트롤이라고 가정합니다.
Provider 클래스 선언
Provider 클래스는 IRawElementProviderSimple과 IInvokeProvider를 모두 구현합니다. IInvokeProvider는 버튼 클릭 동작을 위한 Pattern입니다.
// CustomButtonProvider.h
#pragma once
#include <windows.h>
#include <UIAutomation.h>
#include <string>
class CustomButtonProvider : public IRawElementProviderSimple,
public IInvokeProvider
{
public:
// 생성자
CustomButtonProvider(HWND hwnd, const wchar_t* name);
// IUnknown 메서드
ULONG STDMETHODCALLTYPE AddRef() override;
ULONG STDMETHODCALLTYPE Release() override;
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override;
// IRawElementProviderSimple 메서드
HRESULT STDMETHODCALLTYPE get_ProviderOptions(ProviderOptions* pRetVal) override;
HRESULT STDMETHODCALLTYPE GetPatternProvider(PATTERNID patternId, IUnknown** pRetVal) override;
HRESULT STDMETHODCALLTYPE GetPropertyValue(PROPERTYID propertyId, VARIANT* pRetVal) override;
HRESULT STDMETHODCALLTYPE get_HostRawElementProvider(IRawElementProviderSimple** pRetVal) override;
// IInvokeProvider 메서드
HRESULT STDMETHODCALLTYPE Invoke() override;
private:
HWND m_hwnd;
LONG m_refCount;
std::wstring m_name;
};
IUnknown 구현
COM 인터페이스이므로 참조 카운팅을 구현해야 합니다. WinUI 3에서는 C++/WinRT가 이 부분을 자동으로 처리해주었습니다.
// CustomButtonProvider.cpp
CustomButtonProvider::CustomButtonProvider(HWND hwnd, const wchar_t* name)
: m_hwnd(hwnd)
, m_refCount(1)
, m_name(name)
{
}
ULONG CustomButtonProvider::AddRef()
{
return InterlockedIncrement(&m_refCount);
}
ULONG CustomButtonProvider::Release()
{
ULONG count = InterlockedDecrement(&m_refCount);
if (count == 0)
{
delete this;
}
return count;
}
HRESULT CustomButtonProvider::QueryInterface(REFIID riid, void** ppv)
{
if (ppv == nullptr)
{
return E_POINTER;
}
if (riid == __uuidof(IUnknown))
{
*ppv = static_cast<IRawElementProviderSimple*>(this);
}
else if (riid == __uuidof(IRawElementProviderSimple))
{
*ppv = static_cast<IRawElementProviderSimple*>(this);
}
else if (riid == __uuidof(IInvokeProvider))
{
*ppv = static_cast<IInvokeProvider*>(this);
}
else
{
*ppv = nullptr;
return E_NOINTERFACE;
}
AddRef();
return S_OK;
}
get_ProviderOptions 구현
Server-Side Provider임을 나타내는 옵션을 반환합니다.
HRESULT CustomButtonProvider::get_ProviderOptions(ProviderOptions* pRetVal)
{
if (pRetVal == nullptr)
{
return E_POINTER;
}
*pRetVal = ProviderOptions_ServerSideProvider;
return S_OK;
}
GetPropertyValue 구현
이 메서드는 UIA Core가 다양한 속성을 요청할 때 호출됩니다. 2부에서 AutomationProperties로 설정한 값들을 여기서 직접 반환합니다.
HRESULT CustomButtonProvider::GetPropertyValue(PROPERTYID propertyId, VARIANT* pRetVal)
{
if (pRetVal == nullptr)
{
return E_POINTER;
}
// 기본값: 빈 값
pRetVal->vt = VT_EMPTY;
switch (propertyId)
{
case UIA_NamePropertyId:
// 접근 가능한 이름
pRetVal->vt = VT_BSTR;
pRetVal->bstrVal = SysAllocString(m_name.c_str());
break;
case UIA_ControlTypePropertyId:
// 컨트롤 유형: 버튼
pRetVal->vt = VT_I4;
pRetVal->lVal = UIA_ButtonControlTypeId;
break;
case UIA_IsKeyboardFocusablePropertyId:
// 키보드 포커스 가능
pRetVal->vt = VT_BOOL;
pRetVal->boolVal = VARIANT_TRUE;
break;
case UIA_HasKeyboardFocusPropertyId:
// 현재 포커스 상태
pRetVal->vt = VT_BOOL;
pRetVal->boolVal = (GetFocus() == m_hwnd) ? VARIANT_TRUE : VARIANT_FALSE;
break;
case UIA_IsEnabledPropertyId:
// 활성화 상태
pRetVal->vt = VT_BOOL;
pRetVal->boolVal = IsWindowEnabled(m_hwnd) ? VARIANT_TRUE : VARIANT_FALSE;
break;
case UIA_IsControlElementPropertyId:
case UIA_IsContentElementPropertyId:
// UIA 트리에 표시
pRetVal->vt = VT_BOOL;
pRetVal->boolVal = VARIANT_TRUE;
break;
case UIA_LocalizedControlTypePropertyId:
// 지역화된 컨트롤 유형 이름
pRetVal->vt = VT_BSTR;
pRetVal->bstrVal = SysAllocString(L"버튼");
break;
}
return S_OK;
}
get_HostRawElementProvider 구현
HWND를 가진 컨트롤은 UiaHostProviderFromHwnd 함수를 사용하여 호스트 창의 기본 Provider를 반환합니다. 이 기본 Provider는 창의 위치, 크기 등 기본 정보를 제공합니다.
HRESULT CustomButtonProvider::get_HostRawElementProvider(IRawElementProviderSimple** pRetVal)
{
if (pRetVal == nullptr)
{
return E_POINTER;
}
return UiaHostProviderFromHwnd(m_hwnd, pRetVal);
}
IInvokeProvider 구현
버튼은 클릭할 수 있는 컨트롤이므로 Invoke Pattern을 지원해야 합니다. 2부에서 배운 Control Pattern 중 가장 기본적인 패턴입니다.
GetPatternProvider 구현
UIA Core가 특정 Pattern의 Provider를 요청할 때 호출됩니다. 버튼은 Invoke Pattern을 지원하므로, UIA_InvokePatternId가 요청되면 IInvokeProvider 인터페이스를 반환합니다.
HRESULT CustomButtonProvider::GetPatternProvider(PATTERNID patternId, IUnknown** pRetVal)
{
if (pRetVal == nullptr)
{
return E_POINTER;
}
*pRetVal = nullptr;
if (patternId == UIA_InvokePatternId)
{
// 이 클래스가 IInvokeProvider도 구현하므로 자기 자신을 반환
*pRetVal = static_cast<IInvokeProvider*>(this);
AddRef();
}
return S_OK;
}
Invoke 메서드 구현
스크린 리더 사용자가 버튼을 활성화하면 (Enter 키 또는 스크린 리더의 활성화 명령), UIA Core는 이 메서드를 호출합니다. 여기서 실제 버튼 클릭 동작을 수행하고, UIA 이벤트를 발생시켜 보조 기술에 피드백을 제공합니다.
HRESULT CustomButtonProvider::Invoke()
{
// 창이 비활성화되어 있으면 실행하지 않음
if (!IsWindowEnabled(m_hwnd))
{
return UIA_E_ELEMENTNOTENABLED;
}
// 1. 버튼 클릭 동작 수행
PostMessage(
GetParent(m_hwnd),
WM_COMMAND,
MAKEWPARAM(GetDlgCtrlID(m_hwnd), BN_CLICKED),
reinterpret_cast<LPARAM>(m_hwnd));
// 2. UIA 이벤트 발생 - 스크린 리더에 버튼이 눌렸음을 알림
// 이 이벤트가 없으면 스크린 리더 사용자는 버튼이 눌렸는지 알 수 없습니다.
UiaRaiseAutomationEvent(this, UIA_Invoke_InvokedEventId);
return S_OK;
}
UiaRaiseAutomationEvent 호출은 필수입니다. 이 이벤트를 통해 버튼이 눌리면 스크린 리더가 UI 변화를 인지하도록하기 위함입니다. 이벤트 없이 넘어가면 스크린 리더가 변화된 화면을 적절히 인식하지 못하거나, 동기화에 문제가 있을 수 있습니다.
WM_GETOBJECT 메시지 처리
Provider를 구현했으니, 이제 UIA Core가 Provider를 가져갈 수 있도록 연결해야 합니다. WinUI 3에서는 OnCreateAutomationPeer()를 오버라이드했지만, Win32에서는 WM_GETOBJECT 메시지를 처리합니다.
Window Procedure에서 처리
커스텀 버튼의 Window Procedure에서 WM_GETOBJECT 메시지를 처리합니다.
LRESULT CALLBACK CustomButtonWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
switch (msg)
{
case WM_GETOBJECT:
// lParam이 UiaRootObjectId인지 확인
if (static_cast<LPARAM>(lParam) == static_cast<LPARAM>(UiaRootObjectId))
{
// 창 데이터에서 Provider 포인터 가져오기
CustomButtonProvider* pProvider = reinterpret_cast<CustomButtonProvider*>(
GetWindowLongPtr(hwnd, GWLP_USERDATA));
if (pProvider != nullptr)
{
// UIA Core에 Provider 반환
// UiaReturnRawElementProvider가 내부적으로 필요한 참조 관리를 수행하므로
// 여기서 별도로 AddRef를 호출하지 않습니다.
return UiaReturnRawElementProvider(
hwnd, wParam, lParam,
static_cast<IRawElementProviderSimple*>(pProvider));
}
}
break;
case WM_CREATE:
{
// 창 생성 시 Provider 생성 (참조 카운트 1로 시작)
CustomButtonProvider* pProvider = new CustomButtonProvider(hwnd, L"내 버튼");
SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(pProvider));
}
break;
case WM_DESTROY:
{
// 창 파괴 시 Provider 정리
CustomButtonProvider* pProvider = reinterpret_cast<CustomButtonProvider*>(
GetWindowLongPtr(hwnd, GWLP_USERDATA));
if (pProvider != nullptr)
{
UiaDisconnectProvider(pProvider);
pProvider->Release();
SetWindowLongPtr(hwnd, GWLP_USERDATA, 0);
}
}
break;
}
return DefWindowProc(hwnd, msg, wParam, lParam);
}
UiaReturnRawElementProvider 함수
이 함수는 UIA Core에 Provider를 반환하는 핵심 함수입니다. WM_GETOBJECT의 wParam과 lParam을 그대로 전달하고, Provider 포인터를 함께 전달합니다.
// 함수 시그니처
LRESULT UiaReturnRawElementProvider(
HWND hwnd,
WPARAM wParam,
LPARAM lParam,
IRawElementProviderSimple* el
);
반환값은 WM_GETOBJECT 메시지의 응답으로 그대로 반환해야 합니다.
키보드 접근성 구현
2부에서 강조했듯이 키보드 접근성은 필수입니다. 커스텀 버튼이 키보드로 조작 가능하도록 구현합니다.
포커스 관리
버튼이 Tab 키로 포커스를 받을 수 있도록 WS_TABSTOP 스타일을 설정하고, 포커스 표시를 그려야 합니다.
// 창 생성 시 WS_TABSTOP 스타일 포함
HWND hwndButton = CreateWindowEx(
0,
L"CustomButton",
L"내 버튼",
WS_CHILD | WS_VISIBLE | WS_TABSTOP,
x, y, width, height,
hwndParent,
reinterpret_cast<HMENU>(buttonId),
hInstance,
nullptr);
// WM_PAINT에서 포커스 표시
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
// 버튼 그리기...
// 포커스가 있으면 포커스 사각형 그리기
if (GetFocus() == hwnd)
{
RECT rcFocus;
GetClientRect(hwnd, &rcFocus);
InflateRect(&rcFocus, -2, -2);
DrawFocusRect(hdc, &rcFocus);
}
EndPaint(hwnd, &ps);
}
break;
키보드 입력 처리
Enter 키나 Space 키를 누르면 버튼이 활성화되어야 합니다.
case WM_KEYDOWN:
if (wParam == VK_RETURN || wParam == VK_SPACE)
{
// 버튼 클릭 동작 수행
PostMessage(
GetParent(hwnd),
WM_COMMAND,
MAKEWPARAM(GetDlgCtrlID(hwnd), BN_CLICKED),
reinterpret_cast<LPARAM>(hwnd));
// UIA 이벤트 발생
CustomButtonProvider* pProvider = reinterpret_cast<CustomButtonProvider*>(
GetWindowLongPtr(hwnd, GWLP_USERDATA));
if (pProvider != nullptr)
{
UiaRaiseAutomationEvent(pProvider, UIA_Invoke_InvokedEventId);
}
return 0;
}
break;
Provider 해제와 정리
메모리 누수를 방지하려면 Provider를 올바르게 정리해야 합니다.
UiaDisconnectProvider 호출
컨트롤이 파괴될 때 UiaDisconnectProvider를 호출하여 UIA Core에 Provider가 더 이상 유효하지 않음을 알립니다. 이 함수를 호출하지 않으면 UIA Core가 이미 파괴된 Provider에 접근하려 할 수 있습니다.
case WM_DESTROY:
{
CustomButtonProvider* pProvider = reinterpret_cast<CustomButtonProvider*>(
GetWindowLongPtr(hwnd, GWLP_USERDATA));
if (pProvider != nullptr)
{
// UIA Core에 Provider 연결 해제 알림
UiaDisconnectProvider(pProvider);
// 참조 카운트 감소
pProvider->Release();
// 창 데이터 정리
SetWindowLongPtr(hwnd, GWLP_USERDATA, 0);
}
}
break;
WinUI 3에서는 프레임워크가 이 정리 작업을 자동으로 수행합니다. Win32에서는 개발자가 직접 관리해야 합니다.
검증 및 테스트
구현이 완료되면 2부에서 소개한 도구들로 검증합니다.
Accessibility Insights로 검증
Accessibility Insights for Windows의 Live Inspect 기능을 사용하여 커스텀 버튼에 마우스를 올리면 다음 정보가 표시되어야 합니다.
- Name : GetPropertyValue에서 반환한 이름
- ControlType : Button
- LocalizedControlType : 버튼
- Patterns : Invoke
NVDA로 테스트
NVDA를 실행하고 Tab 키로 커스텀 버튼에 포커스를 이동합니다. NVDA가 "내 버튼, 버튼"과 같이 읽어야 합니다. Enter 키를 누르면 버튼이 활성화되고, NVDA가 클릭 피드백을 제공해야 합니다.
일반적인 문제와 해결
- 스크린 리더가 컨트롤을 인식하지 못함
- WM_GETOBJECT 처리가 올바른지 확인합니다. UiaReturnRawElementProvider의 반환값을 WM_GETOBJECT의 응답으로 반환해야 합니다.
- Name이 비어 있음
- GetPropertyValue에서 UIA_NamePropertyId에 대해 올바른 VARIANT를 반환하는지 확인합니다. vt를 VT_BSTR로, bstrVal을 SysAllocString으로 할당해야 합니다.
- Invoke가 동작하지 않음
- GetPatternProvider에서 UIA_InvokePatternId에 대해 IInvokeProvider를 반환하는지, QueryInterface에서 IInvokeProvider를 지원하는지 확인합니다.
- 버튼을 눌러도 피드백이 없음
- Invoke 메서드에서 UiaRaiseAutomationEvent를 호출하는지 확인합니다. 이 이벤트가 없으면 스크린 리더가 버튼 활성화를 인지하지 못합니다.
글을 맺으며
이번 3부에서는 Win32에서 UIA Provider를 구현하는 기본 방법을 살펴보았습니다.
WinUI 3와 Win32는 같은 UIA를 사용하지만 추상화 수준이 다릅니다. WinUI 3가 자동으로 처리해주던 WM_GETOBJECT 처리, COM 참조 카운팅, VARIANT 변환, 이벤트 발생 등을 Win32에서는 직접 구현해야 합니다. 이 과정을 통해 UIA가 실제로 어떻게 동작하는지 이해할 수 있습니다.
3부에서 구현한 커스텀 버튼은 단일 HWND에 해당하는 단일 UIA 요소입니다. 하지만 2부에서 만든 별점 위젯처럼 하나의 컨트롤 안에 여러 개의 논리적 요소가 있는 경우는 어떻게 해야 할까요?
4부에서는 IRawElementProviderFragment와 IRawElementProviderFragmentRoot를 구현하여 복잡한 컨트롤의 접근성을 제공하는 방법을 다룹니다. 또한 UiaRaiseAutomationPropertyChangedEvent를 사용하여 2부에서 강조한 양방향 동기화를 Win32에서 구현하는 방법도 살펴보겠습니다. 별점 위젯을 Win32로 재구현하면서 이 모든 개념을 종합해보겠습니다.