← 홈으로

useSyncExternalStore

React 19 + Compiler 환경에서 외부 상태를 안전하게 동기화하는 방법

문제 상황: 흔히 사용하던 isMount 패턴

마운트 여부를 확인하기 위해 다음과 같은 코드를 작성해본 적이 있으신가요?

1// 흔히 사용하던 isMount 패턴
2import { useState, useEffect } from 'react';
3 
4const Playground = () => {
5 const [isMounted, setIsMounted] = useState(false);
6 
7 useEffect(() => {
8 setIsMounted(true);
9 
10 return () => {
11 setIsMounted(false);
12 };
13 }, []);
14 
15 return <div>{isMounted ? '마운트 됨' : '마운트 되기 전'}</div>;
16};
⚠️

React 19 + Compiler 환경에서 발생하는 경고

Error: Calling setState synchronously within an effect can trigger cascading renders

Effects are intended to synchronize state between React and external systems. Calling setState synchronously within an effect body causes cascading renders that can hurt performance.

💡

경고 메시지 해석

  1. Effect의 목적: Effect는 React와 외부 시스템(DOM, 상태 관리 라이브러리, 플랫폼 API 등) 간의 동기화를 위한 것입니다.
  2. Effect 내에서 해야 할 것: 외부 시스템을 React의 최신 상태로 업데이트하거나, 외부 시스템의 변경을 구독하고 콜백 함수 내에서 setState를 호출
  3. 문제점: Effect 본문에서 동기적으로 setState를 호출하면 연쇄 렌더링(cascading renders)이 발생하여 성능이 저하됩니다.

즉, useEffect 내에서 setIsMounted(true) 직접 호출하는 것은 Effect의 올바른 사용법이 아닙니다!

해결책: useSyncExternalStore

React는 이런 상황을 위해 useSyncExternalStore 훅을 제공합니다. 이 훅은 외부 상태를 React 렌더링 사이클과 안전하게 동기화합니다.

Before / After

코드 비교

useEffect 방식과 useSyncExternalStore 방식을 비교해보세요. 실행 결과는 동일하지만, 구현 방식이 다릅니다.

useSyncExternalStore 방식
1// GOOD: useSyncExternalStore 사용 (권장)
2import { useSyncExternalStore } from 'react';
3 
4// 빈 구독 함수 (상태 변화 없음)
5const subscribe = () => () => {};
6 
7// 클라이언트: 항상 true
8const getSnapshot = () => true;
9 
10// SSR: 항상 false
11const getServerSnapshot = () => false;
12 
13function useIsMount() {
14 return useSyncExternalStore(
15 subscribe,
16 getSnapshot,
17 getServerSnapshot
18 );
19}
20 
21// 사용 예시
22function MyComponent() {
23 const isMounted = useIsMount();
24 return <div>{isMounted ? '마운트 됨' : '마운트 되기 전'}</div>;
25}
💡동작 원리
  1. 1.
    subscribe: 외부 스토어 변경을 구독합니다. isMount는 변하지 않으므로 빈 함수를 반환합니다.
  2. 2.
    getSnapshot: 클라이언트에서 현재 스냅샷을 반환합니다. 브라우저에서는 항상 true입니다.
  3. 3.
    getServerSnapshot: SSR에서 스냅샷을 반환합니다. 서버에서는 항상 false입니다.

3가지 매개변수 이해하기

useSyncExternalStore는 세 가지 매개변수를 받습니다.

subscribe

외부 스토어의 변경을 구독하는 함수입니다. 콜백을 받아 변경 시 호출하고, cleanup 함수를 반환해야 합니다.

getSnapshot

현재 스토어 상태의 스냅샷을 반환합니다. 매번 같은 참조를 반환해야 무한 루프를 방지할 수 있습니다.

getServerSnapshot

SSR에서 사용할 스냅샷을 반환합니다. 객체 반환 시 캐시 필수. Next.js 등 서버 렌더링 환경에서 hydration 불일치를 방지합니다.

심화: LocalStorage TodoList

로컬 스토리지와 동기화되는 TodoList입니다. 다른 탭에서도 실시간 동기화됩니다.

데이터 흐름 다이어그램
TodoList 데이터 흐름 다이어그램

1. 외부 스토어 정의

1. 외부 스토어 정의 코드
1// stores/todoStore.ts
2const STORAGE_KEY = 'playground-todos';
3let listeners = new Set<() => void>();
4let todos: Todo[] = [];
5 
6export const todoStore = {
7 // 구독: localStorage + storage 이벤트
8 subscribe: (callback: () => void) => {
9 listeners.add(callback);
10 
11 // 다른 탭 동기화
12 const handleStorage = (e: StorageEvent) => {
13 if (e.key === STORAGE_KEY) {
14 todos = loadFromStorage();
15 callback();
16 }
17 };
18 window.addEventListener('storage', handleStorage);
19 
20 return () => {
21 listeners.delete(callback);
22 window.removeEventListener('storage', handleStorage);
23 };
24 },
25 
26 getSnapshot: () => todos,
27 getServerSnapshot: () => [],
28 
29 // 액션들
30 addTodo: (text: string) => { /* ... */ },
31 toggleTodo: (id: string) => { /* ... */ },
32 deleteTodo: (id: string) => { /* ... */ },
33};
TodoList
아직 할 일이 없습니다

다른 탭에서도 열어보세요. 실시간으로 동기화됩니다.

2. 커스텀 훅으로 감싸기

2. 커스텀 훅으로 감싸기 코드
1// hooks/useLocalStorageTodos.ts
2import { useSyncExternalStore } from 'react';
3import { todoStore } from '@/stores/todoStore';
4 
5function useLocalStorageTodos() {
6 const todos = useSyncExternalStore(
7 todoStore.subscribe,
8 todoStore.getSnapshot,
9 todoStore.getServerSnapshot
10 );
11 
12 return {
13 todos,
14 addTodo: todoStore.addTodo,
15 toggleTodo: todoStore.toggleTodo,
16 deleteTodo: todoStore.deleteTodo,
17 };
18}
사용법

useLocalStorageTodos()를 호출하면 todos와 액션들을 반환받습니다.

새 탭을 열어 같은 페이지에 접속하면 실시간으로 동기화되는 것을 확인할 수 있습니다.

💡동작 원리
  1. 1.
    storage 이벤트: 다른 탭에서 localStorage가 변경되면 storage 이벤트가 발생합니다.
  2. 2.
    실시간 동기화: subscribe에서 storage 이벤트를 구독하여 모든 탭에서 상태가 동기화됩니다.
  3. 3.
    참조 동일성: getSnapshot은 메모리에 캐시된 todos 배열을 반환하여 무한 루프를 방지합니다.

주의사항 (Gotchas)

useSyncExternalStore 사용 시 주의할 점들입니다.

getSnapshot에서 매번 새 객체 반환 금지

매 렌더링마다 새 객체를 반환하면 무한 루프가 발생합니다. 반드시 캐시된 값을 반환하세요.

getServerSnapshot 반드시 제공 + 캐싱

Next.js 등 SSR 환경에서는 세 번째 인자를 반드시 제공해야 hydration 에러를 방지할 수 있습니다. 객체 반환 시 getSnapshot과 동일하게 캐시 필수(무한 루프 방지).

💡

subscribe의 cleanup 함수 반환

이벤트 리스너를 등록한 경우 반드시 cleanup 함수에서 제거해야 메모리 누수를 방지할 수 있습니다.