SOYOYU
블로그로 돌아가기기술 SEO

모달을 띄우면 키보드 사용자는 갇히거나 도망갑니다 — dialog 코드 리뷰

직접 만든 모달은 포커스 트랩이 깨지거나 ESC가 안 닫히는 게 기본값입니다. native <dialog>, Radix, Headless UI의 차이를 코드로 비교하고 AI 에이전트가 모달을 어떻게 보는지 정리합니다.

전승엽2026년 6월 20일10 min read
A11y 코드 리뷰시맨틱 HTMLaccessibility treeAI 에이전트웹 접근성dialog 태그모달포커스 트랩KWCAG

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.TitleDialog.Description이 자동으로 aria-labelledbyaria-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.Titlearia-labelledby로, Dialog.Descriptionaria-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"

다른 글 읽기

기술 SEO

프로그래매틱 SEO: 대규모 페이지 자동 생성과 품질 통제

Google의 Scaled Content Abuse 정책 강화 속에서 프로그래매틱 SEO를 안전하게 실행하는 방법을 다룹니다. Zapier, Canva, Wise의 실제 사례와 30-40% 고유 콘텐츠 기준을 정리합니다.

7 min read
기술 SEO

Next.js layout.tsx에 main이 두 개 — landmark 코드 리뷰

main·nav·header·footer 같은 landmark 태그는 페이지의 지도입니다. 지도가 두 장이거나 없으면 스크린리더 사용자도 AI 에이전트도 길을 잃습니다. 가장 자주 잘못된 케이스부터 정리합니다.

9 min read
기술 SEO

네이버 Search Advisor 기술 감사 워크플로우: GSC와의 차이와 6단계 실전 가이드

네이버 서치어드바이저의 6개 핵심 기능을 분석하고, Google Search Console과 13개 항목을 비교합니다. 네이버에 최적화된 6단계 기술 감사 워크플로우를 제공합니다.

7 min read
기술 SEO

h1은 한 개만이라는 미신과 스크린리더 사용자의 실제 길찾기 — heading 코드 리뷰

SEO 도구는 'h1은 한 개여야 한다'고 외치지만 스크린리더 사용자는 헤딩 목록으로 페이지를 탐색합니다. 헤딩의 본질은 SEO가 아닌 목차이며, 그래서 AI Overviews 인용 신호이기도 합니다.

11 min read
기술 SEO

보안 헤더(CSP/HSTS)가 검색 순위에 미치는 영향

HTTPS는 약 12%의 순위 요인으로 확인되었지만, John Mueller는 보안 헤더가 직접적 순위 요인이 아니라고 밝혔습니다. '상위 사이트 71%가 보안 헤더 사용'이라는 통계의 검증 결과, 실제 채택률 데이터, 상관관계와 인과관계의 구분을 정리합니다.

8 min read
기술 SEO

Edge SEO 실전: 리다이렉트, 헤더 인젝션, HTML 변환 코드 예제

Cloudflare Workers로 구현하는 Edge SEO 실전 코드. 301 리다이렉트, KV 대량 리다이렉트, HSTS/CSP/X-Robots-Tag 헤더 인젝션, HTMLRewriter 메타 태그 삽입, 캐노니컬 인젝션, hreflang 인젝션, A/B 테스트까지.

8 min read

검색 최적화가 필요하신가요?

무료 상담을 통해 비즈니스에 맞는 최적화 전략을 확인하세요.