도입 스토리
"모달 창이 열렸을 때 스크린리더가 배경 페이지를 계속 읽어요." 김개발이 NVDA를 켜고 모달을 열었습니다.
스크린리더가 배경의 내비게이션과 본문 텍스트를 계속 읽었습니다. 모달이 열렸다는 것을 전혀 인식하지 못하고 있었습니다.
"배경을 스크린리더에서 숨겨야 해요." 박멘토가 말했습니다. "그리고 role="dialog"와 aria-modal="true", 그리고 포커스 트랩까지. 모달 하나에도 신경 써야 할 게 꽤 많아요."
핵심 개념 설명
완전한 접근성 모달 구조
<!-- 모달 오버레이 --><div id="modal-overlay" class="modal-overlay" hidden aria-hidden="true" onclick="Modal.close()"></div><!-- 모달 대화상자 --><div id="delete-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" aria-describedby="modal-desc" hidden class="modal"> <h2 id="modal-title">게시글 삭제</h2> <p id="modal-desc"> 이 게시글을 삭제하시겠습니까? 삭제된 게시글은 복구할 수 없습니다. </p> <div class="modal-actions"> <button type="button" onclick="Modal.close()">취소</button> <button type="button" class="btn-danger" onclick="deletePost()" > 삭제 </button> </div> <button type="button" class="modal-close" aria-label="모달 닫기" onclick="Modal.close()" > × </button></div>
모달 JavaScript 구현
const Modal = { _opener: null, _element: null, _boundKeydown: null, // bind()는 매번 새 함수를 반환하므로 미리 저장 _boundEscape: null, open(modalId, openerButton) { this._opener = openerButton; this._element = document.getElementById(modalId); const overlay = document.getElementById('modal-overlay'); // 모달과 오버레이 표시 this._element.removeAttribute('hidden'); overlay.removeAttribute('hidden'); overlay.removeAttribute('aria-hidden'); // 배경 페이지를 보조기기에서 숨김 document.getElementById('app').setAttribute('aria-hidden', 'true'); // 배경 스크롤 방지 document.body.style.overflow = 'hidden'; // 모달 첫 번째 포커스 가능 요소로 이동 const focusable = this._element.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); focusable?.focus(); // 바인딩된 함수를 인스턴스에 저장 후 등록 // removeEventListener는 addEventListener에 전달한 것과 동일한 함수 참조가 필요함 this._boundKeydown = this._handleKeydown.bind(this); this._boundEscape = this._handleEscape.bind(this); this._element.addEventListener('keydown', this._boundKeydown); document.addEventListener('keydown', this._boundEscape); }, close() { if (!this._element) return; const overlay = document.getElementById('modal-overlay'); // 모달 숨기기 this._element.setAttribute('hidden', ''); overlay.setAttribute('hidden', ''); overlay.setAttribute('aria-hidden', 'true'); // 배경 페이지 다시 표시 document.getElementById('app').removeAttribute('aria-hidden'); document.body.style.overflow = ''; // 저장된 함수 참조로 이벤트 정리 this._element.removeEventListener('keydown', this._boundKeydown); document.removeEventListener('keydown', this._boundEscape); this._boundKeydown = null; this._boundEscape = null; // 포커스 복원 this._opener?.focus(); this._opener = null; this._element = null; }, _handleKeydown(e) { if (e.key !== 'Tab') return; const focusableElements = this._element.querySelectorAll( 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' ); const first = focusableElements[0]; const last = focusableElements[focusableElements.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }, _handleEscape(e) { if (e.key === 'Escape') this.close(); }};
모달 열기 버튼
<!-- 모달을 여는 버튼 --><button type="button" onclick="Modal.open('delete-modal', this)"> 게시글 삭제</button>
aria-modal의 한계
aria-modal="true"는 최신 스크린리더에서 배경 페이지를 자동으로 숨기지만, 모든 스크린리더가 지원하지는 않습니다. 따라서 aria-hidden="true"를 배경 요소에 직접 적용하는 것이 더 안전합니다.
<!-- 앱 루트 요소 --><div id="app"> <!-- 모달이 열리면 aria-hidden="true" 적용 --> <header>...</header> <main>...</main> <footer>...</footer></div><!-- 모달은 app 외부에 배치 --><div id="modal-overlay" ...></div><div id="delete-modal" role="dialog" ...></div>
확인 대화상자 패턴 (Alert Dialog)
삭제, 중요 결정 등 확인이 필요한 경우 role="alertdialog"를 사용합니다.
<div role="alertdialog" aria-modal="true" aria-labelledby="alert-title" aria-describedby="alert-desc"> <h2 id="alert-title">계정 삭제 확인</h2> <p id="alert-desc"> 계정을 삭제하면 모든 데이터가 삭제됩니다. 계속하시겠습니까? </p> <button onclick="confirmDelete()">삭제</button> <button onclick="Modal.close()">취소</button></div>
alertdialog는 스크린리더가 열리는 즉시 내용을 읽어주어, 사용자가 중요한 결정임을 인지할 수 있습니다.
단계별 실습
따라하기: 접근성 모달 테스트
모달을 구현한 후 다음을 키보드로 테스트합니다.
- 모달 열기 버튼에 Enter/Space로 모달 열기.
- 모달 내부의 모든 인터랙티브 요소를 Tab으로 순환.
- Shift+Tab으로 역순 순환.
- Escape로 모달 닫기.
- 모달 닫힌 후 열기 버튼으로 포커스 복귀 확인.
스크린리더 테스트:
- 모달 열릴 때 "제목, 대화상자"라고 읽히는가.
- 배경 페이지가 더 이상 탐색되지 않는가.
변형하기: 토스트 알림과 모달 구분
토스트 알림은 포커스를 이동시키지 않는 role="status" 또는 role="alert"로 구현합니다.
<!-- 토스트: 포커스 이동 없음 --><div id="toast" role="status" aria-live="polite" class="toast" hidden> 저장이 완료되었습니다.</div><!-- 모달: 포커스 이동 필요 --><div role="dialog" aria-modal="true"> <!-- 중요한 결정이 필요한 내용 --></div>
정리와 확인
핵심 내용 요약
role="dialog": 모달에 대화상자 역할 부여aria-modal="true": 스크린리더에 모달 범위를 알림- 배경 숨기기:
#app에aria-hidden="true"적용 - 포커스 트랩: 첫 번째/마지막 포커스 요소 사이 순환
- 포커스 복원: 닫기 시 열었던 버튼으로 복귀
role="alertdialog": 중요한 결정/확인이 필요한 대화상자
확인 문제
문제 1. 모달을 닫은 후 포커스가 페이지 상단으로 이동하지 않게 하려면?
// 모달 opener 버튼을 기억했다가 닫을 때 복원this._opener?.focus();
문제 2. role="dialog"와 role="alertdialog"의 차이는?
dialog: 일반 대화상자 (설정, 입력 폼 등)
alertdialog: 즉각 주의가 필요한 경고 대화상자 (삭제 확인, 중요 알림)
alertdialog는 열릴 때 스크린리더가 즉시 내용을 읽어줍니다.
다음 챕터에서는 탭 컴포넌트를 다룹니다. role="tablist", "tab", "tabpanel"을 활용한 완전한 접근성 탭 구현을 알아봅니다.