웹사이트를 눈으로 보지 않고도 쓸 수 있어야 한다면, 우리의 코드는 어떻게 달라져야 할까?

웹 접근성(a11y)이란

웹 접근성(Web Accessibility, a11y) 은 장애 여부에 관계없이 모든 사용자가 웹 콘텐츠를 인식하고, 이해하고, 조작할 수 있도록 만드는 것을 뜻합니다. "accessibility"에서 첫 글자 a와 마지막 y 사이에 11글자가 있어서 a11y라고 줄여 부릅니다.


장애 유형별 접근 방식

접근성을 공부하다 보면 "시각 장애만 신경 쓰면 되는 거 아닌가?"라고 생각하기 쉬운데, 실제로는 다양한 유형을 고려해야 합니다.

장애 유형사용 방식개발자가 신경 쓸 것
시각스크린 리더, 화면 확대시맨틱 HTML, alt 텍스트, 색상 대비
** 청각**자막, 수어동영상 자막, 텍스트 대안 제공
** 운동**키보드, 스위치, 음성 입력키보드 내비게이션, 충분한 클릭 영역
** 인지**단순한 UI, 일관된 네비게이션명확한 레이블, 예측 가능한 동작

시맨틱 HTML이 접근성의 기본인 이유

접근성의 90%는 사실 ** 올바른 HTML 태그를 쓰는 것 **만으로 해결됩니다. 가장 흔한 실수가 <div><span>으로 모든 걸 만드는 것입니다.

<div> vs <button> — 왜 이게 중요한지

HTML
<!-- 나쁜 예: div로 만든 버튼 -->
<div class="btn" onclick="handleClick()">클릭하세요</div>

<!-- 좋은 예: 네이티브 button 사용 -->
<button type="button" onclick="handleClick()">클릭하세요</button>

<div>로 만든 버튼은 다음이 ** 전부 빠져** 있습니다:

  • Tab 키로 포커스가 가지 않음
  • Enter/Space 키로 클릭 불가
  • 스크린 리더가 "버튼"이라고 읽어주지 않음

이걸 <div>로 해결하려면 tabindex, role, onkeydown 등을 전부 직접 구현해야 합니다. ** 네이티브 태그 하나로 끝날 일을 왜 굳이 어렵게 만들까요.**

시맨틱 태그가 주는 무료 접근성

HTML
<nav>          <!-- 스크린 리더: "내비게이션 랜드마크" -->
<main>         <!-- 스크린 리더: "메인 콘텐츠 랜드마크" -->
<header>       <!-- 스크린 리더: "배너 랜드마크" -->
<footer>       <!-- 스크린 리더: "콘텐츠 정보 랜드마크" -->
<aside>        <!-- 스크린 리더: "보충 랜드마크" -->
<h1> ~ <h6>    <!-- 스크린 리더: 제목 레벨 기반 탐색 가능 -->

스크린 리더 사용자는 이 랜드마크를 기준으로 페이지를 빠르게 탐색합니다. <div class="nav">로 만들면 이 기능이 동작하지 않습니다.


ARIA 기본: role, label, 그리고 설명

ARIA(Accessible Rich Internet Applications) 는 시맨틱 HTML만으로 표현하기 어려운 위젯이나 상태를 보조 기술에 전달하기 위한 속성 체계입니다.

ARIA의 첫 번째 규칙은 "ARIA를 쓰지 않는 것"입니다. 네이티브 HTML로 해결 가능하면 그걸 쓰세요.

role — 요소의 역할 명시

시맨틱 태그가 없는 커스텀 위젯에서 역할을 알려줍니다.

HTML
<!-- 탭 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 — 보이지 않는 이름 부여

시각적으로 보이는 텍스트가 없는 요소에 접근 가능한 이름을 줍니다.

HTML
<!-- 아이콘만 있는 버튼 — 스크린 리더가 뭔지 알 수 없음 -->
<button aria-label="메뉴 열기">
  <svg><!-- 햄버거 아이콘 --></svg>
</button>

<!-- 검색 입력 필드에 라벨이 없을 때 -->
<input type="search" aria-label="사이트 내 검색" />

aria-labelledby — 다른 요소를 라벨로 참조

HTML
<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부가 설명 입니다.

HTML
<label for="pw">비밀번호</label>
<input type="password" id="pw" aria-describedby="pw-help" />
<p id="pw-help">8자 이상, 특수문자 포함</p>
<!-- 스크린 리더: "비밀번호, 편집, 8자 이상 특수문자 포함" -->

ARIA 상태와 속성

커스텀 위젯의 현재 상태 를 보조 기술에 실시간으로 전달하는 속성들입니다.

aria-expanded — 열림/닫힘 상태

HTML
<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 — 보조 기술에서 숨기기

HTML
<!-- 장식용 아이콘 — 스크린 리더가 읽을 필요 없음 -->
<span aria-hidden="true">🔍</span>
<span>검색</span>

<!-- 주의: aria-hidden="true"를 포커스 가능한 요소에 쓰면 안 됨! -->
<!-- 아래는 잘못된 사용 -->
<button aria-hidden="true">이 버튼은 존재하지만 접근 불가</button>

aria-hidden="true"는 보조 기술의 접근성 트리에서 요소를 완전히 제거합니다. 시각적으로는 그대로 보이지만 스크린 리더는 없는 것처럼 취급합니다.

aria-live — 동적 콘텐츠 변경 알림

페이지에서 JavaScript로 콘텐츠가 바뀔 때, 스크린 리더에게 "여기 바뀌었어"라고 알려주는 속성입니다.

HTML
<!-- 토스트 알림 영역 -->
<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 — 선택 상태

HTML
<!-- 탭 UI에서 현재 선택된 탭 표시 -->
<button role="tab" aria-selected="true">활성 탭</button>
<button role="tab" aria-selected="false">비활성 탭</button>

키보드 내비게이션

마우스를 쓸 수 없는 사용자에게 키보드는 유일한 조작 수단입니다. 키보드만으로 모든 기능에 접근할 수 있어야 합니다.

tabindex — 포커스 순서 제어

HTML
<!-- 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" 이상: 사용 금지 — 전체 포커스 순서를 망가뜨림

포커스 관리 — 모달 예시

모달이 열릴 때 포커스를 모달 안으로 이동시키고, 닫힐 때 원래 위치로 되돌리는 패턴입니다.

JAVASCRIPT
// 모달 열기
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 });
}

키보드 트랩 방지

** 키보드 트랩 은 포커스가 특정 영역에 갇혀서 빠져나갈 수 없는 상태를 말합니다. 모달에서는 ** 의도적으로 트랩을 걸어야 하고, 그 외에는 절대 트랩이 생기면 안 됩니다.

JAVASCRIPT
// 모달 내 포커스 트랩 (의도적 — 모달 밖으로 포커스가 나가면 안 됨)
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 — 반복 내비게이션 건너뛰기

스크린 리더 사용자가 매 페이지마다 전체 내비게이션을 다시 듣지 않도록, ** 본문으로 바로 이동하는 링크 **를 제공합니다.

HTML
<body>
  <!-- 평소에는 화면에 보이지 않다가 포커스 시 나타남 -->
  <a href="#main-content" class="skip-link">본문으로 바로 가기</a>

  <nav><!-- 긴 내비게이션 메뉴 --></nav>

  <main id="main-content" tabindex="-1">
    <!-- 본문 콘텐츠 -->
  </main>
</body>
CSS
/* 평소에는 화면 밖에 숨김 */
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  padding: 8px 16px;
  background: #000;
  color: #fff;
  z-index: 100;
}

/* 포커스 받으면 화면에 나타남 */
.skip-link:focus {
  top: 0;
}

스크린 리더 대응

alt 텍스트 — 이미지의 접근 가능한 이름

HTML
<!-- 정보를 전달하는 이미지 — 내용을 설명 -->
<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 섹션에서 다뤘지만, 실전에서 자주 쓰는 패턴을 추가로 정리합니다.

HTML
<!-- 폼 검증 에러 -->
<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)에서 페이지가 전환될 때 스크린 리더는 변화를 인지하지 못합니다. 새 페이지의 제목이나 메인 콘텐츠로 포커스를 수동 이동해야 합니다.

JAVASCRIPT
// 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:13:1
AAA (강화)7:14.5:1
CSS
/* 나쁜 예: 대비 비율 약 1.9:1 — 읽기 어려움 */
.low-contrast {
  color: #aaa;
  background-color: #eee;
}

/* 좋은 예: 대비 비율 약 8.6:1 */
.high-contrast {
  color: #333;
  background-color: #fff;
}

색상 대비를 확인할 수 있는 도구들:


접근성 테스트 도구

코드를 작성한 뒤에는 반드시 테스트가 필요합니다. 자동화 도구로 잡을 수 있는 것과 직접 테스트해야 하는 것이 다릅니다.

자동화 도구

도구특징사용법
LighthouseChrome 내장, 접근성 점수 제공DevTools → Lighthouse → Accessibility
axe DevTools가장 정확한 자동 검사, 브라우저 확장설치 후 DevTools 패널에서 실행
WAVE페이지 위에 직접 오류 표시https://wave.webaim.org/ 또는 확장 프로그램
eslint-plugin-jsx-a11yReact 프로젝트에서 빌드 타임 검사ESLint 플러그인으로 설치
BASH
# React 프로젝트에서 접근성 린트 추가
npm install eslint-plugin-jsx-a11y --save-dev

수동 테스트 — 직접 써봐야 잡히는 것들

자동화 도구는 전체 접근성 문제의 약 30%만 잡을 수 있다고 합니다. 나머지는 직접 테스트해야 합니다.

** 키보드 테스트 체크리스트:**

  • Tab 키만으로 모든 인터랙티브 요소에 접근 가능한가?
  • 현재 포커스 위치가 시각적으로 확인 가능한가?
  • 모달/드롭다운을 Esc 키로 닫을 수 있는가?
  • 키보드 트랩에 빠지는 곳이 없는가?

** 스크린 리더 직접 테스트:**

스크린 리더OS시작 방법
VoiceOvermacOS/iOSCmd + F5 (macOS)
NVDAWindows무료 다운로드 후 실행
TalkBackAndroid설정 → 접근성 → TalkBack

VoiceOver 기본 조작:

  • Ctrl + Option + →/←: 다음/이전 요소로 이동
  • Ctrl + Option + Space: 현재 요소 활성화 (클릭)
  • Ctrl + Option + U: 로터 열기 (랜드마크, 제목, 링크 탐색)

공부하다 보니 한 번이라도 스크린 리더를 직접 켜고 자기가 만든 페이지를 탐색해보면, 접근성에 대한 감각이 완전히 달라진다는 걸 체감했습니다.


실전 체크리스트 요약

접근성을 처음 적용할 때 이것만 지켜도 대부분의 문제를 예방할 수 있습니다.

  1. ** 시맨틱 태그 먼저** — <div> 대신 <button>, <nav>, <main> 사용
  2. ** 이미지에 alt** — 정보성이면 설명, 장식이면 alt=""
  3. ** 키보드로 전부 동작하는지** — Tab, Enter, Esc, 화살표 키 확인
  4. ** 포커스 스타일 유지** — outline: none을 무조건 쓰지 말 것
  5. ** 색상 대비 4.5:1 이상** — DevTools에서 간단히 확인 가능
  6. ** 동적 콘텐츠에 aria-live** — 토스트, 에러 메시지 등
  7. ARIA는 최후의 수단 — 네이티브로 안 될 때만 사용
댓글 로딩 중...