15줄에서 2줄로: useSyncExternalStore 기반 React Toast 시스템 설계법

ant·2025년 6월 29일
52

react-component

목록 보기
1/3
post-thumbnail

들어가며

안녕하세요, 여러분! 프론트엔드 개발자라면 누구나 한 번쯤 만들어보는 토스트 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, // 서버 사이드용 (동일)
);

왜 이 방식이 혁신적인가?

  1. 독립적 상태: React 컴포넌트 생명주기와 무관하게 동작
  2. 전역 접근성: 컴포넌트 트리 어디서든 접근 가능
  3. 최적화: 필요한 컴포넌트만 리렌더링

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: (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));
}

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,
    });
  }
};

마무리

주요 인사이트 포인트

  1. Zero State Management: 컴포넌트에서 상태 관리 코드 완전 제거
  2. Type-Driven Development: 타입 시스템이 API 설계를 주도
  3. Performance by Design: React 18의 동시성 기능 활용

확장 가능성: Store Core의 진정한 힘

우리가 Toast를 위해 만든 createStore 시스템의 진정한 가치는 확장성에 있습니다.

추상화된 Core의 장점

Toast 구현 과정에서 우리는 단순히 알림 시스템을 만든 것이 아니라, 범용적인 상태 관리 엔진을 구축했습니다. 이 Core 시스템은 다음과 같은 강력한 특성을 가집니다:

  1. 도메인 독립성

    • Store 엔진이 Toast에 종속되지 않음
    • 어떤 종류의 전역 상태든 동일한 패턴으로 관리
    • 비즈니스 로직과 상태 관리 로직의 완전한 분리
  2. 일관된 개발 경험

    • 새로운 전역 컴포넌트 추가 시 학습 비용 Zero
    • 팀 전체가 동일한 패턴으로 개발
    • 코드 리뷰와 유지보수 효율성 극대화
  3. 타입 시스템의 힘

    • 각 도메인별 완벽한 타입 추론
    • 컴파일 타임에 모든 오류 검출
    • IDE에서 완벽한 자동완성 지원

실제 적용 가능한 컴포넌트들

동일한 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. 성능 최적화

  • 필요한 컴포넌트만 리렌더링
  • 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: '작업 완료', 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 하나를 제대로 만들면, 전체 앱의 상태 관리 아키텍처가 완성된다"

이런 과정에서 추상화의 매력을 느낄 수 있었습니다.

  • 하나의 문제를 깊이 파면서 다른 곳에도 활용할 수 있는 패턴을 발견
  • 작은 컴포넌트에서 시작해서 전체 시스템 설계로 확장
  • 구체적인 Toast 문제 해결에서 범용적인 해결책 도출

개인적으로는 이런 접근이 개발자로서 한 단계 성장하는 방법이라고 생각합니다.
그냥 필요한 라이브러리 찾아서 가져다 쓰는 것도 좋지만, 가끔은 이렇게 문제를 깊이 파보고 나만의 해결책을 만들어보는 것도 의미 있는 것 같습니다.

10개의 댓글

comment-user-thumbnail
2025년 6월 29일

좋은 글 감사합니다~

1개의 답글
comment-user-thumbnail
2025년 6월 30일

단순히 상태를 전역에서 관리하는 게 아니라, 실제 UI 상황을 고려해 큐잉과 TTL 관리까지 포함된 점 잘 보았습니다!
사내에서 전역 모달 시스템을 설계해보았던 경험이 있는데, useSyncExternalStore 기반의 useStore 브릿지 추상화로 React 의존도를 최소한 부분은 낯설지만 좋은 방식이라 고려해볼 것 같아요~

1개의 답글
comment-user-thumbnail
2025년 6월 30일

최근에 zustand 라이브러리를 분석했는데, 거기서도 useSyncExternalStore를 사용하더라구요! 덕분에 재밌게 읽었습니다 :)

1개의 답글
comment-user-thumbnail
2025년 6월 30일

useSyncExternalStore라는 내용을 처음 알게되었는데 토스트 말고도 다른 곳에도 적절하게 사용할 수 있을 것 같다는 생각이 듭니다! 좋은 글 잘 읽었습니다~b

1개의 답글
comment-user-thumbnail
2025년 7월 1일

useSyncExternalStore 훅을 오늘 처음알게되어서 고전(?) 훅외에도 react 기능을 좀 더 공부해야겠다는 생각이 드네요 좋은 글 감사합니다~

1개의 답글