[Next.js/TS] 사용성과 안정성을 고려한 Toast 알림 구현 기록

오르미에서 공통적으로 사용할 Toast 시스템을 구현한 기록입니다!

 

지금까지 라이브러리를 사용해 아주 간단하게 토스트 알림을 사용했지만, 이 프로젝트에서는 구현해야할 디자인 요구사항이 명확하다보니 직접 시스템을 만들어보자 생각이 들었습니다.

복잡한 요구사항이 없다면 생각보다 간단하게 구현할 수 있으니 혹시 라이브러리 사용을 고민하며 글을 보신 분이 계시다면 ㅎㅎ 한번 도전해보세요!

 

개요

고려한 점

1) 개발자의 사용성

- 추후 리액트 경험이 적은 팀원이 백엔드 개발을 마무리한 후 프론트엔드 개발에 합류할 계획이 있습니다. 이에 복잡한 로직을 고려하지 않고 간단하게 토스트 알림을 사용할 수 있도록 만들고자 했고, 코드 가독성과 안정성을 높일 수 있는 설계를 위해 고민했습니다.

2) 서비스 사용자의 UX

- 너무 많은 토스트 알림이 화면에 쌓이지 않도록 최대 개수를 제한했습니다.

- fade-in, fade-out 애니메이션과 함께 등록/제거되도록 했습니다.

 

작동 흐름

본격적으로 구현하기 전 주요 흐름을 먼저 정리했습니다.

 

0) 토스트 알림에 대한 정보는 zustand store에서 관리한다

1) 루트 레이아웃에 위치한 ToastContainer는 그 정보를 기반으로 개별 토스트 알림을 렌더링한다

2) 토스트 개별 컴포넌트는 setTimeout을 활용해 주어진 시간이 지나면 fade-out 애니메이션이 재생되도록 하고, 애니메이션 재생 후 zustand에 자신을 삭제해달라고 보낸다 (id 기반) -> 컴포넌트 언마운트시 타이머를 꼭 clean up 한다

 

ID 관리 책임을 어디에 둘까?

토스트 알림을 띄우기 위해 개발자가 명시해야하는 정보는 다음과 같습니다

export type ToastType = 'success' | 'error';

export type ToastData = {
  id: string;
  message: string;
  type: ToastType;
  duration?: number;
};

 

❌ 개선 전: addToast({ id, ... })로 사용자가 직접 ID 생성

처음에는 Toast의 ID를 개발자가 직접 넘겨줘야 했는데, 이 방식은 다른 개발자의 사용성 문제와 함께 에러를 발생시킬 위험이 있다는 생각이 들었습니다.

 

  • 중복된 ID로 인한 렌더링 문제 발생 가능 (완전히 고유한 값을 넘겨주지 않을 경우 ex. 단순 index 등)
  • 사용자가 crypto.randomUUID() 또는 유사 로직을 일일이 작성해야 함
  • 팀원 간 구현 방식이 달라져 사용성 불일치 초래
// ❌ 이전 방식 (사용자가 id 생성)
addToast({
  id: index,
  message: '업데이트 완료!',
  type: 'success',
});

 

 

✅ 개선 후: addToast() 호출만 하면 ID는 내부에서 자동 생성

addToast 함수 내부에서 crypto.randomUUID()를 활용해 ID를 자동 부여하도록 변경했습니다.

 

  • 사용자는 메시지와 타입만 넘기면 됨 → 사용성 증가
  • UUID로 중복 방지 → 안정성 확보
  • 팀원 간 사용법 통일 → 유지보수 쉬움
// ✅ 개선된 방식
addToast({
  message: '업데이트 완료!',
  type: 'success',
});

// toastStore.ts 내용
addToast: (toastData: Omit<ToastData, 'id'>) => {
      const id = generateToastId();
      ...
}

 

 

동시에 보여지는 토스트 개수 제한하기

❌ 개선 전: 무제한으로 쌓이는 토스트

처음엔 개수 제한이 없어서, 짧은 시간 안에 여러 개의 토스트가 발생하면 UI가 부자연스럽게 느껴지는 문제가 생겼습니다.

 

✅ 개선 후: 최대 3개까지만 표시되도록 제한

  • 동시에 3개 이상 발생해도 UI 안정적으로 유지
  • 가장 오래된 알림부터 자동 제거되어 사용자에게 최신 알림에 집중할 수 있게 함
const MAX_TOASTS = 3;

if (state.toasts.length < MAX_TOASTS) {
  return { toasts: [...state.toasts, { ...toastData, id }] };
}

return {
  toasts: [...state.toasts, { ...toastData, id }].slice(1), // 가장 오래된 토스트 제거
};

 

자연스러운 등장/제거

토스트는 나타날 때 translateY로 위에서 내려오고, 사라질 땐 반대로 올라가면서 opacity가 0이 됩니다.

 

보통 특정 조건에서 보여지지 않는 컴포넌트를 구현할 때 return null 등 컴포넌트 내부에서 dom 요소를 반환하지 않도록 구현하는 경우가 많았지만, 현재 시스템에서 렌더링할 토스트 UI를 관리하는 역할은 stores에서 담당하므로 Toast 컴포넌트 내부에서는 보이는 상태만 조절하도록 하고 애니메이션 재생 시간까지 지난 후에 store에 자신을 제거하는 요청을 보내는 방식으로 구현했습니다.

 

// Toast.tsx
const toastTimer = setTimeout(() => {
  setIsVisible(false);
  setTimeout(() => removeToast(id), TOAST_ANIMATION_DURATION);
}, duration);

...

return (
  <div
    className={`transition-all duration-${TOAST_ANIMATION_DURATION} ${
      isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-[20px]'
    }`}
  >
);

 

 

Storybook 기반 문서화 및 테스트

다른 개발자도 사용하기 쉽게 구현하는 목적도 컸기 때문에 간단한 사용법을 스토리북 docs에 추가했습니다.

또한 예외가 발생하지 않았는지 매번 확인하지 않아도 안정성을 보장할 수 있도록 Storybook 테스트를 작성했습니다.

  • 토스트가 일정 시간 후 사라지는지
  • MAX_TOASTS가 초과되어 표시되지 않는지
  • 애니메이션 이후 실제로 DOM에서 제거되는지