FEInterview Prep

YKSS

useSyncExternalStore : 실전 리액트 개발을 위한 심층 해설

useSyncExternalStore는 동시성 렌더링 환경에서 외부 상태를 테어링 없이 구독하기 위한 공식 훅. subscribe/getSnapshot/getServerSnapshot 계약을 정확히 지켜야 한다.

2026-04-13·14분 읽기
React성능
원문 보기 ↗

핵심 요약

useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) 세 인자를 받습니다. getSnapshot렌더 중에 여러 번 호출될 수 있으므로 순수·동기여야 하고, 값이 같으면 같은 참조를 반환해야 합니다 (Object.is로 비교). subscribe(cb)는 외부 변경 시 cb를 호출하고 cleanup을 반환하며, 이 함수 자체가 바뀌면 React가 재구독합니다. SSR에서는 getServerSnapshot이 서버 초기값을 제공해 hydration mismatch를 막습니다. 가장 흔한 실수는 getSnapshot에서 매번 { ... } 새 객체를 반환해 무한 리렌더를 유발하는 것 — 원시값 반환 또는 useSyncExternalStoreWithSelector로 해결합니다.

useSyncExternalStore는 'useEffect + useState로 외부 상태를 흉내내던 패턴'을, React 동시성 렌더링이 깨뜨리지 않도록 언어(React) 레벨에서 공식 보증하는 계약으로 격상시킨 훅으로 읽으세요. 핵심은 '값을 어떻게 가져오나'가 아니라 **'렌더 패스 중간에 외부가 바뀌어도 트리 전체가 같은 스냅샷을 본다'**는 보장입니다.

React 18+ 동시성 기능(startTransition, Suspense)은 렌더링을 중단/재개할 수 있습니다. 이때 외부 스토어가 중간에 바뀌면 트리 일부는 구값, 다른 일부는 신값을 읽는 **테어링(tearing)**이 발생합니다.

  • 면접: "Zustand/Redux는 어떻게 테어링을 막나요?" → useSyncExternalStore 계약을 설명 못하면 상태관리 라이브러리를 깊이 안다고 말할 수 없음
  • 실무: matchMedia, navigator.onLine, 커스텀 이벤트 버스 같은 브라우저 외부 소스 구독의 표준 도구
  • 라이브러리 작성자: 자체 스토어를 React 18 호환으로 만들 때 유일한 공식 API

학습 포인트

면접 답변으로 연결할 학습 포인트입니다.

테어링과 동시성

Concurrent rendering은 한 렌더 패스를 중단/재개할 수 있어서, 그 사이 외부 스토어가 바뀌면 같은 트리 안에서 서로 다른 스냅샷을 읽습니다. useSyncExternalStore는 렌더 패스 동안 스냅샷을 고정(pinning) 해 이를 막습니다.

// 테어링이 날 수 있는 옛날 패턴
function useStore() {
  const [state, setState] = useState(store.getState());
  useEffect(() => store.subscribe(() => setState(store.getState())), []);
  return state; // 렌더 중단/재개 사이에 store가 바뀌면 트리 불일치
}
tearingconcurrent renderingsnapshot pinningstartTransition
자주 하는 오해

'어차피 다시 렌더되면 맞춰진다'는 오해. 한 프레임 안에서의 불일치가 실제 UI 버그로 드러나는 것이 테어링의 본질입니다.

세 인자의 계약

각 인자가 언제·어떻게 호출되는지 정확히 외워야 합니다.

인자호출 시점반환제약
subscribe(cb)마운트/함수 참조 변경 시cleanup 함수cb 호출 시 React가 재렌더 트리거
getSnapshot()매 렌더 + 변경 알림 후현재 스냅샷순수·동기, 같은 값이면 같은 참조
getServerSnapshot()서버 렌더·hydration서버 초기값CSR-전용 값이면 합리적 기본값
subscribegetSnapshotgetServerSnapshotObject.is
자주 하는 오해

subscribe가 한 번만 실행된다고 믿는 것. 함수 참조가 바뀌면 React는 기존 구독을 해제하고 재구독하므로, 보통 스토어 외부에 함수를 고정 정의해야 합니다.

getSnapshot 참조 안정성

React는 Object.is(prev, next)로 스냅샷을 비교합니다. 매번 새 객체 리터럴을 반환하면 항상 다르게 보여 무한 리렌더 또는 The result of getSnapshot should be cached 경고가 납니다.

// 나쁨: 매 호출마다 새 객체
const snap = useSyncExternalStore(sub, () => ({ count: store.count }));

// 좋음 1: 원시값
const count = useSyncExternalStore(sub, () => store.count);

// 좋음 2: 셀렉터 + with-selector
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector';
const count = useSyncExternalStoreWithSelector(sub, store.getState, null, s => s.count);
Object.isuseSyncExternalStoreWithSelector참조 동일성무한 리렌더
자주 하는 오해

useMemo로 감싸면 될 거라 생각하는 것. getSnapshot훅 바깥에서 호출되므로 useMemo가 동작하지 않습니다. 스토어 자체가 안정된 참조를 내려주거나 셀렉터를 써야 합니다.

getServerSnapshot과 SSR

브라우저 전용 API(navigator.onLine, window.matchMedia)는 서버에 없습니다. getServerSnapshot을 빼거나 undefined를 던지면 Suspense가 풀리지 않거나 hydration mismatch가 납니다.

function useOnline() {
  return useSyncExternalStore(
    (cb) => {
      window.addEventListener('online', cb);
      window.addEventListener('offline', cb);
      return () => {
        window.removeEventListener('online', cb);
        window.removeEventListener('offline', cb);
      };
    },
    () => navigator.onLine,
    () => true, // 서버: 일단 online 가정
  );
}
getServerSnapshothydrationSSRSuspense
자주 하는 오해

서버값과 클라값이 다른 걸 숨기려다 hydration을 깨뜨림. 두 값이 다를 수 있음을 인정하고 합리적 서버 기본값을 주는 게 정석입니다.

서드파티 라이브러리

Zustand(v4+), Redux(react-redux 8+), Jotai 등 최신 바인딩은 내부적으로 useSyncExternalStore를 씁니다. 그래서 라이브러리만 최신이면 테어링은 이미 해결되어 있고, 직접 구현할 때만 이 훅을 건드리면 됩니다.

Zustandreact-reduxJotaiexternal store
자주 하는 오해

라이브러리가 해주는 일을 컴포넌트 레벨에서 또 useSyncExternalStore로 직접 호출하는 것. 추상화를 깨뜨리고 유지보수성이 떨어집니다.

읽는 순서

  1. 1이론

    React 공식 문서의 useSyncExternalStore 페이지와 'React 18 concurrent rendering tearing demo' RFC를 읽고, 테어링이 발생하는 정확한 시퀀스(startTransition → 렌더 중단 → 외부 변경 → 재개)를 그림으로 그려보세요.

  2. 2구현

    navigator.onLine을 구독하는 useOnlineStatus 훅을 useSyncExternalStore로 직접 구현하고, getServerSnapshot까지 넣어 Next.js App Router에서 hydration 경고 없이 동작시켜 보세요.

  3. 3실무

    현재 프로젝트의 상태관리 라이브러리(Zustand/react-redux/Jotai) 버전이 내부적으로 useSyncExternalStore를 쓰는지 node_modules에서 grep 해 확인하고, 구버전이면 업그레이드 PR을 설계하세요.

  4. 4설명

    '왜 Zustand는 Context API보다 테어링에 강한가?'를 주니어 동료에게 useSyncExternalStore 계약 기반으로 5분 안에 설명해 보세요. 핵심은 '스냅샷 고정 + Object.is 비교'.

면접 연결 질문

hardReact 18 동시성 렌더링에서 외부 스토어를 구독할 때 생기는 '테어링'이 무엇이며, `useSyncExternalStore`는 어떻게 이를 해결하나요?
힌트

[감점 답변] '리렌더링이 잘 된다' 수준. [좋은 답변] startTransition 등으로 렌더가 중단/재개되는 도중 스토어 변경 → 같은 트리에서 구/신 값 혼재 → UI 불일치 흐름을 짚고, useSyncExternalStore렌더 패스 동안 스냅샷을 고정하는 계약이라는 점, 그리고 Zustand/react-redux 최신 바인딩이 이를 내부적으로 쓴다는 연결까지.

medium`getSnapshot`이 캐시되어야 한다는 경고는 왜 발생하며, 객체 형태 상태를 구독할 때 어떻게 해결하나요?
힌트

[감점 답변] 'useMemo 쓰면 됨' 수준. [좋은 답변] React가 Object.is로 이전/현재 스냅샷을 비교하는데 매번 새 객체를 만들면 항상 다르게 보임 → 무한 리렌더. 해결책 3가지를 트레이드오프와 함께: (1) 원시값 반환, (2) useSyncExternalStoreWithSelector + 셀렉터, (3) 스토어 내부에서 참조 안정성 유지(불변 업데이트).

medium브라우저 전용 API(`navigator.onLine`)를 SSR 앱에서 구독한다면 `useSyncExternalStore`를 어떻게 구성하시겠어요?
힌트

[감점 답변] getServerSnapshot 언급 누락. [좋은 답변] 서버엔 해당 API가 없으니 합리적 기본값(true)getServerSnapshot으로 제공, 클라이언트 subscribe에서 online/offline 이벤트 바인딩, 그리고 서버-클라이언트 초기값 불일치를 인정하면서 hydration mismatch를 최소화하는 설계까지.

자기 점검

`useSyncExternalStore`의 세 인자(`subscribe`, `getSnapshot`, `getServerSnapshot`)가 각각 '언제' 호출되는지 타임라인으로 말해보세요.
매 렌더 중변경 알림 후hydrationcleanup재구독
자주 하는 오해

subscribe가 한 번만 호출된다고 생각하는 것. 인자로 넘긴 함수의 참조가 바뀌면 React는 기존 구독을 해제하고 새로 구독합니다.

직접 만든 Zustand-like 스토어에서 테어링을 재현하려면 어떤 조건을 만들어야 할까요?
startTransitionconcurrent외부 변경렌더 중단
자주 하는 오해

단순 setState만으로 테어링이 드러난다고 생각하는 것. startTransition 같은 동시성 트리거가 있어야 렌더 중단 윈도우가 열립니다.