15줄에서 2줄로: useSyncExternalStore 기반 React Toast 시스템 설계법
2025-06-30 · 25 min
useState와 useEffect로 15줄씩 반복되던 Toast 호출을 2줄로 줄였습니다. useSyncExternalStore로 상태를 컴포넌트 밖 스토어로 빼내, 앱 어디에서든 타입 안전하게 부를 수 있게 만드는 설계.

들어가며
안녕하세요, 여러분! 프론트엔드 개발자라면 누구나 한 번쯤 만들어보는 토스트 UI.
간단해 보이지만, 막상 구현하다 보면 "이걸 어떻게 앱 어디서든 쉽게 호출하지?", "종류별로 다른 스타일은 어떻게 관리하지?" 같은 고민에 빠지게 됩니다.최근 mantine의 토스트 컴포넌트를 분석할 기회가 있었습니다.
이 코드는 단순히 기능을 구현하는 것을 넘어, 유지보수성, 재사용성, 확장성까지 모두 잡는 훌륭한 아키텍처를 보여주었습니다.우리의 리액트 컴포넌트를 한 단계 업그레이드할 수 있는 인사이트를 공유하고자 합니다.
대부분의 React 개발자들이 Toast 컴포넌트를 만들 때 다음과 같은 코드를 작성합니다.
const MyComponent = () => {
const [isOpen, setIsOpen] = useState(false);
const [message, setMessage] = useState('');
const [type, setType] = useState('success');
const showToast = (msg: string, toastType: string) => {
setMessage(msg);
setType(toastType);
setIsOpen(true);
};
return (
<>
<button onClick={() => showToast('성공!', 'success')}>성공 토스트</button>
<Toast
isOpen={isOpen}
message={message}
type={type}
onClose={() => setIsOpen(false)}
/>
</>
);
};하지만 이런 방식에는 몇 가지 문제가 있습니다.
- 컴포넌트마다 반복되는 상태 관리 코드
- 여러 곳에서 Toast를 띄우려면 Context나 상태 끌어올리기 필요
- 비즈니스 로직(타이머, 큐 관리)과 UI 로직의 혼재
오늘은 이 모든 문제를 해결하는 혁신적인 Toast 시스템 설계를 소개하겠습니다.
목표 코드
// 어느 컴포넌트에서든, 어느 로직에서든, 어느 깊이에서든
const handleSuccess = async () => {
try {
await api.submit();
toasts.show({ message: '저장 완료!', type: 'success' });
} catch (error) {
toasts.show({ message: '저장 실패', type: 'error' });
}
};장점
- Zero State: 컴포넌트에서 상태 관리 코드 없음
→ 기존 방식: 매번useState,useEffect로 상태 관리 (15줄 코드)
→ 새로운 방식: 상태 관리 코드 완전 제거 (2줄 호출) - Global Access: 앱 어디서든 동일한 API
→ 컴포넌트 계층 구조와 무관하게 어느 깊이에서든 접근 가능
→ Props drilling이나 Context Provider 설정 불필요 - Type Safe: 컴파일 타임 오류 방지
→ 잘못된 타입 전달 시 IDE에서 즉시 오류 표시 - Auto Management: 타이머, 큐, 애니메이션 자동 처리
→ 자동 타이머: 3초 후 자동 닫힘, 수동 clearTimeout 불필요
→ 큐 관리: 동시에 여러 토스트 표시 시 위치별 개수 제한
→ 애니메이션: 등장/사라짐 효과 자동 처리, 상태 충돌 방지
핵심 아이디어
useSyncExternalStore의 힘
React 18에서 도입된 useSyncExternalStore는 React 외부의 상태를 안전하게 구독할 수 있게 해줍니다. 이를 활용하면:
// React 밖에서 상태 관리
const store = createExternalStore(initialState);
// React 컴포넌트에서 구독
const state = useSyncExternalStore(
store.subscribe, // 구독 함수
store.getState, // 현재 상태 조회
store.getState, // 서버 사이드용 (동일)
);왜 이 방식이 혁신적인가?
- 동시성 안전성: React 18의 Concurrent Features와 완벽 호환
- 독립적 상태: React 컴포넌트 생명주기와 무관하게 동작
- 전역 접근성: 컴포넌트 트리 어디서든 접근 가능
- 최적화: 필요한 컴포넌트만 리렌더링
3-Layer 아키텍처
┌─────────────────┐
│ UI Layer │ ← 어떻게 보여줄 것인가
├─────────────────┤
│ Business Layer │ ← 무엇을 언제 보여줄 것인가
├─────────────────┤
│ Store Layer │ ← 상태를 어떻게 관리할 것인가
└─────────────────┘각 레이어는 명확한 책임을 가지며, 서로 독립적으로 테스트하고 수정할 수 있습니다.
단계별 구현
1단계: Store 엔진 구현
범용 상태 관리 시스템 구축
// types
type StoreSubscriber<Value> = (state: Value) => void;
interface Store<Value> {
getState: () => Value;
setState: (value: Value | ((prev: Value) => Value)) => void;
subscribe: (callback: StoreSubscriber<Value>) => () => void;
}
// 순수한 JavaScript 상태 관리 엔진
export function createStore<Value extends Record<string, any>>(
initialState: Value,
): Store<Value> {
let state = initialState;
const listeners = new Set<StoreSubscriber<Value>>();
return {
getState: () => state,
setState: value => {
state = typeof value === 'function' ? value(state) : value;
listeners.forEach(listener => listener(state));
},
subscribe: callback => {
listeners.add(callback);
return () => listeners.delete(callback);
},
};
}
// React와의 브릿지
export function useStore<TStore extends Store<any>>(store: TStore) {
return useSyncExternalStore(
store.subscribe,
() => store.getState(),
() => store.getState(),
);
}핵심 특징
- 타입 안전성: 제네릭으로 완벽한 타입 추론
- 메모리 효율성: WeakMap이 아닌 Set 사용으로 명시적 구독 해제
- 동시성 지원: useSyncExternalStore로 React 18 Concurrent Features 호환
2단계: Toast 비즈니스 로직
타입 시스템 설계
export type ToastPosition =
| 'top-left'
| 'top-right'
| 'top-center'
| 'bottom-left'
| 'bottom-right'
| 'bottom-center';
// 스타일과 비즈니스 로직 타입 결합
export type ToastData = {
id?: string;
position?: ToastPosition;
message: ReactNode;
duration?: number;
} & ToastRecipeProps; // 스타일 props 자동 상속
// Toast Store 상태
export type ToastsState = {
toasts: ToastData[];
defaultPosition: ToastPosition;
limit: number;
};비즈니스 로직 구현
// Toast 스토어 생성
export const createToastStore = () =>
createStore<ToastsState>({
toasts: [],
defaultPosition: 'top-center',
limit: 1,
});
export const toastsStore = createToastStore();
// 큐 관리 로직 - 위치별 개수 제한
function getDistributedToasts(
data: ToastData[],
defaultPosition: ToastPosition,
limit: number,
) {
const queue: ToastData[] = [];
const toasts: ToastData[] = [];
const count: Record<string, number> = {};
data.forEach(item => {
const position = item.position || defaultPosition;
count[position] = count[position] || 0;
count[position] += 1;
if (count[position] <= limit) {
toasts.push(item);
} else {
queue.push(item);
}
});
return { Toasts, queue };
}
// 상태 업데이트 헬퍼
export function updateToastsState(
store: ToastStore,
update: '2025-06-30'
) {
const state = store.getState();
const toasts = update([...state.toasts]);
const updated = getDistributedToasts(
toasts,
state.defaultPosition,
state.limit,
);
store.setState({
toasts: updated.Toasts,
limit: state.limit,
defaultPosition: state.defaultPosition,
});
}
// 통합 API
export const toasts = {
show: showToast,
hide: hideToast,
update: '2025-06-30'
clean: cleanToasts,
cleanQueue: cleanToastsQueue,
} as const;핵심 함수들 구현
export function showToast(toast: ToastData, store: ToastStore = toastsStore) {
const id = toast.id || randomId();
updateToastsState(store, toasts => {
if (toast.id && toasts.some(n => n.id === toast.id)) {
return toasts; // 중복 방지
}
return [...toasts, { ...toast, id }];
});
return id;
}
export function hideToast(id: string, store: ToastStore = toastsStore) {
updateToastsState(store, toasts => toasts.filter((_, i) => i !== 0));
return id;
}
export function updateToast(toast: ToastData, store: ToastStore = toastsStore) {
updateToastsState(store, toasts =>
toasts.map(item => {
if (item.id === toast.id) {
return { ...item, ...toast };
}
return item;
}),
);
return toast.id;
}
export function cleanToastsQueue(store: ToastStore = toastsStore) {
updateToastsState(store, Toasts => Toasts.slice(0, store.getState().limit));
}3단계: UI 컴포넌트 연결
커스텀 훅으로 상태 구독
export const useToasts = (store: ToastStore = toastsStore) => useStore(store);
// 분산된 토스트 조회
function useDistributedToasts() {
const state = useToasts();
const { Toasts, queue } = getDistributedToasts(
state.toasts,
state.defaultPosition,
state.limit,
);
return { toasts: Toasts, queue, ...state };
}순수한 프레젠테이션 컴포넌트
const Toast = () => {
const { toasts } = useDistributedToasts();
const { start, clear } = useTimeout(() => toasts.hide(), 3000);
// 생명주기 자동 관리
useEffect(() => {
if (toasts.length > 0) start();
return () => clear();
}, [toasts, clear, start]);
return (
<Portal>
<AnimatePresence>
{toasts.map(notification => {
const { container, content } = toastRecipe({
type: notification.type,
size: notification.size,
});
return (
<motion.div
key={notification.id}
initial={{ x: '-50%', y: '-100%', opacity: 0 }}
animate={{
x: 'var(--x-animate)',
y: 'var(--y-animate)',
opacity: 'var(--opacity-animate)',
}}
exit={{ x: '-50%', y: '-100%', opacity: 0 }}
className={container}
>
{notification.type && NotificationIcon[notification.type]}
<div className={content}>{notification.message}</div>
</motion.div>
);
})}
</AnimatePresence>
</Portal>
);
};Panda CSS 스타일 시스템 통합
const toastRecipe = sva({
slots: ['container', 'content'],
base: {
container: {
position: 'fixed',
top: 0,
left: '50%',
'--x-animate': '-50%',
'--y-animate': '24px',
'--opacity-animate': 1,
desktopDown: {
'--y-animate': '8px',
w: 'calc(100vw - 32px)',
},
},
},
variants: {
type: {
error: { container: { '& svg': { fill: 'statusNegative' } } },
success: { container: { '& svg': { fill: 'statusPositive' } } },
info: { container: { '& svg': { fill: 'statusInfo' } } },
},
size: {
small: { container: { minH: 48, maxW: 406 } },
large: { container: { minH: 52, maxW: 500 } },
},
},
});스타일 시스템의 장점:
- 타입 안전한 스타일 props
- 반응형 디자인 자동 적용
- CSS-in-JS 런타임 오버헤드 없음
실제 구현 코드
완전한 구현 코드를 확인하고 싶으시다면:
📁 전체 구현 코드 보기
주요 파일들:
toast-store.ts- useSyncExternalStore 기반 상태 관리toast.tsx- UI 컴포넌트 및 애니메이션index.tsx- 사용 예제 및 데모
실제 사용법
Before: 기존 방식의 번거로움
const UserProfile = () => {
const [toastOpen, setToastOpen] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const handleSave = async () => {
try {
await saveProfile();
setToastMessage('프로필이 저장되었습니다');
setToastOpen(true);
setTimeout(() => setToastOpen(false), 3000);
} catch (error) {
setToastMessage('저장에 실패했습니다');
setToastOpen(true);
setTimeout(() => setToastOpen(false), 3000);
}
};
return (
<>
<button onClick={handleSave}>저장</button>
<Toast
isOpen={toastOpen}
message={toastMessage}
onClose={() => setToastOpen(false)}
/>
</>
);
};After: 새로운 방식의 간결함
const UserProfile = () => {
const handleSave = async () => {
try {
await saveProfile();
toasts.show({
message: '프로필이 저장되었습니다',
type: 'success',
});
} catch (error) {
toasts.show({
message: '저장에 실패했습니다',
type: 'error',
});
}
};
return <button onClick={handleSave}>저장</button>;
};개선 사항
- 상태 관리 코드 완전 제거 (15줄 → 2줄)
- 타이머 관리 자동화
- 코드 양 70% 감소
- 타입 안정성 향상
고급 활용: 비동기 작업과의 통합
// 로딩 상태부터 완료까지 원스톱
const handleUpload = async (file: File) => {
const toastId = toasts.show({
message: '파일 업로드 중...',
type: 'info',
duration: 0, // 수동 관리
});
try {
await uploadFile(file);
toasts.update({
id: toastId,
message: '업로드 완료!',
type: 'success',
duration: 3000,
});
} catch (error) {
toasts.update({
id: toastId,
message: '업로드 실패',
type: 'error',
duration: 5000,
});
}
};마무리
주요 인사이트 포인트
- Zero State Management: 컴포넌트에서 상태 관리 코드 완전 제거
- Type-Driven Development: 타입 시스템이 API 설계를 주도
- Performance by Design: React 18의 동시성 기능 활용
확장 가능성: Store Core의 진정한 힘
우리가 Toast를 위해 만든 createStore 시스템의 진정한 가치는 확장성에 있습니다.
추상화된 Core의 장점
Toast 구현 과정에서 우리는 단순히 알림 시스템을 만든 것이 아니라, 범용적인 상태 관리 엔진을 구축했습니다. 이 Core 시스템은 다음과 같은 강력한 특성을 가집니다:
-
도메인 독립성
- Store 엔진이 Toast에 종속되지 않음
- 어떤 종류의 전역 상태든 동일한 패턴으로 관리
- 비즈니스 로직과 상태 관리 로직의 완전한 분리
-
일관된 개발 경험
- 새로운 전역 컴포넌트 추가 시 학습 비용 Zero
- 팀 전체가 동일한 패턴으로 개발
- 코드 리뷰와 유지보수 효율성 극대화
-
타입 시스템의 힘
- 각 도메인별 완벽한 타입 추론
- 컴파일 타임에 모든 오류 검출
- IDE에서 완벽한 자동완성 지원
실제 적용 가능한 컴포넌트들
동일한 Core 로직으로 구현 가능한 전역 UI 컴포넌트들:
Modal & Dialog 시스템
// 단 3줄로 Modal 스토어 완성
const modalsStore = createStore({ modals: [], stackLimit: 3 });
const modals = { open, close, closeAll };
// 사용: modals.open({ title: '15줄에서 2줄로: useSyncExternalStore 기반 React Toast 시스템 설계법'Loading & Progress 관리
// 복수 작업 동시 추적
const loading = { start, finish, isLoading };
// 사용: loading.start('upload'), loading.finish('upload')Notification Center
// 읽음/안읽음, 카테고리별 필터링
const notifications = { push, markAsRead, clear };
// 사용: notifications.push({ title: '15줄에서 2줄로: useSyncExternalStore 기반 React Toast 시스템 설계법'Form Validation 상태
// 전역 폼 검증 상태 관리
const validation = { setError, clearError, isValid };
// 사용: validation.setError('email', '올바른 이메일을 입력하세요')아키텍처 레벨의 이점
1. 예측 가능한 상태 흐름
- 모든 전역 상태가 동일한 패턴으로 동작
- 디버깅 시 일관된 접근 방식
- 상태 변화 추적과 로깅 용이
2. 성능 최적화
- 필요한 컴포넌트만 리렌더링
- React 18 Concurrent Features 완벽 호환
- 메모리 누수 방지 시스템 내장
3. 테스트 친화적
- 순수 함수로 구성된 비즈니스 로직
- React 없이도 독립적 테스트 가능
- Mock과 Stub 구성 간단
팀 개발에서의 위력
신입 개발자 온보딩
// 패턴 한 번 익히면 모든 컴포넌트 동일
const newFeatureStore = createStore(initialState);
const newFeature = { action1, action2, action3 };코드 리뷰 효율성
- 동일한 패턴이므로 리뷰 포인트 명확
- 버그 발생 패턴 예측 가능
- 베스트 프랙티스 자동 적용
레거시 마이그레이션
- 기존 Context API나 Redux 코드를 점진적 교체
- 각 도메인별로 독립적 마이그레이션
- 사이드 이펙트 최소화
실제 개발 시나리오
복잡한 사용자 플로우
const handleComplexUserFlow = async () => {
loading.start('user-flow');
try {
const result = await processUserData();
toasts.show({ message: '처리 완료', type: 'success' });
notifications.push({ title: '15줄에서 2줄로: useSyncExternalStore 기반 React Toast 시스템 설계법'
} catch (error) {
modals.open({ title: '15줄에서 2줄로: useSyncExternalStore 기반 React Toast 시스템 설계법'
} finally {
loading.finish('user-flow');
}
};멀티 스텝 폼 처리
const handleFormStep = stepData => {
validation.clearErrors();
if (isLastStep) {
loading.start('submit');
// 최종 제출 로직
} else {
toasts.show({ message: '다음 단계로 이동합니다' });
}
};핵심 인사이트
"Toast 하나를 제대로 만들면, 전체 앱의 상태 관리 아키텍처가 완성된다"
이런 과정에서 추상화의 매력을 느낄 수 있었습니다.
- 하나의 문제를 깊이 파면서 다른 곳에도 활용할 수 있는 패턴을 발견
- 작은 컴포넌트에서 시작해서 전체 시스템 설계로 확장
- 구체적인 Toast 문제 해결에서 범용적인 해결책 도출
개인적으로는 이런 접근이 개발자로서 한 단계 성장하는 방법이라고 생각합니다.
그냥 필요한 라이브러리 찾아서 가져다 쓰는 것도 좋지만, 가끔은 이렇게 문제를 깊이 파보고 나만의 해결책을 만들어보는 것도 의미 있는 것 같습니다.