Portal 심화 — 모달과 토스트 직접 구현

React Portal을 활용해 프로덕션 수준의 모달 컴포넌트(Esc 닫기, 오버레이 클릭, 포커스 이동)와 Context 기반 토스트 알림 시스템을 처음부터 구현하는 실전 가이드입니다.

· 5 min read · PALDYN Team

지난 글에서 Portal의 동작 원리와 createPortal API를 배웠다. 이제 실제 프로덕션에 쓸 수 있는 모달 컴포넌트전역 토스트 시스템을 처음부터 구현해 보자.

프로덕션 수준 모달 구현

단순히 createPortal로 DOM에 붙이는 것만으로는 부족하다. 실제 사용에는 여러 가지 부가 기능이 필요하다.

기본 Modal 컴포넌트

import { createPortal } from 'react-dom';
import { useEffect, useRef } from 'react';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}

function Modal({ isOpen, onClose, children }: ModalProps) {
  const firstFocusRef = useRef<HTMLButtonElement>(null);
  const triggerRef = useRef<HTMLElement | null>(null);

  // 열릴 때: 포커스 이동 + 트리거 기억
  useEffect(() => {
    if (isOpen) {
      triggerRef.current = document.activeElement as HTMLElement;
      firstFocusRef.current?.focus();
      document.body.style.overflow = 'hidden';  // 스크롤 잠금
    } else {
      triggerRef.current?.focus();  // 닫힐 때 원래 포커스 복원
      document.body.style.overflow = '';
    }
  }, [isOpen]);

  // Esc 키로 닫기
  useEffect(() => {
    if (!isOpen) return;
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return createPortal(
    <div
      className="modal-overlay"
      onClick={onClose}
      role="presentation"
    >
      <div
        className="modal-box"
        role="dialog"
        aria-modal="true"
        onClick={(e) => e.stopPropagation()}  // 버블링 차단
      >
        <button
          ref={firstFocusRef}
          className="modal-close"
          onClick={onClose}
          aria-label="닫기"
        >
          ×
        </button>
        {children}
      </div>
    </div>,
    document.getElementById('modal-root')!,
  );
}

모달 구현 코드

모달 사용 예시

function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>모달 열기</button>
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
        <h2>확인</h2>
        <p>정말 삭제하시겠습니까?</p>
        <button onClick={() => setIsOpen(false)}>취소</button>
        <button onClick={handleConfirm}>확인</button>
      </Modal>
    </>
  );
}

CSS 설정

/* globals.css */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-box {
  background: #1a1a2e;
  border-radius: 12px;
  padding: 32px;
  min-width: 400px;
  max-width: 90vw;
  position: relative;
}

.modal-close {
  position: absolute;
  top: 16px;
  right: 16px;
}

전역 토스트 시스템

토스트 알림은 앱 어디서든 호출할 수 있어야 한다. Context + Portal 조합으로 구현한다.

// contexts/ToastContext.tsx
import { createContext, useContext, useCallback, useReducer } from 'react';
import { createPortal } from 'react-dom';

type ToastType = 'success' | 'error' | 'info' | 'warning';
interface Toast {
  id: number;
  message: string;
  type: ToastType;
}

type ToastAction =
  | { type: 'ADD'; payload: Toast }
  | { type: 'REMOVE'; payload: number };

function toastReducer(state: Toast[], action: ToastAction): Toast[] {
  switch (action.type) {
    case 'ADD':
      return [...state, action.payload];
    case 'REMOVE':
      return state.filter((t) => t.id !== action.payload);
    default:
      return state;
  }
}

const ToastContext = createContext<((message: string, type?: ToastType) => void) | null>(null);

export function ToastProvider({ children }: { children: React.ReactNode }) {
  const [toasts, dispatch] = useReducer(toastReducer, []);

  const addToast = useCallback((message: string, type: ToastType = 'info') => {
    const id = Date.now();
    dispatch({ type: 'ADD', payload: { id, message, type } });
    setTimeout(() => dispatch({ type: 'REMOVE', payload: id }), 3500);
  }, []);

  return (
    <ToastContext.Provider value={addToast}>
      {children}
      {createPortal(
        <div className="toast-container">
          {toasts.map((toast) => (
            <div key={toast.id} className={`toast toast-${toast.type}`}>
              {toast.message}
            </div>
          ))}
        </div>,
        document.body,
      )}
    </ToastContext.Provider>
  );
}

export function useToast() {
  const ctx = useContext(ToastContext);
  if (!ctx) throw new Error('ToastProvider 외부에서 사용 불가');
  return ctx;
}

토스트 구현

토스트 사용

// 어느 컴포넌트에서든
function SaveButton() {
  const toast = useToast();

  const handleSave = async () => {
    try {
      await saveData();
      toast('저장되었습니다.', 'success');
    } catch {
      toast('저장에 실패했습니다.', 'error');
    }
  };

  return <button onClick={handleSave}>저장</button>;
}
.toast-container {
  position: fixed;
  bottom: 24px;
  right: 24px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  z-index: 9999;
}

.toast {
  padding: 12px 20px;
  border-radius: 8px;
  color: white;
  min-width: 280px;
}

.toast-success { background: #1a5c1a; border-left: 4px solid #55c555; }
.toast-error   { background: #5c1a1a; border-left: 4px solid #e05555; }
.toast-info    { background: #0d2233; border-left: 4px solid #7ec8e3; }

Portal 심화 체크리스트

모달:
  ✓ 열릴 때 첫 번째 요소로 포커스 이동
  ✓ 닫힐 때 트리거 버튼으로 포커스 복원
  ✓ Esc 키 닫기 + 리스너 cleanup
  ✓ 오버레이 클릭 닫기 + stopPropagation
  ✓ body overflow: hidden (스크롤 잠금)
  ✓ role="dialog" + aria-modal="true"
  ✓ aria-label 또는 aria-labelledby

토스트:
  ✓ Context로 전역 접근
  ✓ 타입별 스타일 (success/error/info/warning)
  ✓ 자동 소멸 타이머
  ✓ 스택 가능 (여러 토스트 동시 표시)
  ✓ aria-live="polite"로 스크린 리더 알림

다음 글에서는 모달 구현에서 자연스럽게 등장한 접근성 속성들(role, aria-*)을 체계적으로 정리한다.


지난 글: React Portal — DOM 경계 너머에 렌더링하기

다음 글: React 접근성(a11y) 기초 — 스크린 리더와 시맨틱


읽어주셔서 감사합니다. 😊