토스트 관리의 모든 것: Portal, useRef, hover pause

진밥·2026년 4월 29일
post-thumbnail

🪄 TL;DR

  • 모달과 토스트가 화면 최상단에 나타날 수 있도록 root가 아닌 React Portal 안에 위치하도록 했다.
  • 모달 안에서도 토스트 트리거를 가능하도록 하기 위해 ToastContext, Provider를 이용해 토스트를 전역화했다.
  • useEffect와 setTimeout을 이용해 토스트를 사라지게 했더니, 여러 개가 같은 타이밍에 사라져버리는 문제가 있었다.
  • 토스트를 개별적으로 사라지게 하기 위해서 useRef를 이용해 독립적인 타이머를 적용해주었다.
  • 토스트에 mouse hover 하면 타이머를 멈추고, mouse out 하면 남은 시간이 재개되도록 pauseAll, resumeAll 기능을 구현했다.

Portal 레이어 준비하기

React Portal은 현재 DOM 계층 구조의 부모 자식 관계와 무관하게, root와 같은 레벨에 있는 portal 안에 자식을 렌더링할 수 있게 하는 기능이다. 모달, 툴팁, 토스트 등 컴포넌트 간의 계층 구조를 벗어나 최상위에 보여주어야 할 때 주로 사용한다.

구현은 간단하다. 우선, index.html에 portal 마운트 지점을 추가해주었다.

// index.html
<div id="root"></div>
<div id="portal"></div>

그 후 Portal 컴포넌트를 만들어 위에서 지정한 위치에 React Portal을 생성했다.

// src/features/shared/ui/Portal/Portal.tsx
export const Portal = ({ children }: { children: React.ReactNode }) => {
  return createPortal(children, document.getElementById('portal') as HTMLElement);
};

로컬 상태와 setTimeout을 이용한 초기 구현

초기엔 상세 페이지 내부에서 showToast 상태만 들고 토스트를 띄웠다.

// src/features/stock/ui/StockDetail.tsx
const [showToast, setShowToast] = useState(false);

const handleAddMyStockButton = () => {
  setShowToast(true);
  setTimeout(() => setShowToast(false), 3000);
};

'어떻게 토스트를 보여줄 수 있을까?'만 생각하며 시도해본 방법이었다. 빠르게 붙이기엔 충분했지만, 모달 안에서도 같은 토스트를 써야 하는 케이스가 생기자 바로 한계가 드러났다. 토스트 로직이 여러 컴포넌트에 흩어지면 관리가 어렵다는 걸 느꼈다.

ToastContext / Provider를 이용한 전역화

토스트를 전역에서 관리하도록 Context + Provider 구조로 변경하였다.

// src/features/shared/ui/Toast/ToastProvider.tsx
export const ToastProvider = ({ children }: { children: ReactNode }) => {
  const [toasts, setToasts] = useState<{ id: string; message: string }[]>([]);

  const addToast = (message: string) => {
    const id = crypto.randomUUID();
    setToasts((prev) => [...prev, { id, message }]);
  };

  const removeToast = (id: string) => {
    setToasts((prev) => prev.filter((toast) => toast.id !== id));
  };

  useEffect(() => {
    if (!toasts) return;
    const timer = setTimeout(() => {
      removeToast(toasts[0].id);
    }, 1000);
    return () => clearTimeout(timer);
  }, [toasts]);

  return (
    <ToastContext.Provider value={{ toasts, addToast, removeToast }}>
      {children}
    </ToastContext.Provider>
  );
};

AppProvider에서 감싸고, useToast 훅으로 어디서든 addToast를 호출할 수 있게 했다. 모달 안에서도, 페이지 안에서도 동일하게 토스트를 트리거할 수 있게 됐다.

토스트가 한 번에 다 사라져버리는 문제

전역화까지는 잘 됐는데, 위 코드의 useEffect 부분에서 문제가 생겼다. 얼핏 보면 괜찮은 것 같지만, 실제로 돌려보면 토스트 세 개가 거의 동시에 사라진다.

이유는 이렇다. 첫 번째 토스트가 삭제되면 toasts 배열이 바뀐다 → useEffect가 재실행된다 → 새 setTimeout이 걸린다. 그런데 이미 두 번째, 세 번째 토스트도 각자 이전에 등록된 타이머가 거의 동시에 만료되는 상황이 된다. 타이머가 각 토스트에 독립적으로 걸리는 게 아니라, 배열 전체 변화에 반응하는 구조라 생기는 문제다.

useRef 타이머 맵으로 개선

해결은 "렌더 상태"와 "타이머 상태"를 분리하는 것이었다. useRef<Map>으로 각 토스트 ID마다 타이머를 따로 관리했다.

// src/features/shared/ui/Toast/useToastTimer.ts
const timers = useRef<Map<string, Timer>>(new Map());
const paused = useRef(false);

timers는 토스트 ID와 타이머 정보를 짝지어 보관하는 서랍 역할을 한다. 토스트가 3개면 서랍이 3칸이고, 각 칸에 시작 시각, 남은 시간, setTimeout ID가 들어있다. useRef를 쓰는 이유는 이 값이 바뀌어도 리렌더가 일어나면 안 되기 때문이다. 화면에 보여줄 값이 아니라 내부에서 조용히 관리만 하면 되는 값이다. paused는 지금 hover 중이라 멈춰있는지 기억하는 플래그다.

const register = useCallback((id: string) => {
  if (timers.current.has(id)) return; // 중복 등록 방지
  timers.current.set(id, { remain: TOAST_DURATION, start: Date.now() });
  if (!paused.current) start(id); // hover 중이 아닐 때만 시작
}, [start]);
  • useCallback은 함수를 메모이제이션해서 매 렌더마다 새 함수가 만들어지는 걸 막는다. 이 함수를 의존성으로 받는 useEffect 같은 훅이 불필요하게 재실행되는 걸 방지하기 위해 사용했다.
  • timers.current.has(id)를 체크해서 이미 등록된 ID면 바로 리턴한다. 리렌더 때마다 register가 다시 불릴 수 있어서 중복 등록을 막아준다.
  • remain은 남은 시간이다. 처음엔 전체 시간(TOAST_DURATION)이지만, hover로 멈췄다 재개될 때 이 값이 줄어든다.
  • start: Date.now()는 얼마나 시간이 흘렀는지 계산하기 위한 시작 시각이다.
  • if (!paused.current) start(id) — hover 중에 토스트가 추가되면 등록만 해두고, 마우스를 뗄 때 resumeAll이 시작시킨다.
const pauseAll = () => {
  paused.current = true;
  for (const t of timers.current.values()) {
    clearTimeout(t.timerId); // 각 토스트 setTimeout 취소
    t.remain -= Date.now() - t.start; // 지금까지 흐른 시간만큼 남은 시간 차감
  }
};
  • clearTimeout(t.timerId)은 각 토스트의 setTimeout을 취소한다. 이걸 안 하면 타이머가 멈추지 않고 계속 흘러간다.
  • t.remain -= Date.now() - t.start이 바로 이 코드의 핵심이다. 3초짜리 토스트가 1초 흐른 뒤 멈췄다면 remain이 2000ms로 업데이트된다. 이 값이 있어야 나중에 남은 시간부터 재개할 수 있다.
const resumeAll = () => {
  paused.current = false;
  for (const id of timers.current.keys()) start(id); // 남은 시간으로 재시작
};

start(id)은 pauseAll이 계산해둔 remain 값을 setTimeout 딜레이로 사용한다. 처음부터 3초가 아니라 남은 시간부터 재개되는 이유다.

실제 JSX에서는 이렇게 연결된다.

// src/features/shared/ui/Toast/Toast.tsx
<div onMouseEnter={pauseAll} onMouseLeave={resumeAll}>
  {toasts.map((toast) => (
    <div key={toast.id} onClick={() => removeToast(toast.id)}>
      {toast.message}
    </div>
  ))}
</div>

전체 흐름을 정리하면 이렇다.

  1. 토스트 추가 → register(id) → 서랍에 등록 → setTimeout 시작
  2. 마우스 올림 → pauseAll() → setTimeout 취소 + 남은 시간 계산
  3. 마우스 뗌 → resumeAll() → 남은 시간으로 setTimeout 재시작
  4. 시간 다 됨 → removeToast(id) → 서랍에서 삭제
  5. 클릭 → removeToast(id) → clearTimeout 후 서랍에서 삭제

z-index 충돌

실제로 겪은 문제는 토스트가 모달 뒤로 가려지는 것이었다. 모달과 토스트를 같은 portal 컨테이너에 마운트하고 있으니 z-index를 명시적으로 정해줘야 했다.

// NewsModal.tsx
className="fixed inset-0 z-50 ..."

// Toast.tsx
className="fixed ... z-100 ..."

이때 숫자는 미리 프로젝트에서 정해둔 규칙에 따라 사용하면 된다. 0, 1, 2 같은 작은 값들을 사용할 수도 있고, 50, 100, 1000 같은 숫자를 사용할 수도 있다. 중요한 건, 전체 프로젝트에서 z-index에 대한 기준을 잡고 들어가야 나중에 위계가 꼬이지 않고 잘 적용할 수 있다는 것이다.

마무리

이번 구현에서 가장 크게 배운 건, 간단해 보이는 토스트가 사실 꽤 복잡한 상태 관리를 필요로 한다는 점이었다. 처음엔 단순히 '어떻게 보여줄까'만 생각했는데, hover, 클릭, 타이머, 여러 개가 쌓일 때의 동작까지 생각해야 할 것들이 많았다. useEffect + setTimeout으로 시작해서 왜 이상하게 동작하는지 직접 부딪히고, useRef 기반으로 바꿔서 해결하는 과정이 있었기 때문에 지금 구조를 제대로 이해할 수 있게 됐다.

이렇게 처음 목표했던 무한스크롤과 모달까지 모두 구현을 완료했다! 짝짝짝

profile
꼬들밥 말고 진밥

0개의 댓글