Excalidraw의 다크모드는 왜 상태를 두 개 사용할까
나는 클라이언트 상태를 깊게 다뤄본 경험이 부족하다.
그래서 최근에 Excalidraw를 까보는 중이다.
기록으로 남겨놓고 싶은 코드 있어서 적어본다.
appTheme, editorTheme
Excalidraw는 다크모드 구현을 위해 상태를 2개로 관리한다.
const [appTheme, setAppTheme] = useState<AppTheme>("light");
// Light 버튼 → appTheme = "light"
// Dark 버튼 → appTheme = "dark"
// System 버튼 → appTheme = "system"
appTheme는 사용자가 UI에 선택한 값을 관리한다.
const [editorTheme, setEditorTheme] = useState<Theme>("light");
// appTheme = light → editorTheme = light
// appTheme = dark → editorTheme = dark
// appTheme = system → OS 설정에 따라 light 또는 dark
editorTheme는 실제 화면에 적용할 값이다.
동작 방식
const [appTheme, setAppTheme] = useState<AppTheme>("light");
const [editorTheme, setEditorTheme] = useState<Theme>("light");
useEffect(() => {
if (appTheme !== "system") {
setEditorTheme(appTheme);
return;
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const updateEditorTheme = (matches: boolean) => {
setEditorTheme(matches ? "dark" : "light");
};
const handleChange = (event: MediaQueryListEvent) => {
updateEditorTheme(event.matches);
};
updateEditorTheme(mediaQuery.matches);
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, [appTheme]);
Light/Dark 선택 시
만약 유저가 dark를 선택했다면 다음과 같이 동작한다.
사용자가 Dark 클릭
→ setAppTheme("dark")
→ appTheme 변경
→ 리렌더링
→ useEffect 실행[dependency appTheme]
→ setEditorTheme("dark")
→ editorTheme 변경
→ 다시 리렌더링
editorTheme가 실제 화면에 반영되는 것이다.
System 상태에서 OS 테마 변경 시
이미 appTheme이 system인 상태에서 OS 테마가 변경되면 다음과 같이 동작한다.
OS 테마 변경
→ useEffect 재실행 안됨(=이미 appTheme은 system)
→ mediaQuery 변경(onChange)
→ 브라우저가 handleChange 호출
→ updateEditorTheme 호출
→ setEditorTheme 호출
→ editorTheme 변경
→ 리렌더링
useEffect는 재실행 되지 않지만 OS테마가 변경되면 MediaQueryList의 매칭 결과가 달라지면서 change 이벤트가 발생한다. 등록된 handleChange가 이를 받아 editorTheme을 변경한다.
appTheme은 여전히 system 상태를 유지한다.
useSyncExternalStore로 변경해보기
function subscribeToSystemTheme(callback: () => void) {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", callback);
return () => {
mediaQuery.removeEventListener("change", callback);
};
}
function getSystemTheme(): Theme {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
export default function App() {
const [appTheme, setAppTheme] = useState<AppTheme>("light");
const systemTheme = useSyncExternalStore(
subscribeToSystemTheme,
getSystemTheme,
);
const editorTheme = appTheme === "system" ? systemTheme : appTheme;
return (
//...
);
}
System 상태에서 OS 테마 변경 시
OS 테마 변경
→ mediaQuery 변경(onChange)
→ callback function 호출
→ React가 getSystemTheme 호출
→ systemTheme 변경 (dark or light)
→ editorTheme이 변경
→ 리렌더링
React 상태를 appTheme 하나로 유지한다. 사용자가 system 테마를 선택한 경우에는 OS 테마를 별도로 React 상태로 직접 관리하지 않고, useSyncExternalStore를 사용해 외부 시스템 상태를 구독한다.
OS 테마가 변경되면 MediaQueryList의 change 이벤트가 발생하고, 등록된 callback이 호출된다. 참고로 이 callback은 getSystemTheme이 아니라, React에게 외부 값이 변경되었다는 사실을 알리는 함수다.
React는 callback 호출을 통해 변경 사실을 알게 되고, getSystemTheme을 다시 호출해 현재 OS 테마를 읽는다.
getSystemTheme의 결과가 기존 값과 다르면 systemTheme이 갱신되고 리렌더링이 발생한다.
이때 렌더링 과정에서 editorTheme이 다시 계산되므로 에디터의 dark/light 모드도 변경된다.
참고로 이는 #10637에 PR이 올라와있는 상태이다.
상태 + CSS로 변경해보기
다르게 한번 고민해보고 싶었다.
처음 excalidraw의 dark-mode 구현된 소스코드를 보고 가장 먼저 떠올랐던 생각은 두 개의 상태를 동기화하는 게 아니라, 1개의 상태와 1개의 변수로 관리할 수 있지 않을까? 이었다. useSyncExternalStore와 같은 hooks을 사용하지 않은 채 말이다.
컴포넌트를 적절히 나눠서 draft state를 두면 가능하지 않을까 싶었는데 불가능했다.
function ThemeEditor({ initialTheme = "light" }: { initialTheme: AppTheme }) {
const [draftTheme, setDraftTheme] = useState<AppTheme>();
const appTheme = draftTheme ?? initialTheme;
const editorTheme = appTheme === "system" ? getSystemTheme() : appTheme;
return (
//...
);
}
사용자가 light, dark, system을 선택할 때는 모두 대응가능했다.
하지만 사용자가 system을 선택한 상태에서 OS 테마가 변경되면 이를 대응할 방법이 없다.
방법을 고민하다가 CSS를 이용해서 변경을 감지하면 될 것 같았다.
React가 OS 테마 변경을 직접 감지하는게 아니라, 브라우저의 CSS 엔진이 prefers-color-scheme 미디어 쿼리를 통해 OS 테마 변경에 반응하도록 만들 수 있다.
이때 React의 상태는 사용자가 선택한 테마만 관리한다.
function App() {
const [appTheme, setAppTheme] = useState<AppTheme>("light");
const usesSystemTheme = appTheme === "system";
return (
<main
className="theme-demo"
data-theme={usesSystemTheme ? undefined : appTheme}
>
//...
</main>
);
}
data-theme은 useState와 같은 별도의 상태 저장소는 아니다. React 상태인 appTheme의 값을 DOM에 표시하는 데이터 속성이다.
.theme-demo {
/* 기본값: light */
}
.theme-demo[data-theme="dark"] {
/* 사용자가 명시적으로 dark를 선택한 경우 */
}
@media (prefers-color-scheme: dark) {
.theme-demo:not([data-theme]) {
/* data-theme이 없을 때만 시스템 설정을 따른다 */
}
}
CSS에서는 data-theme 속성이 있는 경우 사용자가 선택한 테마를 적용하고, 속성이 없는 경우 OS 테마를 따르도록 한다.
사용자가 light 또는 dark 선택
→ appTheme 변경
→ React 리렌더링
→ data-theme 속성 변경
→ 명시적인 CSS 규칙 적용
사용자가 system 선택
→ appTheme 변경
→ React 리렌더링
→ data-theme 속성 제거
→ CSS가 OS 테마를 따름
system 상태에서 OS 테마 변경
→ React 리렌더링 없음
→ prefers-color-scheme 조건 변경
→ 브라우저가 CSS 스타일 재계산
→ 화면 스타일 변경
하지만 이 방법만으로 excalidraw의 dark-mode 영역을 모두 커버할 순 없다.
내가 생각한 것보다, 훨씬 많은 고민들이 excalidraw app의 dark-mode 구현체에 묻어있었다.
그 간의 고민을 읽어보기
1. 최초의 dark-mode
#1148 excalidraw의 dark-mode에 대한 issue를 거슬러 올라가면 2020년에 초기안이 개발된 것으로 보인다.
#2006 PR을 살펴보면, 초기 Dark Mode를 어떻게 구현했는지 확인할 수 있다.
초기는 단촐하다. CSS에 변수를 지정하고 light와 dark 속성에 따라 단순 스타일을 전환시켜서 구현했다. system도 고려되지 않았다.
2. 시스템 테마 논의
#2025 시스템 테마에 대한 논의가 시작되었다.
@media (prefers-color-scheme: dark) {
/* 자동으로 dark mode */
}
하지만 당시 Excalidraw 팀은 시스템 설정을 기본 테마로 바로 반영하지 않았다. macOS를 다크모드로 사용하는 사람도 Excalidraw에서는 검은 칠판이 아닌 흰 도화지를 원할 수 있다고 판단했기 때문이다.
3. 내보내기 테마
#2026 이번엔 내보내기 기능에 다크모드를 지원해야할지 말아야할지 고민에 대한 이슈이다.
- 내보내기 미리보기 캔버스에 다크모드 전환을 지원할 것인가?
- PNG, SVG, 클립보드 등 실제 출력물에도 다크모드를 지원할 것인가?
최종적으로 편집 화면의 테마와 내보내기 테마를 분리하고, 내보내기 창에 별도의 light/dark 토글을 추가하기로 결정했다.
이 토글은 미리보기와 실제 출력물에 함께 적용되며 기본값은 light이다.
4. 테마 저장과 초기 깜빡임
initialData의 dark 테마가 적용되기 전에 기본 light 화면이 잠깐 노출되는 초기화 깜빡임이 제기되었다.
#5660 테마를 별도의 localStorage에 저장하고, 이를 React 상태 및 로딩 화면과 동기화했다. 이를 통해 애플리케이션 최초 렌더링부터 저장된 테마를 사용할 수 있도록 개선한 PR이다.
하지만, React와 애플리케이션 번들이 실행되기 전에 여전히 기본 HTML 배경이 노출될 수 있었다.
후속 PR인 #5701에서는 index.html에 인라인 스크립트를 추가했다. 이 스크립트는 애플리케이션이 실행되기 전에 localStorage 테마를 읽고, 다크모드라면 html에 dark 클래스를 적용한다.
5. System 모드와 두 상태
이전에 언급했던 #2025는 #7853애서 System 모드가 처음 추가된 것으로 보인다.
#7853에서는 jotai를 사용한 것으로 보이는데, 이 또한 #9015에서 useState로 변경되었고 현재도 지역상태를 유지 중이다.
위에서 살펴본 것과 유사하지만, 한 번도 언급되지 않은 것은 useLayoutEffect이다.
localStorage를 추가하고 난 후 동기화하는 과정에서 index.html에 script까지 추가했는데, useLayoutEffect까지 필요한 이유는 뭘까?
하나하나 살펴보면 각 역할은 다르다.
먼저 앱이 실행되는데 다음의 순서를 따른다.
1. HTML 파싱
→ index.html의 인라인 스크립트가 localStorage의 테마를 읽고 html.dark 클래스를 적용한다. 이 단계는 React가 실행되기 전에 HTML 배경이 흰색으로 보이는 현상을 줄이기 위한 것이다.
2. React 초기화
→ appTheme은 저장된 값을 읽어 초기화된다. 저장된 값이 없다면 light를 사용한다. 반면 editorTheme은 우선 light로 초기화된다.
3. React 커밋 후
→ React가 DOM을 반영한 직후, 브라우저가 페인트하기 전에 useLayoutEffect가 실행된다. 이곳에서 appTheme을 실제 적용값인 editorTheme으로 동기화한다.
4. 브라우저 페인트
→ 브라우저는 동기화가 끝난 최종 테마를 화면에 표시한다.
5. 이후
→ 이후 useEffect가 prefers-color-scheme 변경 이벤트를 구독한다. 운영체제의 테마가 바뀌면 editorTheme을 다시 업데이트한다.
즉 위의 소스코드와 동작방식은 동일하다. 단 하나의 차이는, useLayoutEffect를 통해 브라우저 페인트 이전에 appTheme과 editorTheme의 상태를 동기화 한다는 점이다.
useEffect와 useLayoutEffect는 최종적으로 같은 테마를 만들 수 있다. 하지만 중간 상태가 브라우저에 노출되는 시점이 다르다.
index.html의 스크립트가 React 이전의 HTML 배경을 처리한다면, useLayoutEffect는 React 이후의 appTheme과 editorTheme을 브라우저가 paint 하기 전에 동기화한다.
6. UI 다크모드와 Canvas 다크모드
한 가지 더 살펴볼 점은 Excalidraw에서는 Canvas를 사용한다는 점이다.
Canvas에서 그려진 영역은 어떻게 색상을 반전시켰을까?
위에서 언급했었던 #2006 PR을 다시 살펴보면, Canvas 내부 도형의 색상을 하나씩 바꾼게 아니라, Canvas 전체에 CSS Filter를 적용하는 방식을 채택했었다.
.Appearance_dark canvas {
filter: invert(93%) hue-rotate(180deg);
}
invert()는 색상의 명도를 반전시키고, hue-rotate()는 색상환을 회전시킨다. 따라서 검정색 선은 밝은 색으로, 흰색 배경은 어두운 색으로 보이게 한다.
더 나아가 #2026에서는 크게 두 가지가 논의되었다.
- 내보내기 전 미리보기에서 다크모드 전환을 지원할 것인가
- 내보내기 한 이미지의 다크모드 전환을 지원할 것인가
#3046에서 viewMode,exportWithDarkMode상태가 추가되었으며, 위에서 언급한 두 가지 모두 지원하기 시작했다.
그 방법은 여전히 css filter였다.
이후 css filter를 통해 많은 이슈가 제기되었던 것으로 보인다.
먼저 성능문제가 있었다. #4074에서는 Chrome에서 다크모드를 사용할 때 심각한 지연이 발생한다고 보고됐다.
#4616은 이러한 성능 문제와 함께 브라우저별 Canvas, SVG Filter 지원 차이 및 Safari에서 SVG invert가 제대로 동작하지 않는 문제를 정리하고 있다.
색상 정확도도 문제였다. filter: invert(93%) hue-rotate(180deg);는 단순히 검은색과 흰색만 서로 바꾸는 것이 아니다. 색상마다 명도와 색조가 함께 변하기 때문에 사용자가 지정한 색상이 예상과 다른 색으로 보일 수 있다.
#3531에서는 사용자가 입력한 custom hex 색상이 다크모드에서 정확하게 표시되지 않는 문제가 보고됐다.
이미지도 예외가 필요했다.
Canvas 전체에 filter를 적용하면 삽입된 이미지의 색상까지 함께 변하기 때문이다. 기존 구현에서는 전체 Canvas를 반전한 뒤 다시 counter filter를 적용해 원래 색상으로 되돌리려 했다. 하지만 filter를 두 번 적용한다고 해서 원본 이미지 색상으로 정확하게 복원되는 것이 아니었다.
#6516에서도 다크모드에서 삽입된 이미지의 색상이 원본과 다르게 보이는 문제가 보고되었다.
결국 CSS filter에서 Javascript 색상 변환으로 전환했다.
변경 후에는 Canvas 전체를 사후 처리하는 대신, 각 요소를 그리기 직전에 색상을 변환한다.
// before → canvas filter 적용
context.filter = "invert(93%) hue-rotate(180deg)";
context.strokeStyle = element.strokeColor;
// after → 요소의 색상을 직접 변환
context.strokeStyle = applyDarkModeFilter(element.strokeColor);
context.fillStyle = applyDarkModeFilter(element.backgroundColor);
마무리
소스코드에는 역사가 묻어있는 것 같다. 단순 이 코드를 보며 더 나은 코드가 생각나는 것 같지만, 돌아보면 오랜 고민 끝에 탄생한 코드들이었다.
비슷한 경험도 떠올랐다. '또 동일한 실수를 저지를 뻔했네' 하며 안도했다.