SOYOYU
기술 SEO

Radix Dialog도 깨지는 그 케이스 — focus trap 코드 리뷰

포커스 트랩은 native <dialog>가 무료로 해주는 일이지만, 라이브러리·SPA 라우팅·autoFocus가 섞이면 미세하게 깨집니다. 깨지는 다섯 시나리오를 코드로 짚어봅니다.

전승엽2026년 8월 8일10 min read
A11y 코드 리뷰시맨틱 HTMLaccessibility treeAI 에이전트웹 접근성focus trap포커스 관리RadixHeadless UIKWCAG

A11y 코드 리뷰 #010 · 오늘 도감에 등록할 패턴 — focus trap (그리고 SPA의 포커스 관리 전반)

이번 도감 카드는 라이브러리도 깨지는 영역입니다. 시리즈 #3에서 dialog 자체를 다뤘다면, 이번 글은 그 dialog 안에서도 깨지는 다섯 가지 케이스와 SPA 라우팅 시점의 포커스 관리를 봅니다.

TL;DR

  • focus trap은 모달 안에서만 포커스가 순환하는 메커니즘입니다. native <dialog>가 무료로 해주지만 — 직접 만들거나 라이브러리를 써도 깨지는 미세 케이스가 5가지 정도 있습니다.
  • 가장 자주 깨지는 케이스 — autoFocus 충돌, 동적 콘텐츠 추가, iframe·portal 경계, 닫기 후 포커스 복원 누락, SPA 라우팅 시점 포커스 누락.
  • focus trap은 그 자체로 a11y가 좋은 것이 아닙니다. 트랩 안에 명확한 탈출구(ESC, 닫기 버튼)가 있어야 정당합니다 — 그렇지 않으면 WCAG 2.1.2 위반.
  • SPA 라우팅 시점의 포커스 관리는 시리즈에서 다루는 마지막 깊이입니다. 페이지 전환 시 어디에 포커스를 두는가가 스크린리더 사용자와 AI 에이전트의 페이지 이해 효율을 결정합니다.
  • AI 에이전트가 모달·다이얼로그·라우팅을 처리할 때, 포커스 관리가 깨진 페이지에서 자동화 실패율이 가장 높습니다.

1. 어디서나 본 그 깨진 포커스

다음 다섯 패턴은 라이브러리를 쓰는 React 사이트에서도 자주 나타납니다.

autoFocus와 라이브러리 포커스 관리 충돌

<Dialog.Root open={isOpen}>
  <Dialog.Content>
    <input autoFocus />  {/* ❌ 라이브러리 포커스와 충돌 */}
    <input />
  </Dialog.Content>
</Dialog.Root>

SPA 라우팅 후 포커스가 페이지 맨 위로

function ProductPage() {
  // ❌ 페이지 진입 시 포커스 관리 없음
  // 스크린리더 사용자는 *다른 페이지에서 클릭한 링크*에 포커스가 남아 있음
  return <article>...</article>;
}

모달 안에서 동적으로 콘텐츠 추가

<Dialog.Content>
  {step === 1 && <Step1Form />}
  {step === 2 && <Step2Form />}  {/* 추가될 때 포커스 처리 누락 */}
</Dialog.Content>

닫힌 모달의 포커스 복원 누락

<Dialog.Content>
  <button onClick={() => {
    setOpen(false);
    router.push("/other");  // 라우팅까지 함께 → 포커스 어디로?
  }}>
    확인하고 다음으로
  </button>
</Dialog.Content>

iframe과 portal의 포커스 누출

<Dialog.Content>
  <iframe src="/embed/payment" />  {/* iframe 안으로 포커스가 들어가면 트랩 안 됨 */}
</Dialog.Content>

다섯 패턴 모두 시각적으로는 모달이 잘 동작하는 것처럼 보입니다. 키보드로 Tab을 눌러보면 — 어떤 케이스는 포커스가 모달 밖으로 나가고, 어떤 케이스는 닫힌 후에 포커스가 페이지 맨 위로 점프하고, 어떤 케이스는 iframe 안에 갇혀 ESC가 안 먹습니다.


2. 한 가지 질문

모달이 열렸을 때, 동적으로 콘텐츠가 변할 때, 모달이 닫힐 때, 페이지가 라우팅될 때 — 포커스가 어디에 있어야 하는지를 명확히 답할 수 있나요?

이 질문에 네 가지 시점 각각에 답할 수 있어야 SPA의 포커스 관리가 완성됩니다.


3. focus trap — 점진적 해부

3.1 가장 단순한 케이스 — native dialog

<dialog id="confirm">
  <form method="dialog">
    <p>삭제하시겠습니까?</p>
    <button value="yes">예</button>
    <button value="no">아니오</button>
  </form>
</dialog>

<script>
  document.getElementById("confirm").showModal();
</script>

showModal()이 자동으로 처리하는 것:

  • 모달 안의 첫 번째 포커스 가능 요소에 포커스 설정 (또는 autofocus 속성이 있는 요소)
  • 모달 밖 모든 요소에 inert 적용 (Tab 못 감, 마우스 클릭 안 됨)
  • ESC로 닫힘
  • 닫힐 때 포커스가 트리거(showModal을 호출한 버튼)로 복원

이 네 가지가 무료입니다. 시리즈 #3에서 봤듯, 직접 구현하면 50줄 이상의 코드가 필요합니다.

3.2 inert 속성 — 포커스 트랩의 근본 메커니즘

inert는 HTML5에 추가된 속성으로 — 해당 요소와 그 자손을 완전히 비활성화합니다.

<div id="page-content" inert>
  <!-- 이 안의 모든 input, button, link가 -->
  <!-- Tab으로 포커스 못 받고, 마우스 클릭 안 되고, 스크린리더가 안 읽음 -->
</div>

<dialog open>
  <!-- inert가 아니므로 정상 동작 -->
</dialog>

native dialog가 자동으로 하는 일이 이거지만 — 직접 트랩을 만들 때도 inert를 활용할 수 있습니다.

function ManualModal({ open, onClose, children }) {
  useEffect(() => {
    const main = document.getElementById("page-content");
    if (open) {
      main?.setAttribute("inert", "");
    } else {
      main?.removeAttribute("inert");
    }
  }, [open]);

  return open ? (
    <div className="modal">{children}</div>
  ) : null;
}

inert 한 줄이 모달 밖의 모든 포커스를 막아주는 메커니즘. focus-trap-react 같은 라이브러리도 내부적으로 비슷한 방식을 씁니다.

3.3 focus-trap-react 라이브러리

import { FocusTrap } from "focus-trap-react";

<FocusTrap active={isOpen}>
  <div className="modal">
    <h2>제목</h2>
    <input />
    <button>닫기</button>
  </div>
</FocusTrap>

FocusTrap 컴포넌트는 — 활성 상태일 때 포커스를 자식 안으로 끌어오고, Tab 순환을 관리하고, 비활성화 시 트리거로 복원합니다. 직접 구현보다 안전.

다만 — 이 라이브러리도 동적으로 추가되는 포커스 요소 처리가 미세하게 어렵습니다. 모달이 열린 후 새 input이 추가되면 — Tab 순환에 자동으로 포함되지 않을 수 있어 containerElements prop으로 재계산 트리거가 필요합니다.

3.4 Radix Focus Scope

Radix UI는 내부적으로 Focus Scope라는 primitive를 사용합니다. Dialog·Popover·Toast 모두가 이걸 씁니다.

import { FocusScope } from "@radix-ui/react-focus-scope";

<FocusScope trapped loop>
  <div>
    <input />
    <button>닫기</button>
  </div>
</FocusScope>

trapped로 트랩 활성화, loop로 마지막 요소에서 Tab 시 첫 요소로 순환. 별도 라이브러리로 추출해 쓸 수도 있습니다.

3.5 SPA 라우팅 시점의 포커스

이게 SPA의 가장 자주 빠지는 부분입니다. 페이지 전환 시 포커스가 어디로 가야 하는지가 정해져 있지 않습니다. 브라우저의 native 페이지 이동은 자동으로 새 페이지의 body 맨 위로 포커스를 보냅니다. SPA 라우팅은 자바스크립트로 DOM만 바꿀 뿐이라 — 포커스가 이전 페이지의 클릭한 링크에 남아 있습니다.

"use client";
import { useEffect } from "react";
import { usePathname } from "next/navigation";

function FocusOnRouteChange() {
  const pathname = usePathname();

  useEffect(() => {
    // 라우팅 시 main 또는 h1으로 포커스 이동
    const h1 = document.querySelector("main h1") as HTMLElement;
    if (h1) {
      h1.setAttribute("tabindex", "-1");
      h1.focus();
    }
  }, [pathname]);

  return null;
}

페이지 진입 시 h1으로 포커스가 자동 이동합니다. 스크린리더 사용자는 페이지 제목을 듣고 어디로 왔는지를 인식합니다.

tabindex="-1"프로그램적으로 포커스를 받을 수 있게 합니다 — Tab으로는 못 가지만 .focus() 호출은 받습니다.


4. 왜 문제인가 — 3중 영향

4a. 스크린리더 사용자

깨진 focus trap이 스크린리더 사용자에게 만드는 문제:

모달이 열렸는데 모달 밖 콘텐츠가 들림 — inert 누락. 사용자가 지금 모달이 떠 있는 건지 아닌지를 헷갈립니다.

Tab으로 모달 밖으로 빠짐 — 트랩이 깨진 상태. 사용자가 모달 외부에 갔는데 모달이 여전히 열려 있는 채로 페이지를 만지게 됩니다.

모달 닫은 후 포커스가 페이지 맨 위로 — 복원 누락. 사용자는 방금 자신이 어디에 있었는지 잊습니다. 한 번 모달을 열 때마다 페이지 탐색이 처음부터 다시 시작됩니다.

SPA 라우팅 후 포커스가 클릭한 링크에 남아 있음 — 새 페이지를 처음부터 듣지 않고 그 링크 텍스트가 발화됩니다. 새 페이지로 왔다는 인식이 안 됩니다.

4b. 키보드 사용자

키보드 사용자가 마우스 없이 모달 + SPA를 쓰려면 — 위 네 가지가 모두 다 깨지면 작업 자체를 못 합니다. 모달 안의 input에 못 들어가거나, 닫은 후 다음 액션을 어디서 시작해야 할지 모릅니다.

4c. AI 에이전트

AI 에이전트의 모달·라우팅 처리:

모달 식별 실패 — inert가 없는 모달이면 에이전트가 현재 페이지의 활성 영역을 식별 못 합니다. 모달 뒤 버튼을 누르려고 시도하다 멈춥니다.

닫기 후 다음 액션 실패 — 닫기 후 포커스가 어디로 갔는지 모르면 에이전트는 다음 액션의 시작점을 추측해야 합니다.

SPA 라우팅 인식 실패 — 페이지가 바뀌었는데 URL만 바뀌고 포커스나 페이지 구조에 큰 변화가 없으면 — 에이전트는 같은 페이지가 아직 떠 있다고 인식할 수 있습니다.

선행 글 구글이 'AI 검색 최적화 가이드'를 냈는데, 정작 핵심은 접근성 트리였습니다에서 본 사실 — accessibility tree의 현재 active region이 명확해야 에이전트가 페이지 상태를 추적합니다.


5. SEO/GEO/AEO 영향

5.1 SEO — 모달 안 콘텐츠의 인덱싱 (#3과 보완)

시리즈 #3에서 모달 안 콘텐츠가 SEO에 불리하다는 점을 다뤘습니다. 여기서 한 단계 더 — focus trap이 깨진 모달은 사용자가 모달 안의 콘텐츠를 끝까지 읽기 어렵습니다. 결과적으로 그 페이지의 task success rate가 떨어지고 검색 신호가 나빠집니다.

5.2 GEO — 에이전트 자동화 완료율

GEO에서 가장 큰 손실 지점이 SPA의 모달 + 라우팅 조합입니다. 회원가입 → 약관 모달 → 닫기 → 다음 단계로 라우팅 — 이 흐름의 각 단계에서 포커스가 깨지면 에이전트의 자동화 완료율이 단계마다 떨어집니다.

5.3 AEO — 멀티스텝 답변에서의 페이지 탐색

AI Overviews가 멀티스텝 가이드를 답변할 때 — 각 단계에서 페이지가 어떻게 바뀌는지를 인용에 포함하기도 합니다. 라우팅 시점의 포커스가 잘 관리된 페이지는 "3단계: 약관 동의 후 다음으로" 같은 정확한 답변에 더 잘 인용됩니다.


6. React에서 — 다섯 깨지는 케이스 해결

6.1 autoFocus와 라이브러리 충돌

// ❌ 충돌
<Dialog.Content>
  <input autoFocus />
</Dialog.Content>

// ✅ 라이브러리에 위임
<Dialog.Content
  onOpenAutoFocus={(e) => {
    // 라이브러리의 기본 포커스 동작
  }}
>
  <input />  {/* autoFocus 없이 */}
</Dialog.Content>

// ✅ 또는 명시적 초기 포커스 지정 (Radix)
<Dialog.Content
  onOpenAutoFocus={(e) => {
    e.preventDefault();
    document.getElementById("email-input")?.focus();
  }}
>
  <input id="email-input" />
</Dialog.Content>

Radix·Headless UI 모두 모달이 열릴 때의 첫 포커스를 제어하는 prop을 제공합니다. autoFocus로 강제하지 말고 라이브러리의 API를 활용.

6.2 동적 콘텐츠 추가

function MultiStepDialog() {
  const [step, setStep] = useState(1);

  return (
    <Dialog.Content>
      {step === 1 && <Step1 onNext={() => setStep(2)} />}
      {step === 2 && <Step2 ref={(el) => el?.focus()} />}
    </Dialog.Content>
  );
}

새 step이 렌더될 때 첫 요소에 포커스를 명시적으로 이동합니다. 그렇지 않으면 — 포커스가 직전 step의 사라진 요소에 남아 body로 점프합니다.

6.3 닫기 후 포커스 복원

function DialogWithReturnNav({ open, onClose }: Props) {
  return (
    <Dialog.Root open={open} onOpenChange={(o) => {
      if (!o) {
        onClose();
        // Dialog의 기본 동작 — 트리거로 복원
        // 라우팅까지 함께 한다면 명시적 처리 필요
      }
    }}>
      ...
    </Dialog.Root>
  );
}

라우팅과 모달 닫기가 함께 일어날 때 — 라우팅 후의 페이지에서 포커스를 명시적으로 설정해야 합니다 (위 6.5의 패턴).

6.4 iframe과 portal 경계

<Dialog.Content>
  <iframe
    src="/embed/payment"
    title="결제 처리"
  />
</Dialog.Content>

iframe이 모달 안에 있을 때 — 포커스가 iframe으로 들어가면 iframe 안의 별도 document입니다. 부모 페이지의 ESC 핸들러가 iframe 안 키 입력을 못 받습니다.

해결책 — iframe 안에서도 ESC를 처리하거나 (iframe 출처가 같은 경우 postMessage), 모달 닫기 버튼을 iframe 밖에 명시적으로 두기.

6.5 SPA 라우팅 시점의 포커스

// app/layout.tsx
"use client";
import { usePathname } from "next/navigation";
import { useEffect } from "react";

export default function RootLayout({ children }: Props) {
  const pathname = usePathname();

  useEffect(() => {
    const main = document.querySelector("main") as HTMLElement;
    if (!main) return;

    // 이전 라우팅의 클릭 위치가 아닌 main으로 포커스
    main.setAttribute("tabindex", "-1");
    main.focus();

    // 스크롤도 페이지 맨 위로
    window.scrollTo(0, 0);
  }, [pathname]);

  return <html>...</html>;
}

라우팅마다 main에 포커스 → 스크린리더가 main의 첫 콘텐츠를 발화합니다. 일반적으로 h1이 발화되며 — 사용자는 새 페이지로 왔다는 사실과 그 페이지의 주제를 동시에 인식합니다.

더 명시적으로 — 라우팅 시점에 페이지 변경을 alert로 알리는 패턴도 있습니다.

const [routeAnnouncement, setRouteAnnouncement] = useState("");

useEffect(() => {
  setRouteAnnouncement(document.title);
}, [pathname]);

return (
  <>
    <div role="status" aria-live="polite" className="sr-only">
      {routeAnnouncement}
    </div>
    {children}
  </>
);

role="status"로 페이지 제목을 발화. 스크린리더 사용자가 어디로 이동했는지를 즉시 알 수 있습니다.


7. 한국 맥락 — 가볍게

KWCAG 관련 항목:

  • 2.1.2 초점 이동 방해 금지: 초점이 특정 영역에 갇혀 빠져나갈 수 없으면 위반. focus trap이 정당하려면 ESC와 닫기 버튼이 있어야 합니다.
  • 2.4.3 초점 이동과 표시: 초점이 의미와 작동 측면에서 논리적인 순서로 이동돼야 합니다 — 모달 열림/닫힘, 라우팅 모두 의미적 다음 위치로.

한국 사이트의 SPA(Next.js, Nuxt 등)에서 라우팅 시점 포커스 관리가 빠진 경우가 매우 흔합니다. Page Speed는 잘 챙기지만 Route Speed의 a11y는 빠집니다.


8. "라이브러리만 쓰면 다 해결되지 않나요?"

자주 듣는 반론들:

"Radix Dialog는 다 알아서 해주는 거 아닌가요?" — 모달의 기본 트랩, 복원은 자동입니다. 다만 — autoFocus 충돌, 동적 콘텐츠, iframe 경계는 사용자가 처리해야 합니다.

"focus-trap-react로 충분하지 않나요?" — 단일 모달에는 충분합니다. 중첩 모달, 라우팅과 함께는 자체적으로 처리.

"SPA에서 포커스 관리는 너무 복잡하지 않나요?" — 한 번 layout.tsx에 5줄 추가하면 모든 라우트에서 자동 동작합니다. 개별 페이지에서 신경 쓸 필요 없습니다.

"포커스를 강제로 옮기면 사용자가 헷갈리지 않나요?" — 시각 사용자는 시각적 페이지 변화로 라우팅을 인식합니다. 스크린리더 사용자는 포커스 이동 = 페이지 변경 신호. 옮기지 않으면 변경 사실이 사라집니다.

"AI 에이전트는 어차피 시각으로 보는 거 아닌가요?" — Anthropic Computer Use는 시각 기반이지만, 미래의 browser agent들은 accessibility tree 중심으로 갈 가능성이 높습니다 (시리즈 #1 참조). 포커스 관리는 현재의 a11y인 동시에 미래의 GEO 신호입니다.


9. 체크리스트

  • 모달이 열렸을 때 모달 밖이 inert 또는 동등한 상태인가 (native dialog 자동, 라이브러리도 자동, 직접 만들 때는 명시)
  • 모달 안에서 Tab이 모달 안에서만 순환하는가
  • 모달 안의 첫 포커스 요소가 의도한 곳인가 (autoFocus 충돌이 없는가)
  • 모달 닫힐 때 포커스가 모달을 연 트리거로 복원되는가
  • SPA 라우팅 시점에 포커스가 새 페이지의 main 또는 h1으로 이동하는가
  • 라우팅 시점에 페이지 변경을 alert/status로 발화하는가
  • 모달이 ESC로 닫히는가 (트랩 안 진짜 탈출구 보장)
  • iframe·portal 경계에서 ESC와 포커스 복원이 보장되는가

마무리

이 글이 16편 시리즈의 열 번째 도감 카드입니다. 시리즈의 절반을 지났습니다. 다음 카드는 <input> 타입별 차이 — type="tel"과 type="number"가 모바일에서 어떻게 다르고 AI 자동완성에서는 어떻게 다른지 봅니다.

focus trap의 단 한 줄 규칙 — 모달의 트랩은 무료가 아닙니다. native dialog가 아니거나 라이브러리를 쓰더라도 — autoFocus·동적 콘텐츠·iframe·라우팅 네 가지 경계에서 직접 챙겨야 합니다.

SPA의 포커스 관리·모달·라우팅 a11y 통합 컨설팅이 필요하시면 문의하기로 연락 주세요.


codex:
  primary_tag: focus-trap
  secondary_tags: [dialog, inert, tabindex]
  aria_roles: [dialog, alertdialog, status]
  aria_attributes: [aria-modal, aria-live, tabindex, inert]
  related_patterns: [modal-focus, route-change-focus, autofocus-conflict, iframe-boundary, nested-dialog]
  kwcag: ["2.1.2", "2.4.3"]
  wcag: ["2.1.2", "2.4.3", "3.2.1"]
  ai_agent_impact: high
  seo_geo_impact: medium
  difficulty: advanced
  test_envs:
    - "NVDA 2024.x + Chrome 125"
    - "VoiceOver macOS 14 + Safari 17"
    - "Keyboard-only with focus visible"
  references:
    - "WAI-ARIA Authoring Practices, Modal Dialog Pattern"
    - "Radix UI Focus Scope source"
    - "focus-trap-react GitHub"
    - "MDN, inert attribute"
    - "Manuel Matuzović, Hiding content responsibly"

다른 글 읽기

기술 SEO

신규 페이지를 만들어도 구글이 며칠째 안 가져가는 이유

사이트맵을 제출해도 신규 페이지가 바로 색인되지는 않습니다. 발견·크롤·색인 단계를 구분하고 공식 도구로 원인을 점검하는 대응 순서.

5 min read
기술 SEO

구글에 우리 회사 정보가 잘못 표시되는 문제 해결법

주소가 틀리거나 폐업 표시가 뜨거나 옛 로고가 보일 때. 구글 비즈니스 프로필·지식 패널·검색 결과 3채널 각각의 수정 경로와 우선순위를 정리했습니다.

6 min read
기술 SEO

Toast로 띄운 에러는 누구에게 안 들립니다 — form 에러 메시지 코드 리뷰

폼 에러를 toast나 alert로만 띄우면 스크린리더 사용자는 못 듣고, AI 에이전트는 폼 자동 입력에서 실패합니다. aria-describedby와 aria-live의 차이부터 코드로 짚습니다.

9 min read
기술 SEO

사이트맵을 제출했는데도 구글이 내 페이지를 무시하는 이유

사이트맵 제출 성공 = 색인 보장이 아닙니다. 구글이 제출된 URL을 무시하는 8가지 진짜 이유와 각각의 해결 순서를 실무 체크리스트로 정리했습니다.

6 min read
기술 SEO

OG 이미지에 alt를 자동 생성한다는 발상 — alt와 이미지 텍스트 코드 리뷰

alt 속성은 시각장애 사용자만을 위한 게 아닙니다. AI Overviews가 이미지를 답변에 포함하고, 검색엔진이 이미지를 인덱싱하는 가장 강한 신호입니다. 빈 alt와 누락된 alt의 차이부터 봅니다.

9 min read
기술 SEO

구글 서치콘솔에 "색인 생성 안 됨"이 뜨는 진짜 이유

크롤링됨·감지됨·제외됨의 차이부터 실제로 손볼 포인트까지 — 서치콘솔 색인 오류의 숨은 원인과 회복 순서를 실무 관점에서 정리했습니다.

6 min read

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

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