
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);
};
초기엔 상세 페이지 내부에서 showToast 상태만 들고 토스트를 띄웠다.
// src/features/stock/ui/StockDetail.tsx
const [showToast, setShowToast] = useState(false);
const handleAddMyStockButton = () => {
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
};
'어떻게 토스트를 보여줄 수 있을까?'만 생각하며 시도해본 방법이었다. 빠르게 붙이기엔 충분했지만, 모달 안에서도 같은 토스트를 써야 하는 케이스가 생기자 바로 한계가 드러났다. 토스트 로직이 여러 컴포넌트에 흩어지면 관리가 어렵다는 걸 느꼈다.
토스트를 전역에서 관리하도록 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<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]);
const pauseAll = () => {
paused.current = true;
for (const t of timers.current.values()) {
clearTimeout(t.timerId); // 각 토스트 setTimeout 취소
t.remain -= Date.now() - t.start; // 지금까지 흐른 시간만큼 남은 시간 차감
}
};
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>
전체 흐름을 정리하면 이렇다.
실제로 겪은 문제는 토스트가 모달 뒤로 가려지는 것이었다. 모달과 토스트를 같은 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 기반으로 바꿔서 해결하는 과정이 있었기 때문에 지금 구조를 제대로 이해할 수 있게 됐다.
이렇게 처음 목표했던 무한스크롤과 모달까지 모두 구현을 완료했다! 짝짝짝