접근성 자동화 전문가
자동화된 테스트, 키보드 탐색, ARIA 패턴, 스크린 리더 최적화 및 포커스 관리를 통해 WCAG 2.2 AA/AAA 규정 준수를 구현하고 강화합니다.
활성화 트리거
다음 경우에 활성화: 접근성 감사 실패, WCAG 규정 준수 요구 사항, 키보드 탐색 손상, 스크린 리더가 콘텐츠를 알리지 않음, axe-core 위반, 모달/다이얼로그 포커스 트랩, aria-* 속성 관련 질문, 탐색 링크 건너뛰기.
다음 경우에는 사용하지 않음: 디자인 수준의 접근성 검토(색상 선택, 레이아웃 결정) -- design-accessibility-auditor를 사용하세요. 순수 색상 대비 확인 -- OKLCH와 함께 css-in-js-architect를 사용하세요.
빠른 시작
- axe-core 실행 --
npx @axe-core/cli http://localhost:3000또는 개발 모드에서@axe-core/react를 통합하여 콘솔 경고를 확인하세요. - 심각한 위반 사항부터 수정 -- 대체 텍스트 누락, 양식 레이블 누락, 대비 부족, 랜드마크 누락.
- 키보드 탐색 구현 -- 모든 대화형 요소는 Tab, Enter, Space, Escape 및 화살표 키로 접근 및 조작 가능해야 합니다.
- 포커스 관리 추가 -- 모달에서 포커스 트랩, 닫을 때 포커스 복원, 경로 변경 시 포커스 관리.
- 스크린 리더로 테스트 -- 실제 검증을 위해 VoiceOver(macOS), NVDA(Windows) 또는 TalkBack(Android)을 사용하세요.
핵심 기능
| 영역 | 기술 | 주요 패턴 |
|---|---|---|
| 자동화 테스트 | axe-core, Lighthouse, jest-axe, Playwright axe | CI/CD 접근성 게이트 |
| 키보드 탐색 | tabindex, onKeyDown, 로빙 tabindex | 화살표 키 탐색, 포커스 그룹 |
| 스크린 리더 | ARIA 역할, 라이브 리전, aria-label | 알림, 상태 변경, 설명 |
| 포커스 관리 | focus-visible, 포커스 트랩, inert 속성 | 모달 포커스 잠금, 링크 건너뛰기, 경로 변경 포커스 |
| 시맨틱 HTML | <main>, <nav>, <article>, <aside> | 랜드마크, 제목 계층, 목록 |
| 양식 | <label>, aria-describedby, aria-invalid | 오류 알림, 필수 필드, 필드셋 |
아키텍처 패턴
패턴 1: CI에서 자동화된 axe-core
// e2e/accessibility.spec.ts (Playwright + axe-core)
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const pages = ['/', '/products', '/checkout', '/account'];
for (const path of pages) {
test(`${path}에 접근성 위반이 없습니다`, async ({ page }) => {
await page.goto(path);
await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) // WCAG 2.2 AA
.analyze();
expect(results.violations).toEqual([]);
});
}
// 개발 모드: React에서 axe-core (콘솔에 위반 사항 표시)
// app/layout.tsx
if (process.env.NODE_ENV === 'development') {
import('@axe-core/react').then((axe) => {
axe.default(React, ReactDOM, 1000);
});
}
패턴 2: 모달을 위한 포커스 관리
import { useEffect, useRef, useCallback } from 'react';
function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen) return;
// 나중에 복원하기 위해 현재 포커스 저장
previousFocusRef.current = document.activeElement as HTMLElement;
// 첫 번째 포커스 가능 요소로 포커스 이동
const container = containerRef.current;
if (!container) return;
const focusable = container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
focusable[0]?.focus();
// 컨테이너 내에서 포커스 트랩
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
};
container.addEventListener('keydown', handleKeyDown);
// 배경 콘텐츠에 inert 사용
const mainContent = document.querySelector('main');
mainContent?.setAttribute('inert', '');
return () => {
container.removeEventListener('keydown', handleKeyDown);
mainContent?.removeAttribute('inert');
previousFocusRef.current?.focus(); // 포커스 복원
};
}, [isOpen]);
return containerRef;
}
패턴 3: 동적 콘텐츠를 위한 ARIA 라이브 리전
// 양식 오류, 로딩 상태 및 업데이트를 스크린 리더에 알림
function useAnnounce() {
const announce = useCallback((message: string, priority: 'polite' | 'assertive' = 'polite') => {
const el = document.getElementById(`aria-live-${priority}`);
if (el) {
el.textContent = ''; // 다시 알림을 트리거하기 위해 먼저 지움
requestAnimationFrame(() => { el.textContent = message; });
}
}, []);
return announce;
}
// 레이아웃에 한 번 마운트:
function AriaLiveRegions() {
return (
<>
<div id="aria-live-polite" aria-live="polite" aria-atomic="true" className="sr-only" />
<div id="aria-live-assertive" aria-live="assertive" aria-atomic="true" className="sr-only" />
</>
);
}
┌─ 접근성 테스트 피라미드 ─────────────────────────────┐
│ │
│ ▲ 수동 스크린 리더 테스트 │
│ ╱ ╲ (VoiceOver, NVDA — 분기별) │
│ ╱───╲ │
│ ╱ ╲ Playwright + axe-core E2E │
│ ╱ E2E ╲ (모든 페이지, CI 게이트) │
│ ╱─────────╲ │
│ ╱ ╲ jest-axe 컴포넌트 테스트 │
│ ╱ 컴포넌트 ╲ (대화형 컴포넌트별) │
│ ╱───────────────╲ │
│ ╱ ╲ ESLint jsx-a11y │
│╱ 정적 린트 ╲ (모든 커밋 시) │
│╲___________________╱ │
└─────────────────────────────────────────────────────┘
안티 패턴
button대신onClick이 있는div-- div는 키보드 상호 작용, 역할, 포커스가 없습니다. 시맨틱<button>또는<a>요소를 사용하세요. div를 사용해야 한다면role="button",tabindex="0", Enter/Space에 대한onKeyDown을 추가하세요.- 모든 것에
aria-label사용 -- 과도한 레이블은 스크린 리더 사용자에게 소음을 발생시킵니다. 가시적인 텍스트 레이블을 선호하고, 가시적인 텍스트가 불가능한 경우에만aria-label을 사용하세요. - 포커스 아웃라인 숨기기 -- 대체 없이
outline: none을 사용하면 키보드 탐색이 보이지 않게 됩니다. 마우스 클릭 시 나타나지 않는 키보드 전용 포커스 표시기를 위해:focus-visible을 사용하세요. - 탐색 링크 건너뛰기 누락 -- 키보드 사용자는 모든 페이지에서 전체 탐색을 탭으로 이동해야 합니다. 첫 번째 포커스 가능 요소로
<a href="#main" class="sr-only focus:not-sr-only">주 콘텐츠로 건너뛰기</a>를 추가하세요. - 라이브 리전이 없는 토스트 알림 -- 스크린 리더는 동적으로 추가된 토스트를 감지할 수 없습니다. 이를 알리기 위해
aria-live="polite"리전을 사용하세요.
품질 체크리스트
- axe-core CI 게이트가 모든 페이지에서 위반 사항 없이 통과
-
eslint-plugin-jsx-a11y가 활성화되고 경고 없음 - 모든 이미지에 설명적인
alt텍스트가 있음 (장식용 이미지는alt="") - 모든 양식 입력에 연결된
<label>요소가 있음 (플레이스홀더 텍스트만 있는 경우 제외) - 제목 계층이 순차적임 (
h1>h2>h3, 건너뛰기 없음) - 페이지 랜드마크 있음:
<main>,<nav>,<header>,<footer> - 탐색 건너뛰기 링크가 첫 번째 포커스 가능 요소임
- 모든 대화형 요소에 포커스 표시됨 (
:focus-visible스타일 정의됨) - 모달이 포커스를 가두고 닫힐 때 포커스를 복원함
- 동적 콘텐츠 변경이
aria-live리전을 통해 알림 - 색상 대비 비율이 WCAG AA 충족 (일반 텍스트 4.5:1, 큰 텍스트 3:1)
- 모든 기능이 키보드만으로 작동 가능 (Tab, Enter, Space, Escape, 화살표 키)