Ray Book
React 18에서 19까지

외부 상태와 동기화, useSyncExternalStore와 tearing

Concurrent Rendering이 도입한 미묘한 버그 'tearing'이 무엇이고, useSyncExternalStore가 어떻게 이를 해결하는지를 정리합니다.

reactreact18useSyncExternalStoretearingconcurrent

React 외부의 상태

React 컴포넌트는 자기만의 상태 (useState) 와 Context API로 대부분의 상태를 다룰 수 있습니다. 하지만 어떤 상태는 React 밖에 존재해야 합니다.

  • 외부 상태 관리 라이브러리 , Redux, Zustand 같은 라이브러리는 모듈 스코프에 스토어를 둡니다
  • 브라우저 API , window.location, localStorage, navigator.onLine 같은 값은 React가 모르는 사이에 변경될 수 있습니다
  • WebSocket, EventSource , 외부 데이터 소스가 비동기로 값을 푸시합니다

이런 외부 상태를 React 컴포넌트에서 사용하려면 구독 메커니즘이 필요합니다. 외부 값이 바뀌면 컴포넌트가 알아차리고 다시 렌더링되어야 합니다.

React 17까지는 이 패턴이 단순했습니다. useEffect로 구독하고, useState로 값을 보관하면 됐습니다.

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const update = () => setIsOnline(navigator.onLine);
    window.addEventListener('online', update);
    window.addEventListener('offline', update);
    return () => {
      window.removeEventListener('online', update);
      window.removeEventListener('offline', update);
    };
  }, []);

  return isOnline;
}

작동은 합니다. 하지만 React 18의 Concurrent Rendering에서 이 패턴은 미묘한 버그를 일으킬 수 있습니다.

Tearing, 화면이 찢어지는 버그

Tearing 은 같은 렌더링 사이클 안에서 컴포넌트들이 외부 상태의 서로 다른 값 을 읽는 현상입니다. 결과적으로 화면의 일부는 이전 값을, 다른 일부는 새 값을 보여줍니다, 마치 화면이 찢어진 것처럼.

Concurrent Rendering 이전에는 tearing이 발생할 수 없었습니다. 렌더링이 한번 시작되면 끝까지 동기적으로 진행되었기 때문에, 렌더링 도중에 외부 값이 바뀌어도 그 변경은 다음 렌더링에 반영되었습니다.

Concurrent Rendering에서는 이 보장이 깨집니다.

시간 →

T1: 렌더링 시작
T2:   ComponentA가 store.value를 읽음 → 5
T3:   (React가 렌더링을 일시 중단, 더 긴급한 작업 처리)
T4:   (이 사이에 외부에서 store.value가 6으로 변경됨)
T5:   (React가 렌더링 재개)
T6:   ComponentB가 store.value를 읽음 → 6
T7: 렌더링 완료

→ 같은 렌더링인데 A는 5, B는 6을 보여줌

이것이 tearing입니다. 사용자에게는 이렇게 보입니다, 헤더의 카운터는 5, 본문의 같은 카운터는 6. 같은 데이터인데 일치하지 않는 화면.

왜 useState로는 부족한가

useEffect + useState 패턴은 tearing에 취약합니다. 이유를 보면 명확합니다.

  1. 외부 값이 변경되면 useEffect 안의 핸들러가 호출됨
  2. setState로 React 상태를 업데이트
  3. React가 리렌더링을 스케줄함
  4. 이 사이에 새로운 렌더링이 이미 시작될 수 있음
  5. 그 렌더링은 이전 React 상태를 사용하므로 일관되지 않을 수 있음

문제의 핵심은 외부 값과 React 상태 사이에 이 있다는 것입니다. 외부 값이 바뀌고 React 상태가 동기화되기 전 사이에 렌더링이 일어날 수 있습니다.

useSyncExternalStore, 해결책

React 18은 이 문제를 해결하기 위해 useSyncExternalStore를 도입했습니다. 이름이 길지만 의미는 명확합니다, 외부 스토어를 동기적으로 사용하는 Hook 입니다.

import { useSyncExternalStore } from 'react';

function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,    // 구독 함수
    getSnapshot,  // 현재 값을 반환하는 함수
    getServerSnapshot // (선택) SSR 시 사용할 값
  );
}

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function getSnapshot() {
  return navigator.onLine;
}

function getServerSnapshot() {
  return true; // 서버에서는 항상 true로 가정
}

세 가지 인자를 받습니다.

  • subscribe , 외부 값이 변경될 때 호출될 콜백을 등록합니다. 등록 해제 함수를 반환해야 합니다
  • getSnapshot , 현재 값을 반환합니다. React는 이 값을 비교해 변경 여부를 판단합니다
  • getServerSnapshot , SSR 시 사용할 값입니다. 서버에서는 subscribe가 동작하지 않으므로 별도의 값이 필요합니다

useSyncExternalStore가 tearing을 막는 방법

핵심은 React가 렌더링 중에도 getSnapshot을 다시 호출 한다는 점입니다.

  1. 렌더링 시작
  2. useSyncExternalStoregetSnapshot을 호출하여 값 A를 받음
  3. 다른 컴포넌트가 또 getSnapshot을 호출
  4. 두 값이 다르면 React는 현재 렌더링을 버리고 동기적으로 다시 시작 합니다
  5. 모든 컴포넌트가 같은 스냅샷을 볼 때까지 반복

이렇게 React는 렌더링 사이클 안에서 모든 컴포넌트가 같은 외부 값을 읽도록 보장합니다. tearing이 원천적으로 차단됩니다.

getSnapshot의 안정성이 중요하다

getSnapshot은 같은 입력에 대해 같은 결과를 반환해야 합니다. 매번 새 객체를 만들면 React가 변경되었다고 잘못 판단하여 무한 루프에 빠질 수 있습니다.

// ❌ 나쁜 예, 매번 새 객체 생성
function getSnapshot() {
  return { online: navigator.onLine };
}

// ✅ 좋은 예, 원시 값 반환
function getSnapshot() {
  return navigator.onLine;
}

// ✅ 객체가 필요하다면 캐싱
let cachedSnapshot = null;
function getSnapshot() {
  const current = navigator.onLine;
  if (!cachedSnapshot || cachedSnapshot.online !== current) {
    cachedSnapshot = { online: current };
  }
  return cachedSnapshot;
}

외부 상태 관리 라이브러리에서의 활용

useSyncExternalStore의 가장 큰 혜택을 받은 것은 외부 상태 관리 라이브러리들입니다. Zustand, Redux, Valtio 등이 모두 이 API를 채택하면서 React 18의 Concurrent Rendering과 안전하게 동작하게 되었습니다.

Zustand의 예

Zustand는 v4부터 useSyncExternalStore를 채택했고 (React 17 이하에서는 use-sync-external-store shim 사용), v5 (2024) 에서 shim을 걷어내고 React 18 이상의 네이티브 API로 완전히 전환했습니다.

// Zustand 내부 구현 (단순화)
function useStore(selector) {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState())
  );
}

사용자 코드는 이전과 거의 동일합니다. 하지만 내부에서는 useSyncExternalStore를 통해 tearing을 방지합니다.

Redux의 예

Redux도 react-redux v8부터 useSyncExternalStore를 사용합니다.

// react-redux의 useSelector (개념적 단순화)
function useSelector(selector) {
  const store = useStore();
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState())
  );
}

직접 작성하는 경우

직접 외부 값을 다룰 때 useSyncExternalStore를 사용하는 것이 베스트 프랙티스입니다. 몇 가지 흔한 케이스를 보겠습니다.

미디어 쿼리 구독

function useMediaQuery(query) {
  const subscribe = useCallback((callback) => {
    const mql = window.matchMedia(query);
    mql.addEventListener('change', callback);
    return () => mql.removeEventListener('change', callback);
  }, [query]);

  const getSnapshot = () => window.matchMedia(query).matches;
  const getServerSnapshot = () => false;

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

// 사용
const isDark = useMediaQuery('(prefers-color-scheme: dark)');

localStorage 구독

function useLocalStorage(key) {
  const subscribe = useCallback((callback) => {
    window.addEventListener('storage', callback);
    return () => window.removeEventListener('storage', callback);
  }, []);

  const getSnapshot = () => localStorage.getItem(key);
  const getServerSnapshot = () => null;

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

storage 이벤트는 다른 탭에서의 변경만 감지합니다. 같은 탭의 변경을 감지하려면 추가 작업이 필요하지만, 핵심 패턴은 동일합니다.

베스트 프랙티스 정리

1. 외부 값에는 무조건 useSyncExternalStore. useState + useEffect 패턴은 React 18에서 tearing 위험이 있습니다. 새로 작성하는 코드는 useSyncExternalStore를 사용합니다.

2. getSnapshot은 안정적이어야 한다. 같은 외부 상태에 대해 같은 참조를 반환해야 합니다. 매번 새 객체를 반환하면 안 됩니다.

3. SSR을 고려한다면 getServerSnapshot을 제공한다. 서버에서는 navigator, window, localStorage 같은 것이 없습니다.

4. 외부 라이브러리를 쓴다면 useSyncExternalStore 채택 여부를 확인한다. Zustand v4+, react-redux v8+, Valtio 등은 이미 채택했습니다. 오래된 라이브러리는 React 18에서 tearing 위험이 있을 수 있습니다.

5. selector 패턴을 사용한다면 셀렉터의 안정성도 확인한다. Zustand에서 객체를 반환하는 selector는 shallow 비교나 useShallow를 사용해야 매번 리렌더링되지 않습니다.

// ❌ 매번 새 객체
const { count, name } = useStore((state) => ({
  count: state.count,
  name: state.name
}));

// ✅ Zustand v5의 useShallow
import { useShallow } from 'zustand/react/shallow';
const { count, name } = useStore(
  useShallow((state) => ({ count: state.count, name: state.name }))
);

다음 단계

다음 글에서는 React 19가 도입한 새로운 패러다임인 Actions 를 다룹니다. 폼 제출, mutation, 낙관적 업데이트, 이전에는 수동으로 관리해야 했던 모든 것이 어떻게 React 자체의 기능으로 통합되었는지를 정리합니다.