React 18부터 도입된 useSyncExternalStore 훅 살펴보기

Changjun·2025년 7월 22일
2

이번에야말로

목록 보기
9/12
post-thumbnail

React 18에는 많은 변화가 있었다. 그 중 동시성 렌더링 (Concurrent Rendering)의 도입으로, 기존 상태 업데이트 방식으로 놓칠 수 있는 문제가 생겼다. Tearing(렌더링 중 상태 스냅샷 불일치) , 외부 스토어(Redux,Zustand, localstorage 등)와 React의 불일치 문제가 대표적이다.

이 글은 useSyncExternalStore가 어떤 문제를 해결하려고 등장했는지, 내부적으로 어떤 원리로 동작하는지 살펴보려고한다.

useSyncExternalStore의 등장 배경

근본적인 문제는 동시성 렌더링과 Tearing 현상 때문이다.

1. 동기 렌더링의 문제점

이전 React 버전에서는 렌더링 작업이 기본적으로 동기(Synchronous) 방식으로 이루어졌다. 이는 수백 개의 컴포넌트가 동시에 렌더링될 때, 해당 작업이 완료될 때까지 다른 작업(사용자 입력, 화면 전환 등)이 블로킹되어 UI가 멈춘 것처럼 느껴지는 문제가 있었다.

2. 동시성 렌더링 도입 (Concurrent Rendering)

이러한 문제를 해결하기 위해 React 18부터 동시성 렌더링이 도입되었다. 동시성 렌더링은 렌더링 작업을 더 작은 단위로 나누어 수행하고, 중간에 더 중요한 작업(예: 사용자 입력)이 발생하면 현재 렌더링을 일시 중지하고 해당 작업을 먼저 처리한 후 다시 렌더링을 이어서 진행할 수 있도록 한다. 이를 통해 UI의 응답성을 향상시킬 수 있게 되었다.

3. Tearing 현상 발생

하지만 동시성 렌더링이 도입되면서 새로운 문제가 생겼는데, 바로 Tearing(찢어짐) 현상이다.
Tearing은 하나의 상태를 참조하는 여러 컴포넌트가 서로 다른 시점의 데이터를 가지고 렌더링되어 UI에 시각적인 불일치(inconsistency)가 발생하는 현상을 말한다

예를 들어, React가 렌더링 작업을 하다가 잠시 멈추고 다른 우선순위 높은 작업을 처리하는 사이에, React 외부의 스토어(예: Redux, MobX 등)에 저장된 데이터가 변경될 수 있다. 이때, 이전에 렌더링을 시작한 컴포넌트들은 변경 전 데이터를 참조하고, 이후에 다시 렌더링되는 컴포넌트들은 변경 후 데이터를 참조하게 되어 화면에 찢어진 듯한 불일치가 발생할 수 있다.

4. useSyncExternalStore 훅의 등장

이러한 Tearing 현상을 방지하기 위해 React는 useSyncExternalStore 훅을 도입하게되었다. 이 훅은 외부 스토어의 데이터를 React의 동시성 렌더링 메커니즘과 동기화하여 Tearing 현상이 발생하지 않도록 보장한다.

useSyncExternalStore는 렌더링이 시작되기 전 또는 커밋 단계(실제 DOM에 반영되는 단계)에서 외부 스토어의 데이터 일관성을 확인하고, 불일치가 감지되면 동기 모드로 강제로 재렌더링하여 모든 컴포넌트가 동일한 최신 데이터를 기반으로 렌더링되도록 한다.

useSyncExternalStore 사용법

useSyncExternalStore는 React 18에서 도입된, 외부 스토어와 React 렌더링 사이의 일관성을 보장하기 위한 표준 훅이다.

useSyncExternalStore 훅은 React 외부 상태 관리 라이브러리와 통합하는 경우, 브라우저 스토리지와 같은 외부 데이터를 구독하는 경우 와 함께 사용할 수 있다.

컴포넌트 최 상위 레벨에서 useSyncExternalStore 를 호출하여 외부 데이터 저장소에서 값을 읽을 수 있다.

이 훅은 총 3가지의 인자를 받는다.

const snapshot = useSyncExternalStore(
  subscribe,
  getSnapshot,
  getServerSnapshot? // SSR용, 선택
);

각각의 역할은 다음과 같다:

subscribe

외부 스토어에서 값이 변경될 때 React에게 알리기 위한 함수.
예: store.subscribe(callback)

// subscribe 예시 코드
function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

getSnapshot

현재 외부 스토어의 최신 값을 가져오는 함수.
렌더링 중 이 값을 읽어, 렌더링 전체에서 동일한 snapshot을 보장한다.

function getSnapshot() {
  return navigator.onLine;
}

getServerSnapshot (선택)

서버 사이드 렌더링(SSR)에서 사용할 snapshot을 제공한다.
예를들어 window.InnterWidth와 같은 속성은 SSR에는 없으니 기본값으로 1024를 넣어주어서 mismatch를 방지할 수 있다.

function getServerSnapshot() {
  return 1024; // 서버에서 생성된 HTML에는 항상 "Online"을 표시합니다.
}

이런식으로 클라이언트 hydrate 시 mismatch 방지를 위해 필요하다.

useSyncExternalStore가 해결하려는 문제

아래 코드는 브라우저의 온라인 상태를 보여주는 코드이다.

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const handleChange = () => setIsOnline(navigator.onLine);
    window.addEventListener('online', handleChange);
    window.addEventListener('offline', handleChange);

    return () => {
      window.removeEventListener('online', handleChange);
      window.removeEventListener('offline', handleChange);
    };
  }, []);

  return <div>{isOnline ? '✅ Online' : '❌ Offline'}</div>;
}

이 코드의 문제점은 아래와 같다.

1. useState로 외부 값인 navigator.onLine을 읽는다.

  • 이 시점에서는 외부 값은 읽었지만, 이건 아직 렌더링 준비단계일 뿐 렌더링중 값이 바뀌면 감지할 방법이 없다.

2. useEffect에서 이벤트 핸들러 등록

  • useEffect는 DOM commit후 실행된다.
  • 즉, 렌더링 -> commit -> 이벤트 핸들러 연결.
  • 근데 만약 그 사이에 navigator.onLine 값이 바뀌어버리면???
    👉 변화가 발생했지만, 아직 이벤트 핸들러가 없으므로 감지 못함.

3. 렌더링 중 상태 변경 -> tearing(찢어짐 현상) 가능성 발생

  • 렌더링 도중에 일부 값이 바뀌어버리면, 일부 컴포넌트는 옛 값으로, 일부는 새 값으로 렌더링될 위험이 크다.
  • 이건 useEffect 뿐만 아니라 useLayoutEffectuseInsertionEffect로도 해결이 안된다.
    👉 이유? 이 훅들은 모두 렌더링 이후 실행되기 때문.

useSyncExternalStore는 이러한 문제를 어떻게 해결하는가?

  • 렌더링 시작 전에 getSnapshot()을 실행해 현재 snapshot을 한 번만 읽어 고정한다.
  • 렌더링 중 값이 바뀌어도, 이미 고정된 snapshot으로만 렌더링이 끝난다.
  • 값이 바뀌면 subscribe를 통해 React에 “다음 렌더에서 snapshot 새로 읽어라”라고만 알린다.
  • SSR 환경에서는 getServerSnapshot()으로 클라이언트-서버 mismatch를 방지한다.

개선된 코드

import { useSyncExternalStore } from 'react';

function useOnlineStatus() {
  return useSyncExternalStore(
    // subscribe: 값 변경 알림 구독
    (callback) => {
      // online/offline 이벤트 발생 시 callback 호출 → React에 업데이트 알림
      window.addEventListener('online', callback);
      window.addEventListener('offline', callback);
      return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
      };
    },
    () => navigator.onLine,
    () => true // SSR 환경에서 기본값
  );
}

function NetworkStatus() {
  const isOnline = useOnlineStatus();

  return <div>{isOnline ? '✅ Online' : '❌ Offline'}</div>;
}

외부스토어와 연결

그렇다면 외부 스토어와는 어떤식으로 연결할까?

1. Redux Store 연결

import { useSyncExternalStore } from 'react';

function useReduxSelector(selector) {
  const store = useReduxStore(); // 예: Context에서 가져오기

  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState()),
    () => selector(store.getState()) // SSR에서는 getState()의 기본값 사용
  );
}
  • store.subscribe: Redux의 구독 시스템 사용
  • getSnapshot: Redux state에서 selector로 필요한 값만 가져오기
  • SSR: 서버에서 기본값 제공

2. window resize 연결하기

function useWindowWidth() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener('resize', callback);
      return () => window.removeEventListener('resize', callback);
    },
    () => window.innerWidth,
    () => 1024 // SSR 기본값
  );
}

브라우저 이벤트도 이런식으로 깔끔히 연결할 수 있다.

⚠️ 주의할 점

  • subscribe 함수는 반드시 값이 바뀌었을 때만 알림을 보내야 한다. 따라서 불필요한 re-render를 유발하지 않도록 신경 써야 한다.
  • getSnapshot 함수는 항상 동기적이어야 한다. 비동기 값(fetch 등)은 이 패턴에 맞지 않는다.

1개의 댓글

comment-user-thumbnail
2025년 7월 23일

짱준님 useSyncExternalStore 잘 보고 가요~ (좋아요 꾹~)

답글 달기