div에 role="button" 붙이면 접근성 끝일까. 아니다. ARIA는 잘못 쓰면 안 쓰느니만 못한 도구다. 모달을 예로 ARIA의 핵심을 정리한다.
ARIA가 뭐고, 왜 필요한가
브라우저는 DOM과 별개로 접근성 트리(accessibility tree) 를 만든다. 스크린리더(SR) 같은 보조기술(AT)은 이 트리를 읽는다. <button>, <nav>, <h1> 같은 시맨틱 HTML은 자동으로 올바른 노드를 만들지만, <div>로 만든 커스텀 UI(모달, 탭, 토글…)는 트리에 "그냥 그룹"으로만 보인다.
ARIA(Accessible Rich Internet Applications) 는 이 접근성 트리에 역할(role)·속성(property)·상태(state) 를 덧붙여, AT에게 "이건 모달 다이얼로그고, 열려 있고, 이름은 OO야"를 알려주는 명세다.
ARIA는 3종류로 나뉜다.
- role — 무엇인가 (
role="dialog",role="tab") - property — 거의 안 변하는 속성 (
aria-labelledby,aria-modal) - state — 변하는 상태 (
aria-expanded,aria-checked,aria-hidden)
여기서 제일 먼저 박아둬야 할 사실이 하나 있다. ARIA는 시각적 동작을 바꾸지 않는다. aria-modal="true"를 붙여도 포커스가 갇히지 않고, role="button"을 붙여도 Enter/Space로 눌리지 않는다. 동작은 직접 JS로 구현해야 하고, ARIA는 "AT에게 알리는 라벨"일 뿐이다. 이 한 문장을 놓치면 ARIA를 붙여 놓고 "왜 안 되지"를 반복하게 된다.
ARIA 제1규칙: 안 쓰는 게 제일 좋다
WAI-ARIA 명세의 첫 번째 규칙은 역설적이다.
"네이티브 HTML 요소나 속성으로 가능하면, ARIA를 쓰지 말고 그걸 써라."
<button>이 <div role="button" tabindex="0"> + 키보드 핸들러보다 항상 낫다. 모달도 마찬가지로 네이티브 <dialog> + showModal()을 쓰면 role·모달 동작·포커스·ESC·배경 차단을 공짜로 얻는다. ARIA는 네이티브로 안 될 때의 보충재다.
1. Accessible Name — 요소의 "이름"
AT는 요소를 읽을 때 이름(accessible name) 을 먼저 말한다. 버튼이면 "구매하기, 버튼", 다이얼로그면 "OO, 대화상자". 이름이 없으면 그냥 "대화상자"라고만 읽어서 무슨 모달인지 알 수 없다.
이름을 정하는 우선순위는 다음과 같다. 위가 이긴다.
aria-labelledby > aria-label > 네이티브(<label>, alt, 콘텐츠 텍스트) > title
aria-label — 보이지 않는 문자열 라벨
<button aria-label="뒤로 가기">
<svg>…</svg> <!-- 아이콘만 있어 텍스트가 없음 -->
</button>
- 화면에 보이는 라벨이 없을 때 쓴다. 아이콘 버튼이 대표적이다.
- 콘텐츠 텍스트를 덮어쓴다. 텍스트가 있는데
aria-label을 또 주면 SR은aria-label만 읽는다. - 단점도 있다. 번역 도구가 못 잡는 경우가 있고, 유지보수 시 시각 텍스트와 어긋날 수 있다.
뒤로가기 버튼 aria-label="뒤로 가기", 사용아이템 트리거 aria-label="사용된 아이템 보기"처럼 아이콘·썸네일만 있어 텍스트가 없는 경우엔 aria-label이 정답이다.
aria-labelledby — 화면의 다른 요소를 가리켜 이름 짓기
<div role="dialog" aria-modal="true" aria-labelledby="popup-title">
<p id="popup-title">사과 410개로 구매하시겠어요?</p>
…
</div>
- 이미 보이는 제목이 있으면 그
id를 가리키는 게 베스트다. 시각 라벨과 SR 라벨이 단일 출처라 어긋나지 않는다. - 여러 id를 공백으로 나열하면 순서대로 이어붙여 이름이 된다:
aria-labelledby="title subtitle". - 자기 자신도 참조 가능하다.
aria-describedby — 이름이 아니라 "설명"
<button aria-describedby="pw-help">비밀번호</button>
<p id="pw-help">영문/숫자 8자 이상</p>
이름 다음에 부가 설명으로 읽힌다. 에러 메시지, 도움말 연결에 쓴다. 이름(name)과 설명(description)은 역할이 다르다.
2. aria-modal — "뒤는 신경 쓰지 마"
<div role="dialog" aria-modal="true"> … </div>
aria-modal="true"는 AT에게 "이 다이얼로그 바깥 콘텐츠는 지금 상호작용 대상이 아니다" 를 알린다. SR의 가상 커서가 배경을 훑지 않게 해 주는 것이다. 예전엔 배경 전체에 aria-hidden을 거는 기법을 썼는데, aria-modal이 이를 대체했다.
하지만 함정이 둘 있다.
- AT 인지 전용이다. 실제 키보드 포커스는 안 막아준다. 그래서
aria-modal만 믿으면 Tab으로 배경 버튼에 포커스가 새어 나간다 → 포커스 트랩을 직접 구현해야 한다. - 지원이 들쭉날쭉이다. 일부 AT/브라우저 조합에선
aria-modal을 무시하고 배경을 계속 탐색할 수 있다.
그래서 견고한 모달은 aria-modal + 포커스 트랩 + 배경 비활성화(inert) 를 함께 쓴다.
3. aria-hidden — 접근성 트리에서 숨기기
<svg aria-hidden="true">…</svg> <!-- 장식 아이콘: SR이 무시 -->
aria-hidden="true"는 요소와 그 하위 전체를 접근성 트리에서 제거한다. AT는 존재 자체를 모른다. 시각적으론 그대로 보인다. 장식용 아이콘, 중복 텍스트 숨김에 유용하다.
aria-hidden의 치명적 함정
포커스 가능한 요소(또는 그 조상)에
aria-hidden="true"를 걸지 마라.
aria-hidden은 a11y 트리에서만 지운다. 포커스는 여전히 간다. 그래서 aria-hidden된 영역 안의 버튼에 Tab으로 포커스가 가면, SR은 "지금 포커스된 요소가 트리에 없음" 상태가 된다 → 유령(ghost) 포커스가 되어 아무것도 안 읽거나 깨진다. 브라우저가 콘솔 경고를 띄우기도 한다.
배경을 가린다고 <body>나 앱 루트에 통째로 aria-hidden을 걸었는데 모달이 그 안에 있으면 모달까지 사라진다. 그래서 모달은 보통 배경 형제 요소에만, 또는 포털로 분리한 뒤 처리한다.
더 나은 대안: inert
<div id="app" inert>…배경…</div> <!-- 모달 열릴 때 -->
<div role="dialog">…모달…</div>
HTML의 inert 속성(이제 모던 브라우저 baseline)은 한 방에 해결한다.
- 접근성 트리에서 제거 (≈
aria-hidden) - 포커스 불가 (Tab 안 됨)
- 클릭/포인터 이벤트 불가
aria-hidden이 못 막는 포커스·클릭까지 막아 줘서, 배경 비활성화엔 inert가 정석이다. "모달 빼고 전부 inert" 패턴을 권한다.
4. 실전: 접근 가능한 모달 체크리스트
OverlayBackdrop 같은 공통 모달을 예로, 갖춰야 할 것을 정리하면 이렇다.
<div
role="dialog" // ① 다이얼로그임을 선언
aria-modal="true" // ② 배경은 비활성 (AT 인지)
aria-labelledby="title" // ③ 이름 = 제목
tabIndex={-1}
>
<h2 id="title">…</h2>
…
</div>
| 항목 | 역할 | 직접 구현? |
|---|---|---|
role="dialog" | 다이얼로그 선언 | ARIA |
aria-modal="true" | 배경 비활성(AT) | ARIA |
aria-labelledby/aria-label | 이름 부여 | ARIA |
| 진입 시 포커스 이동 | 키보드 진입 | JS |
| 포커스 트랩(Tab 순환) | 포커스 가둠 | JS |
| 닫을 때 포커스 복원 | 흐름 유지 | JS |
| ESC 닫기 | 키보드 닫기 | JS |
배경 inert | 포커스·클릭 차단 | HTML/JS |
표에서 ARIA는 4개뿐이고, 나머지는 전부 직접 구현이다. 핵심은 하나로 압축된다. "ARIA는 말만 하고, 동작은 내가 한다."
흔한 실수 하나는 role="dialog"와 aria-modal만 붙여 놓고 끝냈다고 생각하는 것이다. 이러면 SR은 "대화상자"만 읽고(이름 누락), Tab은 배경으로 새어 나간다(트랩 누락). aria-labelledby로 제목을 묶고, 포커스 트랩·복원·ESC를 JS로 채우고, 배경은 inert로 막아야 비로소 진짜 모달이 된다.
5. 흔한 실수 모음
| 실수 | 왜 문제 | 해결 |
|---|---|---|
포커스 요소에 aria-hidden="true" | 유령 포커스 | inert 사용, 또는 포커스 제거 |
<button role="button"> | 중복(redundant) role | 네이티브 그대로 |
aria-label을 <div>(role 없음)에 | role 없으면 무시됨 | 시맨틱 요소 + role |
aria-modal만 쓰고 트랩 없음 | 배경으로 포커스 샘 | 포커스 트랩 구현 |
| 다이얼로그에 이름 없음 | "대화상자"만 읽힘 | aria-labelledby 연결 |
placeholder를 라벨 대용 | 입력하면 사라짐 | <label>/aria-label |
6. 정리
- 시맨틱 HTML이 1순위. ARIA는 네이티브로 안 될 때만.
- ARIA는 알리는 도구일 뿐, 동작(포커스·키보드)은 직접 구현.
- 이름 — 보이는 제목이 있으면
aria-labelledby, 없으면aria-label. aria-modal— 배경 비활성을 AT에 알리지만, 트랩·inert로 보완해야 진짜 모달.aria-hidden— 트리에서만 숨김. 포커스 가능한 곳엔 금지, 배경 차단은inert가 정석.
한 줄로 요약하면 이렇다. ARIA는 접근성 트리에 의미를 더하는 명세지만 동작은 안 바꾼다. 그래서 aria-modal을 써도 포커스 트랩은 직접 해야 하고, 배경 차단은 aria-hidden보다 포커스까지 막는 inert가 낫다.
부록: 자주 쓰는 ARIA 상태/속성 빠른 참조
| 속성 | 용도 | 예 |
|---|---|---|
aria-expanded | 펼침/접힘 | 아코디언, 드롭다운 |
aria-controls | 제어 대상 연결 | 탭→패널 |
aria-selected | 선택됨 | 탭, 옵션 |
aria-checked | 체크 상태 | 커스텀 체크박스/토글 |
aria-disabled | 비활성(포커스는 유지) | disabled와 구분 |
aria-live | 동적 영역 알림 | 토스트(polite/assertive) |
aria-current | 현재 항목 | 페이지네이션, 네비 |
role="alertdialog" | 긴급 다이얼로그 | 삭제 확인 등 |