FEInterview Prep

DevOwen

토스트 컴포넌트 만들기

React에서 재사용 가능한 Toast 컴포넌트를 Context + useReducer + createPortal로 설계하고, 자동 제거와 접근성(aria-live)까지 챙기는 단계별 구현 가이드.

2026-02-19·6분 읽기
React
원문 보기 ↗

핵심 요약

React 토스트 컴포넌트를 밑바닥부터 만들며 네 가지 설계 결정을 차례로 보여 준다. 첫째, 여러 곳에서 호출해야 하므로 ToastContext + useReducer 로 전역 큐를 두고 dispatch({ type: 'ADD' }) / dispatch({ type: 'REMOVE' }) 로만 변경한다. 둘째, ReactDOM.createPortal(<ToastList />, document.body) 로 DOM 계층 밖에 렌더해 overflow:hiddenz-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 사용 중의존성 추가
ContextuseReducerdispatchProvider
자주 하는 오해

useState 로 단일 토스트 하나만 두면 두 번째 호출이 첫 번째를 덮어쓴다. 배열 큐 + 고유 id 가 기본값이어야 한다.

왜 createPortal 인가

토스트를 부모 트리 안에 그대로 렌더하면 부모의 overflow:hidden, transform, z-index 스태킹 컨텍스트에 갇힌다. ReactDOM.createPortalReact 트리에서는 자식이지만 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 트리 기준 유지
createPortalstacking contextz-indexoverflow
자주 하는 오해

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 만 두어 같은 토스트가 재시작되지 않도록 하는 것도 중요 포인트다.

setTimeoutclearTimeoutuseEffectcleanup
자주 하는 오해

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 를 남발하면 오히려 사용자 경험을 깬다.

rolearia-livestatusalert
자주 하는 오해

모든 토스트에 role="alert" 를 박아 놓는 것. 긴급하지 않은 알림까지 현재 낭독을 끊어 버려 접근성이 오히려 나빠진다.

읽는 순서

  1. 1이론

    Context + useReducer 패턴, ReactDOM.createPortal 의 렌더 대상과 이벤트 버블링 규칙, aria-livepolite/assertive 차이를 각각 한 문단으로 정리한다.

  2. 2구현

    useToast() 훅 + ToastProvider + ToastList(createPortal) 를 100줄 내외로 직접 작성한다. ADD/REMOVE 리듀서, 자동 제거 useEffect + clearTimeout, role="status"/aria-live="polite" 를 모두 포함한다.

  3. 3실무

    현재 프로젝트의 모달/토스트/툴팁 컴포넌트를 열어 z-index 싸움이 있는 곳을 찾고, createPortal 로 빠지면 해결되는지, aria-live 속성이 누락된 알림이 있는지 점검한다.

  4. 4설명

    동료에게 "Toast 에 createPortaluseReducer 를 왜 같이 쓰는가" 를 화이트보드로 5분 안에 설명한다. 스태킹 컨텍스트 그림 + Provider 구조 + 타이머 cleanup 세 가지를 빠뜨리지 않는 것이 합격선.

면접 연결 질문

mediumToast 컴포넌트를 만들 때 `ReactDOM.createPortal` 을 쓰는 이유는? `z-index` 만 크게 줘도 되지 않나요?
힌트

[감점 답변] "DOM 위에 띄우려고", "관습적으로" 수준. [좋은 답변] overflow:hidden, transform, filter 같은 속성이 만드는 새 스태킹 컨텍스트때문에 z-index 가 바깥으로 못 빠져나간다 → createPortaldocument.body 직속 렌더. React 이벤트 버블링은 React 트리기준이라 Provider/상위 핸들러가 그대로 동작한다는 점까지 연결.

medium여러 곳에서 호출 가능한 전역 토스트 시스템을 설계한다면 상태 관리는 어떻게 하시겠어요?
힌트

[감점 답변] "그냥 useState 로 배열 만들어요." [좋은 답변] ContextshowToast 를 노출 + useReducerADD/REMOVE 액션 일원화 + 각 토스트마다 id 부여(겹침 방지). 트레이드오프: Provider 필수, 렌더 최적화 필요 시 useSyncExternalStore/외부 store 고려.

mediumToast 의 자동 제거를 `setTimeout` 으로 구현할 때 조심할 점은?
힌트

[감점 답변] setTimeout(() => remove(), 3000) 만 작성. [좋은 답변] useEffect 안에서 타이머 등록 후 cleanup 에서 clearTimeout 해 언마운트/수동 닫기 시 dangling timer 방지, 의존성에 id 만 넣어 타이머 재시작 방지, hover pause 같은 기능은 clearTimeout 후 남은 시간을 기록해 재시작하는 패턴까지.

easy토스트의 접근성을 위해 `aria-live` 와 `role` 을 어떻게 설정하나요?
힌트

[감점 답변] "role="alert" 붙여요" 만 반복. [좋은 답변] 성격에 따라 구분: 일반 정보/성공은 role="status"(= aria-live="polite"), 실패·중요 경고는 role="alert"(= assertive). 포커스 탈취는 하지 말고, 키보드로 닫기(Esc) 와 충분한 표시 시간(최소 5초+)도 함께 언급.

자기 점검

Toast 에 `createPortal` 을 쓰는 이유를, `z-index` 로는 왜 부족한지와 함께 한 문장으로 설명할 수 있나요?
createPortal스태킹 컨텍스트overflowdocument.body
자주 하는 오해

z-index 값만 높이면 된다고 생각하는 것. transform/opacity/filter 가 있는 부모가 스태킹 컨텍스트를 만들면 무력해진다.

`setTimeout` 기반 자동 제거에서 cleanup 을 왜 반드시 써야 하는지 상황 예시로 설명해 보세요.
clearTimeoutuseEffectcleanupunmount
자주 하는 오해

"어차피 곧 사라지니 괜찮다" 고 보는 것. 빠른 라우트 이동/수동 닫기 시 dangling timer 가 제거된 id 에 dispatch 를 보내 경고·버그를 만든다.

`aria-live="polite"` 와 `"assertive"` 중 어떤 토스트에 어떤 값을 주어야 할까요?
politeassertiverole=statusrole=alert
자주 하는 오해

전부 assertive 로 두는 것. 낭독을 강제로 끊어 오히려 방해가 된다.