
Toast는 마치 토스트기에서 빵이 툭 튀어올라오는 모습과 유사하여 이름 지어진 메시지 전달 UI이다. 이와 유사하게 메시지를 전달하는 UI로 "모달"이 있어 상황에 맞게 이를 사용해야 할 것이다.
| 모달 | 토스트 | |
|---|---|---|
| 메시지 전달 방식 | 실행 중인 화면을 가리고 메시지 전달 | 실행 중인 화면에서 메시지에 필요한 공간만 차지하며 상호작용도 유지 |
| UI 스타일 | 메시지와 함께 확인/취소 등의 버튼 | 메시지와 필요하다면 버튼 |
| 종료 방법 | 사용자가 확인/취소 등의 버튼을 누르면 종료 | 일정 시간이 지나면 종료 |
최근 대부분의 모바일 어플리케이션(네이버, 당근, 배달의민족 등)과 웹 페이지(유튜브 등)에서도 토스트를 선호하는 추세인 듯하다. 일부 금융 어플을 제외하고는 모달을 찾아보기 어려웠던 것 같다. 며칠 전 여행 상품 예약이 필요해 클룩(Klook)을 이용하였는데, 쿠폰이나 이벤트 알림 등의 메시지는 모달을 이용하고, 대부분의 알림/성공/경고 등의 메시지는 토스트를 이용하고 있었다.
그동안의 개발에서는 이러한 메시지를 사용자에게 보여줄 때 무조건 모달을 사용하여 작업했었는데, 이번에 나에게 새롭기도 하고 최근에 자주 사용되는 토스트 메시지를 활용하여 작업을 진행해보기로 결정하였다.
대체로 토스트의 구현 방법은 다음과 같다.
useState를 사용하여 토스트의 활성 상태 관리import하지만 이번에는 다음 두 가지를 고려하였고, React의 API인 createPortal 방식을 사용해서 토스트를 개발해보았다.
createPortal이란?우선 createPortal 방식이 무엇인지 알아보자. React의 createPortal API를 사용하면 일부 자식을 DOM의 다른 부분으로 렌더링할 수 있다.
import { createPortal } from 'react-dom';
// ...
<div>
<p>This child is placed in the parent div.</p>
{createPortal(
<p>This child is placed in the document body.</p>,
document.body
)}
</div>
createPortal(children, domNode, key?)Portal을 생성하기 위해서는 createPortal을 호출하여 일부 JSX와 렌더링할 DOM 노드를 전달한다.
매개변수
children<div /> 또는 <SomeComponent />), <Fragment>(<>...</>), 문자열이나 숫자 또는 이들의 배열과 같이 React로 렌더링할 수 있는 모든 것domNodedocument.getElementById()가 반환하는 것과 같은 일부 DOM 노드; 노드가 반드시 존재해야 한다.key (선택사항)key로 사용할 고유한 문자열 또는 숫자반환값
제공된 children을 domNode 안에 반환한다.
Portal을 이용하면 DOM 트리 상에서 부모 컴포넌트의 영향을 받지 않도록 요소를 그릴 수 있어 의도치 않은 CSS의 방해 등을 방지할 수 있다는 장점이 있다.
나는 화면에 토스트 메시지가 똭! 하고 올라오는 기능을 개발해야 했기 때문에, DOM 트리의 영향을 받지 않고 뷰포트 기준으로 스타일링을 적용하기 위해 createPortal을 이용하였다.
이번 개발 목표는 토스트 메시지를 한 페이지에서만 사용하는 것이 아니라 모든 페이지에서 확인할 수 있도록 만드는 것이었다. 따라서 전역에서 토스트의 상태를 관리하는 방식을 선택하였고, 우리는 상태 관리 라이브러리로 zustand를 사용하고 있었기 때문에 useToastStore라는 전역 상태를 생성하였다.
import { create } from 'zustand';
export interface ToastType {
id: number;
content: string;
}
interface ToastState {
toasts: ToastType[];
setToast: (toast: ToastType) => void;
removeToast: (id: number) => void;
}
export const useToastStore = create<ToastState>()((set) => ({
toasts: [], // 여러 개의 토스트를 배열에 저장
setToast: (toast) =>
set((state) => ({
...state,
toasts: [...state.toasts, toast],
})), // 새로운 토스트가 추가되면 기존 토스트 배열에 추가
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
})), // 지정된 시간이 지난 토스트를 토스트 배열에서 제거
}));
토스트 컴포넌트 생성은 어렵지 않다. CSS 스타일링은 디자인에 따라 적용하면 되고, 중요한 것은 위에서 언급한 createPortal을 사용하는 것이다.
createPortal에서 두 번째 인자로 전달하는 domNode는 반드시 존재해야 한다. 기존 DOM 트리와는 별개로 관리하는 것이 목표이므로, index.html 파일에서 새로운 DOM 영역을 생성하는 과정이 필요하다.
index.html의 body에 Portal 추가...
<!-- 기존에 사용하던 root node -->
<div id="root"></div>
<!-- 토스트 메시지를 띄울 toast-portal node 추가 -->
<div id="toast-portal"></div>
...
import { createPortal } from 'react-dom';
...
const Toast = () => {
// 토스트 전역 상태 불러오기
const toasts = useToastStore((state) => state.toasts);
// 생성한 toast-portal DOM
const element = document.getElementById('toast-portal') as HTMLElement;
// 토스트 메시지가 존재할 때만 'toast-portal'에 추가
return createPortal(
toasts.length > 0 && (
<div>
{toasts.map(({ id, content }) => (
<div key={id}>
{content}
</div>
))}
</div>
),
element,
);
};
export default Toast;
전역 상태로 관리하고 있는 토스트 배열에 아이템이 있을 경우, toast-portal이라는 domNode에 토스트 메시지를 추가하는 컴포넌트이다.
App.tsx에 Toast 추가생성한 토스트를 모든 페이지에서 확인할 수 있도록 App.tsx 파일에 추가하였다.
import Toast from '@components/Toast/Toast.tsx';
...
const App = () => {
return (
...
<Suspense fallback={<div></div>}>
<RouterProvider router={router} />
// 토스트 컴포넌트 추가
<Toast />
</Suspense>
...
);
}
...
팀원들이 토스트를 편리하게 사용할 수 있고, 여러 개의 토스트를 적용할 수 있도록 useToast라는 커스텀 훅을 설계하였다.
import { ToastType, useToastStore } from '@store/useToastStore';
const useToast = () => {
// 토스트 전역 상태에서 메서드 불러오기
const { setToast, removeToast } = useToastStore();
const openToast = (content: string) => {
const newToast: ToastType = {
id: Date.now(),
content,
};
setToast(newToast);
// 토스트 메시지를 3초 동안 실행
setTimeout(() => {
removeToast(newToast.id);
}, 3000);
};
return openToast;
};
export default useToast;
Toast를 사용하는 팀원이 openToast 함수에 사용자에게 전달할 메시지를 넘기면, 현재 시간을 id로, 메시지를 content로 하는 newToast 객체가 생성된다.
setToast 함수가 실행되며 전역에서 관리 중인 토스트 배열에 새로운 토스트가 추가된다.
각 토스트 별로 타이머가 실행되어 3초가 지나면 해당 id를 가지는 토스트 메시지를 토스트 배열에서 제거하여 화면에서도 제거한다.
사용법은 간단하다. 버튼을 누르면 "버튼을 클릭했습니다!"라는 토스트 메시지를 실행한다고 가정하자.
import useToast from '@hooks/useToast';
const ExampleComponent = () => {
const openToast = useToast();
const handleClick = () => {
openToast("버튼을 클릭했습니다!");
}
return (
<button type="button" onClick={handleClick}>버튼</button>
)
}
이렇게 하면 토스트 메시지 구현 완료다!
우리 서비스에서 로그인하지 않은 사용자가 스튜디오 예약을 시도하면 로그인을 하도록 안내해야 하는 상황에서 토스트 메시지를 사용하여 로그인을 해야한다는 메시지를 사용자에게 전달하였다.

진욱쌤 고마와요 덕분에 토스트 만들었어요