A11y 코드 리뷰 #003 · 오늘 도감에 등록할 태그 —
<dialog>(그리고 모달 패턴 전반)모달은 사이트에서 가장 자주 만들지만 가장 자주 깨지는 컴포넌트입니다. 표면적으로는 모달 a11y 글이지만, 본질은 AI 에이전트의 자동화 실패율을 줄이고 모달 안 콘텐츠의 인덱싱·인용 가능성을 살리는 글입니다.
TL;DR
- 직접 만든 모달은 거의 항상 한 가지 이상이 깨집니다 — ESC 미동작, 포커스 트랩 실패, 모달 밖 스크린리더 누출, body 스크롤 누출, 닫은 뒤 포커스 미복원.
- 2022년 이후 모든 모던 브라우저가
<dialog>요소를 지원합니다.showModal()한 줄이면 위 다섯 가지가 무료로 해결됩니다. - Radix Dialog와 Headless UI Dialog는 native dialog의 부족한 부분(포커스 트랩의 미세 동작, 애니메이션)을 보강하는 추상화입니다. 직접 만드는 것보다 안전합니다.
- AI 에이전트는 모달을 인식하면 포커스를 따라 들어가야 합니다. 인식하지 못하면 모달 뒤의 사라진 페이지를 계속 클릭하려고 시도합니다 — 자동화 실패의 가장 흔한 원인 중 하나입니다.
- 모달 안 콘텐츠는 SEO 인덱싱·AI 인용에서 일반 콘텐츠보다 불리합니다. 모달을 쓸지 말지 자체가 검색 전략의 결정입니다.
1. 어디서나 본 그 모달
모달은 모든 웹 사이트가 만듭니다. 그래서 모든 사이트가 어딘가 깨져 있습니다.
z-index 1000짜리 직접 만든 모달
function CustomModal({ open, onClose, children }: Props) {
if (!open) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal-content"
style={{ zIndex: 1000, position: "fixed" }}
onClick={(e) => e.stopPropagation()}
>
{children}
<button onClick={onClose}>닫기</button>
</div>
</div>
);
}
body 스크롤 막기
useEffect(() => {
if (open) document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, [open]);
모달 안 폼이 외부 폼과 섞이는 케이스
<form onSubmit={mainSubmit}>
<input name="name" />
<CustomModal open={termsOpen}>
<form onSubmit={termsSubmit}>{/* nested form */}</form>
</CustomModal>
<button type="submit">제출</button>
</form>
세 패턴 모두 시각적으로는 모달처럼 보입니다. 그런데 키보드로 모달을 띄워서 Tab 키를 눌러보면 — 포커스가 모달 밖으로 나갑니다. ESC를 눌러도 안 닫힙니다. 스크린리더는 모달이 떠 있는데도 모달 뒤의 페이지를 계속 읽습니다. AI 에이전트는 모달 뒤의 결제 버튼을 누르려다가 멈춥니다.
2. 한 가지 질문
모달이 열리는 순간, 키보드 포커스는 어디로 가야 할까요? 스크린리더는 무엇을 읽어야 할까요? AI 에이전트는 페이지를 다시 어떻게 인식해야 할까요?
답을 정확히 하려면 모달이 단순한 "z-index가 높은 div"가 아니라 페이지의 새로운 컨텍스트라는 사실부터 인정해야 합니다. 그 인정이 시맨틱 <dialog> 태그로 옮겨가게 합니다.
3. dialog — 점진적 해부
3.1 가장 단순한 native dialog
<dialog id="terms-dialog">
<p>약관에 동의하시겠습니까?</p>
<form method="dialog">
<button value="agree">동의</button>
<button value="cancel">취소</button>
</form>
</dialog>
<button onclick="document.getElementById('terms-dialog').showModal()">
약관 보기
</button>
이 코드 한 묶음으로 따라오는 동작은 다음과 같습니다.
- 다이얼로그가 화면 중앙에 자동 배치됩니다 (브라우저 기본값).
- 다이얼로그 뒤의 모든 콘텐츠가
inert상태가 됩니다 — Tab으로 포커스가 못 갑니다. - ESC 키로 자동으로 닫힙니다.
::backdrop의사 요소로 배경을 어둡게 칠할 수 있습니다.form method="dialog"안의 button을 누르면 form이 submit되면서 dialog가 닫히고, 닫힐 때 button의 value를 반환합니다.- 닫힌 뒤 포커스가 다이얼로그를 연 트리거(
<button>)로 자동 복귀합니다.
직접 구현했다면 200줄짜리 컴포넌트가 5줄로 줄어드는 셈입니다.
3.2 showModal vs show — modal vs non-modal
<dialog>에는 두 가지 열기 방식이 있습니다.
dialog.showModal(); // 페이지 전체를 차단하는 진짜 모달
dialog.show(); // 페이지 위에 떠 있지만 페이지와 인터랙션 가능
showModal()은 페이지 전체에 inert를 적용합니다. show()는 다이얼로그만 떠 있고 페이지와의 인터랙션은 계속 가능합니다. 비모달 알림이나 색상 선택기 같은 토글 가능한 보조 패널에는 show()가 어울립니다.
3.3 ::backdrop으로 배경 처리
dialog::backdrop {
background: rgba(0, 0, 0, 0.6);
}
이게 전부입니다. 직접 만든 모달의 .modal-backdrop div와 z-index 계산은 다 불필요합니다.
3.4 닫기 동작 표준화
<dialog id="confirm-dialog">
<form method="dialog">
<h2>정말 삭제하시겠습니까?</h2>
<p>이 작업은 되돌릴 수 없습니다.</p>
<button value="confirm">삭제</button>
<button value="cancel">취소</button>
</form>
</dialog>
dialog.addEventListener("close", () => {
if (dialog.returnValue === "confirm") {
handleDelete();
}
});
form method="dialog"는 native HTML이 제공하는 모달 닫기 메커니즘입니다. submit이 navigation을 일으키지 않고, 다이얼로그만 닫고, value를 returnValue로 반환합니다.
4. 왜 문제인가 — 3중 영향
4a. 스크린리더 사용자
직접 만든 모달의 가장 흔한 누출 — 모달이 떠 있는데 스크린리더는 모달 뒤의 페이지를 계속 읽습니다.
<div className="modal" open>...</div>
<div className="page-content">...</div>
→ NVDA: 모달 + 페이지 모두 읽음 (실패)
native <dialog>는:
<dialog open>...</dialog>
<div className="page-content">...</div>
→ NVDA: dialog 안 콘텐츠만 읽음, 페이지는 inert
이게 진짜 "모달"의 의미입니다 — modal은 "모드를 만든다"는 뜻입니다. 페이지의 모드가 바뀌어서, 다른 콘텐츠는 잠시 없는 것처럼 동작해야 합니다.
4b. 키보드 사용자
직접 만든 모달의 두 번째 흔한 실패 — Tab 키 누르면 포커스가 모달 밖으로 나갑니다. 끝에 도달하면 브라우저 주소창으로 빠져나갑니다.
native dialog는 inert 덕분에 포커스가 다이얼로그 안에서만 순환합니다. 추가 코드 없이도 포커스 트랩이 자동입니다.
세 번째 흔한 실패 — ESC 키 미동작. 직접 만든 모달은 ESC 핸들러를 직접 붙여야 하는데 자주 빠집니다. native dialog는 ESC가 기본 동작입니다.
네 번째 흔한 실패 — 닫힌 후 포커스 복원 누락. 모달을 닫은 다음 키보드 사용자의 포커스가 페이지 맨 위 또는 body로 점프합니다. native dialog는 다이얼로그를 연 트리거로 자동 복귀합니다.
4c. AI 에이전트
AI 에이전트가 모달 있는 페이지에서 작업할 때 흔한 실패 시나리오:
시나리오 1 — 직접 만든 모달, role 누락
<div className="modal-content"> <!-- role 없음 -->
<h2>약관 동의</h2>
...
</div>
에이전트가 페이지의 accessibility tree를 봤을 때 — 이 div는 트리에서 generic으로 노출됩니다. 에이전트는 "지금 페이지 위에 새로운 컨텍스트가 떠 있다"는 사실을 모릅니다. 결과적으로 모달 뒤에 있던 "결제하기" 버튼을 누르려고 시도하고 — 실제로는 클릭이 모달 백드롭에 막혀서 실패합니다. 에이전트는 "버튼이 안 눌리네?"라고 보고 재시도하고, 무한 루프에 갇힙니다.
시나리오 2 — native dialog 사용
<dialog open>
<h2>약관 동의</h2>
...
</dialog>
<dialog open>은 트리에서 명시적으로 dialog role로 노출됩니다. 에이전트는 "지금 다이얼로그가 떠 있다, 다이얼로그 안의 인터랙션부터 처리해야 한다"를 인식합니다. 자동화 성공률이 올라갑니다.
선행 글 구글이 'AI 검색 최적화 가이드'를 냈는데, 정작 핵심은 접근성 트리였습니다에서 봤듯 — 구글 가이드가 명시한 browser agent의 입력값 세 가지(스크린샷·DOM·accessibility tree) 중 마지막이 dialog의 정체성을 결정합니다.
5. SEO/GEO/AEO 영향
5.1 모달 안 콘텐츠는 인덱싱·인용에서 불리합니다
검색엔진은 페이지를 렌더링해서 본문을 추출합니다. 모달이 기본적으로 닫혀 있다면 — 그 안의 콘텐츠는 렌더 시점에 DOM에 존재만 합니다. 검색엔진이 본문의 중요도를 평가할 때는 보이는 부분이 더 가중치를 받습니다.
특히 모달 안에 약관, FAQ, 상세 설명 같은 SEO에 중요한 콘텐츠를 넣는 패턴은 안티패턴입니다. 모달이 정말 필요한 상호작용이라면 OK이지만, "스페이스가 부족해서 모달로 숨김"이라면 SEO 측면에서 손실입니다.
5.2 AI Overviews와 모달
AI Overviews와 Perplexity가 페이지에서 답변을 끌어올 때 — 모달 안에 묻힌 답변은 후순위입니다. 동일한 정보가 본문에 노출된 경쟁 페이지가 있다면 그쪽이 인용됩니다.
다만 모달이 시맨틱 <dialog>라면 — AI 인덱서는 적어도 "이 콘텐츠가 모달이라는 별도 컨텍스트에 있다"는 사실을 인식합니다. div 모달은 이 메타 정보조차 잃습니다.
5.3 GEO — 에이전트가 모달을 닫고 자동화를 이어갈 수 있는가
GEO의 실용적 정의 중 하나 — 에이전트가 우리 사이트에서 작업을 끝까지 완료할 수 있는가. 한국 사이트의 자동화 실패율을 측정해보면 대부분 모달에서 막힙니다. 회원가입 모달, 약관 동의 모달, 쿠키 안내 모달, 푸시 알림 권한 모달. 에이전트가 시맨틱 dialog는 닫을 줄 압니다. div 모달은 어디가 닫기 버튼인지부터 추측해야 합니다.
6. React 라이브러리에서
6.1 native dialog를 React로 감싸기
function Dialog({ open, onClose, children }: Props) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog ref={ref} onClose={onClose}>
{children}
</dialog>
);
}
10줄짜리 컴포넌트로 ESC·포커스 트랩·포커스 복원·inert가 모두 처리됩니다.
6.2 Radix Dialog
import * as Dialog from "@radix-ui/react-dialog";
<Dialog.Root>
<Dialog.Trigger asChild>
<button>약관 보기</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="overlay" />
<Dialog.Content className="content">
<Dialog.Title>약관 동의</Dialog.Title>
<Dialog.Description>아래 약관을 확인해 주세요.</Dialog.Description>
...
<Dialog.Close asChild>
<button>닫기</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
Radix는 native dialog를 쓰지 않고 자체 portal + focus trap을 구현합니다. 이유는 — 애니메이션을 자유롭게 하기 위해서, 그리고 native dialog의 일부 미세 동작(예: backdrop 클릭으로 닫기) 커스터마이징을 위해서입니다.
Radix Dialog의 강점은 Dialog.Title과 Dialog.Description이 자동으로 aria-labelledby와 aria-describedby로 연결된다는 점입니다. 직접 만들 때 가장 자주 빠지는 부분입니다.
6.3 Headless UI Dialog
import { Dialog } from "@headlessui/react";
<Dialog open={isOpen} onClose={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>약관 동의</Dialog.Title>
<Dialog.Description>아래 약관을 확인해 주세요.</Dialog.Description>
...
</Dialog.Panel>
</Dialog>
Headless UI도 Radix와 비슷한 철학입니다. onClose가 ESC와 백드롭 클릭을 모두 잡아줍니다.
6.4 라이브러리도 깨지는 케이스
세 라이브러리 모두 깨지는 흔한 케이스 — autoFocus 충돌. 다이얼로그가 열릴 때 라이브러리가 첫 번째 포커스 가능 요소에 포커스를 잡는데, 그 요소가 autoFocus를 가진 input이면 더 이른 시점에 포커스가 잡히고 라이브러리의 포커스 관리와 어긋납니다.
해결책 — Radix와 Headless UI 모두 initialFocus 같은 prop으로 명시적으로 첫 포커스 요소를 지정할 수 있습니다. native dialog는 다이얼로그 안의 autofocus 속성을 인식하지만 명시적 트리거는 없습니다.
7. 한국 맥락 — 가볍게
KWCAG 관련 항목:
- 2.4.3 초점 이동과 표시: 초점은 의미와 작동 측면에서 논리적인 순서로 이동돼야 합니다. 모달이 열렸을 때 모달 안으로, 닫혔을 때 트리거로 — 이 순서가 native dialog의 기본값입니다.
- 2.1.2 초점 이동 방해 금지: 초점이 특정 영역에 갇혀서 빠져나갈 수 없으면 안 됩니다. 진짜 모달의 포커스 트랩은 ESC와 닫기 버튼이라는 명확한 탈출구가 있는 한 위반이 아닙니다. 직접 만든 모달이 ESC 없이 트랩만 있으면 위반입니다.
한국 사이트에 특히 많은 패턴 하나 — 쿠키/약관/마케팅 동의 모달의 반강제 패턴. 이 모달들이 native dialog로 만들어졌는지 div로 만들어졌는지가 검색엔진의 페이지 평가에 영향을 줄 수 있습니다. 사용자가 페이지 핵심 콘텐츠에 도달하는 데 걸리는 시간이 길수록 검색 신호가 나빠집니다.
8. "div + z-index로 안 되나요?"
자주 듣는 반론들에 대한 점검입니다.
"애니메이션이 native dialog로는 안 돼요" — Chrome 116+는 transition-behavior: allow-discrete로 dialog의 fade in/out 애니메이션을 지원합니다. 그 전이라도 dialog 안에 div를 한 겹 더 두고 그 안에 transition을 거는 방식으로 가능합니다.
"backdrop 색상 커스텀이 어려워요" — dialog::backdrop 의사 요소로 직접 스타일링 가능합니다. 직접 div 백드롭을 만드는 것보다 훨씬 짧습니다.
"여러 dialog가 동시에 떠야 해요" — native dialog는 stack을 자동 관리합니다. 두 번째 dialog가 열리면 첫 번째 dialog 자체도 inert가 됩니다.
"라이브러리 dependency를 추가하기 싫어요" — native dialog는 zero dependency입니다. 라이브러리 없이 직접 만든 div 모달보다 코드가 짧습니다.
"브라우저 호환성이 걱정돼요" — 2026년 기준 모든 모던 브라우저(Chrome 37+, Firefox 98+, Safari 15.4+)가 dialog를 지원합니다. 한국 사이트가 더 이상 IE를 지원할 필요가 없다면 사용 가능합니다.
남은 케이스는 거의 없습니다.
9. 체크리스트
- 모달이 native
<dialog>또는 검증된 라이브러리(Radix·Headless UI)로 만들어졌는가 - 모달이 열렸을 때 ESC 키로 닫히는가
- 모달 안에서 Tab 키를 누르면 포커스가 모달 안에서 순환하는가 (밖으로 안 빠지는가)
- 모달이 닫혔을 때 포커스가 모달을 연 트리거로 돌아가는가
- 모달 뒤의 콘텐츠가 inert인가 (스크린리더가 안 읽고, 마우스 클릭이 안 되는가)
- body 스크롤이 모달 열렸을 때 막히는가 (모바일에서 특히 중요)
-
Dialog.Title이aria-labelledby로,Dialog.Description이aria-describedby로 연결돼 있는가 - SEO·AI 인용이 중요한 콘텐츠가 기본적으로 모달 안에 숨어 있지 않은가
마무리
이 글이 16편 시리즈의 세 번째 도감 카드입니다. 다음 카드는 <details>와 <summary> — JavaScript 한 줄도 없이 풀리는 FAQ 아코디언입니다.
dialog의 단 한 줄 규칙 — 모달은 페이지의 새로운 컨텍스트입니다. div가 z-index로 떠 있는 것이 아니라 페이지 자체가 새로운 모드로 전환된 상태입니다. native <dialog> 한 태그가 이 의미를 모든 보조 기술과 AI 에이전트에게 한 번에 전달합니다.
모달·다이얼로그·약관 페이지의 a11y·SEO 컨설팅이 필요하시면 문의하기로 연락 주세요.
codex:
primary_tag: dialog
secondary_tags: [button, form]
aria_roles: [dialog, alertdialog]
aria_attributes: [aria-labelledby, aria-describedby, aria-modal, inert]
related_patterns: [focus-trap, focus-restoration, body-scroll-lock, backdrop, portal]
kwcag: ["2.4.3", "2.1.2"]
wcag: ["2.4.3", "2.1.2", "4.1.2"]
ai_agent_impact: high
seo_geo_impact: medium
difficulty: intermediate
test_envs:
- "NVDA 2024.x + Chrome 125"
- "VoiceOver macOS 14 + Safari 17"
references:
- "MDN, <dialog>: The Dialog element"
- "WAI-ARIA Authoring Practices, Dialog (Modal) Pattern"
- "Adrian Roselli, Use the Dialog Element (2024)"
- "Scott O'Hara, The dialog element"