📚 목차
[React] useSyncExternalStore로 Toast 구현하여 기존 코드 60% 줄이기
토스트는 UI 컴포넌트지만, 실제로는 “어디서든 호출 가능한 전역 이벤트”에 가깝습니다.
- API 요청 성공/실패 콜백(onSuccess, onError)
- 라우팅 이후 처리
- SSE/웹소켓 알림 수신
- 폼 제출 완료
같이 UI 트리 깊숙한 곳에서 즉시 호출되어야 하고, 그 순간 화면 어디에서든 떠야 합니다..
그런데 기존 토스트 구현이 보통 다음 문제를 계속 일으켰습니다.
1. Provider/Context 기반 구현의 “전파 비용”
Context로 토스트를 만들면 보통,
ToastProvider를 앱 최상단에 두고useToast()로 API를 노출하고- 내부에서 상태 업데이트로 렌더링을 트리거
이 패턴은 익숙하지만, Context value가 바뀌면 Provider 하위가 전반적으로 영향을 받는 구조라서(최적화 없으면) 토스트처럼 "앱 전체에서 자주 호출되는 이벤트"에는 비용이 커질 수 있습니다.
그리고 이를 피하려고 useMemo, useCallback, split context 같은 보일러플레이트가 늘어나는 경우가 많았습니다.
2. "토스트는 React 트리의 자식이어야만 한다"는 제약
토스트는 사실상 document.body 아래로 포탈 렌더하는 게 자연스러운데, Context 패턴을 강하게 쓰면 "호출은 어디서든" 하면서도 "렌더는 특정 Provider 아래에서만" 가능하게 설계가 꼬이기 쉽습니다.
3. 3. 코드가 늘어나는 지점이 명확했다
내가 줄이고 싶었던 건 “토스트 로직”이 아니라:
- Provider wiring
- Context 구성
- 중복된 상태/액션 정의
- 어디서든 접근시키기 위한 훅/타입/보일러플레이트
즉 토스트의 본질(전역 이벤트 큐 + 렌더링)만 남기고 싶었습니다.
React 외부 스토어 + useSyncExternalStore
useSyncExternalStore는 한 줄로 말하면 이거예요.
React 바깥에 있는 상태(external store)를 React 렌더링과 안전하게 동기화하기 위한 공식 훅
토스트는 "전역 큐"가 핵심이니까, 상태를 React 밖에 둬도 전혀 이상하지 않습니다.
그리고 React는 "그 큐가 바뀔 때만" 구독해서 다시 그려주면 끝이죠.
이 접근으로 얻은 효과는 단순했습니다.
- Provider/Context를 제거
- 상태는 store 하나로 고정
- 액션 API는
toast.success()처럼 함수 호출로 통일 - 렌더는
<Toast />컴포넌트 한 번만 배치
결과적으로, 토스트 관련 코드가 '설계(Why) 때문에 붙은 코드'가 사라지고 '동작(What/How) 코드'만 남아서 전체 라인이 크게 줄었습니다.
실제로 기존 구현 대비 60% 이상 줄어든 코드로 동일한 기능을 구현할 수 있었어요.
useSyncExternalStore는 어떻게 동작할까?
핵심은 세 가지 함수의 협업입니다.
-
subscribe(listener): 상태 변경 시 리스너를 호출 -
getSnapshot(): 현재 상태를 읽어옴 -
getServerSnapshot(): SSR 환경에서 초기 상태 제공 (선택적) -
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot): 구독 + 스냅샷 읽기를 React 렌더링 사이클과 맞춰줌
시각적으로 보면 아래 흐름입니다.

즉, React는 store를 직접 관리하지 않습니다.
그냥 "구독하고 스냅샷을 읽는" 역할만 맡습니다.
코드로 보는 구현 흐름
이제부터는 "무엇을 만들었고(What)", "어떻게 동작하는지(How)"가 한 번에 보이게,
실제 실행 순서 기준으로 뜯어보겠습니다.
1. 범용 스토어 팩토리(core.ts) — “React 밖 상태 컨테이너”
createStore는 React와 무관한 전역 상태 저장소입니다.
state를 들고 있고listeners를 관리하고setState로 바꾸면 listeners를 깨웁니다.
export function createStore<TState>(initialState: TState): Store<TState> {
let state = initialState;
const listeners = new Set<StoreSubscriber>();
return {
getState: () => state,
setState: (value) => {
const newState =
typeof value === 'function' ? (value as (prev: TState) => TState)(state) : value;
if (newState !== state) {
state = newState;
listeners.forEach((listener) => listener());
}
},
subscribe: (callback) => {
listeners.add(callback);
return () => listeners.delete(callback);
},
};
}토스트가 추가/삭제되는 순간에,
- state를 바꾸고
- 등록된 모든 구독자에게 "바뀜"을 알립니다.
여기까지는 React가 없습니다.
2. useStore(core.ts) — “React와 외부 스토어를 연결하는 다리”
export function useStore<TState>(store: Store<TState>): TState {
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
}useSyncExternalStore는
- store가 바뀌면 리렌더를 트리거하고
- 렌더 시점에는
getState()로 현재 값을 읽어옵니다.
즉 React 입장에선,
“어? 구독 중인 external store가 바뀌었네. 그럼 다시 렌더해야겠다.”
3. toast 스토어(toast.ts) — “토스트 큐 + 제어 API”
const toastStore = createStore<ToastsState>({ toasts: [] });
export const useToasts = () => useStore(toastStore);여기서 구조가 완성됩니다.
toastStore: 전역 큐useToasts: UI가 구독하는 훅
그리고 호출 API는 이렇게 고정합니다。
export const toast = {
success: (message, duration) => addToast({ message, variant: 'success', duration }),
error: (message, duration) => addToast({ message, variant: 'error', duration }),
// ...
} as const;toast.success() 한 줄이 일으키는 실제 흐름
이제 “순서”로 보면 더 명확합니다.
Step 1) 호출
toast.success('코멘트 작성이 완료되었습니다!');Step 2) addToast 실행 → store 업데이트
toastStore.setState((state) => ({ toasts: [...state.toasts, newToast] }));Step 3) setState 내부에서 listeners 호출
listeners.forEach((listener) => listener());Step 4) useSyncExternalStore가 구독 중인 컴포넌트를 리렌더
const { toasts: activeToasts } = useToasts();Step 5) <Toast />가 최신 스냅샷을 읽고 렌더
const { toasts: activeToasts } = useToasts();4. 자동 dismiss 로직(toast.ts) — “토스트 수명 관리”
토스트는 대부분 “일정 시간 뒤 자동으로 사라지는” 정책이 필요합니다.
그래서 timers Map으로 toast id별 타이머를 관리합니다.
const handle = setTimeout(() => {
timers.delete(id);
removeToast(id);
}, duration);removeToast도 똑같이 store를 갱신합니다.
toastStore.setState((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}));이 순간도 동일하게,
store 변경 → listeners → <Toast /> 리렌더 → 화면에서 제거
5. UI 렌더(Toast.tsx) — “구독하는 렌더러 1개만 둔다”
Toast는 단 하나의 역할만 합니다.
- 현재 큐(
activeToasts)를 구독하고 - 있으면 포탈로 화면에 렌더
export const Toast: React.FC = () => {
const { toasts: activeToasts } = useToasts();
if (activeToasts.length === 0) return null;
return createPortal(
<S.ToastContainer>
{activeToasts.map((item) => (
<ToastItem key={item.id} toast={item} onClose={toast.dismiss} />
))}
</S.ToastContainer>,
document.body,
);
};이게 중요한 이유는 단순합니다.
- 구독자(리렌더 대상)를 Toast 하나로 고정
- 토스트가 아무리 여러 곳에서 호출돼도 리렌더 범위가 일정
6. Layout에 한 번만 추가 — “앱 전역에 항상 존재”
...
<Footer />
<Toast />
...이렇게 딱 한 번만 렌더러를 배치하면,
어느 페이지에서 toast.success()를 호출해도 동일하게 동작합니다.
사용자 관점에서의 흐름
이때까지 한 설명들을 이해하기 쉽게 사용자 관점에서 정리해보겠습니다.
Toast 컴포넌트가 사용자의 화면에 렌더되는 순간은 다음과 같습니다.
export const Toast: React.FC = () => {
const { toasts: activeToasts } = useToasts();
if (activeToasts.length === 0) {
return null;
}
return createPortal(
<S.ToastContainer>
{activeToasts.map(item => (
<ToastItem key={item.id} toast={item} onClose={toast.dismiss} />
))}
</S.ToastContainer>,
document.body,
);
};
위 코드를 보면, activeToasts를 불러올 때 useToasts() → useStore() → useSyncExternalStore() 흐름으로 호출이 이어집니다.
이 순간 React가 하는 일은,
컴포넌트가 마운트될 때 subscribe(reactInternalCallback)를 호출하고 그 콜백을 listeners에 등록합니다.
즉,
<Toast />가 처음 화면에 렌더될 때 subscribe가 호출되고 listeners.add(reactCallback)가 실행됩니다.
이전에 언급한 거와 같이 <Toast /> 컴포넌트는 상위에 있는 <Layout> 컴포넌트에서 고정적으로 렌더되고 있기 때문에, 앱이 실행되는 동안 <Toast />는 계속 존재합니다.
따라서 구독은 한 번만 일어나고 앱이 살아있는 동안 유지됩니다.
토스트가 "보였다/사라졌다"는 건 뭘 의미하나?
이 부분은 헷갈릴 수도 있는데, 구독과 다릅니다.
위 코드에서 Toast 컴포넌트 내부에
if (activeToasts.length === 0) {
return null;
}이 코드를 통해 알 수 있는건 UI를 안 그릴 뿐, 컴포넌트는 여전히 마운트 상태입니다.
즉, 구독은 계속 유지되고 UI만 조건부로 렌더링되는 구조입니다.
그럼 unsubscribe는 언제 되나? 라는 의문점이 생길 수 있습니다.
이 글에서 초반에 설명드린 것과 같이 subscribe의 구조는,
subscribe: (callback) => {
listeners.add(callback);
return () => listeners.delete(callback);
};이렇게 구현되어 있기에 <Toast /> 컴포넌트가 언마운트될 때 React가 자동으로 unsubscribe를 호출하여 listeners.delete(reactCallback)이 실행됩니다.
전체 생명주기 정리
🟢 앱 시작
Layout 렌더
→ `<Toast />` 마운트
→ subscribe 호출
→ listeners.add(reactCallback)🟢 토스트 호출
toast.success()
→ setState()
→ listeners.forEach()
→ reactCallback 실행
→ `<Toast />` 리렌더
→ 화면에 토스트 표시
🟢 토스트 제거
removeToast()
→ setState()
→ listeners.forEach()
→ `<Toast />` 리렌더
→ 조건문 때문에 null 반환- 컴포넌트는 그대로 있음
🔴 만약 Layout에서 <Toast />를 제거하면?
컴포넌트 언마운트
→ unsubscribe 실행
→ listeners.delete(callback)
→ 구독 해제구현하면서 느낀점
이번 리팩토링의 핵심은 useSyncExternalStore를 써서 멋있게가 아니라,
토스트의 본질이 "전역 이벤트 큐"라는 점을 인정하고
React는 그 큐를 구독해서 그려주기만 하게 만들었다
는 데 있었습니다.
그래서 좋았던 점은
- 토스트 로직이 UI 트리와 분리되어 단단해짐
- Provider/Context 보일러플레이트 제거 → 코드가 크게 줄어듦
- 렌더 범위가
<Toast />하나로 제한되어 예측 가능 - “어디서든 호출”이 자연스럽게 성립
그리고 동시에, 기준도 더 명확해졌어요.
- 토스트/모달/알림처럼 전역 UI 이벤트에는 아주 잘 맞는다.
하지만 반대로, 전환(transition) UX가 중요한 상태나 페이지 데이터 모델링은
React Query / 로컬 상태 / Context 등 다른 선택지가 더 났습니다.
Toss의 Frontend Fundamentals 의 댓글에서 아래와 같은 댓글을 보게되었습니다.
전역 상태는 결국 리액트의 외부 상태인데, 이러한 값이 리액트와 싱크될 때 꽤 많은 문제를 일으키기도 합니다(많이 사용하는 useSyncExternalStore에 이슈가 있어서 jotai는 useSyncExternalStore를 사용하지 않는 방향으로 개발됐습니다: Link).
헤당 댓글을 읽고, useSyncExternalStore가 subscribe + getSnapshot을 통해 한 렌더 패스에서 일관된 스냅샷을 보장하는 쪽으로 설계되어서 tearing을 막는 게 핵심 장점이 될수도 있지만,
useSyncExternalStore의 "Sync"는, 필요하면 동기 렌더링으로 되돌아가며 정합성을 지키는 쪽이라서,
useTransition 같은 time slicing / transition UX에서 "useState처럼 자연스럽게 pending을 보여주는 경험"과 충돌할 수 있다라는 단점도 있을 수 있겠다는 생각이 들었습니다.
마무리

결론적으로, React Profiler 기준 렌더 시간 7.3ms → 1.8ms (약 75% 감소)가 실제로 측정되었고, 코드 라인 수는 60% 이상 줄어들었습니다.
토스트는 "전역 상태"를 남용한 사례가 아니라,
전역적으로 호출되어야 하는 UI 이벤트라는 특성상 "전역 큐"가 오히려 자연스러운 문제입니다.
useSyncExternalStore를 통해
- 전역 큐는 React 밖에서 관리하고
- React는 구독해서 UI만 그리게 만들면
구조가 단순해지고, 코드가 줄고, 동작은 더 예측 가능해집니다.
따라서, 토스트/모달/알림 같은 "전역 이벤트"에는 제가 소개한 패턴이 꽤 잘 맞는다고 생각하기에 한 번 시도해보시길 추천드립니다.