도입 스토리
"axe 검사에서 이상한 오류가 나와요." 김개발이 화면을 보여주었습니다.
"'id attribute value must be unique'라고요."
박멘토가 소스를 열었습니다. id="modal"이 세 곳에 있었습니다.
"중복 id는 여러 문제를 일으켜요. aria-labelledby="modal-title"이 어느 modal-title을 가리키는지 브라우저가 알 수 없어요. 첫 번째만 연결되는데, 그게 맞는 것인지 보장할 수 없고요."
"왜 이게 생겼을까요?"
"컴포넌트를 재사용했는데 id를 동적으로 생성하지 않았기 때문이에요. 모달이 세 번 렌더링되면 같은 id가 세 번 나오는 거죠."
핵심 개념 설명
HTML 파싱과 유효성 — WCAG 4.1.1
HTML이 올바르게 파싱되어야 보조기기가 DOM을 정확히 해석할 수 있습니다.
자주 발생하는 파싱 오류:
| 오류 | 문제 | 해결 |
|---|---|---|
| 중복 id | ARIA 연결 오작동 | 컴포넌트마다 고유 id 생성 |
| 닫히지 않은 태그 | DOM 구조 왜곡 | 유효성 검사 도구 사용 |
| 잘못된 중첩 | 예상치 못한 렌더링 | HTML 명세 준수 |
| 필수 속성 누락 | 보조기기 오류 | 각 요소 필수 속성 확인 |
// 중복 id 찾기const allIds = [...document.querySelectorAll('[id]')].map(el => el.id);const duplicates = allIds.filter((id, i) => allIds.indexOf(id) !== i);console.log('중복 id:', [...new Set(duplicates)]);
고유 id 생성 패턴
재사용 컴포넌트에서 고유 id를 동적으로 생성합니다.
// id 카운터로 고유 id 생성let idCounter = 0;function generateId(prefix = 'id') { return `${prefix}-${++idCounter}`;}// 사용 예시class Modal { constructor() { const id = generateId('modal'); this.element = document.createElement('div'); this.element.setAttribute('role', 'dialog'); this.element.setAttribute('aria-modal', 'true'); this.element.setAttribute('aria-labelledby', `${id}-title`); this.element.innerHTML = ` <h2 id="${id}-title">모달 제목</h2> <div id="${id}-content">...</div> `; }}
React에서는 useId() 훅을 사용합니다.
import { useId } from 'react';
function FormField({ label, type = 'text' }) {
const id = useId(); // 자동으로 고유 id 생성
return (
<div>
<label htmlFor={id}>{label}</label>
<input type={type} id={id} />
</div>
);
}
시맨틱 HTML의 중요성
시맨틱 요소는 기능 + 의미 + 스타일 기본값 + 키보드 동작을 모두 포함합니다.
<!-- 잘못된 예: div로 모든 것을 구현 --><div class="button" onclick="submit()" tabindex="0">제출</div><div class="heading">제품 소개</div><div class="list"> <div class="item">항목 1</div> <div class="item">항목 2</div></div><!-- 올바른 예: 시맨틱 요소 사용 --><button type="submit">제출</button><h2>제품 소개</h2><ul> <li>항목 1</li> <li>항목 2</li></ul>
시맨틱 요소를 쓰면 추가 ARIA 없이도 스크린리더가 "버튼", "제목 레벨 2", "목록, 2개 항목" 등을 자동으로 읽어줍니다.
헤딩 계층 구조
헤딩은 건너뛰지 않고 순서대로 사용합니다.
<!-- 잘못된 예: 헤딩 레벨 건너뜀 --><h1>웹사이트 제목</h1><h3>소개</h3> <!-- h2를 건너뜀 --><h5>세부 내용</h5> <!-- h4를 건너뜀 --><!-- 올바른 예: 순서대로 --><h1>웹사이트 제목</h1><h2>소개</h2><h3>세부 내용</h3>
// 헤딩 순서 검사const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');let prevLevel = 0;headings.forEach(h => { const level = parseInt(h.tagName[1]); if (level - prevLevel > 1) { console.warn(`헤딩 레벨 건너뜀: h${prevLevel} → h${level}`, h.textContent); } prevLevel = level;});
목록 구조
내비게이션 링크, 선택지 등은 목록으로 구조화합니다.
<!-- 목록이어야 할 것들 --><!-- 내비게이션 --><nav aria-label="주요 메뉴"> <ul> <li><a href="/home">홈</a></li> <li><a href="/about">소개</a></li> </ul></nav><!-- 단계 표시 (Step Indicator) --><ol aria-label="회원가입 단계"> <li aria-current="step">기본 정보</li> <li>약관 동의</li> <li>완료</li></ol><!-- 정의 목록 (용어-설명 쌍) --><dl> <dt>WCAG</dt> <dd>Web Content Accessibility Guidelines</dd> <dt>ARIA</dt> <dd>Accessible Rich Internet Applications</dd></dl>
단계별 실습
따라하기: HTML 유효성 검사
- W3C Markup Validation Service에 접속합니다.
- 페이지 URL 또는 HTML 코드를 입력합니다.
- 오류 목록을 확인하고 수정합니다.
또는 axe DevTools에서 "ARIA 유효성" 관련 규칙을 확인합니다.
aria-required-attr: 필수 ARIA 속성 누락aria-valid-attr-value: 잘못된 ARIA 속성 값duplicate-id-aria: ARIA에서 참조하는 중복 id
변형하기: 헤딩 구조 시각화
// 페이지 헤딩 목차 생성const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');const outline = headings.map(h => ({ level: parseInt(h.tagName[1]), text: h.textContent.trim()}));console.table(outline);// 또는 시각적으로 들여쓰기로 표시outline.forEach(({ level, text }) => { console.log(' '.repeat(level - 1) + `H${level}: ${text}`);});
정리와 확인
핵심 내용 요약
- WCAG 4.1.1: 파싱 오류 없는 유효한 HTML — 중복 id 금지
- 고유 id: 컴포넌트 재사용 시 동적으로 생성 (
useId()) - 시맨틱 요소 우선:
<button>,<ul>,<h2>등 — div/span 남용 금지 - 헤딩 순서: h1 → h2 → h3 건너뜀 없이
- 목록 활용: 내비게이션, 단계, 용어 쌍은
ul/ol/dl사용
확인 문제
문제 1. 같은 모달 컴포넌트가 두 번 렌더링될 때 중복 id를 방지하는 방법은?
// React: useId() 훅const id = useId();// Vanilla JS: 카운터 기반 id 생성const id = generateId('modal');
문제 2. <div class="h2">소개</div>를 쓰면 안 되는 이유는?
시각적으로 h2처럼 보여도 의미론적 역할이 없습니다.
스크린리더가 "소개"를 평범한 텍스트로 읽어 헤딩임을 알 수 없습니다.
랜드마크 탐색에도 나타나지 않습니다.
다음 챕터에서는 상태 메시지와 동적 업데이트를 다룹니다. JavaScript로 동적으로 변하는 콘텐츠를 스크린리더에 알리는 방법을 알아봅니다.