React 18에는 많은 변화가 있었다. 그 중 동시성 렌더링 (Concurrent Rendering)의 도입으로, 기존 상태 업데이트 방식으로 놓칠 수 있는 문제가 생겼다. Tearing(렌더링 중 상태 스냅샷 불일치) , 외부 스토어(Redux,Zustand, localstorage 등)와 React의 불일치 문제가 대표적이다.
이 글은 useSyncExternalStore
가 어떤 문제를 해결하려고 등장했는지, 내부적으로 어떤 원리로 동작하는지 살펴보려고한다.
근본적인 문제는 동시성 렌더링과 Tearing 현상 때문이다.
이전 React 버전에서는 렌더링 작업이 기본적으로 동기(Synchronous) 방식으로 이루어졌다. 이는 수백 개의 컴포넌트가 동시에 렌더링될 때, 해당 작업이 완료될 때까지 다른 작업(사용자 입력, 화면 전환 등)이 블로킹되어 UI가 멈춘 것처럼 느껴지는 문제가 있었다.
이러한 문제를 해결하기 위해 React 18부터 동시성 렌더링이 도입되었다. 동시성 렌더링은 렌더링 작업을 더 작은 단위로 나누어 수행하고, 중간에 더 중요한 작업(예: 사용자 입력)이 발생하면 현재 렌더링을 일시 중지하고 해당 작업을 먼저 처리한 후 다시 렌더링을 이어서 진행할 수 있도록 한다. 이를 통해 UI의 응답성을 향상시킬 수 있게 되었다.
하지만 동시성 렌더링이 도입되면서 새로운 문제가 생겼는데, 바로 Tearing(찢어짐) 현상이다.
Tearing은 하나의 상태를 참조하는 여러 컴포넌트가 서로 다른 시점의 데이터를 가지고 렌더링되어 UI에 시각적인 불일치(inconsistency)가 발생하는 현상을 말한다
예를 들어, React가 렌더링 작업을 하다가 잠시 멈추고 다른 우선순위 높은 작업을 처리하는 사이에, React 외부의 스토어(예: Redux, MobX 등)에 저장된 데이터가 변경될 수 있다. 이때, 이전에 렌더링을 시작한 컴포넌트들은 변경 전 데이터를 참조하고, 이후에 다시 렌더링되는 컴포넌트들은 변경 후 데이터를 참조하게 되어 화면에 찢어진 듯한 불일치가 발생할 수 있다.
이러한 Tearing 현상을 방지하기 위해 React는 useSyncExternalStore 훅을 도입하게되었다. 이 훅은 외부 스토어의 데이터를 React의 동시성 렌더링 메커니즘과 동기화하여 Tearing 현상이 발생하지 않도록 보장한다.
useSyncExternalStore
는 렌더링이 시작되기 전 또는 커밋 단계(실제 DOM에 반영되는 단계)에서 외부 스토어의 데이터 일관성을 확인하고, 불일치가 감지되면 동기 모드로 강제로 재렌더링하여 모든 컴포넌트가 동일한 최신 데이터를 기반으로 렌더링되도록 한다.
useSyncExternalStore는 React 18에서 도입된, 외부 스토어와 React 렌더링 사이의 일관성을 보장하기 위한 표준 훅이다.
useSyncExternalStore
훅은 React 외부 상태 관리 라이브러리와 통합하는 경우, 브라우저 스토리지와 같은 외부 데이터를 구독하는 경우 와 함께 사용할 수 있다.
컴포넌트 최 상위 레벨에서 useSyncExternalStore
를 호출하여 외부 데이터 저장소에서 값을 읽을 수 있다.
이 훅은 총 3가지의 인자를 받는다.
const snapshot = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot? // SSR용, 선택
);
각각의 역할은 다음과 같다:
외부 스토어에서 값이 변경될 때 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);
};
}
현재 외부 스토어의 최신 값을 가져오는 함수.
렌더링 중 이 값을 읽어, 렌더링 전체에서 동일한 snapshot을 보장한다.
function getSnapshot() {
return navigator.onLine;
}
서버 사이드 렌더링(SSR)에서 사용할 snapshot을 제공한다.
예를들어 window.InnterWidth
와 같은 속성은 SSR에는 없으니 기본값으로 1024를 넣어주어서 mismatch를 방지할 수 있다.
function getServerSnapshot() {
return 1024; // 서버에서 생성된 HTML에는 항상 "Online"을 표시합니다.
}
이런식으로 클라이언트 hydrate 시 mismatch 방지를 위해 필요하다.
아래 코드는 브라우저의 온라인 상태를 보여주는 코드이다.
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에서 이벤트 핸들러 등록
3. 렌더링 중 상태 변경 -> tearing(찢어짐 현상) 가능성 발생
useEffect
뿐만 아니라 useLayoutEffect
나 useInsertionEffect
로도 해결이 안된다.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>;
}
그렇다면 외부 스토어와는 어떤식으로 연결할까?
import { useSyncExternalStore } from 'react';
function useReduxSelector(selector) {
const store = useReduxStore(); // 예: Context에서 가져오기
return useSyncExternalStore(
store.subscribe,
() => selector(store.getState()),
() => selector(store.getState()) // SSR에서는 getState()의 기본값 사용
);
}
function useWindowWidth() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
},
() => window.innerWidth,
() => 1024 // SSR 기본값
);
}
브라우저 이벤트도 이런식으로 깔끔히 연결할 수 있다.
짱준님 useSyncExternalStore 잘 보고 가요~ (좋아요 꾹~)