「사내 PC를 Windows 11로 교체했더니 다크 모드를 쓰는 직원이 『우리 업무 앱만 제목 표시줄이 새하얘서 눈이 아프다』고 했다」 「저시력 직원이 대비 테마를 켰더니 수주 화면의 상태 표시가 사라졌다」 ── 둘 다 최근 1~2년 사이 상담이 늘고 있는 증상입니다.
앞의 것은 Windows 11의 「색」 설정에서, 뒤의 것은 「접근성」 설정에서 비롯되지만, 개발자의 눈에는 「테마를 따라가지 못하고 있다」는 같은 문제로 보입니다. 그리고 실제로 대책의 토대는 공통입니다. 색을 하드코딩하지 말고, 시스템 설정을 읽고, 변경을 알아채고, 다시 칠한다. 이 세 가지입니다.
지난번 「Windows 앱 접근성 입문」에서는 화면 판독기가 읽는 구조(UI Automation)와 이름 붙이기·키보드·색의 기본을 다뤘습니다. 거기서 대비 테마 추종은 언급했지만 라이트/다크라는 색 모드 자체는 다루지 않았습니다. 이 글은 그 자매편으로, 색 모드(라이트/다크)와 대비 테마라는 두 축을 DWM(데스크톱 창 관리자)에 의한 제목 표시줄 그리기, WinForms/WPF에서의 시스템 테마 추종, 대비 테마일 때의 그리기 순으로 구현 수준에서 이어 붙입니다. 대상 독자는 WinForms/WPF/Win32로 업무 앱을 개발·유지보수하는 개발자이고, 전제 환경은 Windows 11(다크 제목 표시줄은 빌드 22000 이상)과 .NET 9/10의 WinForms·WPF(.NET Framework 4.8이나 .NET 8에서는 일부를 직접 구현)이며, 난이도는 중급입니다.
flowchart TB
accTitle: 이 글의 흐름
accDescr: 테마의 두 축 정리에서 시작해 기본이 라이트가 되는 이유, DWM의 다크 제목 표시줄, 감지와 추종의 구조, WinForms와 WPF의 구현, 대비 테마일 때의 그리기, 방침을 정하는 법, 검증까지 차례로 잇는 이 글의 구성
axes["테마의 두 축 정리"] --> why["기본이 라이트가 되는 이유"]
why --> dwm["DWM의 다크 제목 표시줄"]
dwm --> detect["감지와 추종의 구조"]
detect --> impl["WinForms/WPF의 구현"]
impl --> hc["대비 테마일 때의 그리기"]
hc --> policy["방침의 결정과 검증"]
그림 1: 이 글은 테마 정리에서 구조·구현·대비 테마·검증까지를 하나의 흐름으로 잇습니다.
1. 먼저 결론
- Windows의 「테마」에는 두 축이 있습니다. 「설정 > 개인 설정 > 색」의 라이트/다크(색 모드)와 「설정 > 접근성 > 대비 테마」입니다. 후자는 대체로 7:1 이상의 대비율로 제약된 팔레트이며 라이트/다크와는 다른 것입니다. 대비 테마가 켜져 있는 동안에는 다크 모드를 쓸 수 없습니다. 판정의 우선순위는 「대비 테마 → 라이트/다크」입니다. 12
- 기존 앱의 제목 표시줄이 흰색 그대로인 것은 호환성을 위한 기본값입니다. Windows는 앱이 다크를 지원하는지 알 수단이 없으므로 모든 창을 기본적으로 라이트로 취급합니다. 3
- 제목 표시줄을 다크로 만드는 것은
DwmSetWindowAttribute의DWMWA_USE_IMMERSIVE_DARK_MODE(값 20)입니다. BOOL의 TRUE를 전달하면 시스템이 다크일 때 프레임이 다크로 그려집니다. 문서화된 지원은 Windows 11 빌드 22000 이상입니다. 43 - 현재 모드는
UISettings.GetColorValue로 읽고, 변경은ColorValuesChanged로 받습니다. 전경색(기본 글자색)이 밝으면 다크라는 판정이 Microsoft의 공식 절차입니다. 이벤트는 UI 스레드에서 도착한다는 보장이 없으므로 UI로 되돌린 다음 다시 칠합니다. 35 - WinForms는 .NET 9에서
Application.SetColorMode가 들어왔고 .NET 10에서 실험적 상태를 벗어났습니다.SystemColorMode.System을Application.Run보다 앞에서 호출합니다. Windows 11 전용, 대비 테마 중에는 무효, 실행 중의 설정 변경은 따라가지 않는다는 세 가지 제약이 있습니다. 672 - WPF는 .NET 9에서 Fluent 테마와
ThemeMode가 들어왔습니다.ThemeMode="System"으로 따라가고 창의 다크화도 함께 제어됩니다. 다만 코드에서의 조작은 .NET 10에서도 실험적(WPF0001)이고 Fluent 스타일은 「작업 중」입니다. 기존 테마 그대로라면 라이트/다크의 ResourceDictionary를 DynamicResource로 교체합니다. 8910 - 대비 테마일 때는 색을 시스템 색의 올바른 짝에 매핑하고, 글자 뒤의 이미지를 없애고, 여러 색의 도형을 전경·배경 두 색으로 그립니다. 판정은
SPI_GETHIGHCONTRAST(WinForms는SystemInformation.HighContrast, WPF는SystemParameters.HighContrast), 통지는WM_SYSCOLORCHANGE/WM_THEMECHANGED(.NET에서는SystemEvents.UserPreferenceChanged)입니다. 111213 - 다크 모드 대응은 접근성 대응을 대신하지 못합니다. 다크 팔레트에서도 4.5:1의 대비율은 필요하고, 색에만 의존하지 않는 전달 방식은 어떤 테마에서도 필요합니다. 1415
한 문장으로 정리하면, 테마 대응이란 「색을 한곳에 모으고, 시스템 설정을 읽고, 변경을 알아채 다시 칠한다. 다만 대비 테마에서는 시스템 색의 짝에 전면적으로 맡긴다」는 것입니다.
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 28건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 「테마」에는 두 축이 있다 ── 라이트/다크와 대비 테마
2.1. 라이트/다크(색 모드)
Windows의 「설정 > 개인 설정 > 색」에 있는 색 모드는 OS와 앱 전체의 전경색과 배경색의 명암을 정하는 설정입니다. Microsoft 문서는 라이트를 「밝은 배경에 어두운 전경」, 다크를 「어두운 배경에 밝은 전경」으로 정의하고, 여기서 말하는 전경이란 「기본 글자색」이라고 덧붙입니다. 다크 모드에서는 전경(글자)이 밝고 배경이 어두워집니다. 3
이 설정은 레지스트리의 HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize에 있는 AppsUseLightTheme(앱의 모드)과 SystemUsesLightTheme(Windows 자체의 모드)이라는 DWORD 값에 저장되어 있으며 Microsoft의 설정 참조 문서에 실려 있습니다. 16 다만 뒤에서 설명하듯 앱에서 읽는 정규 경로는 WinRT의 UISettings입니다.
2.2. 대비 테마(고대비)
「설정 > 접근성 > 대비 테마」에서 고르는 대비 테마는 대체로 7:1 이상의 대비율이 되도록 제약된 팔레트를 사용하며, 전경과 배경의 시각적 분리를 강하게 필요로 하는 사용자를 위한 것입니다. Windows 11에는 Aquatic·Desert·Dusk·Night sky 네 가지가 내장되어 있고, 사용자는 거기서 고르기만 하는 것이 아니라 배경·글자·하이퍼링크·비활성 글자·선택된 글자·단추의 각 색을 개별적으로 편집할 수 있습니다. 왼쪽 Alt+왼쪽 Shift+PrintScreen으로 빠르게 전환할 수 있고, 선택하지 않았으면 Aquatic이 적용됩니다. 1
Microsoft 문서는 「대비 테마를 라이트/다크 테마와 혼동하지 마십시오」라고 명시합니다. 라이트/다크는 넓은 팔레트를 사용하며 최대 대비에 최적화된 것이 아닙니다. 1 그리고 중요한 것은 대비 테마가 켜져 있는 동안에는 다크 모드를 쓸 수 없다는 점입니다. WinForms의 Application.SetColorMode는 대비 테마 중에 다크를 제공하지 않고, XAML의 RequestedTheme도 시스템에 덮어써집니다. 217
2.3. 판정의 우선순위
따라서 앱의 구현은 다음 순서가 됩니다. 먼저 대비 테마인지 판정하고, 켜져 있으면 시스템 색에 전면적으로 맡긴다. 꺼져 있으면 라이트/다크 중 한쪽 팔레트를 고른다.
flowchart TB
accTitle: 테마의 두 축과 판정의 우선순위
accDescr: 대비 테마가 켜져 있으면 시스템 색의 짝에 전면적으로 맡기고, 꺼져 있으면 라이트/다크의 색 모드를 읽어 앱의 팔레트를 고르는 판정 순서
q1{"대비 테마가 켜져 있는가?"}
q1 -->|켜짐| sys["시스템 색의 짝에 맡긴다"]
q1 -->|꺼짐| q2{"색 모드는?"}
q2 -->|라이트| light["라이트 팔레트"]
q2 -->|다크| dark["다크 팔레트"]
sys -.-> note["다크 모드를 쓸 수 없다"]
그림 2: 대비 테마 판정을 먼저 두고, 꺼져 있을 때만 라이트/다크 팔레트를 고릅니다.
3. 왜 기존 앱은 다크 모드에서 흰색 그대로인가
창은 두 영역으로 이루어져 있습니다. 제목 표시줄·테두리·캡션 단추로 이루어진 비클라이언트 영역과 앱이 그리는 클라이언트 영역입니다. Windows Vista 이후 비클라이언트 영역은 DWM(데스크톱 창 관리자)이 합성해 그리고 있고, 앱은 DwmSetWindowAttribute로 그리는 방식의 특성을 지정합니다. 18
Microsoft 문서는 기존 앱이 흰색 그대로인 이유를 솔직하게 설명합니다. 「Windows는 애플리케이션이 다크 모드를 지원할 수 있는지 알지 못하므로, 이전 버전과의 호환성을 이유로 지원할 수 없다고 가정한다」. WinUI나 Windows App SDK처럼 다크 모드를 기본으로 다루는 프레임워크도 있지만, Win32 앱은 대부분 다크 모드를 지원하지 않으므로 Windows는 기본적으로 라이트 제목 표시줄을 줍니다. 3
flowchart TB
accTitle: 창의 두 영역과 그리기의 주체
accDescr: 제목 표시줄과 테두리로 이루어진 비클라이언트 영역은 DWM이 그리고 클라이언트 영역은 앱이나 UI 프레임워크가 그리므로 다크 대응은 양쪽 모두에 필요하다
win["최상위 창"] --> nc["비클라이언트 영역(제목 표시줄·테두리)"]
win --> client["클라이언트 영역(화면의 내용)"]
nc --> dwm["DWM이 합성해 그린다"]
client --> app["앱이나 프레임워크가 그린다"]
dwm -.-> attr["DwmSetWindowAttribute로 지시"]
app -.-> palette["앱 자신의 팔레트"]
그림 3: 제목 표시줄은 DWM이, 내용은 앱이 그리므로 다크 대응에는 DWM에 대한 지시와 앱의 팔레트가 둘 다 필요합니다.
여기서 두 가지 귀결이 나옵니다. 첫째, 제목 표시줄을 다크로 만들려면 앱이 DWM에 명시적으로 요청해야 합니다. 둘째, 요청한 결과 다크가 되는 것은 제목 표시줄뿐이며 클라이언트 영역은 직접 다시 칠해야 한다는 것입니다. 문서도 「다크 모드를 완전히 지원하려면 앱의 표면 전체가 다크 테마를 따라야 한다」고 말하며, 공식 가이드가 다루는 것은 감지와 제목 표시줄까지이고 클라이언트 영역을 다시 칠하는 방법은 다루지 않는다고 밝힙니다. 3 제목 표시줄만 검게 하고 내용이 흰색 그대로인 앱은 흰색 그대로인 앱보다 부자연스럽습니다.
flowchart TB
accTitle: 기본이 라이트가 되는 구조
accDescr: Windows는 앱의 다크 지원 여부를 모르므로 호환성을 위해 기본을 라이트로 하고 앱이 DWM 특성으로 TRUE를 전달했을 때만 시스템의 다크 설정에 따라 프레임을 그린다
unknown["Windows는 지원 여부를 모른다"] --> def["호환성을 위해 기본은 라이트"]
def --> q{"앱이 TRUE를 전달했는가?"}
q -->|아니오| light["항상 라이트 프레임"]
q -->|예| follow["시스템 설정에 따라 그린다"]
그림 4: 지원 여부를 모르는 Windows는 기본을 라이트로 하고, 앱의 명시적인 지시가 있을 때만 따라갑니다.
4. DWM의 다크 제목 표시줄 ── DwmSetWindowAttribute
4.1. DWMWA_USE_IMMERSIVE_DARK_MODE
제목 표시줄을 다크로 만드는 특성은 DWMWA_USE_IMMERSIVE_DARK_MODE입니다. DWMWINDOWATTRIBUTE 열거형의 설명은 이렇습니다. 「다크 모드의 시스템 설정이 켜져 있을 때 이 창의 프레임을 다크 모드 색으로 그리는 것을 허용한다. 호환성을 이유로 모든 창은 시스템 설정과 관계없이 기본적으로 라이트 모드가 된다. pvAttribute는 BOOL을 가리키며, TRUE이면 다크 모드를 존중하고 FALSE이면 항상 라이트 모드가 된다. Windows 11 빌드 22000 이상에서 지원」. 4
즉 TRUE는 「다크로 해라」가 아니라 「시스템이 다크라면 다크로 해도 좋다」는 허가입니다. 앱 쪽이 클라이언트 영역을 다크로 칠할 준비가 되어 있다면 TRUE를 전달하는 것만으로 제목 표시줄은 시스템 설정을 따라갑니다. 반대로 앱을 항상 라이트로 표시하는 설계(뒤에서 설명하는 「라이트 고정」)라면 기본값인 FALSE 그대로여도 문제없습니다.
공식 가이드의 C++ 코드는 다음 형태입니다. 헤더에 상수가 없는 오래된 SDK를 위해 값 20을 직접 정의하는 절차까지 포함되어 있습니다. 3
#include <dwmapi.h>
#pragma comment(lib, "dwmapi.lib")
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif
// Windows 11(빌드 22000) 이상인가. Windows 10 이상의 supportedOS를 선언한
// 매니페스트가 전제(없으면 버전이 Windows 8 수준으로 잘린다)
bool IsWindows11OrGreater()
{
OSVERSIONINFOEXW osvi{ sizeof(osvi) };
osvi.dwMajorVersion = 10;
osvi.dwMinorVersion = 0;
osvi.dwBuildNumber = 22000;
DWORDLONG mask = 0;
VER_SET_CONDITION(mask, VER_MAJORVERSION, VER_GREATER_EQUAL);
VER_SET_CONDITION(mask, VER_MINORVERSION, VER_GREATER_EQUAL);
VER_SET_CONDITION(mask, VER_BUILDNUMBER, VER_GREATER_EQUAL);
return ::VerifyVersionInfoW(
&osvi, VER_MAJORVERSION | VER_MINORVERSION | VER_BUILDNUMBER, mask) != FALSE;
}
// honorDarkMode = true: 시스템이 다크라면 제목 표시줄도 다크로 그려도 좋다
void ApplyTitleBarTheme(HWND hwnd, bool honorDarkMode)
{
if (!IsWindows11OrGreater())
{
// 문서화된 지원은 빌드 22000 이상. 그 미만에서는 호출하지 않고 기본값(라이트)을 따른다
LogInfo(L"DWMWA_USE_IMMERSIVE_DARK_MODE is not documented for this OS build; keeping the default light frame");
return;
}
BOOL value = honorDarkMode ? TRUE : FALSE;
HRESULT hr = ::DwmSetWindowAttribute(
hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &value, sizeof(value));
if (FAILED(hr))
{
// 지원 OS에서의 실패는 비정상. 조용히 묻어 두지 말고 HRESULT를 기록해 드러낸다
LogWarning(L"DwmSetWindowAttribute(DWMWA_USE_IMMERSIVE_DARK_MODE) failed: 0x%08X", hr);
}
}
OS 버전으로 나눠 호출하는 이유를 한마디 덧붙입니다. 문서화된 지원은 Windows 11 빌드 22000 이상입니다. 4 Windows 10에서도 같은 값이 먹힌다는 보고는 드물지 않지만, 문서화되지 않은 동작에 업무 앱의 표시를 의존시켜서는 안 됩니다. 「호출해 보고 실패하면 포기한다」는 설계로 하면, Windows 10에서 호출이 성공해 버렸을 때 문서화되지 않은 동작 위에서 제목 표시줄이 다크가 됩니다. 빌드 22000 미만에서는 호출하지 않고 「라이트 제목 표시줄이 된다」는 문서화된 기본값을 따르며, 지원 OS에서의 실패는 HRESULT를 로그에 남겨 드러낸다, 그것뿐입니다. 값 19를 쓰는 오래된 절차나 uxtheme.dll의 서수 내보내기를 호출해 공용 컨트롤을 다크로 만드는 기법도 인터넷에 돌아다니지만, 모두 비공개 API여서 업데이트로 동작이 바뀌어도 아무도 보증해 주지 않습니다.
4.2. 호출하는 시점 ── HWND가 살아 있을 때, 그리고 다시 만들어질 때마다
DwmSetWindowAttribute는 HWND에 대해 호출하므로 창 핸들이 생성된 뒤여야 합니다. 그리고 WinForms의 폼은 ShowInTaskbar 변경 등으로 핸들이 다시 생성되는 경우가 있습니다. 다시 생성된 새 HWND에는 특성이 실려 있지 않으므로, 호출할 위치는 「생성자」가 아니라 「핸들이 생성될 때마다 호출되는 곳」입니다. WinForms라면 OnHandleCreated, WPF라면 SourceInitialized입니다.
flowchart TB
accTitle: DwmSetWindowAttribute를 호출하는 시점
accDescr: 창 핸들 생성 후에 DWM 특성을 설정하고 핸들이 다시 생성되었을 때는 새 핸들에 다시 설정하며 테마 변경 통지를 받았을 때도 다시 설정하는 흐름
create["HWND 생성"] --> apply["DWM 특성을 설정"]
apply --> run["표시 중"]
run -->|핸들 재생성| create
run -->|테마 변경 통지| apply
그림 5: DWM 특성은 HWND에 묶이므로 생성할 때마다, 다시 생성할 때마다 설정을 다시 합니다.
WinForms에서의 P/Invoke는 다음과 같습니다(DllImport의 안전한 작성법은 「C#에서 Win32 API를 안전하게 호출하기」를 참조하십시오).
using System.Runtime.InteropServices;
public partial class MainForm : Form
{
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(
IntPtr hwnd, int attribute, ref int value, int size);
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
ApplyTitleBarTheme();
}
private void ApplyTitleBarTheme()
{
// 문서화된 지원은 빌드 22000 이상. 그 미만에서는 호출하지 않고 기본값(라이트)을 따른다.
// .NET Framework에서도 쓸 수 있는 판정. 다만 .NET Framework에서는 Windows 10 이상의
// supportedOS를 선언한 매니페스트가 없으면 버전이 Windows 8 수준으로 잘린다
// (.NET 5 이후라면 OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000)도 좋다)
if (Environment.OSVersion.Version < new Version(10, 0, 22000))
{
_logger.LogInformation("Dark title bar is not documented for this OS build; keeping the default light frame");
return;
}
// 1(TRUE) = 시스템이 다크라면 다크로 그려도 좋다. 0(FALSE) = 항상 라이트
int honorDarkMode = 1;
int hr = DwmSetWindowAttribute(
Handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref honorDarkMode, sizeof(int));
if (hr < 0)
{
_logger.LogWarning("DwmSetWindowAttribute failed: 0x{Hr:X8}", hr);
}
}
}
이 코드가 필요한 것은 .NET 8 이전·.NET Framework·Win32/MFC 앱입니다. .NET 9 이후의 WinForms에서 Application.SetColorMode를 쓰는 경우, 그리고 .NET 9 이후의 WPF에서 ThemeMode를 쓰는 경우에는 창의 다크화를 프레임워크가 맡습니다(ThemeMode의 문서는 「창에 대한 배경 재질과 다크 모드의 적용도 제어한다」고 명시합니다). 10 이중으로 호출해도 해는 없지만 책임 소재가 모호해지므로 둘 중 하나로 정하십시오.
WPF에서는 SourceInitialized 시점에 HWND가 확정됩니다. WindowInteropHelper에서 핸들을 얻습니다. 19
using System.Windows.Interop;
public partial class MainWindow : Window
{
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
var hwnd = new WindowInteropHelper(this).Handle;
TitleBarTheme.Apply(hwnd, honorDarkMode: true); // 내용은 앞의 P/Invoke
}
}
4.3. 제목 표시줄의 색·글자색·테두리 색과 배경 재질
Windows 11에서는 다크/라이트의 양자택일뿐 아니라 제목 표시줄의 색 자체를 지정하는 특성도 추가되었습니다.
| 특성 | 값 | 내용 | 지원 빌드 |
|---|---|---|---|
DWMWA_USE_IMMERSIVE_DARK_MODE |
20 | 시스템이 다크면 프레임을 다크로 그린다(BOOL) | 22000 |
DWMWA_BORDER_COLOR |
34 | 창 테두리의 색(COLORREF). DWMWA_COLOR_NONE으로 테두리를 없앨 수 있다 |
22000 |
DWMWA_CAPTION_COLOR |
35 | 제목 표시줄의 색(COLORREF) | 22000 |
DWMWA_TEXT_COLOR |
36 | 제목 글자의 색(COLORREF) | 22000 |
DWMWA_SYSTEMBACKDROP_TYPE |
38 | 시스템이 그리는 배경 재질(Mica나 Acrylic) | 22621 |
색을 지정하는 세 특성은 DWMWA_COLOR_DEFAULT(0xFFFFFFFF)를 전달하면 시스템 기본값으로 돌아갑니다. 테두리 색에 대해서는 「창 활성화 같은 상태 변화에 따라 색을 바꾸는 것은 앱의 책임」이라고 되어 있는 점에 주의하십시오. 4 배경 재질은 DWM_SYSTEMBACKDROP_TYPE 열거형으로 지정하며 DWMSBT_MAINWINDOW가 Windows 11에서는 Mica, DWMSBT_TRANSIENTWINDOW가 Acrylic에 해당하지만, 「재질의 효과는 앞으로의 Windows에서 바뀔 수 있다」고 명시되어 있습니다. 20
업무 앱에서의 쓰임새는 신중하게 생각하십시오. 제목 표시줄을 브랜드 색으로 칠하면 그 색 위에서 제목 글자와 캡션 단추의 대비를 직접 보증할 책임이 생깁니다. 다크/라이트 두 상태에 더해 활성/비활성 조합도 늘어납니다. 많은 업무 앱에게 정답은 「시스템 기본값을 따른다(값 20을 TRUE로 하는 것뿐)」이며, 브랜드 색은 정말 필요할 때의 선택지입니다.
flowchart TB
accTitle: 제목 표시줄의 색을 어떻게 정할 것인가
accDescr: 시스템 기본값을 따른다면 값 20을 TRUE로 하는 것으로 끝나지만 브랜드 색으로 칠하면 글자와 캡션 단추의 대비 보증과 활성/비활성 색 관리가 앱의 책임이 되고 되돌릴 때는 DWMWA_COLOR_DEFAULT를 전달한다
q{"제목 표시줄의 색은?"}
q -->|시스템 기본값을 따른다| dark["값 20을 TRUE로 하는 것뿐"]
q -->|브랜드 색으로 칠한다| brand["색 특성(34~36)을 지정"]
brand --> resp["글자와 단추의 대비를 직접 보증"]
brand --> states["활성/비활성도 직접 관리"]
brand -.-> reset["되돌릴 때는 COLOR_DEFAULT"]
그림 6: 브랜드 색을 고르면 대비와 상태 관리의 책임이 앱 쪽으로 옮겨 오고, 많은 업무 앱에서는 기본값을 따르는 것이 정답이 됩니다.
5. 시스템 테마의 감지와 추종 ── 읽고, 알아채고, 다시 칠하기
클라이언트 영역을 따라가게 하는 일은 셋으로 나눌 수 있습니다. 현재 설정을 읽고, 변경을 알아채고, 그리고 다시 칠하는 것입니다.
flowchart TB
accTitle: 감지와 추종의 3단계
accDescr: 시작할 때 UISettings로 현재 색 모드를 읽고 ColorValuesChanged 같은 통지로 변경을 알아채며 UI 스레드로 되돌려 앱의 팔레트를 다시 칠하는 루프
read["읽기: UISettings.GetColorValue"] --> paint["다시 칠하기: 팔레트를 재적용"]
notice["알아채기: ColorValuesChanged"] --> ui["UI 스레드로 되돌리기"]
ui --> read
paint -.-> dwm["DWM 특성도 다시 설정"]
그림 7: 시작할 때 읽고, 통지로 알아채고, UI 스레드로 되돌려 다시 칠하는 루프가 테마 추종의 뼈대가 됩니다.
5.1. 읽기 ── UISettings와 「전경이 밝으면 다크」
Microsoft의 공식 절차는 WinRT의 Windows.UI.ViewManagement.UISettings를 씁니다. GetColorValue(UIColorType::Foreground)로 전경색(기본 글자색)을 얻고, 그 지각 밝기를 정수 연산으로 어림해 「밝은가」를 판정하며, 전경이 밝으면 다크 모드라고 판단합니다. 문서는 이 식이 엄밀한 휘도 분석 모델이 아니라 명암 분류에 충분한 근사라고 밝힙니다. 321
UISettings는 WinRT의 클래스이지만, C#의 WPF/WinForms에서도 TargetFramework를 net8.0-windows10.0.19041.0처럼 Windows SDK 버전이 붙은 형태로 하면 직접 호출할 수 있습니다(구조는 「WinRT는 COM이다」를 참조하십시오). 레지스트리의 AppsUseLightTheme를 직접 읽는 방법도 있지만, 레지스트리는 설정의 저장 위치이지 API 계약이 아니므로 읽는다면 UISettings를 정본으로 삼고 레지스트리는 진단용으로 남겨 두는 것이 옳습니다.
flowchart TB
accTitle: 색 모드를 읽는 경로
accDescr: 정규 경로는 WinRT의 UISettings로 전경색을 얻어 명암을 판정하는 방법이고 레지스트리의 AppsUseLightTheme는 저장 위치이므로 진단용으로 남겨 둔다
q["지금은 라이트인가 다크인가"] --> uis["UISettings.GetColorValue"]
q -.-> reg["레지스트리 AppsUseLightTheme"]
uis --> fg["전경색의 지각 밝기를 판정"]
fg --> ans["밝으면 다크"]
reg -.-> diag["저장 위치. 진단용으로 남긴다"]
그림 8: 정규 읽기 경로는 UISettings이고, 레지스트리는 저장 위치에 지나지 않습니다.
5.2. 알아채기 ── ColorValuesChanged는 UI 스레드로 오지 않는다
변경 감지에도 UISettings를 씁니다. ColorValuesChanged 이벤트는 색 값이 바뀌었을 때 발생하며, 공식 가이드도 이 이벤트로 설정 변경을 추적합니다. 53 여기에 실무상의 주의점이 하나 있습니다. 이 이벤트는 UI 스레드에서 도착한다는 보장이 없습니다. WPF의 Dispatcher나 WinForms의 Control.Invoke, 또는 양쪽에서 쓸 수 있는 SynchronizationContext로 UI 스레드로 되돌린 다음 컨트롤을 만지십시오. UI 스레드를 다루는 방법은 「WPF/WinForms의 UI 스레드와 async/await」에 정리해 두었습니다.
또한 강조색 변경으로도 이 이벤트는 발생합니다. 라이트/다크가 바뀌었을 때만 다시 칠하고 싶다면 이벤트마다 다시 판정해 직전과 다를 때만 통지합니다. 그리고 2장의 우선순위대로 대비 테마가 켜져 있으면 전경색의 밝기를 봐서는 안 됩니다. Aquatic처럼 어두운 배경의 대비 테마에서는 전경이 밝으므로 밝기만 보면 「다크」로 오판합니다. 대비 테마는 독립된 상태로 먼저 판정합니다.
다음 클래스는 그 판정 순서를 하나로 모은 것입니다. UI 스레드로 되돌리기 위한 SynchronizationContext와 대비 테마의 판정(WinForms라면 SystemInformation.HighContrast, WPF라면 SystemParameters.HighContrast, 8장)을 호출하는 쪽에서 전달합니다. 생성 시점에 주의하십시오. WinForms에서는 Program.Main 시점에는 아직 메시지 루프도 Control도 없어서 SynchronizationContext.Current가 null입니다. 폼의 생성자나 OnLoad처럼 컨트롤이 생성된 뒤에 SynchronizationContext.Current를 전달하십시오. WPF라면 new DispatcherSynchronizationContext(Application.Current.Dispatcher)를 전달할 수 있습니다.
using Windows.UI.ViewManagement; // TargetFramework: net8.0-windows10.0.19041.0 이상
public enum ThemeState { Light, Dark, HighContrast }
public sealed class SystemThemeWatcher : IDisposable
{
private readonly UISettings _settings = new();
private readonly SynchronizationContext _ui;
private readonly Func<bool> _isHighContrast;
private bool _disposed;
public ThemeState Current { get; private set; }
public event EventHandler? Changed;
// ui: UI 스레드의 SynchronizationContext. 컨트롤 생성 후의
// SynchronizationContext.Current나, WPF라면 DispatcherSynchronizationContext를 전달
// isHighContrast: () => SystemInformation.HighContrast(WinForms)
// () => SystemParameters.HighContrast(WPF)
public SystemThemeWatcher(SynchronizationContext ui, Func<bool> isHighContrast)
{
_ui = ui ?? throw new ArgumentNullException(nameof(ui));
_isHighContrast = isHighContrast ?? throw new ArgumentNullException(nameof(isHighContrast));
Current = Read();
_settings.ColorValuesChanged += OnColorValuesChanged;
}
private ThemeState Read()
{
// 판정 순서는 2장대로: 대비 테마가 먼저. 어두운 배경의 대비 테마는
// 전경이 밝으므로 밝기만 보면 「다크」로 오판한다
if (_isHighContrast()) return ThemeState.HighContrast;
// 공식 가이드와 같은 판정: 전경(기본 글자색)이 밝으면 다크
var fg = _settings.GetColorValue(UIColorType.Foreground);
bool isDark = (5 * fg.G + 2 * fg.R + fg.B) > 8 * 128;
return isDark ? ThemeState.Dark : ThemeState.Light;
}
// UI 스레드에서 호출한다. UserPreferenceChanged 같은 다른 경로의 통지에서도 호출할 수 있다
public void Refresh()
{
if (_disposed) return;
var next = Read();
// 라이트/다크 사이에서는 강조색만 바뀐 경우 등을 무시하려고 같은 상태면 통지하지 않는다.
// 대비 테마 중에는 예외: 사용자가 테마의 색을 편집해도 상태는 HighContrast 그대로이므로
// 같은 상태여도 통지해 시스템 색을 다시 가져오게 한다
if (next == Current && next != ThemeState.HighContrast) return;
Current = next;
Changed?.Invoke(this, EventArgs.Empty);
}
private void OnColorValuesChanged(UISettings sender, object args)
{
// UI 스레드로 온다는 보장이 없으므로 UI로 되돌린 뒤 판정과 통지를 한다.
// Dispose 후에 도착한(이미 큐에 들어가 있던) 호출은 Refresh 쪽 플래그로 무시한다
_ui.Post(_ => Refresh(), null);
}
public void Dispose()
{
// 구독 해제는 앞으로의 전달을 멈출 뿐이고 UI 스레드에 이미 던져 넣은 호출은 남는다.
// 플래그를 세워 남은 호출 쪽에서 무시하게 한다(UI 스레드에서 호출할 것)
_disposed = true;
_settings.ColorValuesChanged -= OnColorValuesChanged;
}
}
Win32 수준에서는 설정 변경 시 WM_SETTINGCHANGE가 모든 최상위 창으로 보내지고, 22 .NET에서는 그것이 SystemEvents.UserPreferenceChanged로 도착합니다. 23 시각 스타일 전환(대비 테마 켜기 포함)에서는 WM_THEMECHANGED가, 24 시스템 색 변경에서는 WM_SYSCOLORCHANGE가 옵니다. 25 통지 종류마다 다른 처리를 나눠 쓰기보다, 어떤 통지가 오더라도 같은 「읽고 다시 칠한다」 처리를 호출하는 편이 덜 깨지고, 이는 지난번 글에서 소개한 대비 테마 추종과도 같은 형태입니다. 위의 SystemThemeWatcher로 말하자면 UserPreferenceChanged나 StaticPropertyChanged의 처리기에서도 Refresh()를 호출한다는 것입니다.
flowchart TB
accTitle: 테마 변경의 통지 경로와 스레드
accDescr: UISettings의 ColorValuesChanged는 UI 스레드 이외에서 도착할 수 있어 UI로 되돌리고 WM_SETTINGCHANGE는 SystemEvents.UserPreferenceChanged로 도착하며 WM_THEMECHANGED와 WM_SYSCOLORCHANGE는 창 프로시저로 도착한다. 모두 같은 재적용 처리로 모은다
cvc["ColorValuesChanged"] --> marshal["UI 스레드로 되돌리기"]
upc["UserPreferenceChanged"] --> reapply["읽고 다시 칠하기"]
wm["WM_THEMECHANGED 등"] --> reapply
marshal --> reapply
그림 9: 통지 경로는 여럿이지만 모두 같은 「읽고 다시 칠한다」 처리로 모읍니다.
5.3. 다시 칠하기 ── 색을 한곳에 모으기
다시 칠하기를 가능하게 하는 전제는 색이 한곳에 모여 있는 것입니다. Color.White나 #FFFFFF가 폼과 XAML 여기저기에 흩어져 있으면 다시 칠할 곳을 열거할 수 없습니다. WinForms라면 「팔레트」 클래스(라이트용과 다크용 두 인스턴스)를 만들고, 컨트롤은 시작할 때와 통지가 왔을 때 거기서 색을 가져옵니다. WPF라면 색을 ResourceDictionary에 모으고 XAML은 DynamicResource로 참조합니다. WinUI라면 이 구조가 ThemeDictionaries로 처음부터 마련되어 있습니다.
flowchart TB
accTitle: 색을 한곳에 모으는 구조
accDescr: 라이트용과 다크용 팔레트를 앱에 하나씩 두고 현재 모드에 따라 고른 팔레트에서 각 화면이 색을 가져와 다시 칠할 대상을 열거할 수 있게 한다
mode["현재 모드"] --> sel{"어느 쪽을 쓸까?"}
sel -->|라이트| pl["라이트 팔레트"]
sel -->|다크| pd["다크 팔레트"]
pl --> screens["각 화면·각 컨트롤"]
pd --> screens
hard["흩어진 Color.White"] -.-> cannot["다시 칠할 곳을 열거할 수 없다"]
그림 10: 팔레트를 한곳에 두면 다시 칠할 대상을 열거할 수 있지만, 흩어진 하드코딩은 그럴 수 없습니다.
이 「색을 모으는」 작업은 대비 테마 대응에서도, 뒤에서 설명하는 대비율 확인에서도 그대로 효과를 냅니다. 다크 모드 대응의 최대 비용은 API 호출이 아니라 이 정리입니다.
6. WinForms에서의 구현
6.1. .NET 9/10 ── Application.SetColorMode
WinForms에는 .NET 9에서 다크 모드의 예비 지원이 들어왔고 .NET 10에서 「완전히 통합」되었습니다. Application.SetColorMode에 전달하는 값은 셋입니다. 67
SystemColorMode.Classic── 기본값. 기존과 같은 라이트.SystemColorMode.System── Windows의 라이트/다크 설정을 따른다.SystemColorMode.Dark── 다크.
호출할 곳은 Application.Run 앞, UI 요소를 만들기 전입니다. .NET 9에서는 실험적 기능이라서 프로젝트 파일에서 WFO5001을 억제하지 않으면 컴파일 오류가 났지만, .NET 10부터 이 오류는 나지 않습니다. 26
static class Program
{
[STAThread]
static void Main()
{
ApplicationConfiguration.Initialize();
Application.SetColorMode(SystemColorMode.System); // UI를 만들기 전에 호출
Application.Run(new MainForm());
}
}
색 모드가 바뀌면 System.Drawing.SystemColors가 대응하는 색으로 바뀌고 표준 컨트롤은 그에 따라 그려집니다. 6 구조로 보면 SystemColors.UseAlternativeColorSet이라는 실험적 속성(SYSLIB5002)이 「시스템의 KnownColor가 대체 색 집합(현재는 다크 모드판)을 반환하게」 하는 것이며, Win32의 시스템 색 자체는 라이트/다크 설정으로 바뀌지 않으므로 .NET 쪽에서 대체 집합을 갖고 있는 것입니다. 같은 문서는 대비 테마가 켜져 있을 때는 항상 Windows의 현재 색을 반환한다고도 씁니다. 27
SetColorMode 문서에 적힌 제약 세 가지를 짚어 두십시오. 2
- 다크 색 모드는 Windows 11 이상에서만 쓸 수 있습니다.
- 대비 테마가 켜져 있을 때는 다크 모드를 쓸 수 없습니다.
SystemColorMode.System을 지정해도 실행 중에 Windows 설정이 바뀌었을 때 앱은 자동으로 따라가지 않습니다.
세 번째는 업무 앱에서 문의로 이어지기 쉬운 점입니다. 시작할 때의 Windows 설정으로 정해지고 다음 시작 때 반영된다는 동작을 이용자용 설명에 적어 두십시오. 실행 중의 전환을 굳이 따라가고 싶다면 폼을 다시 만드는 설계가 필요해지고, 대개는 수지가 맞지 않습니다.
flowchart TB
accTitle: SetColorMode의 흐름과 제약
accDescr: SetColorMode는 Application.Run 앞에서 호출하고 SystemColors가 대체 집합으로 바뀌어 표준 컨트롤이 따라간다. Windows 11 전용, 대비 테마 중에는 무효, 실행 중의 설정 변경은 따라가지 않는다는 세 제약이 있다
sc["SetColorMode(System)"] --> before["Application.Run 앞"]
before --> colors["SystemColors가 대체 집합으로"]
colors --> ctrls["표준 컨트롤이 따라간다"]
sc -.-> c1["Windows 11 전용"]
sc -.-> c2["대비 테마 중에는 무효"]
sc -.-> c3["실행 중의 변경을 따라가지 않는다"]
그림 11: SetColorMode는 시작 전에 한 번만 효과가 있고, 문서화된 제약 세 가지를 갖습니다.
6.2. 직접 그리는 컨트롤과 ApplyThemingImplicitly
표준 컨트롤은 앱의 색 모드를 따르지만, .NET 10 문서는 두 가지 예외적인 경우를 듭니다. 직접 구성하고 그리는 컨트롤 안에서 스크롤 막대 같은 Win32 공용 컨트롤을 쓰는 경우, 그것들은 명시적으로 옵트인하지 않으면 라이트로 남습니다. 반대로 테마를 따르는 기존 컨트롤을 상속해 그리기를 완전히 직접 제어하고 싶은 경우에는 옵트아웃합니다. 7
둘 다 Control.CreateParams를 재정의하고 base.CreateParams를 읽기 전에 SetStyle(ControlStyles.ApplyThemingImplicitly, true/false)를 호출합니다. 기본 클래스의 생성자가 CreateParams를 읽기 때문에 자신의 생성자에서는 늦다는 점이 이 API의 함정입니다. 7
public partial class GanttChartControl : Control
{
protected override CreateParams CreateParams
{
get
{
// base.CreateParams를 읽기 전에 설정한다. 생성자에서는 늦다
SetStyle(ControlStyles.ApplyThemingImplicitly, true);
return base.CreateParams;
}
}
}
flowchart TB
accTitle: ApplyThemingImplicitly를 설정할 수 있는 시점
accDescr: 기본 클래스의 생성자가 CreateParams를 읽는 시점에 ApplyThemingImplicitly가 정해지므로 CreateParams 재정의 안에서 base.CreateParams보다 앞에 SetStyle을 호출해야 하고 파생 클래스의 생성자에서는 늦다
basector["기본 생성자"] --> cp["CreateParams를 읽는다"]
cp --> st["여기까지 SetStyle이 필요"]
st --> derived["파생 클래스의 생성자"]
derived -.-> late["여기서 호출해도 늦다"]
그림 12: ApplyThemingImplicitly는 기본 생성자가 CreateParams를 읽기 전에 정해져 있어야 합니다.
직접 그리기(OnPaint에서의 GDI+ 그리기) 자체는 SystemColors / SystemBrushes / SystemPens를 쓰고 있으면 대체 집합을 따라갑니다. Color.White로 칠하는 부분은 여기서도 앞서 말한 팔레트로 바꿉니다.
6.3. .NET Framework 4.8과 .NET 8 이전 ── 「라이트 고정」을 명시하기
SetColorMode가 없는 환경에서는 다크 모드의 표준 지원이 없습니다. 선택지는 둘입니다.
- 라이트 고정을 명시한다. DWM 특성은 기본값인 FALSE 그대로(항상 라이트 제목 표시줄), 클라이언트 영역도 기존대로. 대비 테마 대응(8장)만은 반드시 한다.
- 직접 전면 대응한다. 팔레트를 모으고
UISettings로 읽어 따라가며 DWM 특성을 TRUE로 하고 공용 컨트롤의 겉모습까지 포함해 전부 다시 칠한다.
2번은 Win32 공용 컨트롤(스크롤 막대, 헤더, 트리의 확장 단추 등)의 그리기를 앱이 완전히 제어할 수 없으므로 「거의 다크지만 군데군데 라이트」인 상태가 되기 쉽습니다. 공식 가이드가 「표면 전체가 따라야 한다」고 말하는 대로, 3 어중간한 다크는 라이트 고정보다 나쁜 경험이 됩니다. 기존 자산에 대해서는 1번을 골라 「이 앱은 라이트 표시」라고 명시하고, .NET 10으로 이행하는 시점에 SetColorMode로 전환하는 것이 현실적이고 설명하기도 쉬운 방침입니다.
flowchart TB
accTitle: WinForms의 런타임별 선택지
accDescr: .NET 10 이상이면 SetColorMode를 쓰고 .NET 9면 같은 API를 WFO5001 억제와 함께 쓰며 .NET 8 이전이나 .NET Framework에서는 라이트 고정을 명시할지 공용 컨트롤까지 포함해 직접 전면 대응할지 고른다
q{"런타임은?"}
q -->|.NET 10 이상| n10["SetColorMode(System)"]
q -->|.NET 9| n9["SetColorMode + WFO5001 억제"]
q -->|.NET 8 이전 / .NET Framework| legacy{"어떻게 할까?"}
legacy -->|권장| fixed["라이트 고정을 명시"]
legacy -->|각오가 있다면| full["직접 전면 대응"]
full -.-> partial["공용 컨트롤이 남는다"]
그림 13: SetColorMode를 쓸 수 없는 런타임에서는 라이트 고정의 명시가 현실적인 기본값이 됩니다.
7. WPF에서의 구현
7.1. .NET 9/10 ── Fluent 테마와 ThemeMode
.NET 9의 WPF에는 Windows 11의 Fluent 디자인에 맞춘 새 테마가 함께 들어 있으며 라이트/다크와 강조색을 지원합니다. 적용 방법은 둘입니다. ThemeMode 속성을 설정하거나, PresentationFramework.Fluent의 리소스 사전을 MergedDictionaries에 추가하는 것입니다. 8
ThemeMode의 값은 Light / Dark / System / None(기본값. 기존의 Aero2 테마) 네 가지이며, Application에 설정하면 앱 전체, Window에 설정하면 그 창에만 적용됩니다. 8
<Application x:Class="OrderEntry.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml"
ThemeMode="System">
</Application>
ThemeMode는 Fluent 테마의 사전을 리소스에 로드할 뿐 아니라 「창에 대한 배경 재질과 다크 모드의 적용도 제어한다」고 명시되어 있습니다. 즉 4장의 DWM 특성은 WPF 쪽이 돌봐 줍니다. 또 ThemeMode와 Resources는 동기해 동작하도록 설계되어 있으며, 창은 다크인데 안의 컨트롤은 라이트인 불일치를 피하기 위해서라고 설명되어 있습니다. 10
flowchart TB
accTitle: WPF의 ThemeMode가 하는 일
accDescr: ThemeMode를 System으로 하면 Windows 설정에 맞는 Fluent 테마 사전이 리소스에 로드되고 아울러 창의 다크 모드와 배경 재질의 적용도 제어된다
tm["ThemeMode=System"] --> read["Windows 설정을 읽는다"]
read --> dict["Fluent 사전을 리소스로 로드"]
read --> win["창의 다크화와 배경 재질"]
dict --> sync["Resources와 동기해 불일치를 막는다"]
그림 14: ThemeMode는 Fluent 사전의 로드와 창의 다크화를 함께 제어합니다.
채택 전에 알아 두어야 할 주의점이 둘 있습니다. 첫째, ThemeMode를 코드에서 읽고 쓰는 조작은 실험적 기능이라서 접근하면 WPF0001 오류가 납니다. 억제하면 Application.Current.ThemeMode = ThemeMode.Dark처럼 쓸 수 있지만, API 참조는 .NET 10에서도 [Experimental("WPF0001")] 그대로이며 「앞으로 제거될 가능성이 있다」고 주석합니다. 810 둘째, Fluent 스타일의 지원은 .NET 10에서도 「작업 중」입니다. .NET 10에서 DatePicker·GridSplitter·GroupBox·TextBox 등의 스타일이 추가되고 HighContrast 관련 크래시가 수정되었지만, 9 뒤집어 말하면 .NET 9의 Fluent에는 그것들이 없었다는 뜻입니다. 업무 앱에서 쓰는 컨트롤(특히 DataGrid나 서드파티 제품)이 Fluent에서 깨지지 않는지 채택 판단 전에 실제 장치에서 확인하십시오.
7.2. 기존 테마 그대로 따라가기 ── ResourceDictionary 교체
Fluent를 채택하지 않는(또는 .NET 8 이전·.NET Framework의) WPF에서는 다크 모드의 표준 지원이 없습니다. Win32의 시스템 색은 라이트/다크 설정으로 바뀌지 않으므로 WPF의 SystemColors를 참조하고 있어도 어두워지지 않습니다. 직접 따라가게 하는 구조는 다음과 같습니다.
- 라이트용
Themes/Light.xaml과 다크용Themes/Dark.xaml에 같은 키로 색과 브러시를 정의한다. - XAML에서는
{DynamicResource App.WindowBackgroundBrush}처럼 DynamicResource로 참조한다(StaticResource는 로드 시점에 고정되므로 교체를 따라가지 않습니다). - 5장
SystemThemeWatcher의 통지로MergedDictionaries의 해당 사전을 바꿔 넣는다. 대비 테마 중에는 어느 사전을 넣어도 8.4의 트리거가 시스템 색으로 바꿔 주므로 라이트용을 넣어 둔다.
public static class AppTheme
{
private static readonly Uri Light = new("pack://application:,,,/Themes/Light.xaml");
private static readonly Uri Dark = new("pack://application:,,,/Themes/Dark.xaml");
public static void Apply(ThemeState state)
{
var merged = Application.Current.Resources.MergedDictionaries;
var current = merged.FirstOrDefault(d => d.Source == Light || d.Source == Dark);
// 다크일 때만 다크 사전. 대비 테마 중에는 시스템 색(8.4)에 맡긴다
var next = new ResourceDictionary { Source = state == ThemeState.Dark ? Dark : Light };
if (current is null)
{
merged.Add(next);
}
else
{
merged[merged.IndexOf(current)] = next; // 같은 위치에서 바꿔 넣는다
}
}
}
flowchart TB
accTitle: WPF에서의 리소스 사전 교체
accDescr: 라이트용과 다크용 사전에 같은 키로 색을 정의하고 XAML은 DynamicResource로 참조하며 테마 변경 통지로 MergedDictionaries 안의 사전을 바꿔 넣으면 참조 대상이 갱신된다
notify["테마 변경 통지"] --> swap["MergedDictionaries의 사전을 교체"]
light["Light.xaml(같은 키)"] --> swap
dark["Dark.xaml(같은 키)"] --> swap
swap --> dyn["DynamicResource 참조가 갱신"]
static["StaticResource 참조"] -.-> stale["로드 시점의 값 그대로"]
그림 15: 같은 키의 사전을 바꿔 넣으면 DynamicResource 참조만 따라갑니다.
표준 컨트롤의 템플릿(단추의 배경이나 스크롤 막대의 색)은 기존 테마의 색을 갖고 있으므로, 여기서도 「앱 고유의 면은 다크, 표준 컨트롤은 라이트」가 되는 곳이 남습니다. 필요한 컨트롤마다 스타일을 덮어쓰는 작업량을 어림한 다음 Fluent 채택이나 라이트 고정과 비교하십시오.
7.3. 제목 표시줄
ThemeMode를 쓴다면 WPF가 돌봐 줍니다. 기존 테마 그대로 직접 따라가게 하는 경우에는 4.2에서 보인 OnSourceInitialized에서의 DWM 특성 설정을 쓰고, SystemThemeWatcher의 통지에서도 다시 설정합니다.
8. 대비 테마일 때의 그리기 ── 시스템 색의 짝을 지키기
8.1. 감지와 통지
Win32에서는 SystemParametersInfo에 SPI_GETHIGHCONTRAST를 전달해 HIGHCONTRAST 구조체를 받고, dwFlags의 HCF_HIGHCONTRASTON 비트로 판정합니다. 호출할 때 cbSize를 설정해 두어야 합니다. 1128 Microsoft는 이를 「고대비 여부를 확인하는 유일하게 지원되는 방법」으로 자리매김합니다. 12
bool IsContrastThemeActive()
{
HIGHCONTRASTW hc{};
hc.cbSize = sizeof(hc);
if (!::SystemParametersInfoW(SPI_GETHIGHCONTRAST, sizeof(hc), &hc, 0))
{
// 실패를 기본값으로 덮어 가리지 않는다. 원인을 알 수 있도록 오류 코드와 함께 드러낸다
throw std::system_error(::GetLastError(), std::system_category(),
"SystemParametersInfo(SPI_GETHIGHCONTRAST)");
}
return (hc.dwFlags & HCF_HIGHCONTRASTON) != 0;
}
각 프레임워크에는 이 호출을 감싼 속성이 있습니다.
| 환경 | 판정 | 변경의 통지 |
|---|---|---|
| Win32 / MFC | SPI_GETHIGHCONTRAST + HCF_HIGHCONTRASTON |
WM_SYSCOLORCHANGE, WM_THEMECHANGED |
| WinForms | SystemInformation.HighContrast |
SystemEvents.UserPreferenceChanged |
| WPF | SystemParameters.HighContrast(SPI_GETHIGHCONTRAST에 대응) |
SystemParameters.StaticPropertyChanged |
| WinUI 3 | ThemeSettings.HighContrast(Microsoft.UI.System) |
ThemeSettings.Changed |
WinForms의 접근성 안내서는 시작할 때 HighContrast를 확인하고 UserPreferenceChanged로 변화에 대응하도록 요구합니다. 13 WPF의 SystemParameters.HighContrast는 SPI_GETHIGHCONTRAST와 HCF_HIGHCONTRASTON에 대응되어 있고, 29 정적 속성의 변화는 StaticPropertyChanged로 통지됩니다. 30 WinUI 3의 ThemeSettings는 CreateForWindowId로 창에 묶어 만들고 Changed 이벤트를 구독하지만, 객체에 대한 참조를 계속 유지하지 않으면 이벤트가 멈춘다는 점에 주의가 필요합니다. 31
flowchart TB
accTitle: 대비 테마의 감지 경로
accDescr: Win32의 SPI_GETHIGHCONTRAST가 유일하게 지원되는 판정 방법이고 WinForms의 SystemInformation.HighContrast, WPF의 SystemParameters.HighContrast, WinUI 3의 ThemeSettings.HighContrast는 각 프레임워크에서 이를 감싼 형태로 제공된다
spi["SPI_GETHIGHCONTRAST(유일한 판정 방법)"] --> wf["WinForms SystemInformation"]
spi --> wpf["WPF SystemParameters"]
spi --> winui["WinUI 3 ThemeSettings"]
spi --> win32["Win32 직접 호출"]
그림 16: 판정의 뿌리는 하나의 Win32 API이고, 각 프레임워크는 그것을 감싼 속성을 갖습니다.
8.2. 그리기의 원칙 ── 전경과 배경의 짝
Microsoft의 「High contrast parameter」는 고대비가 켜져 있을 때 앱이 해야 할 일을 셋 듭니다. 11
- 모든 색을 전경색과 배경색의 한 쌍에 매핑한다.
GetSysColor로COLOR_WINDOWTEXT와COLOR_WINDOW의 쌍, 또는COLOR_BTNTEXT와COLOR_BTNFACE의 쌍을 쓴다. - 글자 뒤에 표시하는 비트맵 이미지를 없앤다. 고대비를 필요로 하는 사용자에게 시각적인 방해가 된다.
- 여러 색으로 그리는 이미지는 글자용 전경색과 배경색으로 그린다.
「짝」이 핵심입니다. Windows 8 이후의 안내서는 COLOR_HIGHLIGHTTEXT는 COLOR_HIGHLIGHT의 배경과, COLOR_WINDOWTEXT는 COLOR_WINDOW의 배경과 조합하는 전제로 만들어졌다고 설명하며, 글자색을 하드코딩하지 말 것, 사용자가 색을 사용자 지정하므로 적용 중인 테마에 의존하지 않는 UI로 할 것을 요구합니다. 12 「Aero에서는 글자가 항상 검은색이고 선택 색은 연한 파랑이지만, High Contrast Black에서는 선택 색이 검은색이 된다. 검은 글자를 전제로 시스템의 선택 색을 쓰면 검은 바탕에 검은 글자가 된다」는 같은 안내서의 예는 앞머리의 「상태 표시가 사라졌다」 바로 그것입니다.
Windows 11의 대비 테마 안내서는 이 대응 관계를 표로 정리하고 있습니다. 1
| 용도 | 전경 | 배경 |
|---|---|---|
| 제목·본문·목록·테두리·조작할 수 없는 UI | SystemColorWindowText |
SystemColorWindow |
| 하이퍼링크 | SystemColorHotlight |
SystemColorWindow |
| 비활성·활성이 아닌 UI | SystemColorGrayText |
SystemColorWindow |
| 선택·마우스 오버·누름·진행 중 | SystemColorHighlightText |
SystemColorHighlight |
| 단추 등 조작할 수 있는 UI | SystemColorButtonText |
SystemColorButtonFace |
그리고 「하면 안 되는 것」도 명시되어 있습니다. GrayText를 보조 문구나 힌트 문구에 쓰지 말 것(비활성 상태 전용), Hotlight를 하이퍼링크 이외에 쓰지 말 것, 호환되지 않는 전경/배경을 섞지 말 것, 겉모습만 보고 색을 고르지 말 것(사용자는 실제로 색을 바꿉니다). 페이지·창·팝업의 배경은 SystemColorWindow를 기준으로 하고 인접한 면의 배경이 같은 색이 되므로 필요한 경계만 대비 테마 전용 테두리로 나눈다(플라이아웃이나 대화 상자는 2px 권장)는 설계 지침도 있습니다. 1
flowchart TB
accTitle: 짝을 깨뜨렸을 때 읽을 수 없게 되는 흐름
accDescr: 글자는 검은색이라고 단정하고 선택 배경만 시스템의 선택 색으로 하면 High Contrast Black에서는 선택 색이 검은색이 되어 검은 바탕에 검은 글자가 된다. 전경과 배경을 짝으로 가져오면 사용자가 색을 편집해도 읽을 수 있다
assume["글자는 검은색이라고 단정"] --> hl["선택 배경만 시스템의 선택 색"]
hl --> black["High Contrast Black에서는 선택 색이 검은색"]
black --> broken["검은 바탕에 검은 글자"]
pair["전경과 배경을 짝으로 가져온다"] --> ok["사용자가 색을 편집해도 읽을 수 있다"]
edit["사용자가 색을 편집"] -.-> assume
그림 17: 한쪽만 시스템 색으로 하면 검은 바탕에 검은 글자가 될 수 있지만, 짝으로 가져오면 색이 편집되어도 읽을 수 있습니다.
flowchart TB
accTitle: 대비 테마일 때의 그리기 판단
accDescr: 대비 테마가 켜져 있으면 색을 시스템 색의 짝에 매핑하고 글자 뒤의 이미지를 없애며 여러 색의 이미지를 전경과 배경 두 색으로 그리고 하드코딩한 색을 쓰지 않는다
on["대비 테마 켜짐"] --> map["색을 짝에 매핑"]
on --> img["글자 뒤의 이미지를 없앤다"]
on --> multi["여러 색의 도형을 두 색으로 그린다"]
on --> nohard["하드코딩 색을 쓰지 않는다"]
그림 18: 대비 테마가 켜져 있을 때의 그리기는 매핑·생략·두 색화·하드코딩 탈피의 네 가지로 모입니다.
8.3. WinForms에서의 구현
WinForms의 표준 컨트롤은 ForeColor / BackColor를 기본값 그대로 두면 시스템 색을 따릅니다. 고유의 색을 붙인 곳과 직접 그리기만 판정에 따라 전환합니다. 안내서의 예는 평소에는 파란 바탕에 노란 글자인 레이블을 고대비일 때 SystemColors.Window / SystemColors.WindowText로 되돌리는 것입니다. 13 앞서 본 팔레트 구조에 대비 테마용 분기를 더한 형태가 됩니다.
using Microsoft.Win32;
public partial class OrderForm : Form
{
private readonly SynchronizationContext _ui;
public OrderForm()
{
InitializeComponent();
// 컨트롤 생성 후이므로 WindowsFormsSynchronizationContext가 들어 있다
_ui = SynchronizationContext.Current
?? throw new InvalidOperationException("UI 스레드에서 생성하십시오.");
ApplyColorScheme();
SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;
}
private void ApplyColorScheme()
{
if (SystemInformation.HighContrast)
{
// 짝을 지켜 시스템 색에 전면적으로 맡기고 글자 뒤의 이미지를 뺀다
statusLabel.BackColor = SystemColors.Window;
statusLabel.ForeColor = SystemColors.WindowText;
headerPanel.BackgroundImage = null;
}
else
{
var p = AppPalette.Current; // 라이트/다크 팔레트(5장)
statusLabel.BackColor = p.PanelBackground;
statusLabel.ForeColor = p.PanelForeground;
headerPanel.BackgroundImage = Properties.Resources.HeaderPattern;
}
}
private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
{
// 이 이벤트도 UI 스레드로 온다는 보장이 없다. UI로 되돌린 뒤 범주로 거르지 말고 다시 판정
_ui.Post(_ =>
{
if (IsDisposed) return;
ApplyColorScheme();
}, null);
}
// 정적 이벤트이므로 떼지 않으면 폼이 누수된다. 닫히지 않고 폐기되는 경로도
// 지나가는 Dispose(bool)에서 뗀다(디자이너가 생성한 Dispose(bool)가 있으면 거기에 쓴다)
protected override void Dispose(bool disposing)
{
if (disposing)
{
SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged;
}
base.Dispose(disposing);
}
}
OnPaint에서의 직접 그리기는 SystemBrushes.Window / SystemPens.WindowText처럼 짝을 지킨 시스템 브러시로 그리고, 상태를 나타내는 색깔 있는 원 같은 여러 색의 도형은 전경색의 테두리와 글자(「가동」 「정지」)로 바꿉니다. 색에만 의존하지 않는 표시는 지난번 글에서 다룬 성공 기준 1.4.1의 이야기와 같습니다.
flowchart TB
accTitle: WinForms의 배색 적용 분기
accDescr: 시작할 때와 UserPreferenceChanged가 올 때마다 ApplyColorScheme을 호출하고 SystemInformation.HighContrast가 참이면 시스템 색의 짝에 맡겨 배경 이미지를 빼고 거짓이면 라이트/다크 팔레트에서 색을 가져온다
start["시작할 때 / UserPreferenceChanged"] --> apply["ApplyColorScheme"]
apply --> q{"SystemInformation.HighContrast?"}
q -->|참| sys["SystemColors의 짝에 맡긴다"]
sys --> noimg["배경 이미지를 뺀다"]
q -->|거짓| pal["라이트/다크 팔레트에서 가져온다"]
그림 19: WinForms에서는 시작할 때와 통지가 올 때마다 같은 처리를 호출하고, 대비 테마라면 시스템 색에 맡깁니다.
8.4. WPF에서의 구현
WPF의 SystemColors는 WindowBrushKey 같은 리소스 키를 DynamicResource로 참조하면 브러시가 바뀌었을 때 자동으로 갱신됩니다(WindowBrush를 직접 쓰는 정적 참조는 갱신되지 않습니다). 32 대비 테마 중에만 겉모습을 전환하려면 SystemParameters.HighContrast의 값을 DataTrigger에서 참조합니다. 다만 SystemParameters.HighContrast는 정적 속성이므로 그대로는 바인딩의 살아 있는 참조 원본이 되지 못합니다. StaticPropertyChanged를 구독해 값을 보관하고 INotifyPropertyChanged로 통지하는 작은 프록시를 하나 마련해, Source에 그 인스턴스를 지정해 바인딩합니다. 30
public sealed class ThemeSettings : INotifyPropertyChanged
{
public static ThemeSettings Instance { get; } = new();
public bool IsHighContrast { get; private set; } = SystemParameters.HighContrast;
public event PropertyChangedEventHandler? PropertyChanged;
private ThemeSettings()
{
// SystemParameters의 정적 속성이 바뀌면(SPI_GETHIGHCONTRAST를 다시 가져오면) 통지된다
SystemParameters.StaticPropertyChanged += (_, e) =>
{
if (!string.IsNullOrEmpty(e.PropertyName)
&& e.PropertyName != nameof(SystemParameters.HighContrast)) return;
IsHighContrast = SystemParameters.HighContrast;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsHighContrast)));
};
}
}
<!-- xmlns:local="clr-namespace:OrderEntry"를 선언해 둘 것 -->
<Style x:Key="CardStyle" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource App.CardBackgroundBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource App.CardBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Source={x:Static local:ThemeSettings.Instance}, Path=IsHighContrast}"
Value="True">
<!-- 짝을 지킨다: 배경은 Window, 테두리와 글자는 WindowText. 경계는 두껍게 -->
<Setter Property="Background"
Value="{DynamicResource {x:Static SystemColors.WindowBrushKey}}"/>
<Setter Property="BorderBrush"
Value="{DynamicResource {x:Static SystemColors.WindowTextBrushKey}}"/>
<!-- 안의 글자에도 상속시킨다. Foreground를 명시한 자식 요소는 상속을 끊으므로 주의 -->
<Setter Property="TextElement.Foreground"
Value="{DynamicResource {x:Static SystemColors.WindowTextBrushKey}}"/>
<Setter Property="BorderThickness" Value="2"/>
</DataTrigger>
</Style.Triggers>
</Style>
flowchart TB
accTitle: WPF의 시스템 색 참조와 대비 테마의 트리거
accDescr: SystemColors의 리소스 키를 DynamicResource로 참조하면 브러시 변경에 자동으로 따라가고 StaticPropertyChanged를 구독하는 프록시의 IsHighContrast에 바인딩한 트리거가 실행 중의 전환에 반응해 짝을 지킨 색으로 전환한다. WindowBrush의 직접 참조는 갱신되지 않는다
key["WindowBrushKey를 DynamicResource로 참조"] --> auto["브러시 변경에 자동 추종"]
spc["StaticPropertyChanged"] --> proxy["프록시의 IsHighContrast"]
proxy --> trig["DataTrigger가 반응"]
trig --> pair["짝을 지킨 색으로 전환"]
direct["WindowBrush를 직접 참조"] -.-> stale["갱신되지 않는다"]
그림 20: WPF는 리소스 키의 동적 참조와, 정적 속성의 변화를 중계하는 프록시에 대한 바인딩으로 실행 중의 전환을 따라갑니다.
Fluent 테마를 쓰는 경우에도 .NET 10에서 HighContrast 관련 크래시 수정이 들어간 점을 감안해, 9 대비 테마에서의 동작 확인을 채택 조건에 포함하십시오.
8.5. WinUI 3에서의 구현
WinUI 3은 표준 컨트롤이 라이트/다크/대비 테마를 처음부터 따라가고, 앱 고유의 색은 ResourceDictionary.ThemeDictionaries에 Default(다크)·Light·HighContrast 키로 정의합니다. HighContrast에서는 색을 하드코딩하지 말고 SystemColorWindowColor 같은 동적인 시스템 색을 ThemeResource로 참조합니다. Light/Dark를 갖는 사용자 지정 컨트롤에는 반드시 HighContrast도 마련하고, HighContrast는 다른 이름 있는 고대비 테마를 찾지 못했을 때의 대체 키가 됩니다. 331
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Default">
<SolidColorBrush x:Key="App.CardBackgroundBrush" Color="#2B2B2B"/>
</ResourceDictionary>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="App.CardBackgroundBrush" Color="#F3F3F3"/>
</ResourceDictionary>
<ResourceDictionary x:Key="HighContrast">
<SolidColorBrush x:Key="App.CardBackgroundBrush"
Color="{ThemeResource SystemColorWindowColor}"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
또 하나, WinUI에는 HighContrastAdjustment라는 구조가 있고 기본으로 켜져 있습니다. 이는 대비를 지키기 위해 흰 글자와 검은 강조 배경을 강제하는 것으로, 시스템 색을 올바르게 쓴 테마 사전을 마련했다면 None으로 설정해 자신의 스타일이 적용되게 하는 것이 안내서의 권장입니다. 1
flowchart TB
accTitle: WinUI의 ThemeDictionaries 해석
accDescr: 현재 테마에 따라 Default(다크), Light, HighContrast 중 하나의 사전이 선택되고 HighContrast에서는 동적인 시스템 색을 ThemeResource로 참조한다. HighContrast는 이름 있는 고대비 테마가 없을 때의 대체 키가 된다
theme{"현재 테마는?"}
theme -->|다크| def["Default 사전"]
theme -->|라이트| light["Light 사전"]
theme -->|대비 테마| hc["HighContrast 사전"]
hc --> sysc["SystemColor 계열을 ThemeResource로 참조"]
hc -.-> fb["이름 있는 테마가 없을 때의 대체"]
그림 21: WinUI에서는 테마별 사전이 자동으로 선택되고, HighContrast 사전은 시스템 색을 참조합니다.
9. 다크 모드 대응은 접근성 대응을 대신하지 못한다
다크 모드 대응을 「접근성 대응을 했다」고 보고하는 것은 잘못입니다. 둘의 관계는 다음과 같이 정리할 수 있습니다.
- 다크 팔레트에도 대비율 기준은 똑같이 적용됩니다. WCAG의 성공 기준 1.4.3은 텍스트에 4.5:1, 큰 문자에 3:1을 요구하며 이는 배경이 어두워도 달라지지 않습니다. 14 어두운 회색 배경에 중간 회색 글자를 두는 다크 디자인은 라이트의 「흰 바탕에 연한 회색」과 같은 문제를 안고 있습니다.
- 순수한 검정과 흰색을 피하는 것이 Windows 11의 설계입니다. Microsoft의 모범 사례는 Windows 11이 순백과 순흑을 피해 눈에 편한 색조로 바꿨다고 설명합니다. 34 반대로 다크 모드에서 배경을
#000000으로 하면 밝은 글자와의 사이에서 너무 강한 대비 때문에 생기는 번짐(헐레이션)을 호소하는 사람이 있습니다. - 색각 다양성에 대한 배려는 테마와 무관하게 필요합니다. Microsoft의 색 가이드는 색을 주요 전달 수단으로 삼지 말고 시각적 보강으로 쓸 것, 빨강과 초록의 조합을 유일한 구분 수단으로 삼지 말 것을 요구합니다. 3515
- 대비 테마는 다크 모드와 독립된 요구입니다. 2장에서 본 대로 대비 테마가 켜져 있는 동안에는 다크 모드를 쓸 수 없으므로, 다크 대응이 아무리 완벽해도 대비 테마 이용자에게는 닿지 않습니다.
flowchart TB
accTitle: 다크 모드와 접근성의 관계
accDescr: 다크 모드 대응은 겉모습의 취향이나 환경에 대한 배려이고 대비율, 색에만 의존하지 않는 전달 방식, 대비 테마 대응이라는 접근성 요건은 테마와 관계없이 별도로 충족해야 한다
dark["다크 모드 대응"] --> pref["취향과 환경에 대한 배려"]
a11y["접근성"] --> ratio["대비율 4.5:1"]
a11y --> sole["색에만 의존하지 않는다"]
a11y --> ct["대비 테마 대응"]
dark -.->|대신하지 못한다| a11y
그림 22: 다크 모드 대응은 취향과 환경에 대한 배려이고, 접근성 요건은 별도로 충족합니다.
한편 5장의 「색을 한곳에 모으기」 작업은 양쪽 모두의 토대가 됩니다. 팔레트가 한곳에 있으면 라이트와 다크 각각에서 대비율을 실측할 대상을 열거할 수 있고, 대비 테마용 분기도 같은 곳에 쓸 수 있습니다. 다크 대응을 계기로 팔레트를 모으고 그 김에 대비율과 대비 테마를 점검하는 것이 가장 투자 효율이 좋은 순서입니다.
10. 방침을 정하는 법 ── 앱 종류별 권장
| 앱의 종류 | 권장하는 방침 |
|---|---|
| 신규 WinForms(.NET 10) | SetColorMode(System)을 쓴다. 직접 그리기는 SystemColors 기반으로 하고, 공용 컨트롤을 포함한 사용자 지정 컨트롤은 ApplyThemingImplicitly로 옵트인 |
| 신규 WPF(.NET 9/10) | ThemeMode="System"을 사용 컨트롤의 표시 확인과 대비 테마의 동작 확인을 통과시킨 다음 채택. 어렵다면 기존 테마+사전 교체 |
| 기존 WinForms/WPF(.NET Framework 4.8, .NET 8 이전) | 「라이트 고정」을 명시하고 DWM 특성은 기본값(FALSE) 그대로. 대비 테마 대응은 반드시 하고 .NET 10 이행 때 다크 대응으로 |
| WinUI 3 | 기본으로 시스템을 따라간다. 고유의 색은 ThemeDictionaries에 HighContrast를 포함해 정의하고 HighContrastAdjustment를 None으로 |
| Win32 / MFC | DWM 특성+자체 팔레트+WM_THEMECHANGED / WM_SYSCOLORCHANGE에서의 재계산. 공식 가이드가 다루는 것은 감지와 제목 표시줄까지이고 공용 컨트롤의 다시 칠하기는 범위 밖 |
flowchart TB
accTitle: 기존 자산이 다크 대응에 이르는 경로
accDescr: 기존 자산은 먼저 라이트 고정을 명시하고 대비 테마 대응을 반드시 마치며 팔레트를 모아 준비한 다음 .NET 10으로 이행해 SetColorMode나 ThemeMode로 전환한다. 어중간한 다크 대응은 라이트 고정보다 나쁜 경험이 되므로 지나가지 않는다
now["라이트 고정을 명시(현재)"] --> ct["대비 테마 대응(필수)"]
ct --> pal["팔레트의 집약(준비)"]
pal --> mig[".NET 10으로 이행"]
mig --> dark["SetColorMode / ThemeMode로 전환"]
now -.->|지나가지 않는다| half["어중간한 다크 대응"]
half -.-> worse["라이트 고정보다 나쁜 경험"]
그림 23: 기존 자산은 라이트 고정에서 시작해 대비 테마 대응과 팔레트 집약을 거쳐 .NET 10 이행 때 다크 대응으로 나아갑니다.
「라이트 고정」은 패배가 아닙니다. Windows의 기본 동작 그 자체이며 문서화된 동작입니다. 어중간한 다크 대응으로 「제목 표시줄만 검다」 「스크롤 막대만 희다」인 앱을 내놓는 것보다, 라이트로 일관된 편이 이용자에게 훨씬 낫습니다. 다만 대비 테마 대응만은 「고정」할 수 없습니다. 이는 지난번 글에서 다룬 합리적 배려의 영역이며, 테마의 취향이 아니라 쓸 수 있느냐 없느냐의 문제이기 때문입니다.
11. 검증 체크리스트
대응이 끝나면 다음 순서로 실제 장치에서 확인합니다. 모두 설정 화면에서 수십 초면 전환할 수 있습니다.
- 라이트/다크를 실행 중에 전환한다. 「설정 > 개인 설정 > 색」에서 모드를 바꿔, 제목 표시줄과 클라이언트 영역 양쪽이 따라가는지, 아니면 「다음 시작 때 반영」이라는 사양대로인지(WinForms의
SetColorMode는 따라가지 않습니다)를 확인한다. - 핸들 재생성을 일으킨다. WinForms라면
ShowInTaskbar를 실행 중에 전환해 제목 표시줄의 특성이 유지되는지 확인한다. - 대비 테마를 네 가지 모두 시험한다. 왼쪽 Alt+왼쪽 Shift+PrintScreen으로 전환해, Aquatic·Desert·Dusk·Night sky 각각에서 글자·테두리·선택된 행·비활성 항목·링크를 읽을 수 있는지 확인한다. 1
- 대비 테마의 색을 편집한다. 사용자는 실제로 색을 바꿉니다. 배경을 극단적인 색으로 편집해 하드코딩이 남아 있지 않은지 들춰낸다.
- 로그를 본다. 지원 OS에서
DwmSetWindowAttribute나SystemParametersInfo가 실패했을 때 기록되고 있는지, 빌드 22000 미만의 Windows 10에서는 DWM 특성을 호출하지 않고 라이트 그대로 시작하는지를 확인한다. - 대비율을 실측한다. 라이트와 다크 각각에서 팔레트의 글자색과 배경색 조합을 4.5:1로 점검한다. 14
flowchart TB
accTitle: 테마 대응의 검증 절차
accDescr: 실행 중의 라이트/다크 전환, 핸들 재생성, 네 가지 대비 테마, 테마의 색 편집, 실패 로그 확인, 대비율 실측 순으로 실제 장치에서 확인한다
s1["실행 중에 라이트/다크 전환"] --> s2["핸들 재생성"]
s2 --> s3["대비 테마 네 가지"]
s3 --> s4["테마의 색을 편집"]
s4 --> s5["실패 로그 확인"]
s5 --> s6["대비율 실측"]
그림 24: 검증은 설정 전환에서 시작해 로그와 대비율 확인으로 마무리합니다.
12. 정리
- Windows의 테마는 「라이트/다크」와 「대비 테마」 두 축이며, 후자가 켜져 있는 동안에는 다크 모드를 쓸 수 없습니다. 판정은 대비 테마가 먼저입니다.
- 기존 앱의 제목 표시줄이 흰 것은 호환성을 위한 기본값이며,
DwmSetWindowAttribute의DWMWA_USE_IMMERSIVE_DARK_MODE(값 20, Windows 11 빌드 22000 이상)에 TRUE를 전달하면 시스템이 다크일 때 다크로 그려집니다. HWND 생성 때마다 설정하고 실패는 로그에 남깁니다. - 현재 모드는
UISettings.GetColorValue의 전경색 밝기로 판정하고,ColorValuesChanged로 변경을 알아채고, UI 스레드로 되돌려 다시 칠합니다. 색은 한곳에 모아 둡니다. - WinForms는 .NET 9/10의
Application.SetColorMode(SystemColorMode.System). Windows 11 전용, 대비 테마 중에는 무효, 실행 중의 변경을 따라가지 않는다는 세 제약과 사용자 지정 컨트롤의ApplyThemingImplicitly를 짚어 둡니다. - WPF는 .NET 9/10의
ThemeMode="System". 코드에서의 조작은 실험적이고 Fluent는 작업 중이므로 평가한 다음 채택합니다. 기존 테마라면 사전 교체+DynamicResource입니다. - 대비 테마일 때는
SPI_GETHIGHCONTRAST계열로 판정하고, 색을 시스템 색의 짝에 매핑하고, 글자 뒤의 이미지를 없애고, 여러 색의 도형을 두 색으로 그립니다. GrayText는 비활성 상태, Hotlight는 링크 전용입니다. - 다크 대응은 접근성 대응을 대신하지 못합니다. 다크에서도 4.5:1, 색에만 의존하지 않기, 대비 테마 대응은 별도로 필요합니다.
- 기존 자산은 「라이트 고정」의 명시가 현실적인 해법이며, 대비 테마 대응만은 고정할 수 없습니다.
첫걸음으로 권하고 싶은 것은 주력 화면을 하나 골라 먼저 왼쪽 Alt+왼쪽 Shift+PrintScreen으로 대비 테마를 켜서 바라보고, 같은 키로 되돌린 다음 Windows의 색 설정을 다크로 전환해 보는 것입니다(대비 테마가 켜져 있는 동안에는 다크 모드를 쓸 수 없으므로 둘은 따로따로 시험합니다). 몇 분이면 자사 앱이 「색을 어디에 두고 있는지」가 보이기 시작합니다.
관련 글
- Windows 앱 접근성 입문 ── UI Automation과 합리적 배려 의무화에 대비하기
- WinRT는 COM이다 ── IInspectable·.winmd·언어 프로젝션, 그리고 WinUI가 지금도 이진 계약 위에 올라타 있는 이유
- C#에서 Win32 API를 안전하게 호출하기 ── P/Invoke 실무 가이드(DllImport / LibraryImport / CsWin32)
- WPF/WinForms의 async와 UI 스레드를 한 장으로 정리
- WinForms의 고DPI 대응 ── 4K 모니터에서 흐려지거나 레이아웃이 깨지는 원인과 현실적인 대처
- WPF의 고DPI 대응 ── 「DPI에 강해야 하는데」 흐려지고 번지는 원인과 대처
- WinForms/WPF/WinUI 고르는 법 - 실무 판단표
관련 상담 영역
합동회사 코무라소프트에서는 WinForms/WPF 업무 앱의 다크 모드 대응(팔레트의 집약, .NET 9/10의 SetColorMode / ThemeMode로의 이행 평가, DWM 특성의 도입), 대비 테마에서의 표시 깨짐 진단과 수정, Win32/MFC 자산의 테마 추종 상담을 다루고 있습니다. 「다크 모드로 바꿨더니 직원에게서 불만이 왔다」는 단계부터도 괜찮습니다.
참고 링크
-
Microsoft Learn, Contrast themes. 대비 테마가 대체로 7:1 이상의 제약된 팔레트를 쓰며 라이트/다크 테마와 혼동해서는 안 되는 것, Aquatic·Desert·Dusk·Night sky 네 가지와 색의 편집, 왼쪽 Alt+왼쪽 Shift+PrintScreen에서의 전환, SystemColor 계열 리소스의 전경/배경 짝과 용도, GrayText를 비활성 상태 전용으로 하고 Hotlight를 링크 전용으로 하는 것, 하드코딩 색으로 인한 깨짐, 경계의 테두리, ThemeDictionaries의 HighContrast, HighContrastAdjustment를 None으로 하는 것, Microsoft.UI.System.ThemeSettings에 의한 감지에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Microsoft Learn, Application.SetColorMode(SystemColorMode) Method. UI 요소를 만들기 전에 호출할 것, System을 지정해도 시스템 설정이 바뀌었을 때 앱이 자동으로 적응하지 않는 것, 다크 색 모드가 Windows 11 이상에서만 쓸 수 있고 고대비 모드일 때는 쓸 수 없다는 것에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Support Dark and Light themes in Win32 apps. 색 모드에서의 전경과 배경의 정의, Windows가 앱의 다크 지원 가능 여부를 알지 못해 호환성을 위해 기본적으로 라이트 제목 표시줄을 주는 것, UISettings.GetColorValue로 전경색을 얻어 지각 밝기로 명암을 판정해 다크 모드를 감지하는 절차, ColorValuesChanged에 의한 추적, DwmSetWindowAttribute와 DWMWA_USE_IMMERSIVE_DARK_MODE(값 20)에 의한 다크 제목 표시줄 활성화, 표면 전체가 다크를 따라야 하는 것에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
Microsoft Learn, DWMWINDOWATTRIBUTE enumeration (dwmapi.h). DWMWA_USE_IMMERSIVE_DARK_MODE가 시스템의 다크 설정이 켜져 있을 때 프레임을 다크로 그리는 것을 허용하고 모든 창이 기본적으로 라이트가 되는 것, DWMWA_BORDER_COLOR·DWMWA_CAPTION_COLOR·DWMWA_TEXT_COLOR의 COLORREF 지정과 DWMWA_COLOR_DEFAULT에 의한 기본값 복귀, Windows 11 빌드 22000 이상의 지원, DWMWA_SYSTEMBACKDROP_TYPE의 빌드 22621 이상 지원에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, UISettings.ColorValuesChanged Event. 색 값이 바뀌었을 때 발생하는 이벤트에 대해. ↩ ↩2
-
Microsoft Learn, What’s new in Windows Forms for .NET 9. 실험적인 다크 모드의 예비 지원, 색 모드 변경 시 SystemColors가 대응해 바뀌는 것, SystemColorMode의 Classic·System·Dark 세 값, Application.SetColorMode를 시작 코드에서 호출하는 것, WFO5001의 억제에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, What’s new in Windows Forms for .NET 10. 다크 모드의 완전 통합과 SetColorMode가 실험적이지 않게 된 것, 직접 그리는 컨트롤 안의 Win32 공용 컨트롤이 옵트인하지 않으면 라이트로 남는 것, CreateParams 재정의 안에서 base.CreateParams보다 앞에 SetStyle(ControlStyles.ApplyThemingImplicitly)을 호출해야 하고 생성자에서는 늦다는 것에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, What’s new in WPF for .NET 9. 라이트/다크와 강조색을 지원하는 Fluent 테마, ThemeMode의 Light·Dark·System·None 네 값과 Application/Window에 대한 설정, 리소스 사전에 의한 적용, 코드에서의 ThemeMode 설정이 실험적이고 WPF0001의 억제가 필요한 것에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, What’s new in WPF for .NET 10. Fluent UI 스타일 지원이 아직 작업 중인 것, DatePicker·GridSplitter·GridView·GroupBox·Hyperlink·Label·NavigationWindow·RichTextBox·TextBox의 Fluent 스타일 추가, HighContrast 관련 크래시 수정에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Application.ThemeMode Property. Fluent 테마를 라이트·다크·시스템 중 어느 모드로 로드할지 제어하고 창에 대한 배경 재질과 다크 모드의 적용도 제어하는 것, ThemeMode와 Resources가 동기해 불일치를 피하도록 설계된 것, Experimental(“WPF0001”) 특성이 붙어 있고 앞으로 제거될 가능성이 있는 것에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, High contrast parameter. 초기화 시와 WM_SYSCOLORCHANGE 처리 시에 SPI_GETHIGHCONTRAST로 HIGHCONTRAST 구조체를 가져와 HCF_HIGHCONTRASTON을 확인하는 것, 켜져 있을 때는 모든 색을 COLOR_WINDOWTEXT와 COLOR_WINDOW 또는 COLOR_BTNTEXT와 COLOR_BTNFACE의 한 쌍에 매핑하고 글자 뒤의 비트맵 이미지를 없애며 여러 색의 이미지를 전경색과 배경색으로 그리는 것에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, High-contrast mode. Aero에서는 검은 글자와 연한 파랑 선택 색이지만 High Contrast Black에서는 선택 색이 검은색이 되어 검은 바탕에 검은 글자가 될 수 있는 것, COLOR_HIGHLIGHTTEXT는 COLOR_HIGHLIGHT와, COLOR_WINDOWTEXT는 COLOR_WINDOW와 조합하는 전제인 것, 글자색을 하드코딩하지 말 것, 사용자가 색을 사용자 지정하므로 테마에 의존하지 않는 UI로 할 것, WM_THEMECHANGED에서 색을 다시 계산할 것, SPI_GETHIGHCONTRAST가 유일하게 지원되는 확인 방법인 것에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Walkthrough: Creating an Accessible Windows-based Application. SystemInformation.HighContrast에 의한 판정, 켜져 있을 때 시스템의 배색을 쓰고 색으로 전달하는 정보에 시각적 단서를 덧붙이며 글자 뒤의 이미지를 없애는 것, 시작할 때의 확인과 UserPreferenceChanged 이벤트에 대한 추종, SystemColors에 의한 레이블 배색 전환의 예에 대해. ↩ ↩2 ↩3
-
W3C / 웹 접근성 기반 위원회(WAIC) 번역, Web Content Accessibility Guidelines (WCAG) 2.1 일본어 번역. 성공 기준 1.4.3(대비(최소))의 텍스트 4.5:1·큰 문자 3:1과 성공 기준 1.4.1(색의 사용)에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Color in Windows. Windows가 라이트와 다크 두 색 모드를 갖는 것, 강조색과 테마의 선택이 사용자의 체험 전체에 반영되는 것, 대비의 확보와 색각 다양성에 대한 배려에 대해. ↩ ↩2
-
Microsoft Learn, Reference for Windows 11 and Windows 10 settings. HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize 아래의 AppsUseLightTheme와 SystemUsesLightTheme가 앱과 Windows의 라이트/다크를 나타내는 DWORD 값이라는 것에 대해. ↩
-
Microsoft Learn, Theming in Windows apps. RequestedTheme를 없애면 시스템 설정을 따르는 것, 사용자가 고대비 테마를 고르면 시스템이 RequestedTheme를 덮어쓰는 것, 사용자 지정 템플릿에서는 색을 하드코딩하지 말고 테마 브러시를 쓰는 것에 대해. ↩
-
Microsoft Learn, DwmSetWindowAttribute function (dwmapi.h). 창의 비클라이언트 영역의 DWM 그리기 특성을 설정하는 함수와 Windows Vista 이후에서 쓸 수 있다는 것에 대해. ↩
-
Microsoft Learn, Retrieve a window handle (HWND). WPF의 WindowInteropHelper에서 Handle을 얻는 방법에 대해. ↩
-
Microsoft Learn, DWM_SYSTEMBACKDROP_TYPE enumeration (dwmapi.h). DWMSBT_MAINWINDOW가 Windows 11에서는 Mica, DWMSBT_TRANSIENTWINDOW가 Acrylic에 해당하고 재질의 효과가 앞으로의 Windows에서 바뀔 수 있다는 것, Windows 11 빌드 22621 이상의 지원에 대해. ↩
-
Microsoft Learn, UISettings.GetColorValue(UIColorType) Method. 지정한 UIColorType의 색 값을 반환하는 메서드에 대해. ↩
-
Microsoft Learn, WM_SETTINGCHANGE message. SystemParametersInfo가 시스템 전체의 설정을 변경했을 때나 정책 설정이 바뀌었을 때 모든 최상위 창으로 보내지는 메시지에 대해. ↩
-
Microsoft Learn, SystemEvents.UserPreferenceChanged Event. 사용자 설정이 변경되었을 때 발생하는 정적 이벤트와 처리기를 떼지 않으면 메모리 누수가 되는 것에 대해. ↩
-
Microsoft Learn, WM_THEMECHANGED message. 테마의 활성화·비활성화·전환 후에 모든 창으로 브로드캐스트되고 기존 테마 핸들이 무효가 되므로 다시 열어야 하는 것에 대해. ↩
-
Microsoft Learn, WM_SYSCOLORCHANGE message. 시스템 색 설정 변경 시 모든 최상위 창으로 보내지고 시스템 색을 쓰는 브러시는 다시 만들며 공용 컨트롤로 전달해야 하는 것에 대해. ↩
-
Microsoft Learn, Compiler Error WFO5001. .NET 9에서 SetColorMode와 SystemColorMode가 평가 목적의 실험적 기능으로 보호되고 .NET 10 이후에는 이 오류가 적용되지 않는 것에 대해. ↩
-
Microsoft Learn, SystemColors.UseAlternativeColorSet Property. true로 하면 시스템의 KnownColor가 대체 색 집합(현재는 다크 모드판)을 반환하는 것, SYSLIB5002의 실험적 기능인 것, Windows에서 고대비 테마가 켜져 있을 때는 시스템의 KnownColor가 항상 현재 Windows의 색을 반환하는 것에 대해. ↩
-
Microsoft Learn, HIGHCONTRASTW structure (winuser.h). dwFlags의 HCF_HIGHCONTRASTON(0x00000001)과 SPI_GETHIGHCONTRAST에서 쓸 때 cbSize를 지정해야 하는 것에 대해. ↩
-
Microsoft Learn, SystemParameters.HighContrast Property. SPI_GETHIGHCONTRAST와 HCF_HIGHCONTRASTON에 대응된 WPF의 정적 속성에 대해. ↩
-
Microsoft Learn, SystemParameters.StaticPropertyChanged Event. SystemParameters의 어느 속성이 바뀌었을 때 발생하는 정적 이벤트에 대해. ↩ ↩2
-
Microsoft Learn, ThemeSettings Class (Microsoft.UI.System). CreateForWindowId로 창에 묶어 만들고 Changed 이벤트로 고대비의 변화를 받는 것, 참조를 해제하면 객체가 폐기되어 이벤트가 발생하지 않게 되는 것에 대해. ↩
-
Microsoft Learn, SystemColors.WindowBrushKey Property. 리소스 키로 동적 참조를 만들면 브러시 변경 시 자동 갱신되고 WindowBrush에 의한 정적 참조는 자동 갱신되지 않는 것에 대해. ↩
-
Microsoft Learn, ResourceDictionary.ThemeDictionaries Property (Microsoft.UI.Xaml). Light와 Dark 테마 사전을 갖는 사용자 지정 컨트롤에는 HighContrast 사전도 마련할 것, HighContrast가 다른 고대비 테마가 없을 때의 대체 키인 것, Default가 테마의 ResourceDictionary를 찾지 못했을 때 쓰이는 것, SystemColorButtonFaceColor 같은 시스템 색 리소스를 HighContrast에서 쓸 수 있는 것에 대해. ↩
-
Microsoft Learn, Windows app development best practices. Windows 11이 순백과 순흑을 피해 눈에 편한 색조로 갱신한 것과 다크/라이트 테마가 사용자의 시각적 취향에 적응하는 수단이라는 것에 대해. ↩
-
Microsoft Learn, Color (Windows UX guidelines). 색을 주요 전달 수단이 아니라 시각적 보강으로 쓸 것, 목적에 따라 테마 색과 시스템 색을 고르고 전경과 배경을 대응하는 조합으로 쓸 것, 테마 변경을 WM_THEMECHANGED로 처리할 것, High Contrast Black이 Windows 11에서는 Aquatic, High Contrast White가 Desert에 해당하는 것에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows 앱 접근성 입문 ── UI Automation과 합리적 배려 의무화에 대비하기
2024년 4월 시행된 개정 장애인차별해소법을 배경으로, 스크린 리더가 Windows 앱을 읽는 기반 UI Automation을 축으로 WinForms/WPF의 이름 지정, 키보드 조작, 대비, 검증 도구까지 실무 관점에서 정리합니다.
Windows 프린터 드라이버 제공 종료 ── 업무 앱의 장표·라벨 인쇄는 어떻게 대비할 것인가
Microsoft는 v3/v4 프린터 드라이버의 제공 종료를 단계적으로 진행하고 있으며, 2026년 7월부터는 IPP 클래스 드라이버가 우선됩니다. Windows protected print mode에서 무엇이 사라지는지, 업무 앱의 장표·라벨 ...
Windows 앱 외주·수탁 개발을 의뢰하기 전에 정리할 점
Windows 앱 외주·수탁 개발을 의뢰하기 전에, 기존 소프트웨어 수정, 장치 연동, COM/ActiveX, 배포·업데이트, 유지보수를 정리하는 포인트를 설명합니다.
「응답 없음」의 정체 ── Windows가 앱을 「멈췄다」고 판단하는 구조와, 멈추지 않는 설계
Windows의 「응답 없음」은 창이 5초 동안 메시지를 꺼내지 않으면 OS가 판단해 고스트 창으로 교체하는 구조입니다. 판단의 내부 동작부터 멈추는 전형적인 원인, UI 스레드에서 무거운 처리를 빼내는 설계, hang 조사 절차까지 설명합니다.
클립보드와 드래그 앤 드롭의 구조 ── 업무 앱에서 OLE 데이터 전송을 올바르게 다루기
Excel 표를 붙여 넣으면 서식이 무너지고, 원본을 닫으면 붙여 넣을 수 없다── 원인은 같은 내용을 여러 형식으로 두는 클립보드입니다. 표준 형식·지연 렌더링·OLE 드래그 앤 드롭부터 기록과 클라우드 동기 정책까지 설명합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
UI 스레드 & 타이머
WPF / WinForms UI 스레드, async 흐름, Dispatcher 사용, 타이머 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- Windows를 다크 모드로 바꿔도 자사 WinForms 앱의 제목 표시줄만 흰색 그대로입니다. 왜 그렇습니까?
- Windows는 앱이 다크 모드를 지원하는지 알 수단이 없기 때문에, 호환성을 위해 모든 창을 기본적으로 라이트 모드로 취급하기 때문입니다. 제목 표시줄을 포함한 비클라이언트 영역은 데스크톱 창 관리자(DWM)가 그리고 있으며, 앱 쪽에서 DwmSetWindowAttribute로 DWMWA_USE_IMMERSIVE_DARK_MODE(값 20)에 TRUE를 전달해야 비로소 시스템이 다크일 때 다크로 그려집니다. 이 특성의 지원은 Windows 11 빌드 22000 이상으로 문서화되어 있습니다. .NET 9 이후의 WinForms에서 Application.SetColorMode를 쓰는 경우나 .NET 9 이후의 WPF에서 ThemeMode를 쓰는 경우에는 이 호출을 프레임워크가 대신 맡으므로, 직접 호출해야 하는 것은 .NET 8 이전이나 .NET Framework, Win32/MFC 앱에 한정됩니다. 또한 제목 표시줄만 검게 하고 클라이언트 영역이 흰색 그대로이면 오히려 부자연스러워집니다. 앱 전체를 다크로 다시 칠할 준비가 되었을 때만 이 특성을 켜 주십시오.
- 다크 모드와 대비 테마(고대비)는 같은 것입니까?
- 다른 것입니다. 라이트/다크는 「설정 > 개인 설정 > 색」의 색 모드로, 전경과 배경의 명암을 뒤바꾸는 넓은 팔레트를 사용합니다. 대비 테마는 「설정 > 접근성 > 대비 테마」에서 고르는 것으로, 대체로 7:1 이상의 대비율이 되도록 제약된 팔레트(Aquatic·Desert·Dusk·Night sky 네 가지와 사용자가 색을 편집한 것)를 사용합니다. Microsoft 문서는 둘을 혼동하지 말라고 명시하고 있으며, 대비 테마가 켜져 있는 동안에는 다크 모드를 쓸 수 없습니다(WinForms의 SetColorMode는 대비 테마 중에 다크를 제공하지 않고, XAML의 RequestedTheme도 시스템에 덮어써집니다). 앱 구현에서는 먼저 대비 테마인지 판정해 시스템 색에 전면적으로 맡기고, 그렇지 않으면 라이트/다크 팔레트를 고르는 우선순위로 하십시오.
- WinForms 앱을 다크 모드에 대응시키는 가장 빠른 방법은 무엇입니까?
- .NET 9 이후라면 Program.cs의 Application.Run보다 앞에서 Application.SetColorMode(SystemColorMode.System)을 호출하는 것이 가장 빠릅니다. .NET 9에서는 실험적 기능이라서 프로젝트 파일에서 WFO5001을 억제해야 했지만, .NET 10부터는 억제 없이 쓸 수 있습니다. SetColorMode를 호출하면 SystemColors가 다크용 대체 집합으로 바뀌고 표준 컨트롤은 그에 따라 그려집니다. 주의할 점은 세 가지입니다. 첫째, 다크 모드는 Windows 11 이상에서만 쓸 수 있고 대비 테마 중에는 무효입니다. 둘째, SystemColorMode.System을 지정해도 앱 실행 중에 Windows 설정이 바뀌었을 때 자동으로 따라가지 않습니다(다음 시작 때 반영됩니다). 셋째, 직접 그리는 컨트롤에서 스크롤 막대 같은 Win32 공용 컨트롤을 쓰는 경우에는 CreateParams를 재정의해 SetStyle(ControlStyles.ApplyThemingImplicitly, true)를 base.CreateParams보다 앞에서 호출해야 합니다(생성자에서는 늦습니다).
- WPF 앱에서는 무엇을 하면 다크 모드를 따라갑니까?
- .NET 9 이후의 WPF에는 Windows 11의 Fluent 디자인에 맞춘 새 테마가 함께 들어 있어서, App.xaml의 Application 요소에 ThemeMode="System"이라고 쓰기만 하면 Windows의 라이트/다크 설정에 맞춘 Fluent 테마가 로드됩니다. ThemeMode는 창의 다크화(제목 표시줄)와 배경 재질의 적용도 제어합니다. 다만 ThemeMode 속성을 코드에서 읽고 쓰는 조작은 .NET 10에서도 실험적(WPF0001)이고, Fluent 스타일 자체도 .NET 10 문서에서 「아직 작업 중」이라고 되어 있습니다. 업무 앱에 채택할 경우에는 쓰고 있는 컨트롤이 Fluent에서 깨지지 않는지 평가한 다음 결정하십시오. 기존 테마 그대로(.NET 8 이전이나 .NET Framework도 마찬가지) 대응하는 경우에는 라이트용과 다크용 ResourceDictionary를 준비해 MergedDictionaries로 교체하고, XAML 쪽은 DynamicResource로 참조하며, 전환 감지에는 UISettings.ColorValuesChanged를 씁니다. 제목 표시줄은 SourceInitialized에서 WindowInteropHelper로 HWND를 얻어 DwmSetWindowAttribute를 호출합니다.
- 대비 테마(고대비)에서 글자가 사라지거나 읽을 수 없게 되는 이유는 무엇입니까?
- 색을 하드코딩했거나 시스템 색의 전경과 배경 조합(짝)을 깨뜨린 것이 전형적인 원인입니다. 대비 테마에서는 사용자가 배경·글자·링크 등의 색을 자유롭게 편집할 수 있으므로 「글자는 검은색일 것이다」 「선택된 행은 연한 파랑일 것이다」 같은 전제는 모두 무너집니다. 예를 들어 배경만 #E6E6E6으로 고정해 두면 테마에 따라 전경이 흰색이 되어, 흰 글자가 연한 회색 위에 올라가 읽을 수 없게 됩니다. 원칙은 세 가지입니다. SPI_GETHIGHCONTRAST(WinForms라면 SystemInformation.HighContrast, WPF라면 SystemParameters.HighContrast)로 상태를 판정할 것, 색을 모두 시스템 색의 올바른 짝(WindowText와 Window, ButtonText와 ButtonFace, HighlightText와 Highlight)으로 바꿀 것, 글자 뒤의 이미지나 여러 색의 도형을 그만두고 전경색과 배경색만으로 그릴 것입니다. GrayText는 비활성 상태, Hotlight는 하이퍼링크 이외에 써서는 안 됩니다. 변경은 WM_SYSCOLORCHANGE나 WM_THEMECHANGED(.NET에서는 SystemEvents.UserPreferenceChanged)로 통지되므로, 거기서 색을 다시 계산해 다시 그립니다.