토스트 UI 로직 제작기

왜 우리 서비스에 토스트가 필요한가?

사용자 피드백의 중요성

서비스를 개발하다 보면 사용자에게 어떤 일이 일어났는지를 명확히 알려주는 UI 요소의 필요성을 실감하게 된다. 물론 경우에 따라서는 UI 자체가 충분한 피드백이 될 수도 있다. 예를 들어 버튼 클릭 후 새로운 화면으로 이동하거나 리스트에 항목이 추가되는 등의 명확한 시각적 변화가 있다면, 사용자는 "아, 동작이 잘 됐구나" 하고 직관적으로 인지할 수 있다.

하지만 모든 사용자 행동이 그렇게 드러나지는 않는다. 예를 들어, 우리 서비스에는 사용자를 초대하는 기능이 있다. 초대 버튼을 누르면 백엔드로 요청이 전송되고, 성공적으로 처리되면 초대가 완료된다. 하지만 UI에는 아무런 변화가 없다. 초대가 잘 된 건지, 실패한 건지 사용자 입장에서는 알 길이 없다. “한 번 더 눌러야 하나?”, “지금 무슨 문제가 있었던 걸까?” 같은 불확실함과 혼란이 생길 수 있다.

모달과의 비교

이럴 때 흔히 떠올리는 방식이 모달이다. 하지만 모달은 화면 전체를 덮고 사용자의 추가 액션(닫기 등)을 요구한다는 점에서 부담이 크다. 단순히 “초대가 완료되었습니다” 같은 짧은 메시지를 전달하기엔 다소 무겁고, 흐름을 끊는 방식이다.

그래서 보다 자연스럽고 가벼운 방식의 피드백이 필요하다고 판단했고, 그 해답으로 토스트 메시지를 선택했다. 토스트는 화면을 가리지 않으면서도, 잠깐 나타났다 사라지는 형태로 사용자에게 필요한 메시지를 빠르게 전달할 수 있다. 사용자의 흐름을 방해하지 않으면서도, 정보를 놓치지 않게 도와준다.

이번 글에서는 프로젝트 전반에서 재사용 가능한 토스트 시스템을 어떻게 설계하고 구현했는지 그 과정을 자세히 소개하려고 한다.

토스트 시스템 설계 방향

토스트는 사용자 경험을 방해하지 않으면서도 명확한 피드백을 제공할 수 있어야 한다. 이를 위해서는 다음과 같은 핵심 요구사항들을 고려해야 한다.

1. 전역에서 호출 가능해야 함

토스트 메시지는 UI의 어디서나 호출될 수 있어야 한다. 예를 들어, 사용자가 버튼을 클릭하여 동작을 수행한 후 그 결과를 사용자에게 알려줘야 할 때, 해당 기능은 UI의 한 부분에서 일어나는 일이지만, 그 피드백은 전체 화면에서 쉽게 보이도록 제공되어야 한다.

따라서, 토스트 시스템은 전역적으로 접근 가능 해야 한다. React의 Context API나 Zustand와 같은 전역 상태 관리 라이브러리를 사용하면, 어떤 컴포넌트에서든 토스트를 호출할 수 있게 된다. 예를 들어, API 호출이 끝나고 그 결과에 따라 토스트를 띄워야 한다면, 전역 상태를 통해 토스트 컴포넌트가 표시될 수 있도록 상태를 업데이트할 수 있다.

2. UI의 흐름을 방해하지 않아야 함

토스트 메시지의 가장 큰 장점 중 하나는 사용자의 흐름을 방해하지 않으면서도 피드백을 제공할 수 있다는 점이다. 만약 사용자 경험을 고려하지 않고 토스트가 너무 자주, 너무 길게 또는 너무 크게 표시된다면, 사용자는 피드백을 받는 것보다는 오히려 UI 흐름을 방해받는다고 느낄 수 있다.

따라서 토스트는 사용자가 현재 작업 중인 화면을 가리지 않도록, 화면의 일부분에 잠시 나타났다가 사라지는 형태여야 한다. 예를 들어, 화면의 상단이나 하단에 표시되도록 위치를 지정하고, 사용자가 작업을 계속할 수 있도록 다른 중요한 UI 요소와 겹치지 않도록 배치해야 한다.

3. 자동으로 사라지도록

토스트 메시지는 사용자에게 정보를 제공하는 역할을 하지만, 이를 오래 표시하는 방식은 적합하지 않다. 왜냐하면 토스트는 간단하고 빠르게 정보를 전달하는 역할을 하기 때문이다.

따라서 토스트 메시지는 일정 시간이 지나면 자동으로 사라지도록 설계하는 것이 바람직하다. 예를 들어, 메시지가 화면에 나타난 후 3초에서 5초 정도의 시간 동안 표시되고 자동으로 사라지도록 설정할 수 있다. 이 방식은 사용자에게 불필요한 클릭을 요구하지 않으며, 피드백을 빠르게 전달하면서도 UI 흐름을 방해하지 않는 효과를 가져온다.

단, 자동으로 사라지기 전에 사용자가 토스트를 이미 확인했다면, 이를 강제로 닫을 수 있는 기능을 제공하는 것도 고려할 수 있다. 예를 들어, 사용자가 토스트를 클릭하면 바로 사라지도록 설정하는 방식이다.

4. 액션 버튼도 지원해야 함

최근 많은 경우, 토스트 메시지가 단순히 정보만 전달하는 것이 아니라, 사용자가 바로 반응할 수 있는 액션 버튼을 포함하기도 한다. 예를 들어, "구독" 관련 메시지에는 "보러가기" 같은 버튼을 추가하여 사용자가 관련 정보를 바로 확인할 수 있도록 돕는다.

이처럼 액션 버튼은 사용자가 토스트 메시지를 통해 바로 행동을 취할 수 있도록 하며, 그 결과에 따른 후속 동작을 쉽게 이어갈 수 있게 돕는다. 하지만, 이러한 버튼이 너무 많거나 복잡하면 사용자의 흐름을 방해할 수 있기 때문에, 버튼의 수와 배치가 간결하고 직관적이어야 한다.

즉, 토스트는 사용자에게 간단한 피드백을 넘어서, 필요한 액션을 바로 취할 수 있도록 돕는 중요한 역할을 한다.

전역 상태를 활용한 토스트 시스템 설계

토스트(Toast) 메시지는 앱 전반에서 사용되는 UI 컴포넌트다. 단순한 알림부터 사용자의 액션을 유도하는 인터랙션까지 다양한 용도로 쓰인다. 이런 토스트 시스템을 효율적으로 관리하기 위해 전역 상태 기반 구조로 설계했다.

전역 상태를 사용한 이유

토스트 메시지는 특정 컴포넌트에 종속되지 않고, 다양한 위치에서 발생한다. 예를 들어 로그인 실패, 저장 완료, 에러 메시지 등 여러 상황에서 필요하다.

이런 특성상 다음 조건을 만족해야 한다:

  • 어디서든 토스트를 띄울 수 있어야 함

  • 중앙에서 상태를 관리해야 함

  • 불필요한 리렌더링은 피해야 함

Context API는 context 내 어떤 값이 변경되면 해당 context를 구독하는 모든 컴포넌트가 불필요하게 리렌더링된다. 이러한 이유로 좀 더 효율적인 구독 모델을 제공하는 전역 상태 관리 라이브러리를 검토했으며, 그 중에서도 Zustand를 선택했다.

왜 Zustand를 선택했나?

Zustand는 가볍고, 보일러플레이트가 적다:

  • 다른 상태 관리 라이브러리보다 번들 크기가 작다

  • 컴포넌트 단위 구독이 가능해서 리렌더링을 최소화할 수 있다

  • 도입이 간단하고 직관적이다

상태와 핸들러 구조

상태

toasts: Toast[]
  • 현재 표시되고 있는 토스트 메시지들의 배열

  • 각 메시지는 고유 ID, 메시지 내용, 타입(success, error, warning 등), 액션 정보 등을 포함한다

핸들러

  • addToast: 새 토스트를 추가한다.

    • allowMultiple이 false면 기존 메시지를 지우고 새로운 메시지로 덮어쓴다

    • duration이 Infinity가 아니면 자동으로 해당 시간 후 제거된다

  • removeToast: ID를 기준으로 토스트를 제거한다.

    • 닫기 버튼 클릭이나 외부 트리거로 호출될 수 있다

구현 코드

const DEFAULT_TOAST_DURATION = 4000;

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

export interface Toast {
  id: string;
  message: string;
  type: ToastType;
  title?: string;
  actionLabel?: string;
  onAction?: () => void;
  duration?: number;
  allowMultiple?: boolean;
}

interface ToastStore {
  toasts: Toast[];
  addToast: (toast: Omit<Toast, 'id'>) => void;
  removeToast: (id: string) => void;
}

export const useToastStore = create<ToastStore>((set) => ({
  toasts: [],

  addToast: (toast) => {
    const { title = '', message, type, duration = DEFAULT_TOAST_DURATION, allowMultiple = false, actionLabel, onAction,} = toast;
    const newToast: Toast = {
      id: generateId(),
      title,
      message,
      type,
      actionLabel,
      onAction,
    };

    set((state) => ({
      toasts: allowMultiple ? [...state.toasts, newToast] : [newToast],
    }));

    if (duration !== Infinity) {
      setTimeout(() => {
        set((state) => ({
          toasts: state.toasts.filter((toast) => toast.id !== newToast.id),
        }));
      }, duration);
    }
  },

  removeToast: (id) => {
    set((state) => ({
      toasts: state.toasts.filter((toast) => toast.id !== id),
    }));
  },
}));

토스트 컴포넌트 구현

컴포넌트 구조 설계

각 요소를 하위 컴포넌트로 분리해서 확장성을 높였고 Object.assign으로 메인 컴포넌트에 하위 요소들을 속성으로 붙여서 <Toast.Header />처럼 쓸 수 있게 만들었다.

Toast (Main)
├── Toast.Header
│   ├── Toast.Bar (색상 표시 바)
│   └── Toast.Icon (아이콘)
├── Toast.Body
│   ├── Toast.Title
│   └── Toast.Message
└── Toast.Footer
    └── Toast.ActionButton or Toast.RemoveButton

예시 코드

<Toast>
  <Toast.Header>
    <Toast.Bar type="success" />
    <Toast.Icon type="success" />
  </Toast.Header>
  <Toast.Body>
    <Toast.Title>초대 완료</Toast.Title>
    <Toast.Message>선아님을 성공적으로 초대했어요</Toast.Message>
  </Toast.Body>
  <Toast.Footer>
    <Toast.RemoveButton/>
  </Toast.Footer>
</Toast>

포탈로 렌더링

토스트 리스트는 루트 DOM 외부#toast라는 고정 위치 노드에 렌더되도록 했다. 이를 위해 ReactDOM.createPortal을 사용했다. 이 방식을 채택한 이유는 토스트 알림이 애플리케이션의 다른 컴포넌트 스타일에 영향을 받지 않고 항상 상위에 표시되어야 하기 때문이다. 예를 들어 부모 컴포넌트의 overflow나 위치 지정 속성에 영향받지 않고 화면의 원하는 위치에 렌더링할 수 있다.

function ToastList() {  
	const toastList = useToastStore((state) => state.toasts));
	
  const toastRoot = document.getElementById('toast');
  if (!toastRoot) return null;

  const TostList = toastList.map((toast) => ...);

  return createPortal(<ul className={styles.toastContainer}>{TostList}</ul>, toastRoot);
}

export default ToastList;

사용 예시와 실전 적용

실제 서비스 내에서 사용한 Toast 예시

우리 서비스에서는 토스트 메시지가 필요한 대표적인 사례 중 하나가 크루 초대 기능이었다.

크루에 새로운 멤버를 초대하면, 상대방에게는 초대 메일이 발송되지만 초대한 사용자 입장에서는 UI 상으로 어떤 변화도 일어나지 않기 때문에 초대가 정상적으로 처리됐는지 바로 확인하기 어려웠다.

이제는 화면 상단에 "초대 메일이 성공적으로 전송되었습니다"와 같은 토스트 메시지를 띄워줌으로써 사용자에게 피드백을 명확하게 제공할 수 있다.

const addToast = useToastStore((state) => state.addToast))

addToast({
  type: 'success',
  message: `'${nickname}'님을 초대했어요`,
});

마무리 및 회고

토스트 시스템을 설계하고 구현하면서 무엇보다 사용자 경험 측면에서 많은 고민과 배움이 있었다.

  1. 정보 전달의 적절한 타이밍: 토스트가 너무 빨리 사라지면 사용자가 메시지를 놓칠 수 있고, 반대로 너무 오래 남아있으면 방해가 된다는 점을 경험했다. 메시지의 중요도와 길이에 따라 표시 시간을 조절하는 것이 중요하다는 것을 깨달았다.

  2. 주의 분산 최소화: 토스트가 지나치게 눈에 띄거나 화려하면 사용자의 주 작업에서 주의를 분산시킬 수 있다. 정보는 명확하게 전달하되 현재 작업의 맥락을 방해하지 않는 미묘한 균형을 찾는 것이 중요했던 것 같다.

  3. 피드백의 일관성: 유사한 작업에 대해서는 일관된 스타일과 위치의 토스트를 보여주어야 사용자가 혼란스럽지 않다는 점을 배웠다. 특히 성공, 경고, 오류 메시지의 시각적 차별화가 직관적 이해에 큰 도움이 된다는 것을 확인했다.

Last updated