useSyncExternalStore로 외부 상태 tearing 막기

🪄 Tearing이란

하나의 상태에 대해 UI가 서로 다른 값을 동시에 보여주는 시각적 불일치를 말한다.

window 너비를 읽는 헤더는 1200, 사이드바는 800을 보여주는 상황이 그렇다. 같은 화면인데 같은 값이 아닌 것이다.

React 18 이전에는 렌더가 항상 동기·일괄로 끝났기 때문에 발생할 수 없던 문제다. 동시성 렌더링이 도입되면서 렌더 도중 중단·재개가 가능해졌고, 그 틈에 React 바깥 값이 바뀌면 컴포넌트마다 다른 시점의 값을 읽게 된다.


tearing 발생 조건

  • 중단 가능한 렌더: startTransition, useDeferredValue
  • 충분히 무거운 렌더: 중단될 기회가 생길 만큼 오래 걸리는 작업
  • React 외부의 변경: WebSocket, setInterval, DOM 이벤트 등
let count = 0;
const listeners = new Set<() => void>();
 
export const store = {
  increment() {
    count++;
    listeners.forEach((l) => l());
  },
  getSnapshot: () => count,
  subscribe(l: () => void) {
    listeners.add(l);
    return () => listeners.delete(l);
  },
};

렌더 패스는 단일 트랜잭션처럼 일관된 스냅샷 위에서 동작해야 한다. 그런데 외부 스토어는 그 트랜잭션에 참여하지 않기 때문에, 값이 바뀌어도 React가 알 방법이 없다.


반면 useState는 값이 전역 변수가 아니라 Fiber에 연결된 hook 리스트에 저장되고, 렌더는 current 트리가 아닌 work-in-progress 트리 위에서 진행된다. 렌더가 중단돼도 current는 그대로 유지되니 안전한 것이다.


🔧 useSyncExternalStore

외부 스토어의 업데이트를 동기적으로 강제해서 동시성 읽기의 일관성을 보장하는 hook이다.

const count = useSyncExternalStore(
  store.subscribe,        // 변경 구독, cleanup 반환
  store.getSnapshot,      // 현재 값 읽기 — 렌더 중에도 호출됨
  store.getServerSnapshot // SSR / 하이드레이션용 초기값
);

React는 렌더 도중, 그리고 커밋 직전에 한 번 더 getSnapshot을 호출해서 값이 바뀌었는지 확인한다. 바뀌었다면 진행 중인 렌더를 버리고 다시 시작하는데, 이게 tearing 방지의 실제 메커니즘이다.


실전 예시

function useWindowWidth() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener("resize", callback);
      return () => window.removeEventListener("resize", callback);
    },
    () => window.innerWidth,
    () => 0 // 서버엔 window가 없으므로 기본값 제공
  );
}

getSnapshot은 참조적으로 안정적이어야 한다

React는 이전/현재 스냅샷을 Object.is로 비교하기 때문에, 매번 새 배열·객체를 반환하면 "계속 바뀌는 중"으로 오인해서 무한 렌더에 빠진다.

// ❌ 호출할 때마다 새 배열 → 무한 렌더
const activeTodos = useSyncExternalStore(subscribe, () =>
  store.getState().todos.filter((t) => !t.done)
);

해결책은 두 가지다. 원본이 안 바뀌면 같은 참조를 반환하도록 파생값을 캐싱하거나, use-sync-external-store 패키지의 useSyncExternalStoreWithSelector(react-redux가 채택한 방식)에 selector 메모이제이션을 맡기면 된다.


나만의 스토어 만들기

subscribe와 getState만 갖추면 구독 가능한 상태 컨테이너가 된다. 이게 Zustand 같은 라이브러리의 최소 골격이다.

function createStore<T>(initialState: T) {
  let state = initialState;
  const listeners = new Set<() => void>();
 
  return {
    getState: () => state,
    setState: (partial: Partial<T>) => {
      state = { ...state, ...partial };
      listeners.forEach((l) => l());
    },
    subscribe: (l: () => void) => {
      listeners.add(l);
      return () => listeners.delete(l);
    },
  };
}

🤔 트레이드오프는 없을까?

이 훅은 업데이트를 동기적으로 강제한다. 그래서 해당 스토어를 읽는 업데이트는 중단(time slicing)될 수 없고, useTransition으로 전환 중 상태와 현재 상태를 동시에 유지하는 state branching도 불가능하다.

찢어진 UI를 보여주느니 동시성의 일부를 포기한다 — 현재 생태계가 내린 선택

그래서 전역 스토어에는 진짜 클라이언트 상태(UI 상태, 세션 내 임시 상태)만 두고, 서버에서 받아온 데이터는 TanStack Query 같은 서버 상태 도구로 분리하는 게 좋다. 전역 스토어가 무거워질수록 그 상태를 읽는 모든 업데이트가 non-blocking transition의 이점을 잃기 때문이다.


📌 정리

  • tearing은 렌더가 중간에 멈췄다 다시 시작될 수 있다는 점과 외부 스토어는 그 렌더 트랜잭션에 속하지 않는다는 점이 겹칠 때 생긴다.
  • useSyncExternalStore는 커밋 직전에 스냅샷을 한 번 더 확인해서 일관성을 되찾아준다. 대신 그 업데이트는 항상 동기적으로만 처리된다.
  • getSnapshot의 참조 동일성은 지키면 좋은 권장 사항이 아니라, 어기면 무한 렌더로 이어지는 필수 계약이다.

본문은 React 19 기준으로 확인했고, 해당 API는 React 18 도입 이후 동작 변경 없이 유지되고 있다.


참고 자료