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"