
이 글은 내가 이전에 포트폴리오에 작성해둔 것이다. 선언적 토스트 컴포넌트를 찾아보고 정리하고 알게된 것들을 포트폴리오에서 보여주고 싶었는데, 너무 블로그 글 같다는 피드백이 많아 포트폴리오에서 빼게 되었다.
그래서 블로그 글 같은 글을 좀 더 블로그 글 같이 수정하여 블로그에 올려본다 ^.^)~
포트폴리오용 이미지도 만들었었다...
멋지게 변한 본인의 모습에 기쁨의 눈물을 흘리는 토스트를 형상화 한 그림이다 ^^;;
토스트 컴포넌트란 팝업 UI의 한 형태로, 4~5초 정도 노출된 후 메세지 UI를 말한다. 보통 요청 완료, 오류 발생 등 특정 이벤트의 결과로 나타나며, 간단한 메세지를 전달하는 용도로 사용된다.
이전 회사의 디자인 시스템에는 토스트 컴포넌트가 정의되어 있었고, 디자인 시스템 개발 작업을 하며 토스트 컴포넌트의 UI도 개발했다. 다만 UI만 개발하고 토스트의 동작은 구현하지 않아, 토스트를 사용하는 페이지마다 별도의 상태 관리와 동작을 작성해야 했다.
각 페이지에서
1. 토스트 노출에 대한 state를 페이지에 정의하고,
2. 노출 상태가 true가 되면 n초 후 노출 상태를 false로 변경시키는 동작을 작성하고,
3. render 부분에서 토스트 노출 여부에 따라 토스트가 조건적으로 렌더링
하는 코드를 별도로 작성해야 했다. 사실 이는 토스트의 최소 요구사항이며, 토스트 닫기, 토스트 업데이트, 토스트 stacking 등의 기능 구현이 필요한 경우도 종종 있었다.
우선 1차적으로 위 코드를 반복해서 작성해야하는 것은 큰 비효율이다. 또, 각 토스트마다의 동작이 달라 통일되지 않은 사용자 경험을 제공할 수 있는 위험이 있다.
그러나 가장 우려된 지점은 코드의 가독성이었다. 토스트 컴포넌트 관련 코드가 전체 페이지 컴포넌트의 가독성을 해치고 있었다.
토스트는 사용자 행동에 대한 feedback으로 중요한 컴포넌트이지만, 페이지에서 비중이 높은 컴포넌트는 아니다. 한 페이지에서 토스트가 노출되는 시간보다 그렇지 않은 시간이 훨씬 많다. 그럼에도 토스트 관련 코드가 다른 주요 로직 코드와 섞여있는 점이 페이지 컴포넌트의 관심사 분리를 모호하게 만들고 있다는 생각이 들었다. 또, 토스트를 띄운다는 것은 하나의 단편적인 동작인데, 관련된 코드만 보고는 해당 동작을 직관적으로 떠올리기 어려워 보였다.
회사의 다른 프로젝트에서 UI 라이브러리로 Chakra-ui를 사용했는데, 그 때 사용했던 Chakra-ui의 useToast가 직관적이고 편리했던 것이 떠올랐다. 별다른 추가 로직 작성 없이 페이지에서 토스트의 노출을 이벤트처럼 trigger할 수 있었다.
이 방식대로 토스트 컴포넌트를 사용할 수 있다면 현재 코드의 문제점인 반복 코드로 인한 비효율, 애매한 관심사 분리, 낮은 코드 가독성을 해결할 수 있으리라
그래서 Chakra-ui의 useToast와 비슷하게 동작하는 useToast를 만들어보기로 했다.
결과적으로는 Chakra-ui의 useToast와 거의 동일하게 동작하는 useToast를 만들게 되었다.
막상 작업에 들어가니 페이지의 render에 를 포함하지 않고 DOM에 추가하는 방법이 떠오르지 않아, Chakra-ui의 코드를 클론받아서 샅샅이 뜯어보며 코드를 작성했기 때문이다.
사실, 처음 코드를 보았을 때 구조가 한 눈에 파악되지 않았다. 해당 코드 베이스에 익숙하지 않을 뿐더러,생각보다 추상화 수준이 높아 바로 이해하기 어려웠다. 그러나 조급해하기보다는 여유를 가지고 한 줄씩 코드를 읽어나갔고(사실 퇴근 후에 집에서 속편하게 읽었다), 중간 중간 메모도 하며 구조를 이해해나갔다.
코드를 분석하며 파악한 전체적인 구조는 아래와 같다.

- 전역 상태 Toast Store는 Toast Props를 배열로 저장한다.
- 위치에 따라 나누어 저장한다.
{ [key in ToastPosition]: ToastProps[] }- App.jsx에 있는 Portal 컴포넌트인 Toast Provider는 Toast Store의 상태를 구독한다.
- store의 값에 따라 Portal에 토스트를 렌더링한다.
- useToast hook은 store에 toast를 추가하는 method를 반환한다.
- 각 Toast 컴포넌트는 내부적인 Id를 갖는다.
- Id를 이용해 컴포넌트 생성 n 초 후 자신을 삭제하는 요청을 store에 보낸다.
앞서 분석한 코드를 참고해, 현재 프로젝트의 개발 환경에 적합한 useToast를 만들었다.
Chakra-UI의 것과 동일하게 작성하기보다는 필요한 기능만 선택하였고 프로젝트의 기술 스택에 맞춰 변경하였다.
전체적인 구조는 동일하게 가져가되, 토스트의 위치 설정, 토스트 닫기 기능은 제외했다. (디자인 시스템 상 상단 외의 다른 위치에 토스트가 노출되는 시나리오가 없음) 또, Context API로 작성된 부분은 프로젝트에서 사용하고 있는 상태관리 라이브러리인 Zustand의 store로 대체했다.
동료 개발자들이 읽고 리뷰하기에 코드가 어렵지 않았으면 했고, 또 오버 엔지니어링도 피하고자 최소한의 요구사항만 추려서 구현했다.
그렇게 정리된 구현 사항은 아래와 같다.
- ToastStore, ToastContainer(ToastProvider), useToast를 만든다.
- Toast는 1) duration 2) variant 3) title 3가지 option을 갖는다.
- n개의 Toast는 세로로 쌓인다.
- Toast Component는 mount/unmount 애니메이션을 갖는다. (framer-motion 활용)
- 토스트 외부에서는 토스트 추가 외에 다른 상태 변경을 요청할 수 없다.
- (토스트 삭제, 토스트 업데이트 등 toast store 값 변경 불가)
(당시 구현 사항을 정리하며 작성한 메모)
useToast 작업 Pull Request
useToast를 통해 처음 목표했던 대로 코드가 개선되었다.
토스트 노출을 trigger 할 수 있는 직관적인 코드가 되었다.
// as-is
const Page = () => {
const [isToastShow, setIsToastShow] = useState(false);
useEffect(() => {
if(isToastShow) {
useTimeout(() => setIsToastShow(false), 4000)
}
}, [isToastShow]);
return (
<PageWrapper>
<button onClick={() => setIsToastShow(true)}>fire toast</button>
{isToastShow && <Toast title="토스트는 맛있어! 🍞" />}
</PageWrapper>
);
}
// to-be
const Page = () => {
const { toast } = useToast({ duration: 4000 });
return (
<PageWrapper>
<button onClick={() => toast({ message: "토스트는 맛있어! 🍞"})}>
fire toast
</button>
</PageWrapper>
);
}
useToast를 만들고 싶다는 생각은 한참 전부터 해왔지만, 구현 방법에 대한 아이디어가 떠오르지 않아 미뤄왔었다. 막연하게 구상만 했던 것을 완료했다는 사실이 무척 뿌듯했다. 오픈 소스 코드를 살펴보는 과정도 재미있었다.
사실 이 작업을 다 완료한 후에 @toss/useOverlay를 알게 되었다. 세부적으로 다른 점들이 있었지만 전체적인 컨셉이 비슷하여 신기하기도 했고, useToast보다 좀 더 범용적으로 사용할 수 있을 것 같아보여서, 다음에 좀 더 자세히 살펴보고 싶다.