YKSS
useSyncExternalStore : 실전 리액트 개발을 위한 심층 해설
useSyncExternalStore는 동시성 렌더링 환경에서 외부 상태를 테어링 없이 구독하기 위한 공식 훅. subscribe/getSnapshot/getServerSnapshot 계약을 정확히 지켜야 한다.
핵심 요약
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가 바뀌면 트리 불일치
}
'어차피 다시 렌더되면 맞춰진다'는 오해. 한 프레임 안에서의 불일치가 실제 UI 버그로 드러나는 것이 테어링의 본질입니다.
세 인자의 계약
각 인자가 언제·어떻게 호출되는지 정확히 외워야 합니다.
| 인자 | 호출 시점 | 반환 | 제약 |
|---|---|---|---|
subscribe(cb) | 마운트/함수 참조 변경 시 | cleanup 함수 | cb 호출 시 React가 재렌더 트리거 |
getSnapshot() | 매 렌더 + 변경 알림 후 | 현재 스냅샷 | 순수·동기, 같은 값이면 같은 참조 |
getServerSnapshot() | 서버 렌더·hydration | 서버 초기값 | CSR-전용 값이면 합리적 기본값 |
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);
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 가정
);
}
서버값과 클라값이 다른 걸 숨기려다 hydration을 깨뜨림. 두 값이 다를 수 있음을 인정하고 합리적 서버 기본값을 주는 게 정석입니다.
서드파티 라이브러리
Zustand(v4+), Redux(react-redux 8+), Jotai 등 최신 바인딩은 내부적으로 useSyncExternalStore를 씁니다. 그래서 라이브러리만 최신이면 테어링은 이미 해결되어 있고, 직접 구현할 때만 이 훅을 건드리면 됩니다.
라이브러리가 해주는 일을 컴포넌트 레벨에서 또 useSyncExternalStore로 직접 호출하는 것. 추상화를 깨뜨리고 유지보수성이 떨어집니다.
읽는 순서
- 1이론
React 공식 문서의
useSyncExternalStore페이지와 'React 18 concurrent rendering tearing demo' RFC를 읽고, 테어링이 발생하는 정확한 시퀀스(startTransition→ 렌더 중단 → 외부 변경 → 재개)를 그림으로 그려보세요. - 2구현
navigator.onLine을 구독하는useOnlineStatus훅을useSyncExternalStore로 직접 구현하고,getServerSnapshot까지 넣어 Next.js App Router에서 hydration 경고 없이 동작시켜 보세요. - 3실무
현재 프로젝트의 상태관리 라이브러리(Zustand/react-redux/Jotai) 버전이 내부적으로
useSyncExternalStore를 쓰는지node_modules에서 grep 해 확인하고, 구버전이면 업그레이드 PR을 설계하세요. - 4설명
'왜 Zustand는 Context API보다 테어링에 강한가?'를 주니어 동료에게
useSyncExternalStore계약 기반으로 5분 안에 설명해 보세요. 핵심은 '스냅샷 고정 +Object.is비교'.
면접 연결 질문
[감점 답변] '리렌더링이 잘 된다' 수준. [좋은 답변] startTransition 등으로 렌더가 중단/재개되는 도중 스토어 변경 → 같은 트리에서 구/신 값 혼재 → UI 불일치 흐름을 짚고, useSyncExternalStore가 렌더 패스 동안 스냅샷을 고정하는 계약이라는 점, 그리고 Zustand/react-redux 최신 바인딩이 이를 내부적으로 쓴다는 연결까지.
[감점 답변] 'useMemo 쓰면 됨' 수준. [좋은 답변] React가 Object.is로 이전/현재 스냅샷을 비교하는데 매번 새 객체를 만들면 항상 다르게 보임 → 무한 리렌더. 해결책 3가지를 트레이드오프와 함께: (1) 원시값 반환, (2) useSyncExternalStoreWithSelector + 셀렉터, (3) 스토어 내부에서 참조 안정성 유지(불변 업데이트).
[감점 답변] getServerSnapshot 언급 누락. [좋은 답변] 서버엔 해당 API가 없으니 합리적 기본값(true) 을 getServerSnapshot으로 제공, 클라이언트 subscribe에서 online/offline 이벤트 바인딩, 그리고 서버-클라이언트 초기값 불일치를 인정하면서 hydration mismatch를 최소화하는 설계까지.
자기 점검
subscribe가 한 번만 호출된다고 생각하는 것. 인자로 넘긴 함수의 참조가 바뀌면 React는 기존 구독을 해제하고 새로 구독합니다.
단순 setState만으로 테어링이 드러난다고 생각하는 것. startTransition 같은 동시성 트리거가 있어야 렌더 중단 윈도우가 열립니다.