상태관리 라이브러리 없이 진행하는 프로젝트가 있었다.
규모가 크지 않아서 Redux나 Zustand까지 끌어오는 건 좀 과한 느낌이라, useState랑 Context 정도로 버티고 있었다.
그러다 컴포넌트 트리 바깥에서 바뀌는 값을 화면 여기저기서 읽어야 하는 상황이 왔다. 예를 들면 사용자 설정 같은 거 — localStorage에 저장해두고, 다른 탭에서 값이 바뀌면 이쪽에서도 반영돼야 하는 값. Context로 묶기엔 너무 깊은 구석에서 쓰이는 값이라, 그냥 모듈 단위 변수에 구독 함수를 붙여서 작은 store를 직접 만들었다.
대충 이런 모양으로.
let value = readFromStorage();
const listeners = new Set();
const store = {
get: () => value,
set: (next) => {
value = next;
listeners.forEach((fn) => fn());
},
subscribe: (fn) => {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
문제는 이걸 React 컴포넌트에 어떻게 잇느냐였다. 처음엔 자연스럽게 이렇게 짰다.
function useStoreValue() {
const [value, setValue] = useState(store.get());
useEffect(() => store.subscribe(() => setValue(store.get())), []);
return value;
}
돌아는 갔다. 그런데 자료를 찾다 보니 "이렇게 짜면 동시성 렌더링에서 tearing이 날 수 있다"는 얘기가 자꾸 나왔다. 처음엔 무슨 소린지 잘 몰랐다. 그리고 React 18에 그 문제를 정확히 해결하라고 만들어둔 훅이 있다는 걸 알게 됐다.
useSyncExternalStore(subscribe, getSnapshot, ...);
이름이 길다. 그리고 "Sync"라는 단어가 묘하게 거슬렸다. 그냥 useState + useEffect로는 왜 안 되는 걸까. 그 답을 따라가다 보니 키워드 두 개가 같이 따라왔다. 동시성 렌더링(concurrent rendering) 과 tearing.
React 18에서 정식으로 들어온 그것. startTransition, useDeferredValue, Suspense 같은 기능의 바탕이다.
핵심 한 줄로 말하면, 렌더링이 중간에 멈췄다 재개될 수 있다.
예전 React는 한 번 렌더가 시작되면 끝까지 동기적으로 달렸다. 이제는 우선순위 높은 업데이트(예: 사용자 입력)가 끼어들면 진행 중이던 낮은 우선순위 렌더를 잠깐 멈췄다가 나중에 다시 한다. 심지어 중간 결과가 버려질 수도 있다.
성능적으론 좋은데, 이게 외부 상태랑 만나면 문제가 생긴다.
상황을 그려보자. 외부 store가 있고, 두 컴포넌트 A, B가 같은 값을 읽는다.
0을 읽는다.1로 바뀐다.1이 나온다.같은 한 번의 렌더에서 동일한 상태를 두 번 읽었는데 값이 다르다. 이게 tearing이다. 화면이 "찢어진" 것처럼 일관성이 깨졌다고 해서 붙은 이름.
useState로 관리하는 React 내부 상태는 React가 모든 걸 알고 스케줄링하니까 이 문제가 없다. 문제는 React 바깥의 상태들이다. Zustand store, Redux store, 전역 변수, window.matchMedia, localStorage, navigator.onLine... React 모르게 바뀌는 모든 것.
이 훅의 일은 한 줄로 요약된다. "외부 store를 React의 렌더 사이클과 안전하게 동기화한다."
API는 단순하다.
const value = useSyncExternalStore(
subscribe, // (callback) => unsubscribe
getSnapshot, // () => 현재 값
getServerSnapshot // () => SSR용 값 (선택)
);
세 인자가 각각 뭘 하는지.
subscribe: store가 바뀔 때 React에게 알려달라고 콜백을 등록하는 함수. 해제용 함수를 리턴한다.getSnapshot: 지금 시점의 store 값을 반환. React가 필요할 때마다 호출한다.getServerSnapshot: SSR 환경에서 hydration용으로 쓰는 값. 클라이언트 전용 코드면 안 써도 된다.React 내부에서 대충 이런 일이 벌어진다.
getSnapshot()을 호출해서 값을 읽는다.getSnapshot()을 부른다. 렌더 시작 때 읽었던 값과 다르면? "어, 그 사이에 바뀌었네" 하고 동시성 렌더링을 포기한 뒤 동기적으로 처음부터 다시 렌더한다.요약하면, 외부 store는 동시성 최적화 일부를 포기하는 대신 정확성을 얻는다. 이름이 Sync인 이유가 여기 있다. 동시성을 살짝 깨고서라도 동기화는 보장하겠다는 뜻이다.
useState + useEffect 조합으로 흔히 짜는 useMediaQuery를 useSyncExternalStore로 다시 짜보자.
function useMediaQuery(query) {
const subscribe = (callback) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
};
const getSnapshot = () => window.matchMedia(query).matches;
return useSyncExternalStore(subscribe, getSnapshot);
}
useState + useEffect로 짠 버전과 동작은 비슷해 보이는데, 결정적인 차이는 두 가지다.
getServerSnapshot만 넣어주면 hydration mismatch도 줄어든다.물론 미디어 쿼리 정도면 tearing이 생겨도 사용자가 거의 못 알아챈다. 근데 store 값이 카운터나 라벨처럼 같은 화면 곳곳에 노출되는 거라면 얘기가 다르다.
처음 써보면 거의 100% 한 번씩 만나는 실수가 있다.
// 무한 렌더링 지옥
const todos = useSyncExternalStore(
subscribe,
() => store.getState().todos.filter(t => !t.done),
);
React는 getSnapshot의 반환값이 이전과 같은지 ===로 비교한다. 근데 filter는 매번 새 배열을 만들어내니까 항상 다르다고 판단된다. → 매번 리렌더 → 또 새 배열 → 또 리렌더... 무한 루프.
해결은 두 가지다. 첫째, store 쪽에서 "파생값"을 미리 계산해서 들고 있는다.
// store 변경 시점에만 캐싱
function setTodos(next) {
state.todos = next;
state.activeTodos = next.filter(t => !t.done); // 미리 만들어둔다
emit();
}
둘째, use-sync-external-store/shim/with-selector의 useSyncExternalStoreWithSelector를 쓴다. 셀렉터와 비교 함수를 따로 받아서 안전하게 비교해준다. Redux가 이걸 쓴다.
핵심 감각은 이거다. getSnapshot은 매번 같은 참조를 돌려줄 수 있어야 한다. 1차 데이터를 그대로 돌려주는 게 가장 안전하고, 변형이 필요하면 store 쪽에서 미리 해두는 게 깔끔하다. 훅 안에서 파생값을 만들고 싶다는 욕구가 자꾸 생기는데, 여기선 참아야 한다.
솔직히 거의 없다. Zustand, Jotai, Redux 같은 라이브러리가 다 깔고 있다. 우리는 그 라이브러리들의 셀렉터/구독 API만 잘 쓰면 되고, 내부에서 useSyncExternalStore는 알아서 돌아간다.
직접 쓸 만한 경우를 굳이 꼽자면 이 정도다.
window.matchMedia, online/offline, scroll, localStorage 같은 브라우저 자체 상태이 정도 케이스가 아니면 평소엔 useState로 충분하다. 이번 프로젝트가 딱 두 번째 경우였다. 라이브러리는 안 쓰는데 모듈 단위 store는 필요해서, 결국 직접 잇대게 됐다.
평소 안 쓰는 훅인데, 왜 있는지 알고 나니까 React 18의 동시성 기능들이 좀 더 잘 보였다. startTransition, useDeferredValue, Suspense가 작동하는 그 "중간에 멈출 수 있는 렌더"라는 개념이 머릿속에서 한 칸 또렷해졌다.
tearing이라는 단어 하나 알고 나니까 — 그게 왜 문제고, 어떻게 막는지까지 — React 18의 모양이 한 줄 더 정돈된 느낌이다.
공식 문서가 의외로 잘 정리돼 있어서 거의 여기서 다 가져왔다.
공식 문서
같이 보면 좋은 글