다크모드 깜빡임, useSyncExternalStore가 정말 해결했을까?
localStorage 읽기를 useState + useEffect에서 useSyncExternalStore로 바꾸며 깜빡임을 고쳤다고 커밋에 적었습니다. headless Chrome으로 재보니 두 구현의 DOM 변화는 같았습니다. 측정 방법과, 이 훅을 쓸 진짜 이유.
geonoooo
1 min read
useSyncExternalStore가 뭔가
React 외부에 있는 값을 컴포넌트가 읽게 해주는 훅입니다. localStorage, window.matchMedia, 직접 만든 스토어처럼 React state가 아닌 것들이 대상입니다.
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);인자는 셋입니다.
subscribe(listener)— 값이 바뀌면listener를 부르도록 등록하고, 해제 함수를 반환합니다.getSnapshot()— 지금 값을 반환합니다. 클라이언트에서 쓰입니다.getServerSnapshot()— 서버 렌더링과 클라이언트 hydration 렌더에서 쓰입니다. SSR을 쓴다면 필수입니다.
useEffect 안에서 값을 읽어 setState하는 방식과 비교하면, 구독 경로가 훅 안으로 들어오고 서버용 값을 반드시 명시하게 됩니다. 원래 목적은 concurrent 렌더링에서 같은 스토어를 읽는 컴포넌트들이 한 커밋 안에서 서로 다른 값을 보는 tearing을 막는 것입니다. 자세한 건 React 공식 문서에 있습니다.
고쳤다고 적은 것
이 블로그의 테마 프로바이더를 useState + useEffect에서 useSyncExternalStore로 바꾸면서, 코드에 이런 주석을 달았다.
// localStorage를 외부 스토어로 읽는다. mount 후 setState로 동기화하면
// 첫 커밋에서 theme이 잠깐 "system"이라 ThemeScript가 칠해둔 클래스를
// applyTheme이 덮어쓴다(저장값이 light, OS가 dark일 때 화면이 깜빡임).재보니 틀렸습니다. 바꾸기 전과 후 모두 <html>의 class는 똑같이 한 번 틀린 값으로 칠해졌다가 되돌아옵니다. 그리고 두 경우 모두 사용자 눈에는 보이지 않습니다. 안 보이는 이유는 훅이 아니라, React가 그 교정을 브라우저에 제어권을 넘기기 전에 끝내기 때문입니다.
useSyncExternalStore를 쓸 이유는 따로 있습니다. 깜빡임이 아닙니다.
NOTE
측정 환경 React 19.2.4, Next 16.2.4 (App Router), 프로덕션 빌드. Chrome 151 headless를 CDP로 몰아 측정했습니다. CPU 스로틀 1x / 6x / 20x에서 모두 같은 결과가 나왔습니다.
예상한 흐름
문제로 지목한 조합은 저장값이 light, OS 설정이 dark 일 때입니다. 흐름은 이렇게 예상했습니다.
<body> 최상단의 인라인 스크립트가 먼저 돕니다. localStorage에서 light를 읽고, .dark를 붙이지 않습니다. 첫 페인트는 라이트입니다. 여기까지는 맞습니다.
그다음 hydration이 일어납니다. 이 시점의 theme 값이 문제입니다.
function getServerSnapshot(): Theme {
return "system";
}서버는 사용자의 테마를 모르니 "system"을 반환할 수밖에 없습니다. 그런데 이 값은 서버에서만 쓰이는 게 아닙니다. React 공식 문서는 getServerSnapshot을 이렇게 정의합니다.
It will be used only during server rendering and during hydration of server-rendered content on the client.
즉 클라이언트의 hydration 렌더에서도 theme은 "system"입니다. 그러면 [theme] effect가 applyTheme("system")으로 돌고, OS가 dark이므로 .dark를 붙입니다. 인라인 스크립트가 라이트로 맞춰둔 것을 덮어씁니다.
주석이 설명하는 건 여기까지입니다. 그런데 이 흐름은 useSyncExternalStore로 바꾼 뒤에도 그대로입니다. 주석은 바꾸기 전 코드의 문제로 적혀 있지만, 바꾼 코드에도 똑같이 해당됩니다.
두 구현은 DOM을 똑같이 건드린다
두 버전을 같은 조건에서 각각 측정했습니다. MutationObserver로 <html>의 class 변경을 전부 기록하고, requestAnimationFrame으로 프레임 경계를 함께 찍었습니다. 프레임 사이에 값이 뒤집혔다가 돌아오면 눈에 보이는 깜빡임입니다.
useSyncExternalStore 버전, CPU 20x 스로틀:
3.3ms | class | before ThemeScript: dark=false
331.2ms | paint | first-contentful-paint
394.7ms | frame | #10 dark=false
402.1ms | effect | theme=system -> applyTheme(resolved=dark)
422.2ms | effect | theme=light -> applyTheme(resolved=light)
426.2ms | class | dark false -> false
426.2ms | class | dark true -> false
442.7ms | frame | #11 dark=falseuseState + useEffect 버전, 같은 조건:
3.1ms | class | before ThemeScript: dark=false
340.3ms | paint | first-contentful-paint
400.4ms | frame | #9 dark=false
408.6ms | effect | theme=system -> applyTheme(resolved=dark)
429.4ms | effect | theme=light -> applyTheme(resolved=light)
432.3ms | class | dark false -> false
432.3ms | class | dark true -> false
446.4ms | frame | #10 dark=false숫자만 다르고 구조가 같습니다. 둘 다 effect가 두 번 돕니다. 먼저 theme=system으로, 그다음 저장값으로. 그리고 class 변경이 두 번 기록됩니다.
class 줄이 둘 다 -> false로 끝나는 건 계측기의 한계입니다. MutationObserver 콜백은 마이크로태스크로 묶여서 두 변경이 다 끝난 뒤에 한 번에 돕니다. 현재값을 읽으면 둘 다 false입니다. 판단은 oldValue로 합니다. 첫 레코드는 false에서 출발했으니 추가, 둘째는 true에서 출발했으니 제거입니다.
핵심은 마지막 두 줄입니다. .dark가 붙어 있던 402ms~422ms 구간에 프레임이 하나도 없습니다. 직전 프레임은 394.7ms, 다음은 442.7ms입니다. 그 사이 브라우저는 페인트할 기회가 없었습니다.
useState 버전도 같습니다. 400.4ms 다음 프레임이 446.4ms입니다.
계측기가 맞는지부터 확인했다
"프레임이 안 낀다"는 결과는 계측기의 맹점일 수도 있습니다. 그래서 일부러 프레임을 건너뛰며 깜빡이게 만드는 코드를 넣고 같은 계측기로 재봤습니다.
requestAnimationFrame(() => {
document.documentElement.classList.add("dark");
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.documentElement.classList.remove("dark");
});
});
});결과:
39.9ms | frame | #2 dark=false
39.9ms | class | dark false -> true
52.6ms | frame | #3 dark=true
70.3ms | frame | #4 dark=true
70.3ms | class | dark true -> false프레임 #3, #4가 dark=true로 찍혔습니다. 계측기는 실제 깜빡임을 잡아냅니다. 앞의 결과는 맹점이 아니라 결론입니다.
왜 안 보이나
관측된 동작은 이렇게 설명됩니다.
useSyncExternalStore 버전에서 React는 hydration 커밋의 passive effect를 getServerSnapshot 값으로 실행합니다. 그 뒤 스토어의 실제 값이 다르면 재렌더합니다. 이 재렌더는 브라우저에 제어권을 넘기지 않고 같은 작업 안에서 처리됩니다. 그래서 그사이에 페인트가 없습니다. tearing을 막는 게 이 훅의 목적이므로, 이런 처리는 목적과 일관됩니다.
useState 버전에서는 mount effect가 localStorage를 읽어 setState를 부릅니다. passive effect 안에서 발생한 상태 변경도 같은 작업 안에서 flush됩니다. 결과는 동일합니다.
두 경우 모두 DOM은 잠깐 틀린 상태가 됩니다. 다만 그 상태가 화면에 도달하지 않습니다. DOM 변경과 사용자가 보는 것은 다릅니다. 깜빡임을 판단하려면 class 변경 횟수가 아니라 프레임 경계를 봐야 합니다.
CPU를 20배 늦춰도 결과가 같았습니다. 두 effect 사이가 20ms까지 벌어졌지만, 그 구간은 메인 스레드가 계속 바쁜 구간입니다. 메인 스레드가 바쁘면 페인트도 없습니다.
그럼 이 훅은 왜 쓰나
깜빡임이 아니라면 바꿀 이유가 없었나. 그건 아닙니다. 이유가 주석에 적힌 것과 다를 뿐입니다.
첫째, 서버 스냅샷을 강제합니다. 문서에 따르면 getServerSnapshot을 생략하면 서버 렌더링에서 에러가 납니다. 서버에서 이 값이 무엇이어야 하는지 반드시 답하게 만듭니다. useState 방식은 초기값에 아무거나 넣어도 조용히 통과합니다. hydration 불일치가 생겨도 런타임에 드러날 때까지 모릅니다.
둘째, 스토어를 구독 가능한 형태로 만듭니다. useState 방식은 mount 시점에 localStorage를 한 번 읽고 끝입니다. 그 뒤 값이 바뀌어도 다시 읽을 경로가 없습니다.
셋째, tearing을 막습니다. 같은 스토어를 읽는 컴포넌트가 여럿일 때, 한 커밋 안에서 서로 다른 값을 보는 상황이 생기지 않습니다.
세 가지 다 유효합니다. 깜빡임만 아닙니다.
글을 쓰다 찾은 진짜 문제
둘째 이유를 적어놓고 실제로는 쓰지 않고 있었습니다.
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function notify() {
for (const listener of listeners) listener();
}notify()를 부르는 곳은 setTheme 하나뿐이었습니다. subscribe는 window의 storage 이벤트를 듣지 않았습니다. 브라우저는 한 탭이 localStorage를 바꾸면 다른 탭들에 storage 이벤트를 보내는데, 그걸 아무도 듣고 있지 않았습니다.
증상은 이렇습니다. 블로그를 두 탭에 열어두고 A탭에서 다크로 바꾸면, B탭은 라이트 그대로입니다. B탭의 localStorage는 이미 dark인데 화면만 안 따라옵니다.
구독 경로를 만들어놓고 외부 변경을 연결하지 않았으니 useState 방식과 동작이 같았습니다. 훅을 바꿔서 얻은 게 없는 상태였습니다. 이건 고쳤습니다.
function subscribe(listener: () => void) {
listeners.add(listener);
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) listener();
};
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(listener);
window.removeEventListener("storage", onStorage);
};
}storage 이벤트는 값을 바꾼 탭에는 오지 않고 다른 탭에만 옵니다. 그래서 같은 탭용 notify()와 둘 다 필요합니다.
깜빡임은 고칠 것이 없었고, 정작 고칠 수 있었던 것은 이 글을 쓰기 전까지 비어 있었습니다. 커밋 메시지에 적은 이유는 검증하지 않으면 그대로 남습니다.
Comments