DevOwen
토스트 컴포넌트 만들기
React에서 재사용 가능한 Toast 컴포넌트를 Context + useReducer + createPortal로 설계하고, 자동 제거와 접근성(aria-live)까지 챙기는 단계별 구현 가이드.
핵심 요약
React 토스트 컴포넌트를 밑바닥부터 만들며 네 가지 설계 결정을 차례로 보여 준다. 첫째, 여러 곳에서 호출해야 하므로 ToastContext + useReducer 로 전역 큐를 두고 dispatch({ type: 'ADD' }) / dispatch({ type: 'REMOVE' }) 로만 변경한다. 둘째, ReactDOM.createPortal(<ToastList />, document.body) 로 DOM 계층 밖에 렌더해 overflow:hidden 과 z-index 간섭을 끊는다. 셋째, 각 토스트는 useEffect 에서 setTimeout 을 걸고 cleanup 으로 clearTimeout 해 언마운트 시 타이머 누수를 막는다. 넷째, 컨테이너에 role="status" 와 aria-live="polite" 를 부여해 스크린 리더가 읽도록 한다.
Toast는 겉보기엔 작지만 전역 상태 + DOM 레이어 분리 + 타이머 수명관리 + 접근성 네 축이 한 번에 교차하는 컴포넌트다. 이 글은 그 네 축을 각각 Context/useReducer, ReactDOM.createPortal, setTimeout + useEffect cleanup, role="status"/aria-live 로 매핑해 읽으면 지식이 흩어지지 않는다.
토스트는 신입/주니어 React 면접의 단골 라이브 코딩 주제다. 판단 포인트가 많기 때문이다.
- 전역 상태를 Context vs prop drilling vs 외부 store 중 무엇으로 관리할지
- 왜
createPortal이 필요한가 —overflow:hidden,z-index스태킹 컨텍스트 함정을 설명할 수 있는지 setTimeout으로 자동 제거할 때 cleanup 누락으로 인한 메모리 누수를 아는지- 스크린 리더 사용자를 위한
aria-live를 고려했는지
이 네 지점 중 하나라도 근거 없이 답하면 "라이브러리 써본 사람" 수준에서 멈춘다.
학습 포인트
면접 답변으로 연결할 학습 포인트입니다.
Context + useReducer 큐
토스트는 동시에 여러 개가 쌓일 수 있어 배열 상태가 자연스럽다. useState 로 펼치면 ADD/REMOVE 로직이 컴포넌트마다 흩어지므로 useReducer 로 모은다. 호출부는 useToast() 훅이 주는 showToast(message) 만 알면 된다.
type Action =
| { type: 'ADD'; toast: Toast }
| { type: 'REMOVE'; id: string };
function reducer(state: Toast[], action: Action): Toast[] {
switch (action.type) {
case 'ADD': return [...state, action.toast];
case 'REMOVE': return state.filter(t => t.id !== action.id);
}
}
const ToastContext = createContext<Ctx | null>(null);
export const useToast = () => useContext(ToastContext)!;
| 선택 | 언제 | 단점 |
|---|---|---|
| prop drilling | 토스트를 한 화면에서만 씀 | 공용 UI로 못 커짐 |
Context + useReducer | 앱 전역 호출 필요 | Provider 가 루트에 필요 |
| Zustand/Jotai | 이미 외부 store 사용 중 | 의존성 추가 |
useState 로 단일 토스트 하나만 두면 두 번째 호출이 첫 번째를 덮어쓴다. 배열 큐 + 고유 id 가 기본값이어야 한다.
왜 createPortal 인가
토스트를 부모 트리 안에 그대로 렌더하면 부모의 overflow:hidden, transform, z-index 스태킹 컨텍스트에 갇힌다. ReactDOM.createPortal 은 React 트리에서는 자식이지만 DOM 상은 document.body 직속으로 빠져 레이어 문제를 구조적으로 해결한다.
import { createPortal } from 'react-dom';
function ToastList({ toasts }: { toasts: Toast[] }) {
return createPortal(
<ul className="toast-container" role="status" aria-live="polite">
{toasts.map(t => <ToastItem key={t.id} {...t} />)}
</ul>,
document.body,
);
}
| 상황 | 인라인 렌더 | createPortal |
|---|---|---|
overflow:hidden 부모 | 잘림 | 영향 없음 |
transform 부모 | 새 스태킹 컨텍스트에 갇힘 | 탈출 |
| 이벤트 버블링 | DOM 따라감 | React 트리 기준 유지 |
z-index: 9999 만 크게 올리면 해결된다고 답하는 것. 부모가 별도 스태킹 컨텍스트면 아무리 큰 값도 바깥으로 못 빠져나간다.
setTimeout + cleanup 수명관리
자동 제거는 setTimeout 으로 하되, 반드시 useEffect cleanup 에서 clearTimeout 해야 한다. 빠른 언마운트나 수동 닫기 후에도 타이머가 살아 있으면 unmounted 컴포넌트에 dispatch 가 날아가 경고/메모리 누수가 난다.
useEffect(() => {
const id = setTimeout(() => {
dispatch({ type: 'REMOVE', id: toast.id });
}, toast.duration ?? 3000);
return () => clearTimeout(id);
}, [toast.id]);
의존성 배열에 toast.id 만 두어 같은 토스트가 재시작되지 않도록 하는 것도 중요 포인트다.
setTimeout 의 반환값을 무시하거나 cleanup 없이 두는 것. hover 로 pause 같은 기능을 붙일 때 타이머가 중복 등록되어 동작이 꼬인다.
접근성: role과 aria-live
시각적으로만 뜨고 스크린 리더에 읽히지 않으면 에러 메시지를 놓친다. 컨테이너에 role="status" 또는 role="alert" 를 주고 성격에 맞는 aria-live 값을 쓴다.
| 값 | 언제 | 동작 |
|---|---|---|
aria-live="off" | 기본값 | 읽지 않음 |
aria-live="polite" | 일반 알림(저장 완료 등) | 현재 읽던 문장 끝나고 읽음 |
aria-live="assertive" | 실패/결제 오류 등 급한 것 | 즉시 끊고 읽음 |
role="alert" 은 암묵적으로 assertive, role="status" 는 polite 다. 중요도 낮은 성공 토스트에 assertive 를 남발하면 오히려 사용자 경험을 깬다.
모든 토스트에 role="alert" 를 박아 놓는 것. 긴급하지 않은 알림까지 현재 낭독을 끊어 버려 접근성이 오히려 나빠진다.
읽는 순서
- 1이론
Context+useReducer패턴,ReactDOM.createPortal의 렌더 대상과 이벤트 버블링 규칙,aria-live의polite/assertive차이를 각각 한 문단으로 정리한다. - 2구현
useToast()훅 +ToastProvider+ToastList(createPortal) 를 100줄 내외로 직접 작성한다.ADD/REMOVE리듀서, 자동 제거useEffect+clearTimeout,role="status"/aria-live="polite"를 모두 포함한다. - 3실무
현재 프로젝트의 모달/토스트/툴팁 컴포넌트를 열어
z-index싸움이 있는 곳을 찾고,createPortal로 빠지면 해결되는지,aria-live속성이 누락된 알림이 있는지 점검한다. - 4설명
동료에게 "Toast 에
createPortal과useReducer를 왜 같이 쓰는가" 를 화이트보드로 5분 안에 설명한다. 스태킹 컨텍스트 그림 + Provider 구조 + 타이머 cleanup 세 가지를 빠뜨리지 않는 것이 합격선.
면접 연결 질문
[감점 답변] "DOM 위에 띄우려고", "관습적으로" 수준. [좋은 답변] overflow:hidden, transform, filter 같은 속성이 만드는 새 스태킹 컨텍스트때문에 z-index 가 바깥으로 못 빠져나간다 → createPortal 로 document.body 직속 렌더. React 이벤트 버블링은 React 트리기준이라 Provider/상위 핸들러가 그대로 동작한다는 점까지 연결.
[감점 답변] "그냥 useState 로 배열 만들어요." [좋은 답변] Context 로 showToast 를 노출 + useReducer 로 ADD/REMOVE 액션 일원화 + 각 토스트마다 id 부여(겹침 방지). 트레이드오프: Provider 필수, 렌더 최적화 필요 시 useSyncExternalStore/외부 store 고려.
[감점 답변] setTimeout(() => remove(), 3000) 만 작성. [좋은 답변] useEffect 안에서 타이머 등록 후 cleanup 에서 clearTimeout 해 언마운트/수동 닫기 시 dangling timer 방지, 의존성에 id 만 넣어 타이머 재시작 방지, hover pause 같은 기능은 clearTimeout 후 남은 시간을 기록해 재시작하는 패턴까지.
[감점 답변] "role="alert" 붙여요" 만 반복. [좋은 답변] 성격에 따라 구분: 일반 정보/성공은 role="status"(= aria-live="polite"), 실패·중요 경고는 role="alert"(= assertive). 포커스 탈취는 하지 말고, 키보드로 닫기(Esc) 와 충분한 표시 시간(최소 5초+)도 함께 언급.
자기 점검
z-index 값만 높이면 된다고 생각하는 것. transform/opacity/filter 가 있는 부모가 스태킹 컨텍스트를 만들면 무력해진다.
"어차피 곧 사라지니 괜찮다" 고 보는 것. 빠른 라우트 이동/수동 닫기 시 dangling timer 가 제거된 id 에 dispatch 를 보내 경고·버그를 만든다.
전부 assertive 로 두는 것. 낭독을 강제로 끊어 오히려 방해가 된다.