스크린 리더는 페이지를 어떻게 읽는가
눈이 보이지 않는 사용자에게 웹 페이지는 "화면"이 아닙니다. 음성으로 전달되는 텍스트 스트림입니다. 스크린 리더 (VoiceOver, NVDA, JAWS 등) 는 브라우저의 접근성 트리 를 순회하면서 각 요소의 역할, 이름, 상태를 음성으로 읽어줍니다.
시각 사용자가 페이지를 "스캔"하듯이, 스크린 리더 사용자는 랜드마크 , 헤딩 , 링크 목록 같은 단축키를 사용해 페이지를 탐색합니다. 이 탐색이 효과적으로 동작하려면, HTML이 올바른 의미 구조를 가지고 있어야 합니다.
랜드마크: 페이지의 지도
랜드마크는 페이지의 주요 영역을 표시하는 접근성 역할입니다. 스크린 리더 사용자는 랜드마크 단축키로 영역 간을 빠르게 이동할 수 있습니다.
| HTML 요소 | 암묵적 랜드마크 역할 | 의미 |
|---|---|---|
header (최상위) | banner | 사이트 헤더, 로고, 전역 네비게이션 |
nav | navigation | 네비게이션 링크 그룹 |
main | main | 페이지의 주요 콘텐츠 (하나만) |
footer (최상위) | contentinfo | 사이트 푸터, 저작권, 연락처 |
aside | complementary | 보조 콘텐츠, 사이드바 |
form (name 있을 때) | form | 이름이 지정된 폼 영역 |
section (name 있을 때) | region | 이름이 지정된 콘텐츠 영역 |
주의할 점이 있습니다. header와 footer는 최상위에 있을 때만 banner와 contentinfo 역할을 가집니다. article이나 section 안에 중첩된 header/footer는 랜드마크가 아닙니다.
<body>
<header>...</header> <!-- banner 랜드마크 ✓ -->
<nav>...</nav> <!-- navigation 랜드마크 ✓ -->
<main> <!-- main 랜드마크 ✓ -->
<article>
<header>...</header> <!-- 랜드마크 아님 ✗ -->
<footer>...</footer> <!-- 랜드마크 아님 ✗ -->
</article>
</main>
<footer>...</footer> <!-- contentinfo 랜드마크 ✓ -->
</body>같은 유형의 랜드마크가 여러 개일 때는 aria-label로 구분해야 합니다. 예를 들어 네비게이션이 두 개라면:
<nav aria-label="메인 메뉴">...</nav>
<nav aria-label="푸터 링크">...</nav>레이블을 지정할 때 "navigation"이라는 단어를 포함하지 마세요. 스크린 리더가 "메인 메뉴, navigation"이라고 읽어야지, "메인 메뉴 네비게이션, navigation"이라고 중복되면 안 됩니다.
시각화: 스크린 리더 탐색 방식
아래 시각화에서 스크린 리더가 페이지를 탐색하는 다섯 가지 방식을 확인해 보세요. 각 단계에서 스크린 리더가 무엇을 읽어주는지 (announcement) 에 주목합니다.
ARIA의 첫 번째 규칙, 다시 한 번
이전 글에서 강조했지만 다시 반복할 가치가 있습니다.
네이티브 HTML 요소로 원하는 시맨틱과 동작을 구현할 수 있다면, ARIA를 사용하지 마라.
ARIA는 HTML의 접근성 정보를 덮어씁니다 . role="button"을 div에 추가하면 접근성 트리에서 버튼으로 인식되지만, 키보드 동작 (Enter/Space) 이나 폼 제출 같은 네이티브 기능은 따라오지 않습니다. ARIA는 "정보"만 바꿀 뿐, "동작"은 바꾸지 않습니다.
이 구분이 중요합니다.
- 시맨틱 HTML = 정보 + 동작 + 접근성 (한 번에 해결)
- div + ARIA = 정보만 변경 (동작은 직접 구현해야 함)
필수 ARIA 속성들
시맨틱 HTML만으로 해결할 수 없는 경우, ARIA가 필요합니다. 가장 자주 사용하는 속성들을 정리합니다.
aria-label과 aria-labelledby
요소에 접근 가능한 이름을 부여합니다.
<!-- aria-label: 텍스트를 직접 지정 -->
<button aria-label="닫기">
<svg>...</svg> <!-- 아이콘만 있는 버튼 -->
</button>
<!-- aria-labelledby: 다른 요소의 텍스트를 참조 -->
<h2 id="section-title">검색 결과</h2>
<section aria-labelledby="section-title">
...
</section>aria-labelledby는 aria-label보다 우선순위가 높습니다. 화면에 이미 텍스트가 있다면 aria-labelledby로 참조하는 것이 좋습니다, 텍스트가 변경될 때 자동으로 반영되기 때문입니다.
aria-hidden
접근성 트리에서 요소를 숨깁니다. 장식용 아이콘이나 시각적으로만 의미 있는 요소에 사용합니다.
<!-- 장식용 아이콘 숨기기 -->
<button>
<span aria-hidden="true">★</span>
즐겨찾기에 추가
</button>주의: aria-hidden="true"를 포커스 가능한 요소나 그 부모에 적용하면, 스크린 리더가 요소를 무시하지만 키보드로는 포커스할 수 있는 상태가 됩니다. 이것은 WCAG 위반입니다. 포커스 가능한 요소를 숨기려면 aria-hidden과 함께 tabindex="-1"을 사용하거나, inert 속성을 사용하세요.
aria-live
동적으로 변경되는 콘텐츠를 스크린 리더에 알립니다.
<!-- polite: 현재 읽기가 끝난 후 알림 -->
<div aria-live="polite" role="status">
저장되었습니다
</div>
<!-- assertive: 즉시 알림 (긴급한 경우만) -->
<div aria-live="assertive" role="alert">
세션이 만료되었습니다. 다시 로그인해 주세요.
</div>aria-live 영역은 페이지 로드 시점에 DOM에 존재해야 합니다. JavaScript로 나중에 영역 자체를 추가하면 일부 스크린 리더가 인식하지 못합니다. 빈 상태로 미리 마크업해 두고, 내용만 동적으로 변경하는 것이 안전합니다.
| 값 | 동작 | 사용 사례 |
|---|---|---|
polite | 현재 읽기 완료 후 알림 | 저장 확인, 검색 결과 수 변경 |
assertive | 즉시 알림 (현재 읽기 중단) | 에러 메시지, 세션 만료 경고 |
off | 알리지 않음 (기본값) | - |
aria-expanded
토글 가능한 요소의 열림/닫힘 상태를 전달합니다.
<button aria-expanded="false" aria-controls="faq-1">
자주 묻는 질문 1
</button>
<div id="faq-1" hidden>
답변 내용입니다...
</div>JavaScript에서 토글할 때 aria-expanded 값을 "true"/"false"로 변경합니다. 스크린 리더는 "자주 묻는 질문 1, button, collapsed" 또는 "expanded"로 상태를 읽어줍니다.
aria-controls
aria-controls는 버튼이 제어하는 대상 요소의 ID를 가리킵니다. 일부 스크린 리더 (특히 JAWS) 에서 "이 버튼이 어떤 영역을 제어하는지" 알려줍니다. aria-expanded와 함께 사용하는 것이 일반적입니다.
자주 하는 실수
빈 링크와 버튼
<!-- Bad: 스크린 리더가 "link"라고만 읽음 -->
<a href="/profile">
<img src="avatar.png">
</a>
<!-- Good: 접근 가능한 이름 제공 -->
<a href="/profile">
<img src="avatar.png" alt="내 프로필">
</a>의미 없는 alt 텍스트
<!-- Bad: 파일명을 그대로 사용 -->
<img src="chart.png" alt="chart.png">
<!-- Bad: 장식용 이미지에 설명 추가 -->
<img src="divider.png" alt="구분선 이미지">
<!-- Good: 의미 전달 -->
<img src="chart.png" alt="2025년 매출 추이: 1분기 대비 4분기 23% 증가">
<!-- Good: 장식용 이미지는 빈 alt -->
<img src="divider.png" alt="">role의 잘못된 사용
<!-- Bad: div에 role만 추가하고 키보드 지원 누락 -->
<div role="button">삭제</div>
<!-- Bad: 이미 의미가 있는 요소에 중복 역할 -->
<button role="button">저장</button>
<!-- Bad: role로 시맨틱 HTML을 대체 -->
<div role="navigation">
<div role="link" onclick="goto('/')">Home</div>
</div>
<!-- Good: 시맨틱 HTML 사용 -->
<nav>
<a href="/">Home</a>
</nav>aria-live 남용
<!-- Bad: 너무 많은 영역을 assertive로 설정 -->
<div aria-live="assertive">
검색 결과 42개 <!-- 매 키 입력마다 읽어줌 = 매우 시끄러움 -->
</div>
<!-- Good: polite로 설정하고, 디바운스 적용 -->
<div aria-live="polite" role="status">
검색 결과 42개
</div>체크리스트
- 페이지에 올바른 랜드마크 구조가 있는가? (
header,nav,main,footer) - 같은 유형의 랜드마크가 여러 개일 때
aria-label로 구분했는가? - 헤딩 레벨이 건너뛰지 않고 계층 구조를 유지하는가?
- 아이콘만 있는 버튼/링크에
aria-label이 있는가? - 장식용 이미지와 아이콘에
aria-hidden="true"또는alt=""를 사용했는가? - 동적 콘텐츠 변경에
aria-live를 사용하고 있는가? -
aria-live영역이 페이지 로드 시 DOM에 존재하는가? - 토글 요소에
aria-expanded를 사용하고 있는가? - VoiceOver/NVDA로 실제 테스트해 보았는가?
다음 글에서는 색상, 대비, 모션 을 다룹니다. 색각 이상 사용자를 위한 대비 기준, 색상만으로 정보를 전달하지 않는 방법, 그리고 prefers-reduced-motion을 시각화합니다.