WPF의 고DPI 대응 ── 「DPI에 강할 텐데」인데도 흐릿하고・번지는 원인과 대처
· 小村 豪 · WPF, 고DPI, Windows, .NET, C#, .NET Framework, XAML, UI, 기술 상담
「WPF라면 DPI 대응은 아무것도 하지 않아도 되는 거죠?」라는 질문을 고DPI 관련 상담 자리에서 자주 받습니다. 절반은 맞고, 절반은 틀립니다. 실제 증상으로 들어오는 사례는, 「노트북 단독으로는 깨끗한데 회의실의 외부 모니터에 연결하면 화면 전체가 번진다」「글자는 선명한데 툴바의 아이콘만 흐릿하다」「목록의 테두리 선이 위치에 따라 굵어지거나 사라진다」「화면에 넣어 둔 오래된 보고서 컨트롤 부분만 작다」──모두 「DPI에 강할 것」이라고 여겨지는 WPF 앱에서 실제로 일어나는 현상입니다.
전날 올린 글 「WinForms의 고DPI 대응」에서는 Windows의 DPI 스케일링 구조와 DPI 인식 모드(Unaware / System Aware / Per-Monitor V2), 그리고 96 DPI를 그대로 사용하던 시대의 WinForms를 어떻게 구제할지를 정리했습니다. 이 글은 그 WPF 버전입니다. DPI 가상화와 DPI 인식 모드에 대한 일반론은 WinForms 글에 맡기고, 여기서는 「처음부터 DPI에 대응하고 있을 WPF에서, 왜・어디에 문제가 남는가」라는 WPF 고유의 이야기에 집중합니다. 증상 구분, Per-Monitor DPI 대응 선언 방법(.NET Framework 4.6.2 / .NET 각각), 단선과 비트맵의 번짐 대책, WindowsFormsHost 혼재의 함정, 그리고 어디까지 대응할지에 대한 판단표까지, 실무에서 사용하는 순서로 정리합니다.
1. 먼저 결론
- WPF는 「시작 시점의 DPI」에는 처음부터 올바르게 추종합니다. 레이아웃 단위가 1/96인치의 디바이스 독립 단위(DIP)이고, 렌더링 시스템이 시스템 DPI에 맞춰 자동으로 스케일되기 때문에, 기본적으로 System DPI Aware입니다. WinForms처럼 AutoScaleMode를 설정・점검할 필요가 없습니다. 1
- 그럼에도 남는 문제는 4가지 계통으로 나뉩니다. (a) DPI가 다른 모니터로 옮겼을 때의 전체적인 번짐, (b) 비트맵 이미지・아이콘의 흐릿함, (c) 단선・테두리 선의 번짐, (d) WindowsFormsHost / WebBrowser 등의 혼재 콘텐츠. 원인도 해결 방법도 서로 다르므로, 먼저 3장의 표로 구분해 주십시오.
- (a)의 근본적인 해결책은 Per-Monitor DPI 대응입니다. .NET Framework 4.6.2 이후의 WPF는 이를 프레임워크 차원에서 지원하고 있으며, 매니페스트에서 선언만 하면 창의 재스케일링 자체는 자동으로 이루어집니다. 23
- .NET(Core 3.1~.NET 8)의 WPF도 기본값은 System Aware 그대로입니다. WinForms의
ApplicationHighDpiMode/SetHighDpiMode에 해당하는 방법은 WPF에는 없으며, 선언 방법은 .NET Framework와 마찬가지로 매니페스트입니다. - (c)는 DPI 이전의 서브픽셀 배치 문제입니다. 루트 요소에
UseLayoutRounding="True"를 설정하는 것이 첫 번째 대책이며,SnapsToDevicePixels는 그리기 시점의 스냅이라는 별도의 도구입니다. 둘 다 기본값은 비활성입니다. 45 - (b)는 Path / Geometry 등의 벡터 자산을 첫 번째 후보로 삼습니다. 비트맵만 있는 자산은 여러 해상도를 준비해 DPI로 전환합니다. WPF의 기본 보간(Linear)은 정수 배가 아닌 스케일에서 아이콘처럼 작은 이미지를 가장 흐릿하게 만듭니다. 6
- (d)의 혼재 콘텐츠는 Per-Monitor 대응의 상한을 결정하는 요소입니다. 특히 ElementHost / HwndSource에 「탑재된」 WPF의 Per-Monitor 동작은 공식적으로 지원되지 않는다고 명시되어 있습니다. 3
- DPI 인식 모드의 일반론(DPI 가상화, System Aware와 Per-Monitor V2의 차이, exe 속성에서의 사용자 측 재정의)과 혼재 DPI 테스트 환경을 만드는 방법은 WinForms 글의 2~3장・7장과 공통이므로 이 글에서는 반복하지 않습니다.
2. WPF는 왜 「DPI에 강한가」 ── DIP와 System DPI Aware
WinForms의 고DPI 문제의 근본 원인은, 좌표와 크기 모두를 물리적 픽셀로 직접 기술하고, 96 DPI를 전제로 한 레이아웃을 실행 시점에 다시 환산하는 구조(AutoScale)를 나중에 덧붙였다는 데에 있었습니다. WPF는 출발점이 다릅니다. XAML에 쓰는 Width="120"의 120은 물리적 픽셀이 아니라, 1/96인치를 1로 하는 디바이스 독립 단위(DIP)입니다. 레이아웃은 전부 이 단위로 계산되고, 그리기 시점에 시스템 DPI에 맞춘 배율(150%라면 1.5배)로 변환됩니다. 글자도 벡터 폰트로 그려지기 때문에 확대해도 비트맵과 같은 품질 저하가 없습니다. 이 구조 덕분에 WPF 앱은 아무것도 선언하지 않아도 System DPI Aware로 동작합니다. 1
WinForms와의 차이를 표로 정리해 둡니다.
| WinForms | WPF | |
|---|---|---|
| 좌표・크기의 단위 | 물리적 픽셀 | DIP(1/96인치) |
| 아무것도 선언하지 않은 경우 | Unaware(OS의 비트맵 확대로 흐릿해짐) | System Aware(주 모니터에서는 선명함) |
| 시작 시점 DPI에 대한 추종 | AutoScaleMode에 의한 폼 단위 재계산. 설정과 점검 필요 | 렌더링 시스템이 자동 스케일. 설정 불필요 |
| 고DPI에서 무너지는 전형적인 패턴 | 고정 좌표 레이아웃의 겹침・잘림 | 레이아웃은 무너지지 않고, 번짐・흐릿함으로 나타남 |
| 디자이너로 인한 사고 | AutoScaleDimensions의 재작성(자주 발생) | 원칙적으로 없음(XAML은 DIP 그대로 저장됨) |
이 표대로, WinForms에서 작업량의 중심이었던 「레이아웃이 무너지는 것을 고치는」 「디자이너 사고를 방지하는」 작업이 WPF에서는 거의 발생하지 않습니다. 「WPF는 DPI에 강하다」는 올바른 인식입니다.
다만, 자동으로 처리되는 것은 System Aware 범위까지입니다. System Aware란 「로그인 시점의 주 모니터 DPI에 맞춘다」는 모드로, 모니터마다 DPI가 다른 환경에 대한 추종(Per-Monitor)은 포함하지 않습니다. 또한 DIP로 확대되는 것은 어디까지나 WPF가 스스로 그리는 것(텍스트, 도형, 컨트롤)이며, 비트맵 이미지는 확대하면 흐릿해지고, HWND를 가져오는 혼재 콘텐츠는 WPF의 스케일링 바깥에 있습니다. 「DPI에 강할 텐데」라는 상담은 거의 이 남은 부분에서 발생합니다.
3. 그래도 발생하는 문제 ── 증상으로 원인 구분하기
상담을 받았을 때 당사가 가장 먼저 사용하는 구분표입니다. WPF에서는 증상과 원인의 대응 관계가 WinForms보다 더 명확하게 나뉘기 때문에, 이 표만으로도 원인을 거의 특정할 수 있습니다.
| 증상 | 원인 | 대처 |
|---|---|---|
| DPI가 다른 모니터로 옮기면 전체가 균일하게 번진다. 원래 모니터로 되돌리면 정상 | System Aware 상태 그대로. OS가 창 전체를 비트맵으로 확대하고 있음 | 4장(Per-Monitor화) |
| 스케일링 설정을 변경한 직후나, 고DPI 클라이언트에서의 RDP 연결에서 번진다 | 위와 동일(System DPI는 로그인 시점에 고정되기 때문) | 4장 |
| 글자는 선명한데 아이콘・이미지만 흐릿하거나 작다 | 비트맵 자산이 보간 확대되고 있음 | 5.3절 |
| 1px 테두리 선・경계선이 위치에 따라 두께가 다르고, 살짝 번진다 | 서브픽셀 배치와 안티앨리어싱 | 5.1절 |
| 100%(96 DPI) 모니터에서 작은 글자가 번져 보인다 | 기본 텍스트 정형(Ideal)의 안티앨리어싱 | 5.2절 |
| 직접 만드는 이미지(WriteableBitmap, RenderTargetBitmap 등)가 흐릿하다 | 픽셀 크기를 96 DPI 전제로 계산하고 있음 | 6장 |
| 창 위치・마우스 좌표・스크린샷의 좌표가 어긋난다 | DIP와 물리적 픽셀의 혼동 | 6장 |
| WindowsFormsHost / WebBrowser / 일부 서드파티 컨트롤의 내부만 작거나, 거칠거나, 무너진다 | 혼재 콘텐츠(WPF의 스케일링 대상 외) | 7장 |
첫 번째 행의 「전체가 균일하게 번진다」에 대해 보충합니다. 이는 WinForms 글의 2장에서 다룬 DPI 가상화(OS의 비트맵 확대)가 System Aware 앱에 대해 작동하고 있는 상태로, 앱이 고장난 것이 아닙니다. System Aware 앱은 「로그인 시점의 주 모니터 DPI」로 그려지며, 그 외의 DPI를 가진 모니터에서는 OS가 확대・축소해 맞춰 줍니다. 레이아웃은 무너지지 않는 대신 번지는, OS의 구제 조치입니다. 1 이를 고친다는 것은 이 구제를 거부하고 「모니터마다의 DPI에 스스로 추종하겠다」고 선언하는 것, 즉 Per-Monitor화를 의미합니다.
반대로 3번째 행 이후의 증상은 System Aware 상태를 유지한 채로도 고칠 수 있습니다. 「멀티 모니터 운용은 없지만, 150% 화면에서 아이콘과 테두리 선이 지저분하다」는 상담이라면, 4장을 건너뛰고 5장부터 착수해도 무방합니다.
4. 멀티 모니터의 번짐 ── Per-Monitor DPI 대응
4.1 System Aware의 한계
System DPI는 로그인 시점에 주 모니터의 DPI로 고정됩니다. 150%인 노트북 내장 디스플레이가 주 모니터라면, WPF는 모든 창을 1.5배로 그리고, 100%인 외부 모니터로 옮기면 OS가 2/3로 축소해서 표시합니다. 이 OS 스케일링은 정수 배가 아닐 때 특히 흐릿하게 보입니다. 1 200%와 100%처럼 2배 차이가 나는 것보다, 125%와 150%처럼 애매한 조합에서 겉모습의 품질 저하가 더 크다는 것은 직접 검증해 보면 체감할 수 있습니다.
즉 System Aware인 WPF에서 곤란해지는 것은, DPI가 다른 모니터를 오가는 사용 방식과, 로그인 후 스케일링 설정이 바뀌는 상황(도킹 스테이션을 연결・분리하면서 주 모니터가 바뀌거나, RDP로 클라이언트 측 DPI가 유입되는 경우 등)에 한정됩니다. 모든 사용자가 단일 모니터의 고정 데스크톱에서 사용하는 사내 앱이라면, System Aware 상태로도 실무상 충분합니다. 이 판단은 8장의 표에서 다시 다룹니다.
4.2 .NET Framework 4.6.2 이후에서의 선언 방법
WPF의 Per-Monitor DPI 대응은 .NET Framework 4.6.2에서 도입되었습니다. 2 그 이전에는 OS에 Per-Monitor를 선언해도 WPF 쪽이 DPI 변경에 추종해 주지 않아서, 창의 재스케일링을 전부 직접 작성해야 했습니다(Windows 8.1 시절의 샘플이 네이티브 헬퍼 DLL을 사용하는 대규모 구조였던 것도 이 때문입니다). 4.6.2 이후에는 WPF가 WM_DPICHANGED를 처리하고, 창 크기 조정・재레이아웃・재렌더링까지 자동으로 수행합니다.
요구 사항은 두 가지로, Windows 10 Anniversary Update(1607) 이후의 OS와, .NET Framework 4.6.2 이후를 타겟으로 한 빌드입니다. 3 선언은 애플리케이션 매니페스트에 작성합니다.
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:windowsSettings>
<!-- .NET Framework 4.6.2~4.7.2에서는 PerMonitor 단독으로 선언한다(개발자 가이드와 동일).
WPF의 PerMonitorV2 지원은 .NET Framework 4.8 이후이므로,
4.8+ / .NET에서는 「PerMonitorV2, PerMonitor」처럼 V2를 앞에 둔다 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings"
>PerMonitor</dpiAwareness>
<!-- dpiAwareness를 인식하지 못하는 구형 OS 대상(System Aware로 폴백) -->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</asmv3:windowsSettings>
</asmv3:application>
이 2단 구성에는 의미가 있습니다. dpiAwareness 요소는 Windows 10 1607 이후에서 인식되며, 쉼표로 구분해 나열한 값 중 가장 먼저 인식된 것이 사용됩니다. 7 dpiAwareness 요소 자체를 모르는 구형 OS에서는 dpiAware의 선언(System Aware)으로 폴백된다는 구조입니다.
여기서 PerMonitor와 PerMonitorV2를 타겟 프레임워크에 따라 구분해 사용해야 한다는 점에 주의하십시오. WPF가 PerMonitorV2(및 Mixed-Mode DPI)를 정식으로 지원하는 것은 .NET Framework 4.8 이후이며8, 4.6.2 개발자 가이드의 샘플도 PerMonitor 단독 선언입니다. 3 4.6.2~4.7.2 타겟에서 V2를 앞에 쓰면, Windows 10 1703 이후 OS는 프레임워크가 대응하지 않는 V2 모드를 선택하게 되어, 특히 WindowsFormsHost 등 혼재 콘텐츠 주변에서 예상치 못한 동작이 발생합니다. 4.8 이후(및 .NET의 WPF)로 올린 뒤 PerMonitorV2, PerMonitor처럼 V2를 앞에 두고, 제목 표시줄・스크롤바 등 비클라이언트 영역의 스케일링을 OS에 맡기는 것(WinForms 글의 3장 참조)이 당사의 권장 사항입니다.
한 가지, 타겟 프레임워크에 관련된 함정이 있습니다. 실행 환경의 .NET Framework가 4.6.2 이후라도, 프로젝트의 타겟이 4.6.1 이전 그대로라면 Per-Monitor 추종은 기본적으로 비활성화됩니다. 이 경우 app.config의 AppContext 스위치로 명시적으로 활성화합니다. 3
<configuration>
<runtime>
<!-- 이중 부정에 주의: 「DPI 변경 시 스케일하지 않음」을 false로 설정 = 활성화 -->
<AppContextSwitchOverrides value="Switch.System.Windows.DoNotScaleForDpiChanges=false"/>
</runtime>
</configuration>
「매니페스트를 작성했는데 동작하지 않는다」는 문의의 원인은, 경험상 거의 이 타겟 프레임워크이거나, 매니페스트가 빌드에 포함되지 않은(프로젝트 설정에서 기본 매니페스트 그대로인) 경우 둘 중 하나입니다.
4.3 .NET (Core 3.1~.NET 8)에서의 선언 방법
.NET 위에서 동작하는 WPF도, 매니페스트가 없을 때의 기본값은 System Aware 그대로입니다. WinForms가 프로젝트 파일의 ApplicationHighDpiMode나 Application.SetHighDpiMode라는 전용 진입점을 갖는 것과 달리, WPF에는 코드나 프로젝트 설정에서 DPI 인식 모드를 전환하는 공식적인 방법이 없습니다. 선언 방법은 .NET Framework와 동일하며, 프로젝트에 app.manifest를 추가하고(Visual Studio의 「새 항목 추가」→「애플리케이션 매니페스트 파일」), 4.2절과 같은 dpiAwareness 선언을 작성한 뒤, 프로젝트 파일에서 참조합니다.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
</Project>
런타임이 최신이므로 4.2절의 AppContext 스위치는 필요하지 않습니다. 「.NET으로 이전하면 고DPI도 자동으로 해결된다」는 것은 아니라는 점만 주의하십시오. 이전으로 편해지는 것은 WinForms 쪽 이야기이며(WinForms 글 4.1절), WPF는 .NET Framework 4.6.2 시점에 이미 지금과 같은 수준에 도달해 있었습니다.
선언 후에도 해야 할 일이 남아 있다는 점은 WinForms와 같지만, 그 내용이 다릅니다. WinForms에서는 레이아웃 붕괴 수정이 주된 전장이었습니다. WPF에서는 레이아웃을 프레임워크가 추종해 주기 때문에, 남는 것은 전체 화면 표시 점검과, 비트맵 자산의 DPI 전환(5.3절), 픽셀을 직접 다루는 코드의 추종(6장), 혼재 콘텐츠 확인(7장)입니다. 화면 수가 같다면, Per-Monitor화의 총 작업량은 WinForms보다 한 단계 작게 끝나는 것이 일반적입니다.
5. 렌더링의 번짐・흐릿함 ── 단선・글자・비트맵
이 장의 내용은 Per-Monitor화와는 독립적입니다. System Aware 상태를 유지해도 효과가 있으므로, 멀티 모니터 운용이 없는 앱에서도 적용할 가치가 있습니다.
5.1 단선・테두리 선의 번짐 ── UseLayoutRounding과 SnapsToDevicePixels
DIP로 레이아웃한다는 것은, 요소의 경계가 물리적 픽셀의 정수 위치에 반드시 놓이는 것은 아니다라는 뜻입니다. 125%라면 1 DIP = 1.25px이므로, 폭 1 DIP의 테두리 선은 물리적 픽셀 1.25개에 해당하며, 픽셀 중간에 떨어진 가장자리는 안티앨리어싱으로 반투명하게 그려집니다. 그 결과가 「선이 번진다」「같은 1px 지정인데도 행마다 두께가 다르게 보인다」입니다. 96 DPI에서도 Grid의 별표 크기(*) 나누기나 가운데 맞춤 여백 계산에서 0.5px에 걸리면 같은 일이 발생합니다. 이는 WPF의 서브픽셀 렌더링 특성이며, DPI 문제라기보다는 「DPI가 올라가면 더 잘 드러나는」 문제입니다. 4
첫 번째 대책은 레이아웃 반올림입니다. 루트 요소에 한 줄을 추가합니다.
<Window x:Class="MyApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
UseLayoutRounding="True">
UseLayoutRounding은 레이아웃 패스에서 비정수 픽셀 값을 반올림하는 메커니즘으로, 기본값은 비활성이며, 루트에 설정하면 비주얼 트리 전체로 전파됩니다. 4 이름이 비슷한 SnapsToDevicePixels는 역할이 다르며, 레이아웃이 아니라 그리기 시점에 가장자리를 픽셀 경계로 스냅합니다. 이쪽도 기본값은 false이며, 설정은 서브트리로 상속됩니다. 96 DPI를 초과하는 환경에서 단선 주변의 안티앨리어싱으로 인한 번짐을 억제하는 용도가 공식 문서에도 언급되어 있습니다. 5
| UseLayoutRounding | SnapsToDevicePixels | |
|---|---|---|
| 작동 시점 | 레이아웃 패스(Measure / Arrange 결과를 정수 픽셀로 반올림) | 그리기 시점(가장자리를 픽셀 경계로 스냅) |
| 기본값 | false | false |
| 적용 범위 | 루트에 설정하면 자손으로 전파 | 마찬가지로 자손에게 상속 |
| 주요 효과 대상 | 레이아웃 전반. 요소 크기・위치의 0.5px 오차와 그로 인한 선・경계의 번짐 | Border・단선 등 개별 요소에 남은 번짐 |
| 사용 기준 | 우선 이것을 루트 Window에. 신규 앱은 모든 창 공통 스타일로 | UseLayoutRounding으로도 남은 부분에 핀포인트로 |
판단하기 어려우면 「루트에 UseLayoutRounding="True", 그래도 남는 부분에 SnapsToDevicePixels="True"」 순서로 진행합니다. 반올림의 부작용으로, 별표 크기로 균등 분할했던 열 너비가 1px 단위로 불균등해지는 경우가 있지만, 업무용 앱 화면에서 문제가 된 경험은 거의 없습니다. 참고로 DrawingContext로 직접 그리는 코드의 번짐에는 GuidelineSet이라는 저수준 수단도 있지만, 우선 그리기 좌표를 반올림하는 것만으로 대부분 해결됩니다.
5.2 글자의 번짐 ── TextFormattingMode
「WPF는 글자가 흐리다・번진다」는 오래전부터 있었던 불만은, DPI라기보다는 텍스트 정형 모드에 관한 이야기입니다. WPF의 텍스트 정형에는, 폰트 본래의 이상적인 메트릭으로 배치하는 Ideal(기본값)과, GDI 호환 메트릭으로 배치하는 Display의 2가지 모드가 있습니다. 9 Ideal은 글자 간격이 아름답고 스케일링에도 자연스럽지만, 96 DPI(100%)에서 작은 글자를 그리면 안티앨리어싱으로 번져 보이는 경우가 있습니다. TextOptions.TextFormattingMode="Display"를 루트에 설정하면 WinForms 같은 선명한 느낌이 됩니다.
다만 고DPI 환경에서는 이야기가 반대가 됩니다. Display는 96 DPI의 픽셀 격자에 맞추는 정형이므로, 스케일링 상황에서는 오히려 품질이 떨어질 수 있습니다. 100% 환경의 사용자가 많다면 Display, 125% 이상이 주류가 된 현재의 표준 환경에서는 기본값인 Ideal 그대로, 라는 것이 실무적인 절충안입니다. 100%일 때만 Display로 전환하는 구현도 가능하지만, 그렇게까지 할 가치가 있는 화면(텍스트 에디터와 같은 화면)은 한정적입니다.
5.3 이미지・아이콘의 흐릿함 ── 벡터 우선과 다중 해상도
WPF는 Image에 지정한 비트맵도 DIP로 배치하고, DPI에 맞춰 확대합니다. 16×16px 아이콘은 150% 환경에서 24×24px로 보간 확대되며, 기본 보간 알고리즘(Linear)으로 인해 확연히 흐릿해집니다. 6 「글자는 깨끗한데 아이콘만 흐리멍텅하다」는 WPF 앱의 겉모습은 거의 전부 이것이 원인입니다.
대책은 우선순위대로 3가지가 있습니다.
- 벡터 자산으로 만든다(첫 번째 후보).
Path/Geometry/DrawingImage로 가지고 있는 아이콘은 어떤 DPI에서도 선명하게 그려지며, 전환 코드도 필요 없습니다. 디자인 도구나 SVG에서 XAML로 변환하는 도구도 충실합니다. 신규 앱의 아이콘은 처음부터 벡터로 보유한다는 것을 규칙으로 만들 가치가 있습니다. Segoe MDL2 Assets 등의 아이콘 폰트도 같은 효과를 냅니다. - 다중 해상도의 비트맵을 DPI로 전환한다. 사진이나 스크린샷 등 벡터화할 수 없는 자산은, 96 / 120 / 144 / 192 DPI용(16 / 20 / 24 / 32px 등)을 준비해 두고, 현재 DPI에 맞춰 선택합니다. 공식 개발자 가이드에서도 흐릿함 대책으로 DPI별 자산 전환을 권장하고 있습니다. 1
RenderOptions.BitmapScalingMode로 보간 방식을 선택한다. 자산을 늘릴 수 없는 경우의 완화책입니다. 200%처럼 정수 배라면NearestNeighbor로 도트 그대로 확대하는 쪽이 더 선명하고, 큰 이미지를 축소할 때는HighQuality(Fant)가 적합합니다. 6
2번의 전환은, Per-Monitor 대응이 완료된 상태라면 OnDpiChanged(.NET Framework 4.6.2 이후에서 사용 가능)로 수행합니다.
public partial class MainWindow : Window
{
protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi)
{
base.OnDpiChanged(oldDpi, newDpi);
// 모니터 간 이동이나 스케일링 변경이 있을 때마다 호출된다
AppIcon.Source = IconAssets.SelectFor(newDpi.DpiScaleX);
}
}
public static class IconAssets
{
// 16 / 24 / 32 px로 준비한 자산 중에서 확대율에 맞는 것을 선택한다
public static BitmapImage SelectFor(double scale) => scale switch
{
<= 1.0 => Load("icon16.png"),
<= 1.5 => Load("icon24.png"),
_ => Load("icon32.png"),
};
private static BitmapImage Load(string name) =>
new(new Uri($"pack://application:,,,/Assets/{name}"));
}
System Aware 상태를 유지한다면 DPI는 시작 후 바뀌지 않으므로, 시작 시점(Loaded)에 VisualTreeHelper.GetDpi(this)로 한 번만 선택하면 됩니다. 전환 코드를 작성하기 전에, 「이 자산을 아예 벡터로 만들 수는 없는가」를 매번 다시 물어보는 것이, 결국 유지보수가 가장 편한 방법입니다.
6. 픽셀을 다루는 코드와 DPI 변경에 대한 추종
WPF의 자동 스케일링이 미치지 않는 영역이, 물리적 픽셀을 스스로 계산하는 코드입니다. 대표적인 것은 다음 3가지로, 모두 「개발 PC(100%)에서는 완벽한데, 고객사의 150%에서만 흐릿하거나 어긋난다」는 골치 아픈 형태로 나타납니다.
첫 번째는 WriteableBitmap / RenderTargetBitmap 등 픽셀 버퍼를 직접 만드는 코드입니다. 픽셀 수를 DIP 크기와 같은 값으로 만들면, 150% 환경에서는 1.5배로 보간 확대되어 번집니다. 현재 DPI는 VisualTreeHelper.GetDpi가 반환하는 DpiScale 구조체로 가져올 수 있으므로, 픽셀 수는 물리적 픽셀로, DPI는 버퍼에 올바르게 반영합니다.
// imageHost: 렌더링 결과를 표시하는 요소(ActualWidth/Height는 DIP)
DpiScale dpi = VisualTreeHelper.GetDpi(imageHost);
int pixelWidth = (int)Math.Ceiling(imageHost.ActualWidth * dpi.DpiScaleX);
int pixelHeight = (int)Math.Ceiling(imageHost.ActualHeight * dpi.DpiScaleY);
// 96,96으로 고정해서 만들지 말고, 실제 DPI를 전달한다(1 물리 픽셀 = 1 버퍼 픽셀이 됨)
var bitmap = new WriteableBitmap(
pixelWidth, pixelHeight,
dpi.PixelsPerInchX, dpi.PixelsPerInchY,
PixelFormats.Bgra32, null);
두 번째는 화면 좌표나 Win32 상호운용입니다. PointToScreen이 반환하는 좌표, WM_MOUSEMOVE나 후크로 받는 좌표, MoveWindow에 전달하는 좌표는 모두 물리적 픽셀이며, XAML 쪽의 DIP와 섞으면 125% 환경에서 1.25배 어긋납니다. 변환에는 CompositionTarget의 행렬을 사용합니다.
var source = PresentationSource.FromVisual(this);
if (source?.CompositionTarget is { } target)
{
// DIP → 물리 픽셀
Point device = target.TransformToDevice.Transform(new Point(x, y));
// 물리 픽셀 → DIP
Point dip = target.TransformFromDevice.Transform(devicePoint);
}
세 번째는 캐시입니다. DPI를 반영해 만든 비트맵・레이아웃 값・포맷된 텍스트를 캐시하고 있는 경우, Per-Monitor화를 하면 모니터 간 이동마다 그 캐시가 낡은 값이 됩니다. 5.3절과 마찬가지로 OnDpiChanged(또는 창의 DpiChanged 이벤트)에서 다시 만드십시오. 여기서도 System Aware 상태를 유지한다면 DPI는 시작 시점에 고정되므로 추종 코드가 필요 없습니다. 「Per-Monitor화의 비용 = 픽셀을 다루는 코드의 개수」라고 생각하고 추정하면, 실제 상황과 잘 맞습니다.
참고로, 이러한 재생성 처리를 비동기로 수행할 때의 UI 스레드 처리는 「WPF/WinForms의 async와 UI 스레드」에서 정리한 대로입니다.
7. 혼재 콘텐츠의 함정 ── WindowsFormsHost・WebBrowser・서드파티
WPF의 자동 스케일링이 미치는 범위는, WPF가 스스로 그리는 것에 한정됩니다. HWND를 가져오는 요소는 WPF 렌더링의 「바깥」에 있기 때문에, DPI 처리가 한층 복잡해집니다. Windows의 공식 자료에서도 「WPF에 탑재한 다른 프레임워크, 다른 프레임워크에 탑재한 WPF는 자동으로 스케일되지 않는다」고 명시되어 있습니다. 10
| 혼재의 형태 | 무슨 일이 일어나는가 | 현실적인 대응 |
|---|---|---|
| WindowsFormsHost(WPF에 WinForms를 탑재) | DIP와 물리적 픽셀의 두 좌표계를 호스트가 변환하지만, 내부 콘텐츠의 스케일링은 WinForms 컨트롤이 대응할 수 있는 범위까지11 | 내부 WinForms 쪽의 DPI 대응(AutoScaleMode 등)을 WinForms 글의 기준으로 점검. Per-Monitor 추종은 기대하지 않는다 |
| WebBrowser 컨트롤(IE 엔진) | 별도 HWND의 네이티브 콘텐츠. 확대율이 앱의 스케일링과 일치하지 않는 경우가 있음 | 레거시로 취급. WebView2로의 이전을 검토할 때의 근거로 추가한다 |
| ElementHost / HwndSource(WinForms나 Win32에 WPF를 탑재) | Per-Monitor 시나리오는 공식적으로 지원되지 않음3 | 호스트 측 앱의 DPI 인식 모드에 맞춰 System Aware 수준까지만 설계한다 |
| 서드파티 컨트롤 | 제품마다 대응 수준이 제각각. Per-Monitor 미대응 제품이 해당 화면의 상한이 됨 | 벤더의 대응 현황・지원 버전을 파악한 뒤 Per-Monitor화를 판단한다 |
실무에서 가장 많은 경우가 첫 번째 행입니다. 「WPF로 이전했지만, 보고서 미리보기나 그래프만 오래된 WinForms / ActiveX 컨트롤을 WindowsFormsHost로 탑재하고 있는」구성의 앱을 Per-Monitor화하면, WPF 부분은 완벽하게 추종하는데도 호스트 내부만 이동 후의 DPI에 추종하지 못해, 작은 채로・거친 채로 남게 됩니다. Win32 수준에는 혼재 DPI를 호스팅하는 메커니즘(SetThreadDpiHostingBehavior)도 있지만, WPF에서 쉽게 사용할 수 있는 것은 아니어서, 당사에서는 「혼재 부분이 Per-Monitor 대응의 상한을 결정한다」고 정리하고, 먼저 혼재 콘텐츠의 목록을 만드는 것부터 시작합니다. 혼재를 없애는 방향의 판단 근거는 「WinForms / WPF / WinUI를 어떻게 선택할 것인가」와 「ActiveX/OCX를 유지할지, 래핑할지, 교체할지」에 정리해 두었습니다.
8. 어디까지 대응할 것인가 ── 판단표와 단계적 진행 방법
WPF의 경우, 선택지는 실질적으로 2가지입니다(WinForms처럼 「아무것도 하지 않음 = Unaware」인 상태는, 선언하지 않아도 System Aware이므로 존재하지 않습니다).
| System Aware 유지 | Per-Monitor 대응 | |
|---|---|---|
| 겉모습 | 주 모니터에서는 선명함. DPI가 다른 모니터・스케일링 변경 후・RDP에서는 번짐 | 모든 모니터에서 선명함 |
| 선언 작업 | 불필요(기본값) | 매니페스트 추가만(4장) |
| 선언 후 남는 작업 | ── | 전체 화면 표시 점검 + 비트맵 자산의 DPI 전환(5.3절) + 픽셀 관련 코드의 DpiChanged 대응(6장) + 혼재 콘텐츠 확인(7장) |
| 상한을 결정하는 요소 | ── | WindowsFormsHost・WebBrowser・서드파티 컨트롤 |
| 적합한 경우 | 단일 모니터・고정 데스크톱 중심의 사내 앱. 혼재 콘텐츠가 많은 앱 | 노트북+외부 모니터 혼용, 장기간 사용하는 주력 앱, 고객에게 배포하는 제품 |
판단 기준은 WinForms 글 7장과 동일(앱의 수명・이용 환경・수정 예산)하지만, WPF는 Per-Monitor화의 한계 비용이 작다는 점이 다릅니다. 선언은 파일 1개, 레이아웃은 프레임워크가 추종해 주고, 남는 작업은 픽셀 관련 코드와 자산의 개수에 비례합니다. 혼재 콘텐츠가 적은 앱이라면, Per-Monitor까지 진행하는 비용 대비 효과가 WinForms보다 확실히 높습니다. 당사가 실제 프로젝트에서 권장하는 진행 방식은 다음 3단계입니다.
- System Aware를 유지한 채로 품질 개선: 루트에
UseLayoutRounding적용, 아이콘의 벡터화・다중 해상도화,WriteableBitmap계열의 DPI 수정. 멀티 모니터의 번짐을 제외한 모든 문제를 여기서 해결하며, Per-Monitor화를 하지 않기로 결정한 경우에도 그대로 자산이 됩니다. - Per-Monitor 선언과 점검: 매니페스트를 추가하고, 혼재 DPI 환경에서 전체 화면을 돌아보며 문제 부분을 찾아냅니다. 여기서 발견되는 문제는 5~7장 중 하나로 분류할 수 있을 것입니다.
- DPI 변경에 대한 추종 구현:
OnDpiChanged에서의 자산 전환・캐시 재생성. 혼재 콘텐츠는 이 단계에서 「어디까지 허용할지」를 결정합니다.
테스트 환경은 WinForms 글 7장에 적은 구성(스케일링이 다른 모니터 2대, 주 모니터 교체 + 재로그인, 실행 중 스케일링 변경, 고DPI 클라이언트로부터의 RDP)을 그대로 사용할 수 있습니다. 10 WPF에서 중점적으로 봐야 할 것은, 정수 배가 아닌(125% / 150%) 상황에서의 테두리 선・아이콘・직접 렌더링, 그리고 모니터 사이를 넘나들며 창을 드래그했을 때의 동작입니다. 이용 환경의 분포(몇 %의 사용자가 멀티 모니터인지)를 먼저 파악해 두면 투자 판단이 흔들리지 않습니다. 이 관점은 「Windows 앱 UX 설계」에서도 다뤘습니다.
9. 정리
WPF는 DIP와 자동 스케일링에 의해 처음부터 System DPI Aware이며, WinForms의 주된 전장이었던 「레이아웃 붕괴와의 싸움」은 거의 존재하지 않습니다. 그래도 남는 문제는 4가지 계통──멀티 모니터의 번짐(Per-Monitor 미대응), 비트맵의 흐릿함, 단선의 번짐, 혼재 콘텐츠──이며, 각각에 대한 대처법이 확립되어 있습니다.
- 멀티 모니터의 번짐은, .NET Framework 4.6.2 이후 / .NET의 WPF라면 매니페스트 선언만으로 Per-Monitor 추종을 자동으로 얻을 수 있다
- 단선・테두리 선은 루트의
UseLayoutRounding="True"가 첫 번째 대책이며, 남는 부분에SnapsToDevicePixels - 아이콘은 벡터 자산을 첫 번째 후보로 하고, 비트맵은 다중 해상도 + DPI 전환
WriteableBitmap나 화면 좌표 등 픽셀을 다루는 코드만이 진짜 수정 대상이다. 그 개수가 Per-Monitor화의 작업량을 결정한다- WindowsFormsHost / WebBrowser / 서드파티가 대응의 상한을 결정한다. 먼저 목록을 파악한다
「WPF니까 괜찮을 것」이라는 생각에서 멈춰 있던 앱도, 이렇게 정리해 보면 고칠 부분이 손으로 셀 수 있을 정도밖에 없는 경우가 많습니다. 지금 가지고 있는 앱을 어디까지 고칠 수 있는지, 혼재 콘텐츠를 포함한 현황 조사나 대응 수준 판단이 어려우시다면 도와드릴 수 있습니다.
관련 글
- WinForms의 고DPI 대응 ── 4K 모니터에서 흐릿해지고・깨지는 원인과 현실적인 대처
- WinForms/WPF/WinUI 선택 방법 - 실무 판단표
- WPF/WinForms의 async와 UI 스레드를 한 장으로 정리
- Windows 앱 UX 설계 - 이용 환경별 우선순위
관련 상담 영역
합동회사 코무라소프트에서는 WPF / WinForms 앱의 고DPI 대응(현황 조사, Per-Monitor화 가능 여부 판단, 혼재 콘텐츠 목록 작성과 수정), PC 교체・4K 모니터 도입에 따른 표시 오류 원인 조사, UI 개편 상담을 다루고 있습니다.
참고 링크
-
Microsoft Learn, Developing a Per-Monitor DPI-Aware WPF Application. WPF가 기본적으로 System DPI Aware라는 것, DIP를 통한 자동 스케일링 구조, DPI가 다른 모니터로 이동하면 OS가 스케일링을 수행하며 정수 배가 아닐 때 특히 흐릿해진다는 것, DPI별 비트맵 자산 전환에 대한 사고방식에 대해. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, What’s new in .NET Framework. .NET Framework 4.6.2의 WPF에서 Per-Monitor DPI awareness가 활성화되었다는 것, Switch.System.Windows.DoNotScaleForDpiChanges 스위치, GitHub의 개발자 가이드에 대해. ↩ ↩2
-
GitHub (microsoft/WPF-Samples), Per Monitor DPI Developer Guide. Windows 10 Anniversary Update와 .NET Framework 4.6.2 이후라는 요구 사항, 매니페스트의 dpiAwareness / dpiAware 작성법, 4.6.2보다 오래된 타겟을 위한 AppContextSwitchOverrides, HwndSource / ElementHost에 WPF를 탑재하는 구성이 Per-Monitor에서 지원되지 않는다는 것에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, Layout - WPF. DIP를 통한 레이아웃과 서브픽셀 렌더링으로 가장자리가 흐려지는 메커니즘, 레이아웃 반올림(UseLayoutRounding)이 기본적으로 비활성이라는 것, 루트 요소에 설정하면 비주얼 트리로 전파된다는 것에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, UIElement.SnapsToDevicePixels Property. 기본값이 false라는 것, 루트에 설정하면 서브트리로 상속된다는 것, 96 DPI를 초과하는 환경에서 단선 근처의 안티앨리어싱에 의한 시각적 아티팩트를 줄일 수 있다는 것에 대해. ↩ ↩2
-
Microsoft Learn, BitmapScalingMode Enum. 기본값(Unspecified)이 Linear라는 것, HighQuality(Fant)・NearestNeighbor 각 보간 알고리즘의 특성에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Setting the default DPI awareness for a process. dpiAwareness 요소(Windows 10 1607 이후)가 dpiAware보다 우선된다는 것, 쉼표로 구분해 나열한 값 중 가장 먼저 인식된 것이 사용되는 폴백 동작에 대해. ↩
-
Microsoft Learn, What’s new in .NET Framework. .NET Framework 4.8에서 WPF에 Per-Monitor V2 DPI Awareness와 Mixed-Mode DPI 스케일링 지원이 추가되었다는 것, 호스팅된 HWND / WinForms 상호운용의 개선, 활성화에 필요한 AppContext 스위치에 대해. ↩
-
Microsoft Learn, TextFormattingMode Enum. 텍스트 정형의 Ideal(이상적인 메트릭)과 Display(GDI 호환 메트릭) 2가지 모드에 대해. ↩
-
Microsoft Learn, High DPI Desktop Application Development on Windows. UI 프레임워크별 Per-Monitor 대응표, WPF에 탑재한 다른 프레임워크・다른 프레임워크에 탑재한 WPF가 자동으로 스케일되지 않는다는 것, 혼재 DPI 환경에서의 테스트 관점에 대해. ↩ ↩2
-
Microsoft Learn, Layout Considerations for the WindowsFormsHost Element. WindowsFormsHost가 DIP와 물리적 픽셀의 두 좌표계를 변환한다는 것, 스케일링은 내부의 Windows Forms 컨트롤이 대응할 수 있는 범위까지라는 것에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
WinForms의 고DPI 대응 - 4K 모니터에서 흐려지거나 깨지는 원인과 현실적인 대처법
4K 모니터나 150% 스케일링 환경에서 WinForms 앱이 흐려지거나 레이아웃이 깨지는 원인을 DPI 가상화와 DPI 인식 모드(System Aware / Per-Monitor V2) 관점에서 정리합니다. .NET과 .NET Framework...
WinForms/WPF 앱에 Entra ID 인증 넣기 ── MSAL.NET과 WAM 브로커 실무 구성
WinForms/WPF 데스크톱 앱에 Entra ID(구 Azure AD) 인증을 도입하는 절차를 실무 관점에서 정리합니다. 퍼블릭 클라이언트의 개념, ROPC 폐지 현황, 앱 등록, MSAL.NET의 AcquireTokenSilent 패턴, W...
업무 앱의 날짜/시간과 시간대 ── DateTime의 함정부터 UTC 저장 원칙, 테스트 설계까지
서버 이전 시 시간이 9시간 어긋나거나, 해외 지사에서만 날짜가 하루 전날이 되는 문제──날짜/시간 사고의 원인을 DateTime의 Kind와 암묵적 변환부터 정리합니다. DateTimeOffset과의 구분 사용, UTC 저장과 ISO 8601 ...
Windows의 세션 분리를 어떻게 이해할까 ── Session 0・RDP・다중 사용자 동시 실행
Windows 앱 개발자가 혼란스러워하기 쉬운 '세션' 개념을 정리합니다. 서비스가 UI를 표시할 수 없는 Session 0 분리의 이유, RDP 접속 시 세션의 동작, 명명된 개체의 세션 분리, 공유 PC・RDS 환경에서 흔히 발생하는 설계 실...
Windows 앱의 중복 실행 방지 ── 네임드 Mutex와 재실행 시 창 활성화
업무용 Windows 앱의 정석 요건인 「같은 앱을 두 번 실행시키지 않는다」를 네임드 Mutex로 구현하는 방법을 정리합니다. Global\ 과 Local\ 네임스페이스의 차이로 인한 RDP 환경의 함정, 소유 스레드 제약과 AbandonedM...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
UI 스레드 & 타이머
WPF / WinForms UI 스레드, async 흐름, Dispatcher 사용, 타이머 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- WPF는 DPI 대응이 필요 없다고 들었는데, 왜 번지나요?
- WPF는 레이아웃 단위가 1/96인치의 디바이스 독립 단위(DIP)이며, 기본적으로 System DPI Aware로 동작하기 때문에 시작 시점의 주 모니터 DPI에는 올바르게 추종합니다. 그러나 System DPI는 로그인 시점에 고정되므로, DPI가 다른 모니터로 창을 옮기면 OS가 창 전체를 비트맵으로 확대・축소해 맞추면서 전체가 균일하게 번집니다. 특히 125%와 150%처럼 정수 배가 아닌 조합에서 저하가 눈에 띕니다. 근본적인 해결은 매니페스트에서 Per-Monitor DPI 대응을 선언하는 것으로, .NET Framework 4.6.2 이후의 WPF라면 창의 재스케일링은 자동으로 이루어집니다.
- WPF에서 1px 테두리 선이 번지거나 두께가 맞지 않는 이유는 무엇인가요?
- DIP로 레이아웃하기 때문에 요소의 경계가 물리적 픽셀의 정수 위치에 반드시 놓이는 것은 아니기 때문입니다. 125% 환경에서는 1 DIP = 1.25px가 되어, 픽셀의 중간에 떨어진 가장자리는 안티앨리어싱으로 반투명하게 그려져 번지게 됩니다. 첫 번째 대책은 루트 Window에 UseLayoutRounding="True"를 설정하는 것으로, 레이아웃 패스에서 비정수 픽셀 값이 반올림되어 비주얼 트리 전체로 전파됩니다. 그래도 남는 부분에는 그리기 시점에 가장자리를 픽셀 경계로 스냅하는 SnapsToDevicePixels="True"를 특정 위치에 적용합니다. 둘 다 기본값은 비활성입니다.
- WPF에서 글자는 깨끗한데 아이콘만 흐릿한 이유는 무엇인가요?
- 글자는 벡터 폰트로 그려지기 때문에 어떤 DPI에서도 선명하지만, 비트맵 이미지는 DIP로 배치되어 DPI에 따라 보간 확대되기 때문입니다. 16×16px 아이콘은 150% 환경에서 24×24px로 확대되어, 기본 보간(Linear)으로 흐릿해집니다. 대책의 우선순위는 (1) Path/Geometry/DrawingImage나 아이콘 폰트 등 벡터 자산으로 만든다, (2) 여러 해상도의 비트맵을 준비해 VisualTreeHelper.GetDpi나 OnDpiChanged로 DPI에 맞춰 전환한다, (3) RenderOptions.BitmapScalingMode로 보간 알고리즘을 조정한다, 순입니다.
- WPF의 Per-Monitor 대응은 어떻게 설정하나요?
- 애플리케이션 매니페스트에 dpiAwareness 요소를 작성해 선언합니다. WinForms의 ApplicationHighDpiMode와 같은 프로젝트 설정이나 코드에서의 전환 수단은 WPF에는 없습니다. 요구 사항은 Windows 10 1607 이후 OS와 .NET Framework 4.6.2 이후 타겟이며, 선언하면 WM_DPICHANGED의 처리부터 창의 재스케일링까지 프레임워크가 자동으로 수행합니다. WPF의 PerMonitorV2 지원은 .NET Framework 4.8 이후이므로, 4.6.2~4.7.2에서는 PerMonitor 단독으로 선언합니다. 타겟이 4.6.1 이전이면 추종이 기본적으로 비활성화되므로, AppContext 스위치로 활성화해야 합니다. 참고로 ElementHost나 HwndSource에 탑재된 WPF의 Per-Monitor 동작은 공식적으로 지원되지 않습니다.
저자 프로필
기사 저자의 프로필 페이지입니다.
Go Komura
합동회사 코무라소프트 대표
Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.
공개 링크