Skip to content

1-7. 웹 접근성 ​

한 줄 요약: 접근성 트리라는 동작 모델 위에서, 시맨틱 HTML을 우선하고 ARIA로 보정하며 키보드로 완주 가능한 UI를 만들고 검증할 수 있다.

학습 목표 ​

  • 브라우저가 DOM에서 접근성 트리를 만들고 보조기술이 그것을 소비하는 구조를 설명할 수 있다.
  • "ARIA보다 시맨틱 HTML이 우선"이라는 원칙의 근거를 설명하고, ARIA가 필요한 경계를 판단할 수 있다.
  • 커스텀 위젯에 role과 상태 속성(aria-expanded 등)을 올바르게 적용할 수 있다.
  • 키보드만으로 UI를 완주할 수 있게 포커스를 관리하고, 자동화 도구로 접근성을 점검할 수 있다.

배경: 왜 이것이 존재하는가 ​

접근성(accessibility, a11y)을 "장애인 지원이라는 별도 요구사항"으로 접근하면 항상 뒷순위로 밀린다. 더 정확한 프레임은 이것이다: 웹 UI에는 화면 외에 또 하나의 공식 인터페이스가 있고, 접근성은 그 인터페이스의 품질이다.

브라우저는 DOM을 렌더링해 화면을 만드는 동시에, DOM에서 접근성 트리(accessibility tree) 라는 파생 자료구조를 만든다. 각 노드는 역할(role: 이것이 버튼인가 링크인가), 이름(name: 뭐라고 불리는가), 상태(state: 눌렸는가, 펼쳐졌는가)를 갖는다. 스크린 리더 같은 보조기술은 화면을 "보는" 것이 아니라 OS의 접근성 API를 통해 이 트리를 소비한다. API 서버가 HTML 페이지와 JSON 응답을 함께 제공하듯, 프론트엔드는 픽셀과 접근성 트리를 항상 함께 출고하고 있는 것이다 — 의식하지 않았다면 그 두 번째 인터페이스가 깨진 채 나가고 있었을 뿐이다.

이 관점의 이점은 접근성 작업 대부분이 공짜가 된다는 것이다. 1-2의 시맨틱 요소들은 올바른 role·이름·키보드 동작을 접근성 트리에 자동으로 채운다. 접근성이 비싸지는 것은 시맨틱을 버리고 <div>로 위젯을 재발명할 때다.

수혜자의 범위도 통념보다 넓다. 시각·운동 장애인만이 아니라, 마우스를 놓은 파워 유저의 키보드 탐색, 밝은 야외의 저대비 화면, 일시적 부상, 그리고 접근성 트리를 함께 읽는 검색 엔진·자동화 테스트·LLM 에이전트까지 — 잘 만든 두 번째 인터페이스는 사람과 기계 모두에게 유효하다. 한국은 법적 의무(장애인차별금지법, 한국형 웹 콘텐츠 접근성 지침 KWCAG)도 있으며, 국제 기준은 WCAG(Web Content Accessibility Guidelines)이고 통상 AA 레벨을 목표로 한다.

핵심 개념 ​

접근성 트리 직접 보기 ​

개념을 눈으로 확인하는 것이 가장 빠르다. 크롬 DevTools > Elements > Accessibility 패널(또는 설정에서 전체 트리 보기 활성화)에서 임의 요소를 선택하면 role/name/state가 보인다.

html
<button aria-expanded="false">메뉴</button>
<!-- 접근성 트리: role=button, name="메뉴", state: expanded=false
     스크린 리더 발화: "메뉴, 버튼, 접힘" -->

<div onclick="toggle()">메뉴</div>
<!-- 접근성 트리: role=generic, name 없음
     스크린 리더에게 이것은 그냥 텍스트다. 클릭 가능하다는 정보 자체가 없다 -->

두 번째가 접근성 문제의 원형이다. 시각적으로는 버튼처럼 꾸몄어도 접근성 트리에는 아무것도 전달되지 않았다. 여기서 도출되는 규칙이 다음 절이다.

제1원칙: ARIA를 쓰지 않을 수 있으면 쓰지 않는다 ​

ARIA(Accessible Rich Internet Applications)는 접근성 트리를 수동으로 조작하는 속성 집합이다. W3C의 ARIA 사용 규칙 첫 번째가 역설적이게도 "네이티브 요소로 가능하면 ARIA를 쓰지 말라"다. 이유는 ARIA의 결정적 한계에 있다: ARIA는 트리의 정보만 바꾸고, 동작은 하나도 주지 않는다.

html
<!-- ❌ role은 "버튼이라고 주장"할 뿐이다. 키보드 포커스 불가,
     Enter/Space 반응 없음, 각각을 JS로 재구현해야 한다 -->
<div role="button" tabindex="0" onclick="save()">저장</div>

<!-- ✅ 포커스, Enter/Space 활성화, 폼 연동, 비활성화 상태까지 전부 내장 -->
<button type="button" onclick="save()">저장</button>

<button>, <a>, <input>, <select>, <details> 같은 네이티브 요소는 role·이름·키보드 동작·상태 관리가 검증된 채로 내장되어 있다. ARIA로 div를 버튼으로 "승격"시키는 것은 그 전부를 손으로 재구현하는 일이고, 실무 통계가 반복해서 보여주듯 ARIA를 쓴 사이트가 안 쓴 사이트보다 접근성 오류가 많다 — 도구가 나빠서가 아니라 어설프게 쓰인 ARIA가 침묵(role=generic)보다 해로운 거짓 정보이기 때문이다. role="button"인데 Enter가 안 먹는 요소는, 버튼이라고 안내받고 눌렀는데 아무 일도 안 일어나는 경험을 만든다.

ARIA가 실제로 필요한 곳: 이름, 상태, 커스텀 위젯 ​

그렇다면 ARIA의 정당한 용도는 HTML로 표현할 수 없는 의미를 보충하는 것이다. 세 부류로 정리된다.

1) 이름 붙이기 — aria-label, aria-labelledby. 시각적으로는 아이콘·문맥이 이름을 대신하지만 접근성 트리에는 텍스트가 필요할 때:

html
<button aria-label="닫기">
  <svg aria-hidden="true"><!-- X 아이콘 --></svg>
</button>
<nav aria-label="페이지네이션"><!-- nav가 여러 개일 때 구분 --></nav>

장식용 아이콘·이미지는 aria-hidden="true"(또는 alt="")로 트리에서 제외한다. 반대로 alt 텍스트 판단 기준: 이미지가 전달하는 정보를 쓰는 것이지 이미지를 묘사하는 것이 아니다. 로고라면 "회사명"이지 "파란색 원형 로고"가 아니다.

이름이 어디서 오는지는 표준 알고리즘(accessible name computation)이 정한다. 우선순위만 기억하면 실무의 대부분이 풀린다: aria-labelledby > aria-label > 네이티브 연결(<label>, alt, 텍스트 콘텐츠) > title. 여기서 도출되는 함정 두 가지 — 첫째, aria-label은 보이는 텍스트를 보완하는 게 아니라 대체한다. <button aria-label="전송하기">보내기</button>의 이름은 "보내기"가 아니라 "전송하기"다. 둘째, 그래서 보이는 라벨과 접근 이름이 어긋나면 음성 제어 사용자가 화면의 글자("보내기 클릭")로 명령해도 매칭에 실패한다 — WCAG는 이를 별도 기준(Label in Name, 2.5.3)으로 금지하며, 규칙은 "접근 이름은 보이는 라벨을 포함해야 한다"이다. 텍스트가 이미 보이는 요소에는 aria-label을 얹지 않는 것이 안전한 기본값이다.

2) 상태 알리기 — aria-expanded, aria-selected, aria-current 등. 시각적으로는 화살표 방향·배경색으로 전달되는 상태를 트리에 반영한다. JS로 UI 상태를 바꿀 때 함께 갱신해야 한다는 것이 핵심 규율이다.

3) 커스텀 위젯 — HTML에 대응물이 없는 패턴. 탭, 콤보박스, 트리 뷰처럼 네이티브 요소가 없는 UI가 ARIA의 본래 존재 이유다. 드롭다운 메뉴로 전체 그림을 보면:

html
<button id="menu-btn" aria-expanded="false" aria-controls="menu">
  옵션
</button>
<ul id="menu" role="menu" hidden>
  <li role="menuitem" tabindex="-1">복제</li>
  <li role="menuitem" tabindex="-1">삭제</li>
</ul>

<script>
  const btn = document.getElementById('menu-btn');
  const menu = document.getElementById('menu');
  btn.addEventListener('click', () => {
    const open = btn.getAttribute('aria-expanded') === 'true';
    btn.setAttribute('aria-expanded', String(!open)); // 상태를 트리에도 반영
    menu.hidden = open;
    if (!open) menu.querySelector('[role="menuitem"]').focus(); // 포커스 이동
  });
</script>

버튼(네이티브) + 상태(aria-expanded) + 역할(role=menu/menuitem) + 포커스 이동까지가 한 세트다. 화살표 키 순회 등 위젯별 기대 동작의 표준 답안은 W3C의 APG(ARIA Authoring Practices Guide) 패턴 카탈로그에 있다 — 커스텀 위젯을 만들 때 처음부터 설계하지 말고 APG의 해당 패턴을 그대로 따르는 것이 정석이다.

키보드: 두 번째 인터페이스의 입력 장치 ​

스크린 리더 사용자, 운동 장애 사용자, 파워 유저의 공통 입력은 키보드다. 검증 기준은 명확하다 — 마우스를 치우고 Tab/Shift+Tab/Enter/Space/화살표만으로 모든 기능을 완주할 수 있는가.

동작 모델: 포커스는 문서 내 포커스 가능 요소들을 DOM 순서로 순회한다. 네이티브 인터랙티브 요소(a[href], button, input 등)는 기본 포커스 가능이고, tabindex가 이를 조정한다:

  • tabindex="0" — 순회에 포함 (div로 위젯을 만들 때)
  • tabindex="-1" — Tab 순회에서 제외하되 JS focus()로는 이동 가능 (모달 열릴 때 제목으로 포커스 보내기, 위젯 내부 화살표 순회 항목)
  • tabindex="1" 이상 — 쓰지 않는다. 문서 순서와 다른 순회 순서는 예측 불가능한 UX를 만든다. 순서가 이상하면 DOM 순서를 고친다.

실무 규율 세 가지:

  • 포커스 표시를 죽이지 않는다. outline: none의 해악과 :focus-visible 해법은 1-6에서 다뤘다. 포커스 링이 없는 페이지에서 키보드 사용자는 자기가 어디 있는지 모른다.
  • 포커스를 관리한다. 모달을 열면 포커스를 모달 안으로 보내고, 닫으면 열었던 버튼으로 되돌린다. 모달이 열린 동안 Tab이 뒤 배경으로 새지 않게 가둔다(focus trap). 최신 <dialog> 요소의 showModal()은 이 상당 부분을 내장하고 있다.
  • 보이지 않는 것은 포커스도 받으면 안 된다. opacity: 0이나 화면 밖으로 밀어 숨긴 메뉴가 여전히 Tab에 걸리면, 키보드 사용자는 보이지 않는 요소들을 유령처럼 통과해야 한다. display: none/hidden 속성은 트리에서도 제외되므로 안전하다.

색과 대비: 최소한의 시각 규칙 ​

디자인 영역이지만 개발자가 걸러야 할 최소 규칙 두 가지. WCAG AA 기준 텍스트 대비 4.5:1(큰 텍스트 3:1) — 회색조 플레이스홀더나 흐린 캡션이 단골 위반 지점이며, DevTools에서 색상 피커를 열면 대비율과 AA/AAA 통과 여부가 바로 표시된다. 그리고 색만으로 정보를 전달하지 않는다 — 오류 필드를 빨간 테두리로만 표시하면 색각 이상 사용자를 배제한다. 아이콘·텍스트를 병행한다.

검증: 자동화가 잡는 것과 못 잡는 것 ​

  • 자동 검사 — Lighthouse(Accessibility 카테고리)와 그 엔진인 axe(DevTools 확장)는 대비 부족, 이름 없는 버튼, alt 누락, ARIA 오용 같은 정적 오류를 잡는다. 과제와 실무 모두에서 기본 게이트로 삼는다. 단, 자동 검사가 잡는 것은 전체 문제의 일부(통상 30~50%로 알려져 있다)다.
  • 수동 검사 — 키보드 완주 테스트(위 기준), 그리고 스크린 리더로 직접 들어보기. macOS는 VoiceOver(Cmd+F5), Windows는 NVDA(무료)로 자기 페이지의 첫 화면만 들어 봐도 "이름 없는 버튼"이 어떤 경험인지 몸으로 알게 된다.
  • 가장 싼 검증은 마크업 단계에 있다. 시맨틱 HTML을 쓰는 순간 자동 검사 항목의 다수가 통과 상태로 시작한다. 접근성은 마지막에 바르는 코팅이 아니라 1-1·1-2에서 시작된 선택의 누적이다.

실무 관점 ​

  • 접근성 부채는 보안 부채와 비슷하게 움직인다. 나중에 소급하려면 전수 감사가 필요하고, 커스텀 위젯일수록 수리 비용이 크다. 신규 코드의 규율(시맨틱 요소, 키보드 테스트, CI의 axe 검사)로 유입을 막는 것이 유일하게 싼 전략이다.
  • 컴포넌트 라이브러리 선택 기준이 된다. React 생태계(Phase 5)에서 Radix, React Aria 같은 headless 라이브러리의 존재 이유가 바로 APG 패턴의 검증된 구현이다. "직접 만든 드롭다운"과 "검증된 구현" 사이의 판단에는 이 문서에서 본 재구현 비용이 근거가 된다.
  • 동적 콘텐츠 알림. 토스트, 검색 결과 갱신처럼 포커스 밖에서 일어나는 변화는 aria-live 영역으로 알린다(polite가 기본, 긴급할 때만 assertive). SPA에서 페이지 전환 시 아무 발화도 없는 문제와 함께 Phase 5에서 다시 만난다.
  • 흔한 오해: "스크린 리더 사용자는 소수라 ROI가 안 나온다" — 접근성 트리의 소비자는 스크린 리더만이 아니다(검색 엔진, 테스트 자동화, 에이전트). 접근성 좋은 마크업은 테스트 코드에서 role 기반 셀렉터로 조회 가능하다는 형태의 부수 이익이 즉시 발생하며, Testing Library(Phase 6-4)의 권장 쿼리가 정확히 그 방식이다.

더 깊이 ​

"숨긴다"는 요구는 하나가 아니다 — 화면과 접근성 트리라는 두 인터페이스가 있으므로, 숨김에는 2×2의 조합이 있고 각각 표준 도구가 다르다.

방식화면접근성 트리용도
display: none / hidden 속성제외제외완전한 숨김. 닫힌 메뉴·비활성 탭 패널 — 포커스도 함께 사라져 안전
visibility: hidden제외 (공간은 유지)제외자리를 유지한 채 숨기기. 트리에서도 빠진다는 점은 display: none과 같다
aria-hidden="true"유지제외화면에는 보이지만 트리에는 소음인 것 — 장식 아이콘, 이미 텍스트로 전달된 시각 요소
시각적 숨김(.sr-only 클립 패턴)제외유지화면에는 없지만 트리에는 필요한 것 — 아이콘 버튼의 텍스트 라벨, 건너뛰기 링크

주의점 두 가지. aria-hidden="true"는 서브트리 전체를 트리에서 제거하지만 포커스 가능성은 건드리지 않는다 — 안에 포커스 가능한 요소가 남아 있으면 "포커스는 가는데 아무것도 안 읽히는" 최악의 상태가 된다(축이 어긋난 숨김의 전형). 반대 방향의 .sr-only는 display: none으로 구현하면 안 된다(트리에서도 빠지므로) — clip-path와 1px 크기로 시각만 제거하는 검증된 클립 패턴을 그대로 쓴다. 모달 뒤 배경처럼 "보이지만 상호작용 전체를 꺼야 하는" 경우를 위한 도구가 inert 속성으로, 트리 제외·포커스 차단·클릭 차단을 한 번에 수행한다 — 두 축을 함께 다루는 유일한 원시 도구이며 Baseline이다.

정리 ​

  • 브라우저는 DOM에서 role/name/state를 갖는 접근성 트리를 만들고, 보조기술은 화면이 아니라 이 트리를 소비한다. 프론트엔드는 항상 두 개의 인터페이스를 출고한다.
  • 시맨틱 HTML이 우선이고 ARIA는 보정 수단이다. ARIA는 정보만 바꿀 뿐 동작을 주지 않으므로, 네이티브 요소의 재발명은 검증된 동작 전부의 재구현을 의미한다.
  • ARIA의 정당한 용도는 이름(aria-label), 상태(aria-expanded — JS 상태 변경 시 함께 갱신), 그리고 HTML에 없는 커스텀 위젯(APG 패턴을 따른다)이다.
  • 접근 이름은 aria-labelledby > aria-label > 네이티브 연결 > title 순서로 계산되며, aria-label은 보이는 텍스트를 대체하므로 보이는 라벨과 어긋나게 쓰지 않는다.
  • 키보드 완주가 검증 기준이다. tabindex는 0과 -1만 쓰고, 포커스 표시를 유지하며, 모달 등에서 포커스를 명시적으로 관리한다.
  • Lighthouse/axe 자동 검사를 기본 게이트로 하되, 그것이 잡는 것은 일부다. 키보드와 스크린 리더 수동 테스트를 병행한다.

확인 문제 ​

1. 다음 코드는 axe 자동 검사를 통과한다. 그럼에도 스크린 리더+키보드 사용자에게 이 "버튼"이 사실상 사용 불가인 이유를 두 가지 이상 설명하라.

html
<div class="btn" role="button" tabindex="0" aria-label="저장"
     onclick="save()">저장</div>
정답과 해설

role/tabindex/aria-label이 있어 정적 검사는 통과하지만: (1) 키보드 활성화가 없다. 네이티브 button과 달리 Enter/Space 키가 click 이벤트를 발생시키지 않으므로, 포커스는 가는데 누를 수가 없다 — keydown 핸들러를 직접 구현해야 한다. (2) 버튼이라고 안내받았는데 Enter가 안 먹는 경험은 거짓 정보로, role이 없느니만 못하다. 그 밖에 폼 제출 연동, disabled 시맨틱도 없다. 교훈: 자동 검사 통과 ≠ 접근 가능이며, 애초에 <button>을 쓰면 전부 공짜였다.

2. 검색 아이콘(돋보기 SVG)만 있는 버튼이 있다. 접근성 트리 관점에서 무엇이 문제이고, 최소 수정은? 반대로 아이콘 옆에 "검색" 텍스트가 이미 보인다면 SVG에는 무엇을 해야 하는가?

정답과 해설

아이콘만 있는 버튼은 접근성 트리에서 name이 비어 "버튼"이라고만 읽힌다 — 무엇을 하는 버튼인지 알 수 없다. 최소 수정은 <button aria-label="검색"> 으로 이름을 부여하고 SVG에 aria-hidden="true"를 주는 것. 텍스트 "검색"이 이미 버튼 안에 보인다면 name은 텍스트에서 자동으로 오므로 aria-label이 불필요하고, 장식이 된 SVG는 aria-hidden="true"로 트리에서 제외해 중복 발화를 막는다.

3. 모달 다이얼로그를 직접 구현했다. 마우스로는 완벽한데 키보드 사용자로부터 "모달을 열었는데 Tab을 누르니 뒤 페이지의 링크들이 잡힌다"는 제보가 왔다. 무엇이 빠졌고, 최소한 무엇을 해야 하는가? 더 나은 대안은?

정답과 해설

포커스 관리가 통째로 빠졌다. 필요한 것: (1) 모달이 열릴 때 포커스를 모달 내부(제목 또는 첫 컨트롤)로 이동, (2) 열려 있는 동안 Tab 순회를 모달 안에 가두기(focus trap), (3) 배경 콘텐츠를 inert 속성 등으로 비활성화, (4) 닫힐 때 포커스를 열었던 트리거로 복귀, (5) Esc로 닫기. 더 나은 대안은 네이티브 <dialog> 요소의 showModal() — 배경 비활성화(inert 상태), Esc 닫기 등이 내장되어 있어 재구현 비용이 크게 준다.

참고 자료 ​