모달·다이얼로그·바텀시트: 맥락에 맞는 오버레이 UI 선택 가이드

UX 디자인

모달바텀시트다이얼로그오버레이 UI접근성

이 글은 누구를 위한 것인가

  • 모달 vs 바텀시트 vs 사이드 패널 중 무엇을 써야 할지 모르는 팀
  • 모달을 남발해서 "모달 안에 모달" 상황이 생긴 팀
  • 모달 접근성(포커스 트랩, ESC 닫기)을 구현하고 싶은 개발자

들어가며

"모달이 불편해요"의 진짜 이유는 모달이 아니라 맥락 단절이다. 작업 흐름을 끊지 않아야 할 때 모달을 쓰면 불편하다. 오버레이 유형을 잘 선택하면 작업 흐름을 유지하면서 추가 정보를 제공할 수 있다.

이 글은 bluefoxdev.kr의 오버레이 UI 설계 가이드 를 참고하여 작성했습니다.


1. 오버레이 유형 선택 가이드

[오버레이 유형별 적합한 상황]

모달 다이얼로그:
  ✅ 중요한 확인/취소 (삭제 확인, 결제 확인)
  ✅ 사용자의 즉각적인 결정이 필요한 경우
  ✅ 복잡한 폼 (빠른 추가 작성)
  ❌ 단순 정보 표시
  ❌ 복잡한 멀티스텝 플로우

바텀시트 (Bottom Sheet):
  ✅ 모바일에서 옵션 선택 (공유, 정렬, 필터)
  ✅ 아래에서 올라오는 메뉴
  ❌ 텍스트 많은 폼 (키보드가 올라오면 복잡)

사이드 패널 (Drawer):
  ✅ 관련 정보 탐색 (상품 상세, 주문 상세)
  ✅ 설정 패널
  ✅ 메인 콘텐츠 맥락 유지가 필요할 때
  
팝오버 (Popover):
  ✅ 선택 도구 (컬러 피커, 날짜 선택)
  ✅ 참조 정보 (주석, 도움말)
  
툴팁:
  ✅ 아이콘/버튼 레이블 설명
  ❌ 중요한 정보 (접근성 문제)

[중첩 모달 회피 전략]
  모달 안의 모달 → 대신 인라인 확장
  모달 안의 선택 → 바텀시트로 분리
  복잡한 플로우 → 전체 페이지로 이동

2. 접근성 모달 구현

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

interface DialogProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
  size?: "sm" | "md" | "lg";
}

function Dialog({ isOpen, onClose, title, children, size = "md" }: DialogProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<Element | null>(null);

  useEffect(() => {
    if (isOpen) {
      // 열릴 때 이전 포커스 저장
      previousFocusRef.current = document.activeElement;
      
      // 포커스 이동
      setTimeout(() => {
        dialogRef.current?.focus();
      }, 50);
    } else {
      // 닫힐 때 이전 포커스 복원
      (previousFocusRef.current as HTMLElement)?.focus();
    }
  }, [isOpen]);

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (!isOpen) return;
      
      if (e.key === "Escape") {
        onClose();
        return;
      }
      
      // 포커스 트랩
      if (e.key === "Tab") {
        const focusable = dialogRef.current?.querySelectorAll(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        if (!focusable || focusable.length === 0) return;
        
        const first = focusable[0] as HTMLElement;
        const last = focusable[focusable.length - 1] as HTMLElement;
        
        if (e.shiftKey && document.activeElement === first) {
          e.preventDefault();
          last.focus();
        } else if (!e.shiftKey && document.activeElement === last) {
          e.preventDefault();
          first.focus();
        }
      }
    };

    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  const sizeClasses = {
    sm: "max-w-sm",
    md: "max-w-md",
    lg: "max-w-2xl",
  };

  return createPortal(
    <div
      className="fixed inset-0 z-50 flex items-center justify-center p-4"
      role="dialog"
      aria-modal="true"
      aria-labelledby="dialog-title"
    >
      {/* 배경 오버레이 */}
      <div
        className="absolute inset-0 bg-black/50"
        onClick={onClose}
        aria-hidden="true"
      />
      
      {/* 다이얼로그 패널 */}
      <div
        ref={dialogRef}
        tabIndex={-1}
        className={`relative bg-white rounded-2xl shadow-xl w-full ${sizeClasses[size]} outline-none`}
      >
        {/* 헤더 */}
        <div className="flex items-center justify-between p-6 border-b">
          <h2 id="dialog-title" className="text-lg font-semibold">
            {title}
          </h2>
          <button
            onClick={onClose}
            className="p-2 rounded-lg hover:bg-gray-100"
            aria-label="닫기"
          >
            ✕
          </button>
        </div>
        
        {/* 내용 */}
        <div className="p-6">{children}</div>
      </div>
    </div>,
    document.body
  );
}

마무리

모달 사용의 첫 번째 질문은 "이게 정말 모달이어야 하나?"다. 삭제·결제·중요한 결정만 모달을 쓰고, 나머지는 인라인 확장·사이드 패널·바텀시트로 대체하면 사용자 경험이 훨씬 부드러워진다. 모달을 꼭 써야 한다면 ESC 닫기와 포커스 트랩은 반드시 구현해야 한다.