React 18은 useSyncExternalStore를 추가했습니다. 이 훅은 브라우저 API, WebSocket 또는 서드파티 스토어와 같이 React 트리 외부에 존재하는 모든 소스로부터 컴포넌트가 데이터를 읽을 수 있게 해줍니다. 이는 useState와 useEffect를 사용하여 데이터를 미러링하던 기존 방식에서 발생하던 시각적 글리치(glitch) 없이 가능합니다. UI의 일부는 이전 창 크기를 보여주고 다른 일부는 이미 새로운 크기를 반영하여 화면이 깜빡이는 현상을 본 적이 있다면, 이 훅이 바로 여러분이 기다려온 해결책입니다.
외부 상태에 새로운 훅이 필요한 이유
React 컴포넌트는 항상 자신의 범위를 벗어난 것들, 즉 window 객체, Redux 스토어, WebSocket 연결 등에 구독(subscribe)할 수 있었습니다. 전통적인 방식은 useEffect 내부에서 구독을 설정하고, 들어오는 값을 setState를 통해 로컬 상태로 밀어 넣어 React가 다시 렌더링되도록 하는 것이었습니다. React가 동기적으로 렌더링될 때는 이 방식이 잘 작동하지만, React 18에서는 브라우저가 실제로 화면을 그리기 전에 컴포넌트가 여러 번 렌더링될 수 있는 동시성 렌더링(concurrent rendering)이 도입되었습니다. 이 모드에서 "로컬 상태로 미러링하는" 패턴은 tearing(티어링) 현상을 일으킬 수 있습니다. 즉, 단일 렌더링 사이클 동안 UI의 서로 다른 부분이 동일한 외부 값의 서로 다른 스냅샷을 읽게 되는 현상입니다.
useSyncExternalStore는 이러한 tearing을 방지하기 위해 만들어졌습니다. 이 훅은 외부 소스에 snapshot(현재 값)과 subscribe 함수(변경 사항을 통지받는 방법)를 요청합니다. React는 컴포넌트가 마운트될 때 subscribe를 호출하고, 언마운트 시 자동으로 구독을 해제합니다. getSnapshot이 반환하는 스냅샷이 변경될 때마다 React는 전체 컴포넌트 트리에서 동일한 스냅샷을 읽는 렌더링을 예약하여 일관성을 보장합니다.
필수적인 두 가지 함수
| 함수 | 역할 |
|---|---|
| subscribe | 외부 값이 변경될 때마다 호출되어야 하는 콜백을 받습니다. React가 정리(cleanup) 시 호출할 unsubscribe 함수를 반환합니다. |
| getSnapshot | 외부 소스로부터 현재 값을 반환합니다. React가 스냅샷을 올바르게 비교할 수 있도록 반드시 **참조 안정성(referentially stable)**을 유지해야 합니다(매 렌더링마다 동일한 함수 객체여야 함). |
브라우저의 online 상태를 위한 최소한의 구현은 다음과 같습니다:
function useOnlineStatus() {
return useSyncExternalStore(
(onChange) => {
window.addEventListener('online', onChange);
window.addEventListener('offline', onChange);
return () => {
window.removeEventListener('online', onChange);
window.removeEventListener('offline', onChange);
};
},
() => navigator.onLine
);
}
어떤 컴포넌트든 useOnlineStatus()를 호출하면, React는 navigator.onLine이 실제로 변경될 때만 해당 컴포넌트를 다시 렌더링하며, 모든 렌더링은 동일한 값을 보게 됩니다.
훅의 오작동을 방지하기 위한 규칙
컴포넌트에 필요한 가장 작은 단위의 데이터를 반환하세요.
외부 스토어가 큰 객체를 가지고 있더라도 컴포넌트가 특정 필드 하나에만 관심이 있다면, 해당 필드만 반환하세요. 더 큰 데이터를 반환하면 불필요한 렌더링이 발생합니다.getSnapshot을 메모이제이션(Memoize)하세요.
매 렌더링마다 함수를 새로 생성하면 React는 이를 새로운 소스로 취급하여 무한 루프에 빠질 수 있습니다.useCallback으로 감싸거나 컴포넌트 외부에서 정의하세요.호출할 때마다 새로운 객체를 반환하지 마세요.
새로운 객체(예:{ count: store.getCount() })를 반환하면 매 렌더링마다 새로운 참조가 생성되어, React는 스냅샷이 변경되었다고 판단하고 무한 렌더링 루프를 트리거합니다. 기본 타입(primitives), 문자열, 숫자 또는 메모이제이션된 객체를 사용하세요.컴포넌트 내부 상태용으로 사용하지 마세요.
컴포넌트 내부에서만 존재하는 데이터에는 여전히useState가 적합한 도구입니다.useSyncExternalStore는 로컬 상태에는 필요하지 않은 오버헤드를 추가합니다.
언제 사용해야 할까요
- 브라우저 API – window 크기, 미디어 쿼리,
navigator.onLine, 배터리 상태 등. - 프레임워크에 종속되지 않는 스토어 – Zustand, Redux, MobX 또는 구독 API를 노출하는 모든 커스텀 스토어.
- React 외부에서 업데이트되는 가변 소스(Mutable sources) – WebSocket 메시지, IndexedDB 변경 이벤트, Service Worker 알림 등.
데이터 소스가 이미 React 내부에 있다면(예: 부모 컴포넌트의 상태), useState나 context를 계속 사용하세요.
커뮤니티의 반응
초기 사용자들은 동시성 렌더링 환경에서 창 크기를 조절할 때 발생하던 깜빡임 현상이 useSyncExternalStore를 통해 해결되었다고 보고하고 있습니다. 일부 라이브러리는 이미 내부 훅을 이 API로 전환하여 React 버전 간에 더욱 예측 가능한 동작을 보장하고 있습니다. 다만, 두 함수의 안정성을 고려해야 하고 호출 시마다 새로운 객체를 반환하지 않도록 주의해야 한다는 점에서 약간의 인지적 부담(mental load)이 따릅니다.
다음에 주목할 점
React의 향후 릴리스에서는 useSyncExternalStore와 관련된 규약이 더욱 강화될 수 있으며, 일반적인 브라우저 API를 위한 내장 헬퍼가 추가될 가능성도 있습니다. 기존의 useEffect를 모방하던 패턴이 지원 중단(deprecation)되는지 공식 React 블로그를 주의 깊게 살펴보시기 바랍니다. 그동안 이 훅은 안정적이며 공개 API의 일부이므로, 기존의 외부 상태(external-state) 코드를 안심하고 리팩터링할 수 있습니다.
빠른 테스트 실행
window-size 동기화, 온라인/오프라인 감지, 그리고 간단한 Zustand 스토어를 보여주는 라이브 데모는 https://usesyncexternalstore.vercel.app/에서 확인할 수 있습니다. 주석이 포함된 전체 소스 코드는 https://github.com/dev48v/usesyncexternalstore에서 제공됩니다.
핵심 요약: useSyncExternalStore는 React의 렌더링 사이클 외부에 존재하는 모든 가변 데이터(mutable data)에 대해 React가 신뢰할 수 있는 가교 역할을 할 수 있게 해주며, 텔링(tearing) 현상과 불필요한 리렌더링을 방지합니다. 이를 외부 소스에 적용하고, 스냅샷을 최소화하며 안정적으로 유지하면, 일관성을 유지하기 위한 복잡한 작업은 React가 알아서 처리하도록 맡길 수 있습니다.
