Intro
React 18에서 도입된 동시성 렌더링(Concurrent Rendering)은 사용자 경험을 크게 개선했지만, 동시에 외부 상태 관리에서 예상치 못한 문제를 야기했습니다. 이 글에서는 동시성 렌더링이 무엇인지, 왜 기존의 useEffect 방식에 문제가 생겼는지, 그리고 useSyncExternalStore가 어떻게 이를 해결하는지 알아보겠습니다.
동시성 렌더링이란?
React 18 이전의 렌더링은 동기적(Synchronous)이었습니다. 한 번 렌더링이 시작되면 중단 없이 완료될 때까지 진행되었죠.
React 18의 동시성 렌더링은 렌더링 작업을 중단하고 재개할 수 있습니다. 이를 통해 다음과 같은 이점을 얻습니다:
- 긴급한 업데이트(사용자 입력 등)를 우선 처리
- 덜 중요한 업데이트는 백그라운드에서 처리
- 더 나은 사용자 인터렉션 반응성
하지만 이러한 유연성은 새로운 문제를 만들어냈습니다.
Tearing 문제: 동시성의 부작용
Tearing은 UI의 서로 다른 부분이 같은 데이터의 다른 버전을 표시하는 현상입니다. 마치 화면이 "찢어진" 것처럼 보이죠.
동기적 렌더링에서는 문제없었습니다.
아래 이미지지는 동기적 렌더링에서의 외부 스토어와 렌더링 흐름을 보여주는 이미지 입니다. 단계별로 설명드리도록 하겠습니다.

Step1: Start redering data is blue
리액트 렌더링 트리에서 첫번째 노드가 외부 저장소에서 상태를 읽어서 파란색으로 렌더링됐습니다.
Step2: Continue redering data is still blue
계속해서 다음 노드의 렌더링을 진행합니다. 여전히 외부 스토어의 상태를 참조해서 파란색으로 렌더링됩니다.
핵심은 중단없이 동기적으로 렌더링이 수행되기 때문에 모든 노드가 같은 데이터를 참조한다는 점입니다.
렌더링중에는 인터렉션이 불가함으로 외부 스토어의 업데이트가 불가합니다.
Step3: Finish redering, Ui consistent
세 가지 노드가 모두 파란색으로 일관되게 렌더링됐습니다.
Step4: After render, the external store can update
렌더링 완료 이후에 외부 스토어가 빨간색으로 업데이트됐습니다. 하지만 핵심은 렌더링 컴포넌트가 모두 일관된 상태를 가진다는 점입니다. 외부 스토어와 동기화되는 것은 다음 렌더링에서 수행되면 됩니다.
동기적 렌더링에서는 렌더링이 중단되지 않으므로, 모든 컴포넌트가 같은 버전의 데이터를 읽습니다.
이번에는 동시성 렌더링의 케이스를 살펴보겠습니다.

useEffect의 한계: 기존 외부 상태 구독 패턴
React 18 이전에는 외부 store를 다음과 같이 구독했습니다:
function useExternalStore(store) {
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setState(store.getState());
});
return unsubscribe;
}, [store]);
return state;
}
왜 이 방식이 문제일까?
1. useEffect는 렌더링 후에 실행됩니다
// 렌더링 타임라인
렌더링 시작 → store.getState() 호출 (v1)
↓
렌더링 진행 중...
↓
렌더링 커밋
↓
useEffect 실행 → subscribe 시작
렌더링과 구독 사이에 시간차가 존재하여, 동시성 렌더링에서는 그 사이에 store가 변경될 수 있습니다.
2. 동시성 렌더링 중 일관성 보장 불가
// 문제 시나리오
1. 컴포넌트 A 렌더링 → store 읽음 (v1)
2. React 렌더링 중단
3. Store 업데이트 (v1 → v2)
4. 렌더링 재개
5. 컴포넌트 B 렌더링 → store 읽음 (v2)
6. Tearing 발생! ❌
3. 구독 타이밍 문제
useEffect는 브라우저가 화면을 그린 후에 실행되므로, 빠르게 변경되는 외부 상태를 추적하기에는 너무 느립니다.
useSyncExternalStore의 등장
React 18은 이러한 문제를 해결하기 위해 useSyncExternalStore 훅을 도입했습니다.
import { useSyncExternalStore } from 'react';
function useExternalStore(store) {
const state = useSyncExternalStore(
store.subscribe, // 구독 함수
store.getState, // 상태 가져오기
store.getServerState // 서버 사이드용 (선택)
);
return state;
}
useSyncExternalStore가 해결하는 방법
1. 렌더링 중 스냅샷 일관성 보장
// useSyncExternalStore의 동작
1. 렌더링 시작 → store 스냅샷 생성 (v1)
2. 컴포넌트 A 렌더링 → 스냅샷 읽음 (v1)
3. React 렌더링 중단
4. Store 업데이트 (v1 → v2)
5. 렌더링 재개 전 → React가 변경 감지!
6. React가 렌더링 폐기하고 v2로 재시작 ✅
7. 모든 컴포넌트가 v2로 일관되게 렌더링
2. 동기적 재렌더링 강제
외부 store가 변경되면, React는 즉시 동기적으로 재렌더링을 수행하여 UI 일관성을 보장합니다.
// 내부적으로 이런 로직
function useSyncExternalStore(subscribe, getSnapshot) {
const value = getSnapshot();
const [{ inst }, forceUpdate] = useState({ inst: { value, getSnapshot } });
useLayoutEffect(() => {
inst.value = value;
inst.getSnapshot = getSnapshot;
if (checkIfSnapshotChanged(inst)) {
forceUpdate({ inst });
}
}, [subscribe, value, getSnapshot]);
useEffect(() => {
if (checkIfSnapshotChanged(inst)) {
forceUpdate({ inst });
}
const handleStoreChange = () => {
if (checkIfSnapshotChanged(inst)) {
forceUpdate({ inst });
}
};
return subscribe(handleStoreChange);
}, [subscribe]);
return value;
}
실제 사용 예제: 전역 카운터 Store
// store.js
class CounterStore {
constructor() {
this.count = 0;
this.listeners = new Set();
}
getState = () => {
return this.count;
}
setState = (newCount) => {
this.count = newCount;
this.listeners.forEach(listener => listener());
}
subscribe = (listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
increment = () => {
this.setState(this.count + 1);
}
}
export const counterStore = new CounterStore();
잘못된 방법 (useEffect 사용)
// ❌ Tearing 발생 가능
function Counter() {
const [count, setCount] = useState(counterStore.getState());
useEffect(() => {
const unsubscribe = counterStore.subscribe(() => {
setCount(counterStore.getState());
});
return unsubscribe;
}, []);
return <div>Count: {count}</div>;
}
언제 useSyncExternalStore를 써야 할까?
사용해야 하는 경우
- 외부 상태 관리 라이브러리 구현: Redux, Zustand, Jotai 등
- 브라우저 API 구독: window.matchMedia, navigator.onLine, localStorage
- 외부 이벤트 스트림: WebSocket, Server-Sent Events
- 서드파티 observable 라이브러리: RxJS 등
// 브라우저 API 예제
function useOnlineStatus() {
const isOnline = useSyncExternalStore(
(callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
() => navigator.onLine,
() => true // 서버 사이드에서는 항상 true
);
return isOnline;
}
사용하지 않아도 되는 경우
- React 내부 상태: useState, useReducer는 이미 안전함
- React Context: Context API는 이미 tearing 방지됨
- Props: Props는 React가 관리하므로 안전함
마무리
React 18의 동시성 렌더링은 사용자 경험을 크게 향상시켰지만, 외부 상태 관리에 새로운 도전을 가져왔습니다. useEffect 기반의 구독 패턴은 더 이상 안전하지 않으며, UI tearing 문제를 야기할 수 있습니다.
useSyncExternalStore는 이 문제를 해결하기 위해 설계된 훅으로:
- 렌더링 중 스냅샷 일관성 보장
- Tearing 방지
- 동시성 렌더링과 완벽한 호환
- 간결한 API
외부 상태를 다루는 라이브러리를 만들거나 브라우저 API를 구독한다면, useSyncExternalStore를 사용하는 것이 필수입니다. React 18의 동시성 기능을 안전하게 활용하면서도 일관된 UI를 제공할 수 있습니다.
'react' 카테고리의 다른 글
| 컴포넌트에서 서버 데이터 가져오기 (0) | 2026.01.05 |
|---|---|
| 왜 내 코드의 Effect가 실행됐을까? (0) | 2025.12.29 |
| 함수 컴포넌트 시대에 돌아보는 class 문법 (0) | 2025.12.24 |
| 함수 컴포넌트와 클로저 (0) | 2025.12.23 |
| React에서 상태를 초기화하는 기준 (0) | 2025.12.22 |