모달은 렌더링 위치, 포커스, 스크롤 제어 등에서 까다롭습니다. React Portal을 활용하면 모달을 DOM 트리 바깥의 별도 루트로 렌더링하여 레이아웃/스타일 충돌을 피하고, 접근성 요구사항을 충족하기 쉬워집니다. 실무에서 바로 쓸 수 있는 포털 기반 모달 구현을 정리합니다.
1. Portal이 필요한 이유
모달, 드롭다운, 토스트처럼 화면 위에 떠야 하는 UI는 부모 컴포넌트의 z-index나 overflow에 영향받기 쉽습니다. Portal은 자식 컴포넌트를 별도의 DOM 노드로 렌더링하여 이러한 제약을 제거합니다. 덕분에 오버레이, 스크롤 잠금, 포커스 트랩 같은 모달의 핵심 요구사항을 안정적으로 구현할 수 있습니다.
2. Portal 루트 준비
HTML에 모달을 위한 루트 노드(div#modal-root)를 하나 두는 방식이 가장 단순합니다. 만약 존재하지 않으면 런타임에 생성하도록 처리해도 됩니다. 아래 구현은 루트가 없을 경우 자동으로 생성합니다.
3. 모달 컴포넌트 구현
ESC로 닫기, 백드롭 클릭으로 닫기, 포커스 트랩, 바디 스크롤 잠금을 포함한 최소 구현입니다. 접근성을 위해 role, aria-* 속성도 설정합니다.
// Modal.jsx
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
const getModalRoot = () => {
if (typeof document === 'undefined') return null;
let root = document.getElementById('modal-root');
if (!root) {
root = document.createElement('div');
root.id = 'modal-root';
document.body.appendChild(root);
}
return root;
};
export default function Modal({ open, onClose, children, ariaLabel, ariaLabelledBy }) {
const modalRoot = getModalRoot();
const backdropRef = useRef(null);
const dialogRef = useRef(null);
const prevActiveRef = useRef(null);
useEffect(() => {
if (!open) return;
// 1) 이전 포커스 저장 + 바디 스크롤 잠금
prevActiveRef.current = document.activeElement;
const body = document.body;
const prevOverflow = body.style.overflow;
body.style.overflow = 'hidden';
// 2) 키보드 핸들링: ESC 닫기 + 기본 포커스 트랩
const onKeyDown = (e) => {
if (e.key === 'Escape') onClose?.();
if (e.key === 'Tab') {
const focusable = dialogRef.current?.querySelectorAll(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
if (!focusable || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', onKeyDown);
// 3) 초기 포커스 이동
setTimeout(() => {
const firstFocusable = dialogRef.current?.querySelector(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
(firstFocusable || dialogRef.current)?.focus();
}, 0);
// 4) 정리
return () => {
document.removeEventListener('keydown', onKeyDown);
body.style.overflow = prevOverflow;
prevActiveRef.current?.focus?.();
};
}, [open, onClose]);
if (!open || !modalRoot) return null;
const onBackdropClick = (e) => {
if (e.target === backdropRef.current) onClose?.();
};
// 최소 스타일: 프로젝트 규칙에 맞춰 CSS로 분리 권장
const backdropStyle = {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,.48)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 1000
};
const dialogStyle = {
background: '#fff', borderRadius: 8, minWidth: 320, maxWidth: '90vw',
maxHeight: '85vh', overflow: 'auto', boxShadow: '0 10px 30px rgba(0,0,0,.2)', outline: 'none'
};
return createPortal(
<div ref={backdropRef} style={backdropStyle} onMouseDown={onBackdropClick} role="presentation">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
tabIndex={-1}
style={dialogStyle}
onMouseDown={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
modalRoot
);
}
4. 사용 예시
간단한 버튼으로 모달을 열고 닫습니다. aria-labelledby를 통해 제목과 연결합니다.
// App.jsx
import React, { useState } from 'react';
import Modal from './Modal';
export default function App() {
const [open, setOpen] = useState(false);
return (
<div>
<h1 id="page-title">Portal Modal Demo</h1>
<button onClick={() => setOpen(true)}>모달 열기</button>
<Modal open={open} onClose={() => setOpen(false)} ariaLabelledBy="modal-title">
<div style={{ padding: 24 }}>
<h2 id="modal-title">안내</h2>
<p>이 모달은 React Portal로 렌더링됩니다.</p>
<button onClick={() => setOpen(false)}>닫기</button>
</div>
</Modal>
</div>
);
}
5. 접근성과 실무 팁
role="dialog"와 aria-modal="true"로 보조기기에게 모달 컨텍스트를 알립니다. 제목이 있다면 aria-labelledby로 연결하고, 시각적 제목이 없다면 aria-label을 제공합니다. 열릴 때 첫 포커스를 모달 내부로 이동하고, 닫힐 때 이전 포커스를 복원합니다. 바디 스크롤 잠금은 overflow를 조정하며, 레이아웃 점프가 보이면 스크롤바 폭만큼 padding-right를 더해 보정합니다. 여러 모달 스택이 가능하다면 가장 최근 모달만 키보드 이벤트를 처리하도록 스택 관리가 필요합니다.
6. 흔한 이슈와 디버깅
백드롭 클릭이 안 먹히면 이벤트 버블링을 확인합니다(onMouseDown에서 stopPropagation). z-index 문제가 있으면 포털 루트가 다른 오버레이보다 뒤에 있지 않은지 점검하고, 고정 위치(fixed)와 스택 컨텍스트를 확인합니다. SSR 환경에서는 document 접근을 useEffect 안에서 하거나 안전 가드로 감싸주세요. 테스트에서는 createPortal을 모킹하거나 container를 지정해 접근성을 검증하면 좋습니다.
위 구현을 기반으로 트랜지션, 애니메이션, 포커스 락 라이브러리(예: react-focus-lock) 등을 추가하면 프로덕션 품질의 모달을 빠르게 완성할 수 있습니다.
'React' 카테고리의 다른 글
| React와 GraphQL Apollo Client 연동하기 (1) | 2026.04.22 |
|---|---|
| React에서 환경 변수 관리하기 (0) | 2026.04.22 |
| React 앱에서 다국어(i18n) 지원하기 (2) | 2026.04.21 |
| React에서 Service Worker로 PWA 기능 추가하기 (0) | 2026.04.21 |
| React와 Three.js를 이용한 3D 객체 렌더링 (1) | 2026.04.20 |