React

다크모드 깜빡임, 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=false

useState + 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 하나뿐이었습니다. subscribewindowstorage 이벤트를 듣지 않았습니다. 브라우저는 한 탭이 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