Ray Book
React 18에서 19까지

외부 상태와 tearing

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 자체의 기능으로 통합되었는지를 정리합니다.