안녕하세요, 여러분! 프론트엔드 개발자라면 누구나 한 번쯤 만들어보는 토스트 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 시스템 설계를 소개하겠습니다.
// 어느 컴포넌트에서든, 어느 로직에서든, 어느 깊이에서든
const handleSuccess = async () => {
try {
await api.submit();
toasts.show({ message: '저장 완료!', type: 'success' });
} catch (error) {
toasts.show({ message: '저장 실패', type: 'error' });
}
};
useState
, useEffect
로 상태 관리 (15줄 코드)React 18에서 도입된 useSyncExternalStore
는 React 외부의 상태를 안전하게 구독할 수 있게 해줍니다. 이를 활용하면:
// React 밖에서 상태 관리
const store = createExternalStore(initialState);
// React 컴포넌트에서 구독
const state = useSyncExternalStore(
store.subscribe, // 구독 함수
store.getState, // 현재 상태 조회
store.getState, // 서버 사이드용 (동일)
);
┌─────────────────┐
│ UI Layer │ ← 어떻게 보여줄 것인가
├─────────────────┤
│ Business Layer │ ← 무엇을 언제 보여줄 것인가
├─────────────────┤
│ Store Layer │ ← 상태를 어떻게 관리할 것인가
└─────────────────┘
각 레이어는 명확한 책임을 가지며, 서로 독립적으로 테스트하고 수정할 수 있습니다.
// 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(),
);
}
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: (toasts: ToastData[]) => ToastData[],
) {
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: updateToast,
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));
}
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>
);
};
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 } },
},
},
});
스타일 시스템의 장점:
완전한 구현 코드를 확인하고 싶으시다면:
📁 전체 구현 코드 보기
주요 파일들:
toast-store.ts
- useSyncExternalStore 기반 상태 관리toast.tsx
- UI 컴포넌트 및 애니메이션index.tsx
- 사용 예제 및 데모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)}
/>
</>
);
};
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>;
};
// 로딩 상태부터 완료까지 원스톱
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,
});
}
};
우리가 Toast를 위해 만든 createStore
시스템의 진정한 가치는 확장성에 있습니다.
Toast 구현 과정에서 우리는 단순히 알림 시스템을 만든 것이 아니라, 범용적인 상태 관리 엔진을 구축했습니다. 이 Core 시스템은 다음과 같은 강력한 특성을 가집니다:
도메인 독립성
일관된 개발 경험
타입 시스템의 힘
동일한 Core 로직으로 구현 가능한 전역 UI 컴포넌트들:
Modal & Dialog 시스템
// 단 3줄로 Modal 스토어 완성
const modalsStore = createStore({ modals: [], stackLimit: 3 });
const modals = { open, close, closeAll };
// 사용: modals.open({ title: '확인', type: 'confirm' })
Loading & Progress 관리
// 복수 작업 동시 추적
const loading = { start, finish, isLoading };
// 사용: loading.start('upload'), loading.finish('upload')
Notification Center
// 읽음/안읽음, 카테고리별 필터링
const notifications = { push, markAsRead, clear };
// 사용: notifications.push({ title: '새 메시지', category: 'email' })
Form Validation 상태
// 전역 폼 검증 상태 관리
const validation = { setError, clearError, isValid };
// 사용: validation.setError('email', '올바른 이메일을 입력하세요')
1. 예측 가능한 상태 흐름
2. 성능 최적화
3. 테스트 친화적
신입 개발자 온보딩
// 패턴 한 번 익히면 모든 컴포넌트 동일
const newFeatureStore = createStore(initialState);
const newFeature = { action1, action2, action3 };
코드 리뷰 효율성
레거시 마이그레이션
복잡한 사용자 플로우
const handleComplexUserFlow = async () => {
loading.start('user-flow');
try {
const result = await processUserData();
toasts.show({ message: '처리 완료', type: 'success' });
notifications.push({ title: '작업 완료', category: 'system' });
} catch (error) {
modals.open({ title: '오류', content: error.message, type: 'alert' });
} finally {
loading.finish('user-flow');
}
};
멀티 스텝 폼 처리
const handleFormStep = stepData => {
validation.clearErrors();
if (isLastStep) {
loading.start('submit');
// 최종 제출 로직
} else {
toasts.show({ message: '다음 단계로 이동합니다' });
}
};
"Toast 하나를 제대로 만들면, 전체 앱의 상태 관리 아키텍처가 완성된다"
이런 과정에서 추상화의 매력을 느낄 수 있었습니다.
개인적으로는 이런 접근이 개발자로서 한 단계 성장하는 방법이라고 생각합니다.
그냥 필요한 라이브러리 찾아서 가져다 쓰는 것도 좋지만, 가끔은 이렇게 문제를 깊이 파보고 나만의 해결책을 만들어보는 것도 의미 있는 것 같습니다.
단순히 상태를 전역에서 관리하는 게 아니라, 실제 UI 상황을 고려해 큐잉과 TTL 관리까지 포함된 점 잘 보았습니다!
사내에서 전역 모달 시스템을 설계해보았던 경험이 있는데, useSyncExternalStore 기반의 useStore 브릿지 추상화로 React 의존도를 최소한 부분은 낯설지만 좋은 방식이라 고려해볼 것 같아요~
useSyncExternalStore라는 내용을 처음 알게되었는데 토스트 말고도 다른 곳에도 적절하게 사용할 수 있을 것 같다는 생각이 듭니다! 좋은 글 잘 읽었습니다~b
좋은 글 감사합니다~