키보드 접근성 실무 문제 해결 기록
모달 팝업에서 초점이 유출되는 이유: aria-modal="true"와 포커스 트랩(Focus Trap) 구현 기법
사용자가 설정 열기 버튼으로 모달을 연 뒤 Tab 키 입력 시 배경 검색 링크로 초점이 이탈하고, 닫힌 뒤 초점 복원 및 스크롤 잠금이 누락되는 재현 가능한 실패 시나리오를 바탕으로 6단계 순환 제어 코드로 해결합니다.
1. 문제 상황: 설정 모달 열림 후 키보드 초점 유출과 복원 누락
사용자가 웹 페이지에서 [설정 열기] 버튼으로 모달을 연 뒤 양식을 입력하고 다음 조작을 위해 Tab 키를 세 번 누르면, 초점이 모달 내부에 머무르지 않고 모달 뒤편 배경 페이지의 검색 링크로 빠져나갑니다. 이후 모달을 닫았을 때 초점은 원래 눌렀던 [설정 열기] 버튼으로 돌아오지 않고 웹 문서의 최상단(<body>)으로 소실됩니다.Escape 키로 모달을 닫을 수는 있지만, 모달이 열려 있는 동안 배경 스크롤이 잠기지 않아 모달 뒤 페이지가 계속 움직입니다.
이 글은 격리된 공개 fixture에서 재현하도록 설계한 실패 시나리오를 바탕으로, 키보드 전용 사용자의 탐색 맥락을 지원하는 6단계 포커스 트랩(Focus Trap) 제어 알고리즘과 수동 검토 절차를 기록합니다.
2. 최소 재현 fixture: 모달·배경·트리거 버튼 마크업
다음은 결함 상황을 독립된 브라우저 환경에서 재현할 수 있는 최소 HTML fixture입니다. 모달 열기 버튼, 모달 내부 2개 조작 요소(사용자명 입력창, 저장 버튼), 모달 닫기 버튼, 그리고 모달 뒤편에 위치한 배경 검색 링크를 포함합니다.
<!-- 최소 재현 fixture: index.html -->
<main id="app-main">
<h1>사용자 대시보드</h1>
<!-- 트리거 버튼 (Opener) -->
<button id="open-settings" type="button">설정 열기</button>
<!-- 배경에 존재하는 링크 (초점 유출 대상) -->
<p><a id="bg-search" href="/search">배경 검색 링크 (모달 뒤편)</a></p>
</main>
<!-- 결함 모달: aria-modal="true"만 선언하고 JS 트랩이 누락된 상태 -->
<div id="settings-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" hidden>
<div class="modal-backdrop"></div>
<div class="modal-dialog">
<h2 id="modal-title">환경 설정</h2>
<label for="username">사용자명</label>
<input id="username" type="text" placeholder="사용자 이름 입력">
<div class="modal-actions">
<button id="save-btn" type="button">저장</button>
<button id="close-modal" type="button">닫기</button>
</div>
</div>
</div>3. 포커스 이동 상태표: 조작 단계별 초점 위치와 기대 결과
키보드 조작 단계에 따른 결함 상태와 웹 접근성 표준(WCAG 2.1.2 / 2.4.3) 요구 기대 결과의 대조표입니다.
| 조작 단계 | 사용자 키 입력 | 결함 상태 (현재 실패) | 정상 기대 결과 (수정 목표) | 관련 기준 |
|---|---|---|---|---|
| 1. 열기 전 | Tab 이동 | 초점 위치: 설정 열기 버튼(#open-settings) | 설정 열기 버튼(#open-settings)에 명확한 초점 링 표시 | WCAG 2.4.7 |
| 2. 열림 직후 | Enter / 클릭 | 초점이 여전히 닫힌 버튼에 머묾 (실패) | 사용자명 입력 필드(#username)로 자동 이동 | WCAG 2.4.3 |
| 3. Tab 1~2회 | Tab | 사용자명 입력 필드(#username) → 저장 버튼(#save-btn) 이동 | 사용자명 입력 필드(#username) → 저장 버튼(#save-btn) 이동 | WCAG 2.4.3 |
| 4. Tab 3회 (유출) | Tab | 모달 뒤편의 검색 링크(#bg-search)로 탈출 (결함) | 사용자명 입력 필드(#username)로 순환 갇힘 | WCAG 2.1.2 |
| 5. 역방향 탐색 | Shift + Tab | 첫 요소에서 브라우저 주소창/배경으로 이탈 | 닫기 버튼(#close-modal)으로 역순환 | WCAG 2.4.3 |
| 6. 닫힘 후 | Escape 또는 [닫기] | 초점이 소실되어 body 최상단으로 리셋 | 직전의 설정 열기 버튼(#open-settings)으로 정확히 복원 | WCAG 2.4.3 |
4. 실패 원인 분해: aria-modal과 브라우저 포커스 모델의 차이
1. aria-modal의 한계
aria-modal="true" 속성은 스크린리더의 가상 커서가 모달 바깥 접근성 트리를 탐색하지 못하게 신호를 줄 뿐, 브라우저의 물리적 Tab 키 초점 이동(DOM 순서)을 강제로 가두지 못합니다.
2. 배경 DOM 비활성화 부재
모달이 열렸을 때 배경의 <main> 영역에 inert 속성이 부여되지 않으면, 브라우저는 모달 마지막 조작 요소 다음 순서로 배경의 링크와 입력창을 순차 탐색합니다.
3. Opener 참조 소실
모달을 열기 직전 활성화되어 있던 버튼(document.activeElement)의 참조를 저장하지 않으면, 모달 닫힘 시 브라우저는 초점 복귀 위치를 알 수 없어 문서 최상단으로 튕겨 나갑니다.
5. 수정 전·후 코드: 6단계 포커스 트랩 알고리즘 구현
포커스 트랩 구현 전 vs 구현 후 비교
수정 전 (결함: 단순 aria-modal 선언)
// 결함 코드: 포커스 이동 리스너 없음
function openModal() {
const modal = document.getElementById('settings-modal');
modal.hidden = false;
// 실패: 초기 초점 지정 누락
// 실패: Tab 키 순환 제어 없음 -> 배경으로 유출
// 실패: 닫힘 시 opener 복원 없음
}수정 후 (6단계 포커스 트랩 완비)
// 정상 코드: 6단계 포커스 트랩 완성
let openerElement: HTMLElement;
function openSettingsModal() {
// 1. 직전 활성 요소(Opener) 참조 저장
openerElement = document.activeElement as HTMLElement;
const modal = document.getElementById('settings-modal')!;
const mainApp = document.getElementById('app-main')!;
modal.hidden = false;
// 2. 배경 비활성화 & 스크롤 잠금
mainApp.setAttribute('inert', '');
document.body.style.overflow = 'hidden';
// 3. 모달 내부 첫 번째 조작 요소로 초점 이동
const focusables = modal.querySelectorAll<HTMLElement>(
'input, button, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusables[0];
const lastEl = focusables[focusables.length - 1];
firstEl?.focus();
// 4. Tab 순환 및 Escape 닫기 키보드 이벤트
modal.onkeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeSettingsModal();
return;
}
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus(); // 역방향 순환
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus(); // 순방향 순환
}
}
};
}
function closeSettingsModal() {
const modal = document.getElementById('settings-modal')!;
const mainApp = document.getElementById('app-main')!;
modal.hidden = true;
modal.onkeydown = () => {};
// 5. 배경 비활성화 해제 & 스크롤 복원
mainApp.removeAttribute('inert');
document.body.style.overflow = '';
// 6. 직전 Opener 버튼으로 초점 복원
openerElement?.focus();
}수정된 코드는 모달 열기 직전 openerElement를 보관하고, 열림 직후 첫 요소 초점 지정, Tab/Shift+Tab 양 끝단 순환 제어,Escape 닫기, 배경 inert 속성 토글, 바디 스크롤 잠금 및 해제, 그리고 모달 닫힘 시 정확한 트리거 버튼 포커스 복원을 지원합니다.
6. 실무 경계 조건: 중첩 모달·동적 Opener·모바일 환경
- 중첩 모달(Modal over Modal): 1차 모달 위로 2차 확인 대화상자가 뜰 경우, 단일 변수 대신 포커스 스택(Stack) 배열을 활용하여 상위 모달이 닫힐 때 직전 모달의 활성 요소로 순차 복원해야 합니다.
- 모달 내부 iframe: iframe 내부로 초점이 진입하면 상위 부모 윈도우의
keydown이벤트 캡처가 중단되므로, iframe 내부 포커스 이탈을 감지하는 별도 브릿지 핸들러가 필요합니다. - 동적으로 제거된 Opener: 모달 내에서 삭제 버튼을 눌러 부모 화면의 트리거 버튼이 DOM에서 제거된 경우,
document.body로 방치하지 말고 대체 컨테이너(예: 모달을 띄웠던 리스트의 헤딩<h1>)로 fallback 초점을 지정합니다. - 모바일 가상 키보드 및 바운스: 모바일 뷰포트에서 입력창 활성화 시 가상 키보드가 올라오며 배경이 흔들리지 않도록
position: fixed레이어 및touchmove제어를 적용합니다. - CSS transition 완료 타이밍: 모달 페이드인 애니메이션 도중
.focus()를 호출하면 초점 이동이 씹힐 수 있으므로, 렌더링이 완료된 직후나requestAnimationFrame시점에 초점을 맞춥니다. - inert 속성 브라우저 호환성: 최신 브라우저는 네이티브
inert를 지원하지만, 구형 브라우저 호환성을 위해wicg-inert폴리필 또는 수동 키 이벤트 트랩을 병행합니다.
7. 수동 검토 기록 양식: 6단계 키보드 조작 체크 표
개발자 및 QA 담당자가 배포 전 마우스 없이 키보드만으로 직접 확인하고 기록할 수 있는 6단계 검토 양식입니다.
| 순번 | 검증 조작 단계 | 통과 판정 기준 (Pass Criteria) | 핵심 확인 질문 |
|---|---|---|---|
| 1 | 모달 열기 | 설정 열기 버튼(#open-settings)에서 Enter 입력 시 모달 내부 첫 조작 요소(사용자명 입력 필드(#username))로 초점 즉시 이동 | 모달 오픈 즉시 첫 입력 필드에 포커스 링이 표시되는가? |
| 2 | 순방향 순환 | 모달 마지막 조작 요소(닫기 버튼(#close-modal))에서 Tab 입력 시 모달 뒤편의 검색 링크(#bg-search)로 탈출하지 않고 첫 조작 요소(사용자명 입력 필드(#username))로 순환 | Tab 키를 연속으로 눌러도 포커스가 모달 내부에만 갇혀 있는가? |
| 3 | 역방향 순환 | 모달 첫 조작 요소(사용자명 입력 필드(#username))에서 Shift+Tab 입력 시 마지막 조작 요소(닫기 버튼(#close-modal))로 순환 | 역방향 탭 입력 시 브라우저 주소창으로 이탈하지 않는가? |
| 4 | Escape 닫기 | 모달 내부 어느 위치에서든 Escape 키 입력 시 모달이 즉시 닫힘 | 입력창에 포커스가 있는 상태에서도 Escape로 닫히는가? |
| 5 | 초점 복원 | 모달이 닫힌 직후 포커스가 직전의 설정 열기 버튼(#open-settings)으로 정확히 복귀 | 모달 닫힘 후 사용자의 탐색 위치가 문서 최상단으로 튕기지 않는가? |
| 6 | 스크롤 잠금 | 모달이 열려 있는 상태에서 마우스 휠 및 방향키 조작 시 모달 뒤 배경 웹 페이지가 고정됨 | 모달 활성화 중 배경 문서가 함께 스크롤되지 않는가? |
8. 검증 상태: 재현 조건 및 기술 명세
검증 상태: reproduced · 공개 fixture 및 재현 조건 제공
- 검증 기준: 공개 fixture 및 재현 조건
- 검증 범위: 대화상자 키보드 포커스 제어 및 복원 재현 (명시된 입력값에 한정)
- 수동 확인: 실제 브라우저·키보드·스크린리더 환경에서 별도 확인 필요
- 미검사·한계: 실제 특정 보조공학기기(스크린리더) 조합별 음성 출력 뉘앙스는 실기기 수동 청취가 권장됩니다.
9. 공식 근거 및 실무 조치 가이드
- WCAG 2.2 Success Criterion 2.1.2 — No Keyboard Trap (Level A)
- WCAG 2.2 Success Criterion 2.4.3 — Focus Order (Level A)
- WAI-ARIA APG — Dialog (Modal) Pattern Focus Management
프로젝트 내 모달 컴포넌트의 마크업을 점검하여 배경 영역에 inert 속성을 토글하고, 모달 열기 직전 document.activeElement를 보관한 후 닫힘 시 복원하도록 JavaScript 코드를 수정하세요. 단순히 aria-modal="true"만 선언하는 것으로는 키보드 접근성이 완료되지 않으므로, 위 6단계 수동 검토 양식을 활용해 실제 키보드로 Tab 순환과 초점 복원을 직접 확인해야 합니다.