iBetter Books
수정

도입 스토리

"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 유효성 검사

  1. W3C Markup Validation Service에 접속합니다.
  2. 페이지 URL 또는 HTML 코드를 입력합니다.
  3. 오류 목록을 확인하고 수정합니다.

또는 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로 동적으로 변하는 콘텐츠를 스크린리더에 알리는 방법을 알아봅니다.