useSyncExternalStore, 왜 써야 할까?
SSR 환경에서 localStorage, window.innerWidth, matchMedia 같은 클라이언트 전용 값에 의존하는 렌더링을 해야 할 때가 있습니다. 서버에는 존재하지 않는 값이니 보통 useEffect + useState로 마운트 후에 동기화하는 패턴을 쓰게 됩니다. 그런데 이 패턴을 사용하면 react-hooks/set-state-in-effect 린트 규칙이 경고를 표시합니다. Effect 내부에서 동기적으로 setState를 호출하면 연쇄 렌더링(cascading renders)이 발생할 수 있다는 이유입니다. React 공식 문서에서도 이런 상황에 useSyncExternalStore를 권장하고 있습니다. 왜 그런지 정리해봤습니다.
useSyncExternalStore에 대해서
const snapshot = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);- subscribe — 스토어가 변경될 때 호출할 콜백을 등록하는 함수
- getSnapshot — 스토어의 현재 값을 반환하는 함수
- getServerSnapshot (Optional) — SSR 시 사용할 값을 반환하는 함수
React 외부에 존재하는 값(브라우저 API, 전역 변수, 서드파티 스토어 등)을 React 컴포넌트에 안전하게 연결해주는 훅입니다.
흔한 패턴: useEffect + useState
localStorage에 저장된 최근 검색어 목록을 읽어 표시하는 컴포넌트를 만든다고 해봅시다. localStorage는 서버에 존재하지 않으므로 보통 이렇게 작성합니다.
function RecentSearches() {
const [searches, setSearches] = useState<string[]>([]); // 서버에서의 기본값
useEffect(() => {
const stored = localStorage.getItem("recentSearches");
setSearches(stored ? JSON.parse(stored) : []);
const handleStorage = (e: StorageEvent) => {
if (e.key === "recentSearches") {
setSearches(e.newValue ? JSON.parse(e.newValue) : []);
}
};
window.addEventListener("storage", handleStorage);
return () => {
window.removeEventListener("storage", handleStorage);
};
}, []);
return (
<ul>
{searches.map((query) => (
<li key={query}>{query}</li>
))}
</ul>
);
}동작은 하지만 몇 가지 문제가 있습니다.
1. paint 후에 렌더링 사이클을 한 번 더 돕니다
useEffect는 일반적으로 브라우저가 paint한 이후에 실행됩니다. 하이드레이션이 완료되고 브라우저가 서버 값을 paint한 다음에야 useEffect 안의 setState가 호출되므로, 초기값과 실제 값이 다를 경우 추가 렌더링이 발생하며 깜빡임으로 보일 수 있습니다.
서버 HTML 도착 → 브라우저 paint → JS 로드 → 하이드레이션 시작
[useEffect + useState]
하이드레이션 완료 → paint → useEffect → setState → 리렌더링 → paint
↑
서버 값이 한 번 더 paint됨
[useSyncExternalStore]
하이드레이션(getServerSnapshot) → 커밋 직후 getSnapshot 비교 → 다르면 재렌더링 → paint반면 useSyncExternalStore는 getServerSnapshot으로 SSR 시점의 값을 렌더링해 hydration mismatch를 방지합니다. 이후 React는 커밋 직후 getSnapshot()을 호출해 클라이언트 실제 값과 다르면 해당 컴포넌트를 재렌더링하여 최종적으로 클라이언트 값으로 동기화합니다. 이 덕분에 서버에 존재하지 않는 값(예: localStorage, matchMedia)도 mismatch 에러 없이 다룰 수 있습니다.
2. 동시성 렌더링에서 테어링이 발생할 수 있습니다
테어링은 하나의 렌더링 안에서 같은 외부 값을 읽었는데 컴포넌트마다 다른 값을 보여주는 현상입니다. 직접 테어링이 발생하는 모습을 확인해볼 수 있는 데모를 만들었습니다.
React 18의 Concurrent Rendering에서는 렌더링 도중 중단과 재개가 가능합니다. 그 사이에 외부 값이 바뀌면 중단 전에 렌더링된 컴포넌트와 재개 후에 렌더링된 컴포넌트가 서로 다른 값을 보게 됩니다.
외부 스토어 값: ["React", "Next.js"]
[렌더링 시작]
컴포넌트 A: ["React", "Next.js"] 읽음
[렌더링 중단] ← 다른 긴급한 작업 처리
... 이 사이에 외부 스토어 값이 ["React", "Next.js", "Vite"]로 변경 ...
[렌더링 재개]
컴포넌트 B: ["React", "Next.js", "Vite"] 읽음
컴포넌트 C: ["React", "Next.js", "Vite"] 읽음
[렌더링 끝]
결과:
컴포넌트 A → ["React", "Next.js"]
컴포넌트 B → ["React", "Next.js", "Vite"]
컴포넌트 C → ["React", "Next.js", "Vite"]
같은 렌더링인데 A만 다른 값을 표시 = 테어링!useEffect + useState로 외부 값을 동기화하면 React는 렌더링 도중에 값이 바뀌었는지 알 수 없기 때문에 이 문제를 막을 방법이 없습니다.
useSyncExternalStore로 개선
같은 기능을 useSyncExternalStore로 작성하면 이렇습니다.
const subscribeRecentSearches = (callback: () => void) => {
const handler = (e: StorageEvent) => {
if (e.key === "recentSearches") callback();
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
};
function RecentSearches() {
const searches = useSyncExternalStore(
subscribeRecentSearches,
() => localStorage.getItem("recentSearches") ?? "[]",
() => "[]", // 서버에서의 기본값
);
return (
<ul>
{(JSON.parse(searches) as string[]).map((query) => (
<li key={query}>{query}</li>
))}
</ul>
);
}세 번째 인자 getServerSnapshot이 핵심입니다. 서버에서는 () => "[]"가 호출되어 안전하게 빈 배열을 반환하고, 클라이언트에서는 () => localStorage.getItem("recentSearches") ?? "[]"가 호출되어 실제 값을 읽습니다.
하이드레이션 시점에는 getServerSnapshot이 반환한 값으로 먼저 렌더링되어 서버 HTML과 일치하므로 mismatch가 발생하지 않습니다. 이후 React는 커밋 직후 getSnapshot()을 호출해 클라이언트 실제 값이 서버 스냅샷과 다르면 해당 컴포넌트를 재렌더링하여 클라이언트 값으로 동기화합니다. useEffect의 passive effect 스케줄링을 거치지 않으므로 교체가 더 빠르고, 두 값이 같다면 추가 렌더링 없이 하이드레이션이 완료됩니다.
테어링도 방지됩니다. useSyncExternalStore는 렌더링 중에 getSnapshot을 호출해서 값을 읽고, 렌더링이 중단·재개되었을 때 값이 바뀌었다면 현재 렌더를 버리고 새로운 렌더를 시작하여 모든 컴포넌트가 동일한 값을 보도록 보장합니다.
useEffect + useState | useSyncExternalStore | |
|---|---|---|
| 테어링 방지 | ❌ | ✅ |
| 렌더링 사이클 | 하이드레이션 후 paint를 한 번 더 거침 | 하이드레이션 직후 getSnapshot 비교 후 재렌더링 |
| 보일러플레이트 | 중간(useState + useEffect) | 큼 (subscribe, getSnapshot, getServerSnapshot) |
정리
useSyncExternalStore는 클라이언트의 외부 값을 구독하면서 테어링을 방지하고, getServerSnapshot으로 하이드레이션 미스매치 에러까지 안전하게 처리할 수 있는 권장되는 방법입니다. 브라우저 API 값(localStorage, matchMedia, window.scrollY 등)을 렌더링해야 하는 상황이라면 useEffect + useState 조합보다 useSyncExternalStore를 먼저 고려해보세요.
모든 클라이언트 분기에 이 훅을 써야 하는 것은 아닙니다. 단순히 마운트 여부만 확인하는 경우처럼 구독할 외부 값이 없는 상황에서는 useEffect가 더 간결합니다.
// 마운트 여부만 확인하는 경우 — 구독할 외부 값이 없으므로 useEffect가 적절
const [isMounted, setIsMounted] = useState(false);
useEffect(() => setIsMounted(true), []);기준은 간단합니다. 변화하는 외부 값을 구독해야 하면 useSyncExternalStore, 한 번 확인하고 끝이면 useEffect입니다.