React Modal 컴포넌트 설계 — createPortal, ESC·백드롭 닫기, 반응형 패널

#createPortal#ESC#Modal#Next.js#React

'use client';

import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/cn';
import { Button, type ButtonVariant } from '@/components/Button/Button';

export type ModalSize = 'sm'  'md'  'lg'  'full';

export interface ModalAction {
  label: string;
  onClick: () = void  Promisevoid;
  variant?: ButtonVariant;
}

export interface ModalProps {
  open: boolean;
  onClose: () = void;
  title?: string;
  size?: ModalSize;
  showCloseIcon?: boolean;
  closeOnBackdrop?: boolean;
  closeOnEsc?: boolean;
  bodyClass?: string;
  footerClass?: string;
  children?: ReactNode;
  footerNode?: ReactNode;
}

/* PC first: 기본=큰 화면, md:=768px, lg:=1024px */
const sizeClassMap: RecordModalSize, string = {
  sm: 'max-w-md md:max-w-full',
  md: 'max-w-lg md:max-w-full',
  lg: 'max-w-2xl md:max-w-full',
  full: 'max-w-5xl max-h-[90vh] md:max-w-full md:max-h-full',
};

export function Modal({
  open,
  onClose,
  title,
  size = 'md',
  showCloseIcon = true,
  closeOnBackdrop = true,
  closeOnEsc = true,
  bodyClass,
  footerClass,
  children,
  footerNode,
}: ModalProps) {
  const dialogRef = useRefHTMLDivElement  null(null);

  // ESC 로 닫기 & body 스크롤 잠금
  useEffect(() = {
    if (!open) return;

    const handleKeyDown = (e: KeyboardEvent) = {
      if (e.key === 'Escape' && closeOnEsc) {
        onClose();
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    const originalOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';

    return () = {
      document.removeEventListener('keydown', handleKeyDown);
      document.body.style.overflow = originalOverflow;
    };
  }, [open, closeOnEsc, onClose]);

  if (!open) return null;
  if (typeof window === 'undefined') return null;

  const sizeClass = sizeClassMap[size];

  const handleBackdropClick = () = {
    if (!closeOnBackdrop) return;
    onClose();
  };

  const handleDialogClick = (e: React.MouseEventHTMLDivElement) = {
    e.stopPropagation();
  };

  const handlePanelMouseDown = (e: React.MouseEventHTMLDivElement) = {
    e.stopPropagation();
  };

  const Dialog = (
    div
      className="fixed inset-0 z-110 flex items-center justify-center bg-slate-900/50 p-4 backdrop-blur-sm md:p-0"
      role="dialog"
      aria-modal="true"
      aria-labelledby={title ? 'modal-title' : undefined}
    
      {/* Backdrop */}
      div
        className="absolute inset-0 cursor-pointer"
        onClick={handleBackdropClick}
        aria-hidden="true"
      /

      {/* Panel: 기본(PC)=콘텐츠 높이에 fit + max-h, md(768px)=전체 화면 */}
      div
        ref={dialogRef}
        className={cn(
          'relative z-10 flex w-full flex-col overflow-hidden bg-white shadow-2xl',
          'h-auto min-h-0 max-h-[90vh] rounded-2xl border border-slate-200 ring-1 ring-slate-200/80',
          'md:absolute md:inset-0 md:h-full md:max-h-none md:rounded-none md:border-0 md:shadow-none md:ring-0',
          sizeClass,
        )}
        onClick={handleDialogClick}
        onMouseDown={handlePanelMouseDown}
      
        div className="flex h-full min-h-0 flex-col"
          {/* Header */}
          {(title  showCloseIcon) && (
            div className="flex shrink-0 items-center justify-between border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white px-5 py-4"
              div className="min-w-0 flex-1"
                {title && (
                  h2
                    id="modal-title"
                    className="text-heading4 font-semibold tracking-tight text-slate-900"
                  
                    {title}
                  /h2
                )}
              /div
              {showCloseIcon && (
                button
                  type="button"
                  onClick={onClose}
                  className="ml-3 flex size-9 shrink-0 items-center justify-center rounded-xl text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-300 focus:ring-offset-2 cursor-pointer"
                  aria-label="닫기"
                
                  svg
                    className="size-5"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  
                    path d="M18 6L6 18" /
                    path d="M6 6L18 18" /
                  /svg
                /button
              )}
            /div
          )}

          {/* Body */}
          div
            className={cn(
              'flex min-h-0 flex-1 overflow-y-auto p-5 text-slate-700',
              bodyClass,
            )}
          
            {children}
          /div

          {/* Footer */}
          {footerNode && (
            div className={cn("shrink-0 border-t border-slate-200 bg-slate-50/80 px-5 py-4", footerClass)}
              {footerNode}
            /div
          )}
        /div
      /div
    /div
  );

  return createPortal(Dialog, document.body);
}



1. 왜 Modal을 따로 만들었는지

카테고리 추가/수정, 링크소셜 설정, 리치 에디터의 첨부파일단축키삽입 UI처럼 창을 띄워서 입력받고 닫기 패턴이 반복되면서, 매번 백드롭ESC스크롤 잠금접근성 속성을 따로 구현하기가 부담됐습니다. 그래서 열림/닫힘만 제어하면 나머지는 한 번에 처리되는 공용 Modal을 만들기로 했습니다. 패널 크기와 닫기 방식은 prop으로 조절하고, 본문과 푸터는 호출하는 쪽에서 자유롭게 채우도록 했습니다.

2. 컴포넌트 구성과 설계 방향

DOM 위치
모달은 페이지 레이아웃z-indexoverflow와 격리되어야 해서 createPortal(Dialog, document.body)로 document.body에 직접 붙이도록 했습니다. 이렇게 하면 상위의 overflow: hidden이나 transform에 잘리지 않고, z-110으로 항상 최상단에 올라옵니다. open이 false면 아예 null을 반환하고, window가 없을 때(SSR)도 null을 반환해 서버에서는 아무것도 렌더하지 않게 했습니다.

접근성
컨테이너에 role="dialog"aria-modal="true"를 주고, 제목이 있으면 aria-labelledby="modal-title"로 연결했습니다. 닫기 버튼은 aria-label="닫기"를 넣었고, 백드롭 div는 aria-hidden="true"로 보조 기술이 무시하도록 했습니다. 포커스 트랩은 넣지 않아서, 필요하면 사용처에서 처리할 수 있게 열어 두었습니다.

스크롤 잠금ESC
open이 true일 때만 useEffect에서 document.body.style.overflow = 'hidden'으로 스크롤을 막고, 키다운으로 Escape를 감지해 closeOnEsc가 true면 onClose()를 호출합니다. 클린업에서 리스너 제거와 overflow 원복을 해서, 모달이 닫혀도 본문 스크롤이 계속 막힌 상태로 남지 않게 했습니다. 이 훅이 있어서 "use client"를 붙였습니다.

반응형
PC에서는 패널이 콘텐츠 높이에 맞고 max-h-[90vh]로 잘리며, md(768px 이하)에서는 absolute inset-0으로 전체 화면이 되도록 클래스를 나눴습니다. 그래서 모바일에서는 풀스크린처럼 쓰이고, 데스크톱에서는 중앙에 떠 있는 창처럼 보이게 했습니다. size는 sm/md/lg/full로 최대 너비만 다르게 주고, full일 때만 max-h-[90vh]를 명시해 큰 내용도 스크롤 가능하게 했습니다.

클릭 구분
백드롭을 눌렀을 때만 닫고, 패널 안을 눌렀을 때는 닫히지 않게 하려고 백드롭 div에 onClick={handleBackdropClick}을 두고, 패널 div에는 onClick={handleDialogClick}에서 e.stopPropagation()을 호출했습니다. onMouseDown에서도 패널 쪽에서 stopPropagation을 해서, 드래그가 백드롭으로 전달되어 의도치 않게 닫히는 일을 막았습니다.

3. 프롭스가 필요한 이유

open, onClose
모달을 제어 컴포넌트로 쓰려고 넣었습니다. 부모가 open으로 열림을 결정하고, 닫을 때는 ESC백드롭닫기 버튼이 모두 onClose()를 한 번만 호출하게 했습니다. 저장 중에는 onClose 안에서 early return으로 닫기를 막는 식으로 사용처에서 제어할 수 있습니다.

title
대화상자 제목을 한 곳에서 보여주고 aria-labelledby로 연결하려고 넣었습니다. 없으면 헤더는 닫기 버튼만 있거나(showCloseIcon일 때) 아예 없을 수 있게 했고, 있으면 h2 id="modal-title"로 렌더링해 시맨틱과 접근성을 맞췄습니다.

size
폼 모달은 작게, 리치에디터 첨부파일 목록은 크게 쓰고 싶어서 sm/md/lg/full 네 단계로 두었습니다. sizeClassMap에서 PC용 max-width와, md 브레이크포인트에서의 full width를 같이 정의해 두어서, 한 번에 반응형 너비가 정해지게 했습니다.

showCloseIcon
헤더 오른쪽에 X 버튼을 둘지 말지 선택하려고 넣었습니다. 기본은 true라서 대부분 닫기 버튼이 있고, 확인/취소만 있는 단순 알림 모달에서는 false로 두어 헤더를 없애거나 제목만 보이게 할 수 있습니다.

closeOnBackdrop, closeOnEsc
백드롭 클릭ESC로 닫을지 말지를 나눠서, 저장하지 않고 나가기 확인처럼 실수로 닫히면 안 되는 경우에는 둘 다 false로 줄 수 있게 했습니다. 기본은 true라서 일반적인 폼목록 모달은 그대로 쓰면 됩니다.

bodyClass
본문 영역에만 패딩간격을 따로 주고 싶을 때 쓰려고 넣었습니다. 기본 p-5에 더해 bodyClass="p-4 space-y-4"처럼 덮어쓰거나 보완할 수 있어서, 폼 모달에서 필드 간격을 일괄로 맞출 때 사용합니다.

children
모달 본문 내용을 자유롭게 채우려고 넣었습니다. InputSelect리스트폼 등 무엇이든 넣을 수 있고, 본문 div는 overflow-y-auto로 내용이 길면 스크롤되게 해 두었습니다.

footerNode
취소저장 같은 버튼을 모달 하단에 고정시키려고 넣었습니다. ReactNode라서 버튼 몇 개를 감싼 div를 넘기면 되고, 헤더본문과 구분되는 푸터 영역에 border-t와 배경을 줘서 시각적으로 구분되게 했습니다. 추가/수정 폼에서는 footerNode에 취소제출 버튼을 넣어서 쓰고 있습니다.

4. 사용 용도와 쓰는 방식

  • 관리자 CRUD: 블로그/유튜브 카테고리, 네비게이션 메뉴, 푸터 링크소셜 링크 추가/수정 시 open을 모드(create/edit)에 따라 열고, 본문에 InputSelect로 폼을 넣고, footerNode에 취소저장 버튼을 두어 제출 후 onClose()로 닫습니다. 필요하면 onClose 안에서 저장 중이면 return 해서 닫기 방지합니다.

  • 리치 에디터: 첨부파일 목록단축키 안내링크/이미지 삽입 UI를 Modal로 띄울 때 title만 바꿔서 쓰고, 본문에 목록폼을 넣습니다. 크기가 큰 목록은 size="lg" 또는 full로 씁니다.

  • 쇼케이스: 기본폼 포함size별 예시를 같은 컴포넌트로 보여줄 때 open 상태만 토글해서 사용합니다.

5. 정리

Modal은 열림/닫힘만 제어하면, 백드롭ESC스크롤 잠금body 포탈반응형 패널접근성까지 한 번에 처리되게 하자는 목적으로 만들었습니다. openonClose는 제어를 위해, titlesizeshowCloseIcon은 UI와 레이아웃을 위해, closeOnBackdropcloseOnEsc는 닫기 동작을 위해, bodyClasschildrenfooterNode는 본문푸터 내용을 자유롭게 채우기 위해 넣었습니다. 카테고리네비푸터소셜 설정과 리치 에디터 보조 UI에서 공통으로 쓰이고 있습니다.



53

댓글