React에서 전역 상태를 다루다 보면 Context와 useSyncExternalStore를 함께 마주치게 됩니다. 둘 다 여러 컴포넌트에서 값을 읽는 데 쓰일 수 있어서 비슷해 보이지만, 실제 역할은 꽤 다릅니다.
한 줄로 정리하면 이렇습니다. Context는 React 트리 안에서 값을 전달하는 도구이고, useSyncExternalStore는 React 바깥에서 변하는 값을 React 렌더링 흐름에 맞게 구독하는 도구입니다.
Context는 값을 전달하는 도구다
React Context는 props를 여러 단계로 계속 넘기지 않아도 하위 컴포넌트에서 공통 값을 읽을 수 있게 해줍니다. 테마, 로그인 사용자, 언어 설정, 앱 전역 설정처럼 여러 컴포넌트가 함께 알아야 하는 값을 내려줄 때 유용합니다.
하지만 Context 자체가 외부 store는 아닙니다. Provider의 value가 바뀌면 해당 Context를 읽는 컴포넌트들이 렌더링 영향을 받을 수 있지만, React 바깥의 mutable 값이 바뀌었다는 사실을 Context가 자동으로 추적해주지는 않습니다.
useSyncExternalStore는 외부 변경을 구독하는 도구다
useSyncExternalStore는 React 외부에 있는 값을 React 컴포넌트에서 안전하게 읽고 구독하기 위한 Hook입니다. 핵심 인자는 두 가지입니다.
subscribe: 값이 바뀌었을 때 React에 알려주는 함수getSnapshot: 현재 store 값을 읽는 함수
React state가 아닌 값은 React가 스스로 변경 시점을 알 수 없습니다. 그래서 외부 store는 subscribe를 통해 “값이 바뀌었다”는 신호를 보내고, React는 getSnapshot으로 현재 값을 다시 읽습니다.
변경 알림이 곧바로 리렌더링을 뜻하진 않는다
자주 헷갈리는 지점이 있습니다. 외부 store가 변경 알림을 보냈다고 해서 무조건 화면이 다시 그려진다고 말하면 부정확합니다.
React는 변경 알림을 받은 뒤 getSnapshot()을 다시 호출합니다. 그리고 이전 snapshot과 새 snapshot을 비교합니다. 이때 값이 달라졌다고 판단되면 해당 Hook을 사용하는 컴포넌트가 다시 렌더링됩니다.
따라서 getSnapshot()이 어떤 값을 반환하는지가 중요합니다. 특히 매번 새 객체를 반환하면 실제 데이터가 바뀌지 않았더라도 React 입장에서는 다른 값처럼 보일 수 있습니다.
getSnapshot: () => ({ count })
객체는 내용이 같아도 참조가 다르면 다른 값입니다. 외부 store를 만들 때는 snapshot이 안정적으로 비교될 수 있도록 설계해야 합니다.
Context와 useSyncExternalStore는 경쟁 관계가 아니다
Context와 useSyncExternalStore는 둘 중 하나를 고르는 관계가 아닙니다. 실무에서는 오히려 함께 쓰는 경우가 많습니다.
Context는 store 객체 자체를 하위 컴포넌트로 전달하고, 실제 값 구독은 useSyncExternalStore로 처리하는 방식입니다.
import { createContext, useContext, useState, useSyncExternalStore } from "react";
type CounterStore = {
getSnapshot: () => number;
subscribe: (listener: () => void) => () => void;
increment: () => void;
};
function createCounterStore(): CounterStore {
let count = 0;
const listeners = new Set<() => void>();
return {
getSnapshot: () => count,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
increment() {
count += 1;
listeners.forEach((listener) => listener());
},
};
}
const CounterStoreContext = createContext<CounterStore | null>(null);
function Counter() {
const store = useContext(CounterStoreContext);
if (store === null) {
throw new Error("CounterStoreContext가 필요합니다.");
}
const count = useSyncExternalStore(store.subscribe, store.getSnapshot);
return <button onClick={store.increment}>count: {count}</button>;
}
export default function App() {
const [store] = useState(createCounterStore);
return (
<CounterStoreContext value={store}>
<Counter />
</CounterStoreContext>
);
}
이 구조에서 Context는 store를 전달합니다. 그리고 Counter 컴포넌트는 useSyncExternalStore를 통해 count 값을 구독합니다.
핵심은 Context에 자주 바뀌는 count 값을 직접 넣지 않는다는 점입니다. 대신 안정적인 store 객체를 내려주고, 필요한 컴포넌트만 snapshot을 구독하게 만듭니다.
실무에서는 무엇을 기준으로 나눠야 할까
시니어 관점에서는 질문을 두 개로 나눠야 합니다.
- 이 값을 전역으로 내려야 하는가?
- 이 값의 변경을 구독해야 하는가?
Context는 첫 번째 질문에 가깝습니다. 여러 컴포넌트가 같은 값이나 객체에 접근해야 한다면 Context가 도움이 됩니다.
useSyncExternalStore는 두 번째 질문에 가깝습니다. React 외부에서 값이 변하고, 그 변경을 React 렌더링과 일관되게 연결해야 한다면 필요합니다.
코드리뷰에서 확인할 것
Context와 외부 store를 함께 쓰는 코드를 리뷰할 때는 다음을 확인하는 것이 좋습니다.
- Provider의
value가 매 렌더마다 새로 만들어지지 않는가 subscribe가 unsubscribe 함수를 제대로 반환하는가getSnapshot이 불필요하게 매번 새 객체를 반환하지 않는가- store 변경 시 필요한 컴포넌트만 렌더링되는가
- Context에 자주 바뀌는 값을 직접 넣어 렌더링 범위를 넓히고 있지 않은가
정리
React Context와 useSyncExternalStore는 역할이 다릅니다. Context는 값을 전달하고, useSyncExternalStore는 외부 store의 변경을 구독합니다.
따라서 둘을 경쟁 관계로 보기보다, “전달”과 “구독”을 분리해서 보는 것이 좋습니다. Context로 안정적인 store 객체를 내려주고, 필요한 컴포넌트에서 useSyncExternalStore로 snapshot을 읽으면 렌더링 범위를 더 명확하게 관리할 수 있습니다.
React 상태관리를 더 깊게 이해하고 싶다면 직접 작은 counter store를 만들어보세요. Context에 count를 직접 넣는 방식과 Context에는 store만 넣고 useSyncExternalStore로 구독하는 방식을 비교해보면 차이가 훨씬 선명하게 보입니다.
참고
'React,Next' 카테고리의 다른 글
| Next.js Server Component와 Client Component 기준 (0) | 2026.07.27 |
|---|---|
| React 렌더링 비용과 브라우저 렌더링 비용의 차이 (0) | 2026.07.20 |
| React Context와 memo, 왜 다시 렌더링될까? (1) | 2026.07.18 |
| React.memo가 안 먹히는 이유: props 참조 안정성 이해하기 (0) | 2026.07.16 |
| React key는 왜 중요할까? index key가 위험한 이유 (1) | 2026.07.15 |
