웹 접근성 심화 — ARIA, 키보드 내비게이션, 스크린 리더 대응
웹사이트를 눈으로 보지 않고도 쓸 수 있어야 한다면, 우리의 코드는 어떻게 달라져야 할까?
웹 접근성(a11y)이란
웹 접근성(Web Accessibility, a11y) 은 장애 여부에 관계없이 모든 사용자가 웹 콘텐츠를 인식하고, 이해하고, 조작할 수 있도록 만드는 것을 뜻합니다. "accessibility"에서 첫 글자 a와 마지막 y 사이에 11글자가 있어서 a11y라고 줄여 부릅니다.
장애 유형별 접근 방식
접근성을 공부하다 보면 "시각 장애만 신경 쓰면 되는 거 아닌가?"라고 생각하기 쉬운데, 실제로는 다양한 유형을 고려해야 합니다.
| 장애 유형 | 사용 방식 | 개발자가 신경 쓸 것 |
|---|---|---|
| 시각 | 스크린 리더, 화면 확대 | 시맨틱 HTML, alt 텍스트, 색상 대비 |
| ** 청각** | 자막, 수어 | 동영상 자막, 텍스트 대안 제공 |
| ** 운동** | 키보드, 스위치, 음성 입력 | 키보드 내비게이션, 충분한 클릭 영역 |
| ** 인지** | 단순한 UI, 일관된 네비게이션 | 명확한 레이블, 예측 가능한 동작 |
시맨틱 HTML이 접근성의 기본인 이유
접근성의 90%는 사실 ** 올바른 HTML 태그를 쓰는 것 **만으로 해결됩니다. 가장 흔한 실수가 <div>와 <span>으로 모든 걸 만드는 것입니다.
<div> vs <button> — 왜 이게 중요한지
<!-- 나쁜 예: div로 만든 버튼 -->
<div class="btn" onclick="handleClick()">클릭하세요</div>
<!-- 좋은 예: 네이티브 button 사용 -->
<button type="button" onclick="handleClick()">클릭하세요</button>
<div>로 만든 버튼은 다음이 ** 전부 빠져** 있습니다:
- Tab 키로 포커스가 가지 않음
- Enter/Space 키로 클릭 불가
- 스크린 리더가 "버튼"이라고 읽어주지 않음
이걸 <div>로 해결하려면 tabindex, role, onkeydown 등을 전부 직접 구현해야 합니다. ** 네이티브 태그 하나로 끝날 일을 왜 굳이 어렵게 만들까요.**
시맨틱 태그가 주는 무료 접근성
<nav> <!-- 스크린 리더: "내비게이션 랜드마크" -->
<main> <!-- 스크린 리더: "메인 콘텐츠 랜드마크" -->
<header> <!-- 스크린 리더: "배너 랜드마크" -->
<footer> <!-- 스크린 리더: "콘텐츠 정보 랜드마크" -->
<aside> <!-- 스크린 리더: "보충 랜드마크" -->
<h1> ~ <h6> <!-- 스크린 리더: 제목 레벨 기반 탐색 가능 -->
스크린 리더 사용자는 이 랜드마크를 기준으로 페이지를 빠르게 탐색합니다. <div class="nav">로 만들면 이 기능이 동작하지 않습니다.
ARIA 기본: role, label, 그리고 설명
ARIA(Accessible Rich Internet Applications) 는 시맨틱 HTML만으로 표현하기 어려운 위젯이나 상태를 보조 기술에 전달하기 위한 속성 체계입니다.
ARIA의 첫 번째 규칙은 "ARIA를 쓰지 않는 것"입니다. 네이티브 HTML로 해결 가능하면 그걸 쓰세요.
role — 요소의 역할 명시
시맨틱 태그가 없는 커스텀 위젯에서 역할을 알려줍니다.
<!-- 탭 UI를 만들 때 — 네이티브 탭 태그가 없으므로 role이 필요 -->
<div role="tablist">
<button role="tab" aria-selected="true">탭 1</button>
<button role="tab" aria-selected="false">탭 2</button>
</div>
<div role="tabpanel">탭 1의 내용</div>
aria-label — 보이지 않는 이름 부여
시각적으로 보이는 텍스트가 없는 요소에 접근 가능한 이름을 줍니다.
<!-- 아이콘만 있는 버튼 — 스크린 리더가 뭔지 알 수 없음 -->
<button aria-label="메뉴 열기">
<svg><!-- 햄버거 아이콘 --></svg>
</button>
<!-- 검색 입력 필드에 라벨이 없을 때 -->
<input type="search" aria-label="사이트 내 검색" />
aria-labelledby — 다른 요소를 라벨로 참조
<h2 id="section-title">최근 게시물</h2>
<nav aria-labelledby="section-title">
<!-- 스크린 리더: "최근 게시물 내비게이션" -->
<a href="/post/1">첫 번째 글</a>
<a href="/post/2">두 번째 글</a>
</nav>
aria-describedby — 추가 설명 연결
aria-label이 이름 이라면, aria-describedby는 부가 설명 입니다.
<label for="pw">비밀번호</label>
<input type="password" id="pw" aria-describedby="pw-help" />
<p id="pw-help">8자 이상, 특수문자 포함</p>
<!-- 스크린 리더: "비밀번호, 편집, 8자 이상 특수문자 포함" -->
ARIA 상태와 속성
커스텀 위젯의 현재 상태 를 보조 기술에 실시간으로 전달하는 속성들입니다.
aria-expanded — 열림/닫힘 상태
<button aria-expanded="false" aria-controls="menu">
카테고리
</button>
<ul id="menu" hidden>
<li>HTML</li>
<li>CSS</li>
<li>JavaScript</li>
</ul>
<script>
const btn = document.querySelector('button');
const menu = document.getElementById('menu');
btn.addEventListener('click', () => {
// 메뉴 토글 시 상태도 함께 업데이트
const isExpanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!isExpanded));
menu.hidden = isExpanded;
});
</script>
aria-hidden — 보조 기술에서 숨기기
<!-- 장식용 아이콘 — 스크린 리더가 읽을 필요 없음 -->
<span aria-hidden="true">🔍</span>
<span>검색</span>
<!-- 주의: aria-hidden="true"를 포커스 가능한 요소에 쓰면 안 됨! -->
<!-- 아래는 잘못된 사용 -->
<button aria-hidden="true">이 버튼은 존재하지만 접근 불가</button>
aria-hidden="true"는 보조 기술의 접근성 트리에서 요소를 완전히 제거합니다. 시각적으로는 그대로 보이지만 스크린 리더는 없는 것처럼 취급합니다.
aria-live — 동적 콘텐츠 변경 알림
페이지에서 JavaScript로 콘텐츠가 바뀔 때, 스크린 리더에게 "여기 바뀌었어"라고 알려주는 속성입니다.
<!-- 토스트 알림 영역 -->
<div aria-live="polite" role="status" id="notification"></div>
<!-- 긴급 에러 메시지 -->
<div aria-live="assertive" role="alert" id="error"></div>
<script>
// polite: 스크린 리더가 현재 읽기를 끝낸 후 알림
document.getElementById('notification').textContent = '저장되었습니다.';
// assertive: 현재 읽기를 중단하고 즉시 알림
document.getElementById('error').textContent = '입력값이 잘못되었습니다!';
</script>
| 값 | 동작 | 용도 |
|---|---|---|
off | 변경 알리지 않음 (기본값) | — |
polite | 현재 읽기 끝난 후 알림 | 성공 메시지, 상태 업데이트 |
assertive | 즉시 끼어들어 알림 | 에러, 긴급 알림 |
aria-selected — 선택 상태
<!-- 탭 UI에서 현재 선택된 탭 표시 -->
<button role="tab" aria-selected="true">활성 탭</button>
<button role="tab" aria-selected="false">비활성 탭</button>
키보드 내비게이션
마우스를 쓸 수 없는 사용자에게 키보드는 유일한 조작 수단입니다. 키보드만으로 모든 기능에 접근할 수 있어야 합니다.
tabindex — 포커스 순서 제어
<!-- tabindex="0": 자연스러운 순서에 포커스 추가 -->
<div tabindex="0" role="button">커스텀 요소에 포커스 추가</div>
<!-- tabindex="-1": Tab으로 접근 불가, JS로만 포커스 가능 -->
<div tabindex="-1" id="modal-title">모달 제목</div>
<!-- tabindex 양수 — 쓰지 마세요! 포커스 순서를 꼬이게 만듦 -->
<!-- <div tabindex="3">이건 안티패턴입니다</div> -->
정리하면 이렇습니다:
tabindex="0": DOM 순서대로 Tab 포커스에 포함tabindex="-1": Tab 불가,element.focus()로만 포커스tabindex="1" 이상: 사용 금지 — 전체 포커스 순서를 망가뜨림
포커스 관리 — 모달 예시
모달이 열릴 때 포커스를 모달 안으로 이동시키고, 닫힐 때 원래 위치로 되돌리는 패턴입니다.
// 모달 열기
function openModal() {
const modal = document.getElementById('modal');
const trigger = document.activeElement; // 현재 포커스된 요소 저장
modal.style.display = 'block';
modal.querySelector('[tabindex="-1"]').focus(); // 모달 내부로 포커스 이동
// 모달 닫을 때 원래 요소로 복귀
modal.addEventListener('close', () => {
trigger.focus(); // 모달을 열었던 버튼으로 포커스 되돌림
}, { once: true });
}
키보드 트랩 방지
** 키보드 트랩 은 포커스가 특정 영역에 갇혀서 빠져나갈 수 없는 상태를 말합니다. 모달에서는 ** 의도적으로 트랩을 걸어야 하고, 그 외에는 절대 트랩이 생기면 안 됩니다.
// 모달 내 포커스 트랩 (의도적 — 모달 밖으로 포커스가 나가면 안 됨)
function trapFocus(modal) {
const focusable = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
modal.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift+Tab으로 첫 요소에서 뒤로 가면 → 마지막 요소로
if (document.activeElement === first) {
last.focus();
e.preventDefault();
}
} else {
// Tab으로 마지막 요소에서 앞으로 가면 → 첫 요소로
if (document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
});
}
Skip Navigation — 반복 내비게이션 건너뛰기
스크린 리더 사용자가 매 페이지마다 전체 내비게이션을 다시 듣지 않도록, ** 본문으로 바로 이동하는 링크 **를 제공합니다.
<body>
<!-- 평소에는 화면에 보이지 않다가 포커스 시 나타남 -->
<a href="#main-content" class="skip-link">본문으로 바로 가기</a>
<nav><!-- 긴 내비게이션 메뉴 --></nav>
<main id="main-content" tabindex="-1">
<!-- 본문 콘텐츠 -->
</main>
</body>
/* 평소에는 화면 밖에 숨김 */
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px 16px;
background: #000;
color: #fff;
z-index: 100;
}
/* 포커스 받으면 화면에 나타남 */
.skip-link:focus {
top: 0;
}
스크린 리더 대응
alt 텍스트 — 이미지의 접근 가능한 이름
<!-- 정보를 전달하는 이미지 — 내용을 설명 -->
<img src="chart.png" alt="2025년 분기별 매출 추이: 1분기 100억, 4분기 250억" />
<!-- 장식용 이미지 — 빈 alt로 스크린 리더가 건너뛰게 함 -->
<img src="decorative-line.png" alt="" />
<!-- 링크 안의 이미지 — 링크의 목적지를 설명 -->
<a href="/home">
<img src="logo.png" alt="홈으로 이동" />
</a>
공부하다 보니 alt 작성에서 많이 실수하는 부분이 있었습니다:
- "이미지", "사진", "그림" 같은 접두어는 불필요 (스크린 리더가 이미 "이미지"라고 알려줌)
- alt 속성 자체를 빼면 스크린 리더가 파일명을 읽음 → 최악의 경험
- 차트나 그래프는 핵심 데이터를 alt에 포함
라이브 리전 — 동적 콘텐츠 변경 알림
위의 aria-live 섹션에서 다뤘지만, 실전에서 자주 쓰는 패턴을 추가로 정리합니다.
<!-- 폼 검증 에러 -->
<form>
<label for="email">이메일</label>
<input type="email" id="email" aria-describedby="email-error" />
<p id="email-error" role="alert" aria-live="assertive"></p>
</form>
<script>
const input = document.getElementById('email');
const error = document.getElementById('email-error');
input.addEventListener('blur', () => {
if (!input.validity.valid) {
// 에러 메시지가 설정되면 스크린 리더가 즉시 읽어줌
error.textContent = '올바른 이메일 형식을 입력하세요.';
} else {
error.textContent = '';
}
});
</script>
포커스 이동 — SPA에서의 라우트 전환
SPA(Single Page Application)에서 페이지가 전환될 때 스크린 리더는 변화를 인지하지 못합니다. 새 페이지의 제목이나 메인 콘텐츠로 포커스를 수동 이동해야 합니다.
// SPA 라우트 전환 후 포커스 처리
function onRouteChange() {
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
// 포커스 아웃라인이 보이지 않도록 스타일 처리
heading.style.outline = 'none';
}
}
색상 대비(Contrast Ratio)
색상만으로 정보를 전달하면 색각 이상 사용자가 구분하지 못합니다. WCAG는 텍스트와 배경 사이의 최소 대비 비율 을 규정합니다.
WCAG 기준
| 레벨 | 일반 텍스트 | 큰 텍스트(18px bold / 24px) |
|---|---|---|
| AA (최소) | 4.5:1 | 3:1 |
| AAA (강화) | 7:1 | 4.5:1 |
/* 나쁜 예: 대비 비율 약 1.9:1 — 읽기 어려움 */
.low-contrast {
color: #aaa;
background-color: #eee;
}
/* 좋은 예: 대비 비율 약 8.6:1 */
.high-contrast {
color: #333;
background-color: #fff;
}
색상 대비를 확인할 수 있는 도구들:
- Chrome DevTools: Elements 패널에서 색상 피커 열면 대비 비율 표시
- WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
- Stark (Figma 플러그인): 디자인 단계에서 미리 확인
접근성 테스트 도구
코드를 작성한 뒤에는 반드시 테스트가 필요합니다. 자동화 도구로 잡을 수 있는 것과 직접 테스트해야 하는 것이 다릅니다.
자동화 도구
| 도구 | 특징 | 사용법 |
|---|---|---|
| Lighthouse | Chrome 내장, 접근성 점수 제공 | DevTools → Lighthouse → Accessibility |
| axe DevTools | 가장 정확한 자동 검사, 브라우저 확장 | 설치 후 DevTools 패널에서 실행 |
| WAVE | 페이지 위에 직접 오류 표시 | https://wave.webaim.org/ 또는 확장 프로그램 |
| eslint-plugin-jsx-a11y | React 프로젝트에서 빌드 타임 검사 | ESLint 플러그인으로 설치 |
# React 프로젝트에서 접근성 린트 추가
npm install eslint-plugin-jsx-a11y --save-dev
수동 테스트 — 직접 써봐야 잡히는 것들
자동화 도구는 전체 접근성 문제의 약 30%만 잡을 수 있다고 합니다. 나머지는 직접 테스트해야 합니다.
** 키보드 테스트 체크리스트:**
- Tab 키만으로 모든 인터랙티브 요소에 접근 가능한가?
- 현재 포커스 위치가 시각적으로 확인 가능한가?
- 모달/드롭다운을 Esc 키로 닫을 수 있는가?
- 키보드 트랩에 빠지는 곳이 없는가?
** 스크린 리더 직접 테스트:**
| 스크린 리더 | OS | 시작 방법 |
|---|---|---|
| VoiceOver | macOS/iOS | Cmd + F5 (macOS) |
| NVDA | Windows | 무료 다운로드 후 실행 |
| TalkBack | Android | 설정 → 접근성 → TalkBack |
VoiceOver 기본 조작:
Ctrl + Option + →/←: 다음/이전 요소로 이동Ctrl + Option + Space: 현재 요소 활성화 (클릭)Ctrl + Option + U: 로터 열기 (랜드마크, 제목, 링크 탐색)
공부하다 보니 한 번이라도 스크린 리더를 직접 켜고 자기가 만든 페이지를 탐색해보면, 접근성에 대한 감각이 완전히 달라진다는 걸 체감했습니다.
실전 체크리스트 요약
접근성을 처음 적용할 때 이것만 지켜도 대부분의 문제를 예방할 수 있습니다.
- ** 시맨틱 태그 먼저** —
<div>대신<button>,<nav>,<main>사용 - ** 이미지에 alt** — 정보성이면 설명, 장식이면
alt="" - ** 키보드로 전부 동작하는지** — Tab, Enter, Esc, 화살표 키 확인
- ** 포커스 스타일 유지** —
outline: none을 무조건 쓰지 말 것 - ** 색상 대비 4.5:1 이상** — DevTools에서 간단히 확인 가능
- ** 동적 콘텐츠에
aria-live** — 토스트, 에러 메시지 등 - ARIA는 최후의 수단 — 네이티브로 안 될 때만 사용