접근성 내장
AI가 자동으로 접근성을 갖춘 컴포넌트를 생성하도록 설계하기
접근성은 선택이 아닌 기본값입니다. AI가 생성하는 모든 컴포넌트가 접근성을 갖추려면, 디자인 시스템 수준에서 ARIA 패턴을 내장해야 합니다.
접근성 내장 전략
ARIA 패턴 표준화
역할(Role) 매핑
// accessibility/roles.ts
/**
* 컴포넌트-ARIA Role 매핑
* @ai-hint 컴포넌트 생성 시 자동으로 적절한 role 적용
*/
export const componentRoleMap = {
// 인터랙티브
Button: 'button',
Link: 'link',
Checkbox: 'checkbox',
Radio: 'radio',
Switch: 'switch',
Slider: 'slider',
// 컨테이너
Dialog: 'dialog',
AlertDialog: 'alertdialog',
Menu: 'menu',
Listbox: 'listbox',
Combobox: 'combobox',
TabList: 'tablist',
Tab: 'tab',
TabPanel: 'tabpanel',
// 정보 표시
Alert: 'alert',
Status: 'status',
Tooltip: 'tooltip',
ProgressBar: 'progressbar',
// 랜드마크
Navigation: 'navigation',
Main: 'main',
Complementary: 'complementary',
Region: 'region',
} as const상태(State) 속성 패턴
// accessibility/states.ts
interface AriaState {
/** 확장/축소 상태 */
'aria-expanded'?: boolean
/** 선택 상태 (단일 선택) */
'aria-selected'?: boolean
/** 체크 상태 (true/false/mixed) */
'aria-checked'?: boolean | 'mixed'
/** 비활성화 */
'aria-disabled'?: boolean
/** 눌림 상태 (토글 버튼) */
'aria-pressed'?: boolean | 'mixed'
/** 숨김 (스크린리더에서) */
'aria-hidden'?: boolean
/** 현재 항목 */
'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | true
/** 유효하지 않은 입력 */
'aria-invalid'?: boolean | 'grammar' | 'spelling'
/** 필수 입력 */
'aria-required'?: boolean
/** 로딩/진행 중 */
'aria-busy'?: boolean
}관계(Relationship) 속성
// accessibility/relationships.ts
interface AriaRelationship {
/** 설명하는 요소 ID */
'aria-describedby'?: string
/** 레이블하는 요소 ID */
'aria-labelledby'?: string
/** 제어하는 요소 ID */
'aria-controls'?: string
/** 소유하는 요소 ID */
'aria-owns'?: string
/** 활성 자손 요소 ID */
'aria-activedescendant'?: string
/** 에러 메시지 요소 ID */
'aria-errormessage'?: string
}
/**
* 관계 ID 생성 유틸리티
* @ai-hint 컴포넌트 간 ARIA 관계 설정 시 사용
*/
export function createAriaIds(baseId: string) {
return {
root: baseId,
label: `${baseId}-label`,
description: `${baseId}-desc`,
error: `${baseId}-error`,
listbox: `${baseId}-listbox`,
option: (index: number) => `${baseId}-option-${index}`,
}
}키보드 인터랙션 패턴
표준 키보드 패턴
// accessibility/keyboard.ts
/**
* 컴포넌트별 키보드 인터랙션 패턴
* @ai-hint 컴포넌트 구현 시 해당 키 핸들링 필수
*/
export const keyboardPatterns = {
Button: {
Enter: 'activate',
Space: 'activate',
},
Menu: {
Enter: 'select',
Space: 'select',
ArrowDown: 'next',
ArrowUp: 'previous',
Home: 'first',
End: 'last',
Escape: 'close',
Tab: 'close and move focus',
},
Tabs: {
ArrowLeft: 'previous tab',
ArrowRight: 'next tab',
Home: 'first tab',
End: 'last tab',
Enter: 'activate (manual)',
Space: 'activate (manual)',
},
Combobox: {
ArrowDown: 'open or next',
ArrowUp: 'previous',
Enter: 'select',
Escape: 'close',
Home: 'first (open)',
End: 'last (open)',
// 문자 입력: typeahead
},
Dialog: {
Tab: 'cycle focus within',
Escape: 'close',
},
Slider: {
ArrowRight: 'increase',
ArrowUp: 'increase',
ArrowLeft: 'decrease',
ArrowDown: 'decrease',
Home: 'minimum',
End: 'maximum',
PageUp: 'large increase',
PageDown: 'large decrease',
},
} as const포커스 트랩 구현
// components/focus-trap/index.tsx
interface FocusTrapProps {
children: React.ReactNode
/** 트랩 활성화 여부 */
active?: boolean
/** 최초 포커스 요소 선택자 */
initialFocus?: string
/** 비활성화 시 복원할 포커스 요소 */
returnFocus?: boolean
}
/**
* 포커스 트랩 컴포넌트
* @ai-hint Dialog, Modal, Drawer 내부에서 사용
* - Tab 순환 (마지막 → 첫 번째)
* - Shift+Tab 역순환
* - 외부 요소 포커스 방지
*/
export function FocusTrap({
children,
active = true,
initialFocus,
returnFocus = true,
}: FocusTrapProps) {
// 구현 생략
}스크린리더 전용 텍스트
VisuallyHidden 컴포넌트
// components/visually-hidden/index.tsx
/**
* 시각적으로 숨기되 스크린리더에는 노출
* @ai-hint 아이콘 버튼, 복잡한 시각 요소에 레이블 추가 시 사용
*/
export function VisuallyHidden({ children }: { children: React.ReactNode }) {
return (
<span
style={{
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
}}
>
{children}
</span>
)
}
// 사용 예시
<Button>
<SearchIcon aria-hidden="true" />
<VisuallyHidden>검색</VisuallyHidden>
</Button>Live Region 패턴
// components/announcer/index.tsx
interface AnnouncerProps {
/** 알림 메시지 */
message: string
/** 긴급도: polite(대기) | assertive(즉시) */
politeness?: 'polite' | 'assertive'
}
/**
* 스크린리더 알림 컴포넌트
* @ai-hint 동적 콘텐츠 변경, 폼 제출 결과 등에서 사용
*/
export function Announcer({ message, politeness = 'polite' }: AnnouncerProps) {
return (
<div
role="status"
aria-live={politeness}
aria-atomic="true"
className="sr-only"
>
{message}
</div>
)
}색상 대비 검증
대비 비율 기준
| 수준 | 일반 텍스트 | 대형 텍스트 | UI 컴포넌트 |
|---|---|---|---|
| AA | 4.5:1 | 3:1 | 3:1 |
| AAA | 7:1 | 4.5:1 | - |
대형 텍스트 기준
18px 이상 또는 14px Bold 이상
토큰 기반 대비 보장
// tokens/colors.contrast.ts
/**
* 전경-배경 페어링 정의
* @ai-hint 이 페어링만 사용하면 WCAG AA 보장
*/
export const contrastPairs = {
// 기본 텍스트
'foreground.default': {
on: ['background.default', 'background.surface'],
contrast: 7.2, // AAA
},
'foreground.muted': {
on: ['background.default', 'background.surface'],
contrast: 4.5, // AA
},
// 인터랙티브
primary: {
on: ['background.default'],
contrast: 4.5,
foreground: 'foreground.inverted', // 위에 올라갈 텍스트
foregroundContrast: 4.8,
},
destructive: {
on: ['background.default'],
contrast: 4.5,
foreground: 'foreground.inverted',
foregroundContrast: 5.1,
},
// 보더
'border.default': {
on: ['background.default'],
contrast: 3.0, // UI 컴포넌트 기준
},
} as const자동 대비 검사 스크립트
// scripts/check-contrast.ts
import { getContrastRatio } from './utils'
import { colors, contrastPairs } from '../tokens'
function checkAllContrasts() {
const failures: string[] = []
for (const [fg, config] of Object.entries(contrastPairs)) {
for (const bg of config.on) {
const ratio = getContrastRatio(colors[fg], colors[bg])
if (ratio < config.contrast) {
failures.push(`${fg} on ${bg}: ${ratio.toFixed(2)} < ${config.contrast}`)
}
}
}
if (failures.length > 0) {
console.error('Contrast check failed:')
failures.forEach((f) => console.error(` - ${f}`))
process.exit(1)
}
console.log('✓ All contrast ratios pass WCAG criteria')
}접근성 테스트 자동화
axe-core 통합
// tests/accessibility.test.ts
import { axe, toHaveNoViolations } from 'jest-axe'
import { render } from '@testing-library/react'
expect.extend(toHaveNoViolations)
describe('Button Accessibility', () => {
it('기본 버튼은 접근성 위반 없음', async () => {
const { container } = render(
<Button onClick={() => {}}>클릭</Button>
)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
it('아이콘 버튼은 aria-label 필수', async () => {
const { container } = render(
<Button size="icon" aria-label="설정">
<SettingsIcon />
</Button>
)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
})Playwright 접근성 테스트
// e2e/accessibility.spec.ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test.describe('Homepage Accessibility', () => {
test('전체 페이지 WCAG 2.1 AA 준수', async ({ page }) => {
await page.goto('/')
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze()
expect(results.violations).toEqual([])
})
test('키보드로 모든 인터랙티브 요소 접근 가능', async ({ page }) => {
await page.goto('/')
// Tab으로 순회
const focusableElements = await page
.locator('button, a, input, select, textarea, [tabindex="0"]')
.all()
for (const el of focusableElements) {
await page.keyboard.press('Tab')
await expect(el).toBeFocused()
}
})
})AI 기반 접근성 자동화
2025~2026년에 등장한 AI 접근성 도구들은 기존 룰 기반 검사를 넘어, 컨텍스트를 이해하고 수정 코드를 직접 제안하는 단계로 진화했습니다.
주요 도구
- Deque axe DevTools: AI 자동 수정 제안으로 코드에 직접 적용 가능. NLP 기반 컨텍스트 라벨링과 영향도/빈도 기반 우선순위 엔진을 제공합니다.
- BrowserStack Accessibility Issue Detection Agent (2025.10): 인간 지능을 모방하여 전통적 정적 분석이 놓치는 동적 인터랙션 이슈를 탐지합니다.
- mabl: AI 네이티브 에이전트가 기존 E2E 테스트를 접근성 테스트로 재활용하여, 별도 테스트 작성 없이 접근성 커버리지를 확보합니다.
- Percy Visual Review Agent: 시각적 리뷰 시간을 3배 단축하고, 비의미적 변경(레이아웃 시프트, 안티앨리어싱 차이 등)을 40% 자동 필터링합니다.
도구 비교
| 도구 | 접근 방식 | 강점 |
|---|---|---|
| axe DevTools | AI 수정 제안 | 코드 직접 적용 |
| BrowserStack Agent | 인간 지능 모방 | 전통 방법이 놓치는 이슈 |
| mabl | E2E 테스트 재활용 | 기존 테스트 자산 활용 |
| Percy AI | 시각적 리뷰 | 비의미적 변경 필터링 |
자동화의 현재 한계
AI 접근성 도구는 웹 이슈의 약 60%, 모바일 이슈의 30% 미만을 감지합니다. 복잡한 인터랙션 패턴, 인지 접근성, 실제 보조 기술 호환성은 여전히 수동 테스트가 필수입니다. 완전 자동화는 아직 불가능하며, AI 도구는 전문가 리뷰를 보조하는 역할로 활용해야 합니다.