useSyncExternalStore로 외부 상태 안전하게 구독하기
윈도우 크기, 네트워크 연결 상태, localStorage, 다른 탭에서 변경된 값… 이런 React 바깥의 데이터를 컴포넌트로 가져오려면 어떻게 해야 할까?
대부분 useState와 useEffect를 조합해 직접 구독하는 패턴을 떠올린다. 잘 동작하는 것처럼 보이지만, React 18부터 도입된 동시성 렌더링과 만나면 미묘한 버그가 생긴다.
같은 화면 안에서 한 컴포넌트는 옛날 값을, 다른 컴포넌트는 새 값을 보여주는, 이른바 tearing이다.
이번 글에서는 외부 상태를 React로 끌어오는 일이 왜 까다로운지, 그리고 React 18에서 새로 등장한 useSyncExternalStore가 어떤 문제를 어떻게 해결하는지 차근차근 살펴본다.
문제 1. useState + useEffect로 외부 상태 구독하기
먼저 외부 상태를 React로 가져오는 가장 흔한 방법을 보자. 화면 너비를 추적해서 모바일/데스크탑을 분기하는 훅을 만든다고 하자.
import { useEffect, useState } from "react";
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
return width;
}tsx브라우저를 좌우로 늘렸다 줄여도 잘 동작한다. 코드도 익숙하다. 어디가 문제일까?
useEffect는 렌더링 이후에 실행된다
useEffect는 컴포넌트가 화면에 그려진 다음 실행된다. 첫 렌더링 ~ effect 실행 사이의 짧은 순간에는 이벤트 리스너가 등록되어 있지 않다. 그 사이에 외부 값이 바뀌어도 React는 알 길이 없다.
평소엔 사람 눈에 보일 정도가 아니라 문제가 없다. 하지만 React 18부터는 이야기가 달라진다.
문제 2. 동시성 모드와 tearing
React 18은 동시성 렌더링(Concurrent Rendering)을 도입했다. 한마디로 렌더링을 중간에 멈췄다가 다시 이어서 할 수 있다는 뜻이다.
긴 리스트를 그리는 도중에 사용자가 입력을 하면, React는 일단 입력 처리를 우선해서 그리고 나머지는 잠시 미뤘다가 이어서 그린다. 무거운 렌더링이 입력을 막지 않게 만드는 핵심 기능이다.
문제는 “잠시 미루는 사이”에 일어난다. 그 짧은 틈에 외부 상태가 바뀌면 어떻게 될까?
tearing이 발생하는 시나리오
같은 useWindowWidth를 두 컴포넌트가 함께 쓴다고 하자.
function App() {
return (
<>
<Header /> {/* useWindowWidth() 사용 */}
<Sidebar /> {/* useWindowWidth() 사용 */}
<Content /> {/* 무거운 컴포넌트 */}
</>
);
}tsx1. App 렌더링 시작
2. Header 렌더링 → width = 1200 (현재 윈도우 너비)
3. ⏸ React가 렌더링을 잠시 중단 (Content가 무거우니까)
4. ⚡ 그 사이에 사용자가 창을 줄여서 width = 800 으로 변함
5. ▶ React가 렌더링을 재개
6. Sidebar 렌더링 → width = 800 (방금 바뀐 값)
7. 화면에 Header(1200 기준) + Sidebar(800 기준)가 동시에 그려진다같은 정보를 가지고 그린 두 컴포넌트가 서로 다른 값을 기준으로 한 화면에 나타난다. 이게 바로 tearing이다. 특히 컴포넌트가 처음 마운트되며 각자 외부 값을 읽어오는 시점에 가장 두드러지고, 마운트 이후에는 “한쪽은 갱신됐는데 다른 쪽은 아직”이라는 짧은 시간 차로 비슷한 결과가 만들어진다.
useState와 useEffect로 만든 구독은 React 입장에서는 그저 평범한 state일 뿐이라, 이 문제를 막을 방법이 없다. 마운트 시점이든 그 이후든 React는 외부 상태가 바뀌었다는 사실을 렌더링 도중에는 알 수 없기 때문이다.
useSyncExternalStore 사용 방법
이 문제를 해결하기 위해 React 18은 useSyncExternalStore라는 훅을 새로 추가했다. 이름이 길지만 풀어보면 의미가 명확하다.
Sync(동기화) + External(외부) + Store(저장소)
외부 저장소를 React 트리와 동기적으로 맞춰주는 훅
const snapshot = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot? // optional
);ts1. subscribe
외부 store가 바뀔 때 React에 알려주는 함수다. React가 콜백을 하나 건네주면, 외부 store는 값이 바뀔 때마다 그 콜백을 호출해 주면 된다.
function subscribe(callback: () => void): () => void {
store.addListener(callback);
return () => store.removeListener(callback); // 정리 함수
}tsuseEffect 안에서 직접 addEventListener를 거는 게 아니라, 구독 등록 자체를 React에 넘기는 셈이다.
2. getSnapshot
외부 store의 현재 값을 반환하는 함수다. 렌더링 중에 React가 이 함수를 호출해서 “지금 store의 값이 뭐야?”를 물어본다.
function getSnapshot() {
return window.innerWidth;
}tsReact는 렌더링 도중에 getSnapshot을 호출해 외부 값을 읽고, 렌더 사이클 안에서 그 값이 바뀐 것을 감지하면 진행 중인 결과를 버리고 동기 렌더로 다시 그린다. 덕분에 한 번의 커밋 안에서는 모든 컴포넌트가 같은 값을 본다. 이게 tearing이 사라지는 이유다.
3. getServerSnapshot (선택)
서버 사이드 렌더링(SSR) 환경에서 사용한다. 서버에는 window가 없으니, 서버에서 안전하게 쓸 기본값을 따로 알려주는 용도다. 자세한 내용은 뒤에서 다룬다.
해결 1. useSyncExternalStore로 다시 작성하기
위에서 만든 useWindowWidth를 다시 써보자.
import { useSyncExternalStore } from "react";
function useWindowWidth() {
return useSyncExternalStore(
(callback) => {
window.addEventListener("resize", callback);
return () => window.removeEventListener("resize", callback);
},
() => window.innerWidth,
);
}tsx훨씬 짧다. 더 중요한 건 React가 이제 구독 시점을 직접 제어하기 때문에, 동시성 렌더링 중에 외부 값이 바뀌어도 한 화면 안에서는 같은 값을 보장한다는 점이다.
내부적으로 React는 다음과 같이 동작한다.
- 렌더링 도중
getSnapshot을 호출해 외부 값을 읽는다. - 렌더 사이클이 끝날 무렵, 그 사이에 외부 값이 바뀐 것이 감지되면 진행 중인 결과를 버리고 동기 렌더로 다시 그린다. 한 번의 커밋 안에서는 모든 컴포넌트가 반드시 같은 값을 보게 된다.
- 렌더가 끝난 뒤 외부 값이 바뀌면 일반적인 절차대로 다시 렌더링한다.
이 보장 덕분에 같은 store를 보는 모든 컴포넌트가 항상 같은 값을 본다. 더 이상 한 화면에 1200과 800이 섞일 일이 없다.
같은 패턴으로 온라인/오프라인 상태 구독하기
navigator.onLine을 구독하는 훅도 똑같이 만들 수 있다.
import { useSyncExternalStore } from "react";
function useOnlineStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
},
() => navigator.onLine,
);
}
function NetworkBadge() {
const isOnline = useOnlineStatus();
return <span>{isOnline ? "🟢 온라인" : "🔴 오프라인"}</span>;
}tsxuseEffect 시절보다 의도가 명확하다. “어떤 사건을 들을지”, “지금 값이 뭔지” 두 가지만 알려주면 동기화는 React가 알아서 한다.
문제 3. 객체를 반환했더니 무한 리렌더링이 일어난다
이제 좀 더 복잡한 값을 반환하고 싶어졌다고 하자. 윈도우의 가로/세로를 객체로 묶어서 반환하면 어떨까?
function useWindowSize() {
return useSyncExternalStore(
(callback) => {
window.addEventListener("resize", callback);
return () => window.removeEventListener("resize", callback);
},
() => ({ width: window.innerWidth, height: window.innerHeight }), // ❌
);
}tsx언뜻 자연스러워 보이지만, 이 코드는 Maximum update depth exceeded 에러를 띄우며 무한 리렌더링에 빠진다.
원인: 매번 새 객체를 반환한다
React는 Object.is로 이전 snapshot과 현재 snapshot을 비교해 변경 여부를 판단한다.
Object.is({ width: 1200, height: 800 }, { width: 1200, height: 800 }); // falsets내용은 같지만 매번 새로 만드는 객체이므로 참조가 달라 항상 “변경됨”으로 판단된다. 그래서 getSnapshot은 값이 실제로 바뀌지 않았다면 같은 참조를 반환해야 한다.
해결 3. 캐시를 컴포넌트 바깥에 두자
값이 바뀐 경우에만 새 객체를 만들고, 그렇지 않으면 이전 객체를 그대로 반환하자. 핵심은 캐시를 컴포넌트 바깥, 즉 모듈 스코프에 두는 것이다. 훅 함수 안에 let cache를 두면 매 렌더마다 다시 null로 초기화돼서 앞에서 본 무한 리렌더링 함정으로 그대로 돌아간다.
// 모듈 스코프에서 캐시 유지 — 컴포넌트 렌더와 무관하게 살아있다
let cache: { width: number; height: number } | null = null;
function getSnapshot() {
const next = { width: window.innerWidth, height: window.innerHeight };
if (cache && cache.width === next.width && cache.height === next.height) {
return cache; // 변하지 않았으면 이전 참조 그대로
}
cache = next;
return cache;
}
function subscribe(callback: () => void) {
window.addEventListener("resize", callback);
return () => window.removeEventListener("resize", callback);
}
function useWindowSize() {
return useSyncExternalStore(subscribe, getSnapshot);
}tsx결국 캐시는 “어딘가 컴포넌트 바깥”에서 유지돼야 한다. 매번 이렇게 직접 캐시 변수를 두는 건 번거로우니, 보통은 store가 값을 들고 있고 getSnapshot은 store에서 꺼내오기만 하는 형태로 설계한다. 다음 절에서 만들 store가 정확히 그 역할을 맡는다.
미니 외부 스토어 직접 만들기
useSyncExternalStore의 진짜 가치는 “외부 store”를 React에 깔끔하게 통합할 수 있다는 점이다. Zustand 같은 상태 관리 라이브러리도 내부적으로 이 훅을 사용한다.
가장 단순한 형태의 store를 만들어보자.
1. createStore: 구독 가능한 상태 컨테이너
type Listener = () => void;
function createStore<T>(initialState: T) {
let state = initialState;
const listeners = new Set<Listener>();
const getState = () => state;
const setState = (next: T | ((prev: T) => T)) => {
state = typeof next === "function" ? (next as (p: T) => T)(state) : next;
listeners.forEach((l) => l());
};
const subscribe = (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
return { getState, setState, subscribe };
}ts상태를 담는 변수, 구독자 목록, 그리고 subscribe / getState / setState 세 함수가 전부다. React는 어디에도 등장하지 않는다는 점에 주목하자.
2. React에서 사용하기
createStore로 만든 store를 컴포넌트에서 읽으려면 useSyncExternalStore로 연결하면 된다.
const counterStore = createStore({ count: 0 });
function useCounterStore() {
return useSyncExternalStore(counterStore.subscribe, counterStore.getState);
}
function Counter() {
const { count } = useCounterStore();
return (
<>
<h1>카운트: {count}</h1>
<button
onClick={() => counterStore.setState((s) => ({ count: s.count + 1 }))}
>
증가
</button>
</>
);
}tsxstore.getState는 같은 객체 참조를 그대로 반환하므로 위에서 봤던 무한 리렌더링도 발생하지 않는다. 컴포넌트 외부에서 store를 갱신해도, 같은 store를 구독하는 모든 컴포넌트가 자동으로 동기화된다.
이 정도가 Zustand가 가진 핵심 아이디어다. 셀렉터, 미들웨어, devtools 같은 살을 더 붙이면 라이브러리가 된다.
셀렉터: 필요한 값만 구독하기
지금은 count 하나뿐이라 괜찮지만, store가 커지면 일부 필드만 바뀌어도 모든 구독자가 리렌더링되는 문제가 생긴다. 셀렉터로 필요한 값만 골라 보면 된다.
type Store<T> = ReturnType<typeof createStore<T>>;
function useStoreSelector<T, U>(store: Store<T>, selector: (s: T) => U): U {
return useSyncExternalStore(store.subscribe, () =>
selector(store.getState()),
);
}tsx⚠️ 여기서 가장 흔히 밟는 함정: selector가 객체나 배열을 합성해 반환하면 매번 새 참조라 무한 리렌더링이 다시 시작된다.
React는 이 문제를 풀려고 useSyncExternalStoreWithSelector를 use-sync-external-store/shim/with-selector 경로에 별도 패키지로 두었다. 동등성 비교 함수(보통 shallowEqual)를 받아 같은 값이면 리렌더링을 건너뛴다. React 18 진입 시 호환성 부담을 줄이려는 목적으로 코어가 아닌 보조 패키지에 분리됐다.
SSR에서는 getServerSnapshot
마지막 인자인 getServerSnapshot은 서버 사이드 렌더링을 쓰는 경우에만 필요하다.
function useWindowWidth() {
return useSyncExternalStore(
(cb) => {
/* ... */
},
() => window.innerWidth, // 클라이언트
() => 1024, // 서버 (예: 데스크탑 기본값)
);
}tsx서버에는 window가 없기 때문에 getSnapshot을 그대로 호출하면 에러가 난다. getServerSnapshot을 따로 둬서 서버에서는 안전한 기본값을 반환하자.
이걸 빠뜨리면 서버에서 렌더된 HTML과 클라이언트의 첫 렌더 결과가 달라져 hydration mismatch 경고가 뜬다. 클라이언트 전용 정보(window, localStorage, navigator 등)를 다룰 때는 항상 서버용 값을 함께 정의하는 습관을 들이자.