개요

웹 접근성(Web Accessibility, A11y)은 장애가 있는 사용자를 포함한 모든 사람이 웹을 이용할 수 있도록 보장하는 것입니다. 2025년 현재, 접근성은 법적 의무사항일 뿐 아니라 SEO, 사용성, 그리고 사회적 책임의 핵심입니다. WCAG 2.2 기준과 최신 HTML/ARIA 패턴을 중심으로 실전 가이드를 정리합니다.

핵심 개념

WCAG(Web Content Accessibility Guidelines)는 웹 접근성의 국제 표준입니다. 2.2 버전은 인지, 조작, 이해, 견고성의 4대 원칙을 기반으로 하며, A, AA, AAA 세 가지 준수 레벨이 있습니다. 대부분의 국가에서 AA 레벨을 법적 기준으로 삼고 있습니다.

ARIA(Accessible Rich Internet Applications)는 HTML만으로 표현할 수 없는 복잡한 인터랙션을 스크린 리더에 전달하는 명세입니다. role, aria-label, aria-describedby 등의 속성을 사용합니다.

키보드 접근성은 마우스 없이 Tab, Enter, Space, 화살표 키만으로 모든 기능을 사용할 수 있어야 함을 의미합니다.

스크린 리더는 시각 장애인이 웹을 이용하는 주요 수단으로, NVDA, JAWS, VoiceOver 등이 있습니다.

실전 예제

시맨틱 HTML로 접근성을 향상시킵니다.

<!-- 나쁜 예 -->
<div class="header">
  <div class="nav">
    <span onclick="navigate('/home')">홈</span>
    <span onclick="navigate('/about')">소개</span>
  </div>
</div>

<!-- 좋은 예 -->
<header>
  <nav aria-label="주요 메뉴">
    <ul>
      <li><a href="/home">홈</a></li>
      <li><a href="/about">소개</a></li>
    </ul>
  </nav>
</header>

버튼과 폼 요소의 올바른 접근성 구현입니다.

<!-- 나쁜 예 -->
<div class="button" onclick="submit()">전송</div>

<!-- 좋은 예 -->
<button type="submit">전송</button>

<!-- 폼 레이블 연결 -->
<label for="email">이메일 주소</label>
<input
  type="email"
  id="email"
  name="email"
  aria-describedby="email-help"
  aria-required="true"
  aria-invalid="false"
>
<small id="email-help">example@domain.com 형식으로 입력하세요</small>

<!-- 에러 상태 -->
<input
  type="email"
  id="email"
  name="email"
  aria-describedby="email-error"
  aria-required="true"
  aria-invalid="true"
>
<span id="email-error" role="alert">유효한 이메일을 입력하세요</span>

모달(대화상자) 접근성 구현입니다.

<button id="open-modal">설정 열기</button>

<div
  role="dialog"
  aria-labelledby="modal-title"
  aria-describedby="modal-description"
  aria-modal="true"
  class="modal"
  hidden
>
  <div class="modal-content">
    <h2 id="modal-title">설정</h2>
    <p id="modal-description">애플리케이션 설정을 변경합니다.</p>

    <label for="theme">테마 선택</label>
    <select id="theme">
      <option value="light">밝게</option>
      <option value="dark">어둡게</option>
      <option value="auto">자동</option>
    </select>

    <div class="modal-actions">
      <button type="button" id="close-modal">닫기</button>
      <button type="submit">저장</button>
    </div>
  </div>
</div>
class AccessibleModal {
  constructor(modalEl, openBtn, closeBtn) {
    this.modal = modalEl;
    this.openBtn = openBtn;
    this.closeBtn = closeBtn;
    this.previousFocus = null;

    this.init();
  }

  init() {
    this.openBtn.addEventListener('click', () => this.open());
    this.closeBtn.addEventListener('click', () => this.close());

    // ESC 키로 닫기
    this.modal.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') this.close();
    });

    // 포커스 트랩
    this.modal.addEventListener('keydown', (e) => {
      if (e.key === 'Tab') {
        this.trapFocus(e);
      }
    });
  }

  open() {
    // 현재 포커스 저장
    this.previousFocus = document.activeElement;

    // 모달 표시
    this.modal.hidden = false;

    // 첫 번째 포커스 가능 요소로 이동
    const firstFocusable = this.modal.querySelector(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    firstFocusable?.focus();

    // 배경 스크롤 방지
    document.body.style.overflow = 'hidden';
  }

  close() {
    this.modal.hidden = true;
    document.body.style.overflow = '';

    // 이전 포커스 복원
    this.previousFocus?.focus();
  }

  trapFocus(e) {
    const focusableElements = this.modal.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstFocusable = focusableElements[0];
    const lastFocusable = focusableElements[focusableElements.length - 1];

    if (e.shiftKey && document.activeElement === firstFocusable) {
      lastFocusable.focus();
      e.preventDefault();
    } else if (!e.shiftKey && document.activeElement === lastFocusable) {
      firstFocusable.focus();
      e.preventDefault();
    }
  }
}

const modal = new AccessibleModal(
  document.querySelector('.modal'),
  document.getElementById('open-modal'),
  document.getElementById('close-modal')
);

동적 콘텐츠 업데이트를 스크린 리더에 알립니다.

<!-- 실시간 알림 (즉시 전달) -->
<div role="alert" aria-live="assertive" aria-atomic="true">
  <!-- JavaScript로 여기에 메시지 삽입 -->
</div>

<!-- 폴라이트 알림 (현재 읽기가 끝난 후 전달) -->
<div role="status" aria-live="polite" aria-atomic="true">
  10개의 새 메시지가 있습니다.
</div>

<!-- 로딩 상태 -->
<button aria-busy="true" aria-live="polite">
  <span class="spinner" aria-hidden="true"></span>
  로딩 중...
</button>

이미지 접근성 모범 사례입니다.

<!-- 의미 있는 이미지 -->
<img
  src="chart.png"
  alt="2025년 1분기 매출 증가 추이: 1월 100만원, 2월 150만원, 3월 200만원"
>

<!-- 장식용 이미지 -->
<img src="decoration.png" alt="" role="presentation">

<!-- 복잡한 이미지 (긴 설명 필요) -->
<figure>
  <img
    src="complex-diagram.png"
    alt="시스템 아키텍처 다이어그램"
    aria-describedby="diagram-description"
  >
  <figcaption id="diagram-description">
    클라이언트는 API Gateway를 통해 백엔드 서비스에 접근합니다.
    백엔드는 마이크로서비스 아키텍처로 구성되어 있으며...
  </figcaption>
</figure>

활용 팁

  • 자동 테스트 도구: axe DevTools, WAVE, Lighthouse를 사용하여 자동 검사를 수행하세요. 하지만 자동 도구는 30-40%만 감지합니다.
  • 실제 스크린 리더 테스트: NVDA(Windows, 무료), VoiceOver(macOS/iOS, 내장)로 실제 사용자 경험을 확인하세요.
  • 키보드 내비게이션: 마우스를 치우고 Tab, Shift+Tab, Enter, Space만으로 모든 기능을 테스트하세요.
  • 색상 대비: WCAG AA는 4.5:1, AAA는 7:1 대비율을 요구합니다. WebAIM Contrast Checker로 확인하세요.
  • 포커스 표시: outline: none을 사용했다면 반드시 대체 포커스 스타일을 제공하세요.
  • 랜드마크 역할: header, nav, main, aside, footer 태그를 사용하여 페이지 구조를 명확히 하세요.

마무리

접근성은 특별한 사용자를 위한 추가 작업이 아니라 모든 사용자에게 더 나은 경험을 제공하는 기본 품질입니다. 시맨틱 HTML을 올바르게 사용하고, ARIA를 필요한 곳에만 적용하며, 키보드와 스크린 리더로 직접 테스트하는 것이 핵심입니다. 접근성을 갖춘 웹사이트는 SEO, 사용성, 법적 준수를 동시에 달성합니다.