AI로 접근성 점검하고 고치기: WCAG 2.2 기준 자동 검사와 수동 확인 나누기

대비·마크업은 스크립트로, 키보드·스크린리더는 사람이 — 이 사이트에 직접 적용한 점검 결과까지

WCAG 2.2 접근성 점검 썸네일

핵심 요약

  1. WCAG 2.2는 성공 기준 9개(2.4.11 포커스 가림, 2.5.8 최소 타깃 24×24 CSS px 등)를 추가하고 4.1.1 Parsing을 제거했다.
  2. 대비는 일반 글자 4.5:1, 큰 글자 3:1이며 토큰 단계에서 계산해 CI로 막는 것이 회귀가 적다.
  3. alt·이름·라벨·제목 단계는 기계로 판별되지만, 키보드 순서와 스크린리더 맥락은 사람이 확인해야 한다.
  4. 이 사이트의 카테고리 페이지에서 h1→h3 건너뜀을 점검 스크립트로 찾아 sr-only h2를 넣어 고쳤다.

접근성 점검을 AI 코딩 도구에 맡기면 "이미지에 alt를 넣으세요" 같은 일반적인 조언과 함께 수정 코드가 나온다. 이 수정이 맞는지 판단하려면 무엇을 기계로 확인할 수 있고 무엇은 사람이 봐야 하는지부터 나눠야 한다. W3C는 평가 도구 선택 안내에서 도구는 접근성을 판정할 수 없고 판정을 도울 뿐이며, 모든 항목을 자동으로 확인할 수는 없어 사람의 판단이 필요하다고 밝힌다. 이 글은 WCAG 2.2 기준으로 그 경계를 정리하고, 기계로 볼 수 있는 항목을 점검하는 스크립트를 이 사이트에 직접 실행한 결과를 싣는다.

WCAG 2.2에서 새로 생긴 기준

WCAG 2.2는 2.1에 성공 기준 9개를 추가했고, 4.1.1 Parsing을 더 이상 유효하지 않은 기준으로 보고 제거했다. AI에 점검 목록을 만들게 할 때 이 기준을 명시하지 않으면 2.1 기준의 일반 목록이 나오기 쉽다.

번호기준수준점검 방법
2.4.11Focus Not Obscured (Minimum): 포커스된 요소가 다른 콘텐츠에 완전히 가려지지 않음AA고정 헤더·쿠키 배너가 있는 화면에서 Tab 이동
2.4.12 / 2.4.13Focus Not Obscured (Enhanced), Focus AppearanceAAA포커스 표시의 크기·대비 확인
2.5.7Dragging Movements: 드래그 동작에 단일 포인터 대안 제공AA정렬·슬라이더를 클릭만으로 조작
2.5.8Target Size (Minimum): 포인터 대상 최소 24×24 CSS 픽셀(예외 있음)AA아이콘 버튼·인라인 링크 크기 측정
3.2.6Consistent Help: 도움말 위치를 페이지마다 일관되게A여러 페이지에서 문의·도움말 위치 비교
3.3.7Redundant Entry: 이미 입력한 정보를 다시 입력하게 하지 않음A다단계 폼에서 재입력 여부 확인
3.3.8 / 3.3.9Accessible Authentication (Minimum/Enhanced)AA / AAA로그인에서 기억·퍼즐 의존 여부 확인

기계로 확인할 항목과 사람이 볼 항목

항목자동 판별사람 확인AI 활용
색 대비가능(색 값이 정해져 있을 때)이미지 위 글자, 그라데이션대체 색 제안
img alt 유무가능alt 내용이 맥락에 맞는지문맥 기반 alt 초안
링크·버튼 이름 유무가능이름이 동작을 설명하는지aria-label 수정안
제목 단계가능제목이 내용을 요약하는지구조 제안
키보드 순서·포커스 가둠불가필수포커스 관리 코드 점검
스크린리더 맥락불가필수읽기 순서 문제 후보 정리

점검 순서

  1. 디자인 토큰의 글자색·배경색 조합으로 대비를 계산한다. 컴포넌트마다 보는 것보다 토큰 단계에서 한 번에 막는 편이 회귀가 적다.
  2. 빌드된 HTML에 마크업 점검을 실행해 alt, 접근 가능한 이름, 라벨, 제목 단계를 확인한다.
  3. 자동 점검 결과와 WCAG 2.2 신규 기준을 함께 AI에 주고, 컴포넌트 단위 수정 목록을 받는다.
  4. 마우스를 치우고 Tab·Shift+Tab만으로 전체 화면을 순회하며 포커스가 보이는지, 가려지지 않는지, 모달에서 Esc로 닫히고 원래 위치로 돌아오는지 확인한다.
  5. 스크린리더 하나(NVDA, VoiceOver, TalkBack 등)로 제목과 랜드마크를 순회하고, 폼 라벨과 오류 메시지가 함께 읽히는지 확인한다.

1. 디자인 토큰 대비 계산

1.4.3 대비(최소)는 일반 글자 4.5:1, 큰 글자(18pt 이상 또는 14pt 굵게) 3:1을 요구한다. 대비는 (밝은 색 휘도 + 0.05) / (어두운 색 휘도 + 0.05)로 계산한다. 아래 스크립트는 토큰 CSS에서 색을 읽어 실제로 함께 쓰는 조합만 계산한다.

// 사용법: node contrast.mjs tokens.css
// 디자인 토큰 CSS에서 색을 읽어, 실제로 함께 쓰는 글자색·배경색 조합의 대비를 WCAG 2.2 기준으로 계산한다.
import { readFileSync } from 'node:fs';

const css = readFileSync(process.argv[2], 'utf8');
const tokens = Object.fromEntries([...css.matchAll(/--(color-[\w-]+):\s*(#[0-9a-f]{6})/gi)].map((m) => [m[1], m[2]]));

// WCAG 상대 휘도: sRGB 채널을 선형화한 뒤 0.2126R + 0.7152G + 0.0722B
const lum = (hex) => {
  const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
    .map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };

// [용도, 글자 토큰, 배경 토큰, 큰 글자 여부]
const PAIRS = [
  ['본문', 'color-body', 'color-bg', false],
  ['보조 설명', 'color-muted', 'color-bg', false],
  ['날짜·메타', 'color-faint', 'color-bg', false],
  ['검색 패널 안 메타', 'color-faint', 'color-surface-alt', false],
  ['링크', 'color-main-ink', 'color-bg', false],
  ['카테고리 배지', 'color-main-ink', 'color-accent-bg', false],
  ['선택된 태그 칩(흰 글자)', '#ffffff', 'color-main', false],
  ['보조 색 배지(흰 글자)', '#ffffff', 'color-sub', false],
  ['입력 placeholder', 'color-faint', 'color-surface', false],
];
const val = (t) => (t.startsWith('#') ? t : tokens[t]);
for (const [use, fg, bg, large] of PAIRS) {
  const r = ratio(val(fg), val(bg));
  const need = large ? 3 : 4.5;
  console.log(`${r >= need ? 'PASS' : 'FAIL'} ${r.toFixed(2).padStart(5)}:1  ${use} (${fg} / ${bg})`);
}
$ node contrast.mjs assets/css/tokens.css   # 이 사이트의 토큰, 2026-10-09
PASS 14.96:1  본문 (color-body / color-bg)
PASS  8.64:1  보조 설명 (color-muted / color-bg)
PASS  4.92:1  날짜·메타 (color-faint / color-bg)
PASS  4.71:1  검색 패널 안 메타 (color-faint / color-surface-alt)
PASS  6.70:1  링크 (color-main-ink / color-bg)
PASS  6.03:1  카테고리 배지 (color-main-ink / color-accent-bg)
PASS  5.17:1  선택된 태그 칩(흰 글자) (#ffffff / color-main)
PASS  5.70:1  보조 색 배지(흰 글자) (#ffffff / color-sub)
PASS  4.92:1  입력 placeholder (color-faint / color-surface)

모두 통과했지만 color-faint를 연한 배경(color-surface-alt) 위에 쓴 조합은 4.71:1로 기준에 가깝다. 배경색을 조금만 어둡게 바꿔도 미달할 수 있으므로, 이런 조합은 토큰을 바꿀 때 이 스크립트를 CI에서 다시 실행해 막는다.

2. 빌드된 HTML 마크업 점검

아래 스크립트는 img의 alt 유무, 링크·버튼의 접근 가능한 이름, input 라벨, html lang, 제목 단계 건너뜀을 정규식으로 확인하는 부분 검사기다. 실제 서비스에서는 axe 같은 검사 엔진을 쓰는 것이 정확하지만, 어떤 항목이 기계로 판별되는지 보여 주기 위해 의존성 없이 만들었다.

// 사용법: node markup-audit.mjs <html 파일...>
// 빌드된 HTML에서 기계로 판별할 수 있는 접근성 항목만 확인한다(의존성 없는 정규식 기반 부분 검사).
// 키보드 순서·스크린리더 맥락처럼 사람이 봐야 하는 항목은 이 스크립트로 판정할 수 없다.
import { readFileSync } from 'node:fs';

const text = (s) => s.replace(/<[^>]+>/g, '').replace(/&[a-z#0-9]+;/gi, ' ').trim();
for (const file of process.argv.slice(2)) {
  const html = readFileSync(file, 'utf8');
  const issues = [];
  if (!/<html[^>]*\blang="[^"]+"/.test(html)) issues.push('html lang 없음');

  for (const tag of html.match(/<img\b[^>]*>/g) || []) {
    if (!/\balt=/.test(tag)) issues.push(`img alt 없음: ${tag.slice(0, 60)}`);
  }
  // 링크·버튼: 텍스트, aria-label, alt 있는 img 중 하나라도 있어야 이름이 생긴다. aria-hidden 링크는 제외.
  for (const m of html.matchAll(/<(a|button)\b([^>]*)>([\s\S]*?)<\/\1>/g)) {
    const [, tag, attrs, inner] = m;
    if (/aria-hidden="true"/.test(attrs)) continue;
    const named = text(inner) || /aria-label="[^"]+"/.test(attrs) || /<img[^>]*alt="[^"]+"/.test(inner);
    if (!named) issues.push(`${tag} 접근 가능한 이름 없음: <${tag}${attrs.slice(0, 50)}>`);
  }
  for (const m of html.matchAll(/<input\b([^>]*)>/g)) {
    const id = (m[1].match(/\bid="([^"]+)"/) || [])[1];
    const labelled = /aria-label=/.test(m[1]) || (id && new RegExp(`<label[^>]*for="${id}"`).test(html));
    if (!labelled && !/type="hidden"/.test(m[1])) issues.push(`input 라벨 없음: ${m[1].slice(0, 50)}`);
  }
  // 제목 단계 건너뛰기(h1 다음 바로 h3 등)
  const levels = [...html.matchAll(/<h([1-6])\b/g)].map((m) => Number(m[1]));
  for (let i = 1; i < levels.length; i++) {
    if (levels[i] > levels[i - 1] + 1) { issues.push(`제목 단계 건너뜀: h${levels[i - 1]} → h${levels[i]}`); break; }
  }
  console.log(`${issues.length ? 'FAIL' : 'PASS'} ${file}`);
  [...new Set(issues)].forEach((x) => console.log(`  - ${x}`));
}
$ node markup-audit.mjs index.html categories/index.html categories/mcp-integration/index.html posts/supabase-rls-policy-writing-and-testing/index.html
PASS index.html
PASS categories/index.html
FAIL categories/mcp-integration/index.html
  - 제목 단계 건너뜀: h1 → h3
PASS posts/supabase-rls-policy-writing-and-testing/index.html

이 사이트의 카테고리 상세 페이지는 h1(카테고리 이름) 다음에 바로 글 카드의 h3가 나왔다. 화면에서는 문제가 없어 보이지만, 스크린리더 사용자가 제목 목록으로 이동할 때 중간 단계가 빠진 구조가 된다. 카드 목록 앞에 화면에는 보이지 않는 h2("○○ 글 목록", sr-only 클래스)를 넣어 고쳤다.

$ node markup-audit.mjs categories/mcp-integration/index.html categories/ai-coding-assistant/index.html   # 수정 후
PASS categories/mcp-integration/index.html
PASS categories/ai-coding-assistant/index.html

AI에 수정을 맡길 때의 범위

수정은 화면이 아니라 Button, Input, Modal 같은 기본 컴포넌트에 넣는다. 같은 버튼이 여러 화면에 흩어져 있으면 한 곳을 고쳐도 다른 곳이 남기 때문이다. AI에는 "이 파일의 IconButton만 고쳐라, 접근 가능한 이름은 필수 prop으로 만들어라"처럼 범위를 좁혀 요청한다. 아이콘만 있는 버튼은 아래처럼 이름을 주고 아이콘은 보조기기에서 숨긴다.

<button type="button" aria-label="검색 열기">
  <svg aria-hidden="true" focusable="false">...</svg>
</button>

AI가 만든 수정안도 다시 확인한다. aria-label에 "버튼" 같은 역할 이름을 넣으면 스크린리더가 역할을 두 번 읽고, 장식용이 아닌 이미지에 aria-hidden을 붙이면 정보가 사라진다. 이 사이트의 헤더 검색 아이콘도 같은 방식(aria-label="글 검색" + 아이콘 aria-hidden)으로 만들었다. 화면 구조와 라우팅을 함께 손봐야 한다면 Server·Client Components 경계 설계를, 성능 지표와 함께 볼 때는 Core Web Vitals 점검 순서를 참고한다.

회귀를 막는 방법

  • 토큰 대비 계산과 마크업 점검을 PR마다 CI에서 실행하고, 실패하면 병합을 막는다.
  • 핵심 화면의 키보드 이동 경로를 E2E 테스트(예: Playwright)로 고정한다.
  • 새 컴포넌트는 접근 가능한 이름을 필수 prop으로 받는다.
  • 릴리스마다 대표 화면 몇 개는 스크린리더로 사람이 직접 확인한다. 자동 점검이 통과해도 이 단계는 남긴다.

초보자가 자주 실수하는 포인트

  • 자동 검사 점수가 높으면 접근성이 확보됐다고 판단한다.
  • 색 대비를 화면마다 따로 고쳐 토큰이 바뀔 때 다시 깨진다.
  • aria-label에 "버튼" 같은 역할 이름을 중복으로 넣는다.
  • outline을 지워 포커스 표시를 없애고 대체 스타일을 넣지 않는다.

체크리스트

  • WCAG 2.2 신규 기준을 점검 목록에 넣었다.
  • 토큰 대비 계산을 CI에서 실행한다.
  • 빌드된 HTML로 alt·이름·라벨·제목 단계를 점검했다.
  • Tab만으로 전체 화면을 순회해 포커스 표시와 가림 여부를 확인했다.
  • 스크린리더로 대표 화면을 확인했다.

자주 묻는 질문

자동 검사 도구로 어디까지 확인할 수 있나요?

대비, alt 유무, 라벨 연결, 제목 단계처럼 규칙이 명확한 항목은 기계로 확인할 수 있습니다. W3C도 도구는 판정을 도울 뿐 접근성을 판정할 수 없고 사람의 판단이 필요하다고 설명합니다.

WCAG 2.2의 최소 타깃 크기는 모든 버튼에 적용되나요?

2.5.8은 포인터 대상이 최소 24×24 CSS 픽셀이어야 한다고 정하지만, 대상 사이 간격이 충분한 경우나 문장 속 인라인 링크 등 예외가 있습니다. 적용 전에 W3C 명세의 예외 조건을 확인하세요.

참고 자료 · 검증 기준

위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.

이 글은 위 참고 자료를 바탕으로 정리했으며, 내용은 운영 과정에서 순차적으로 보완될 수 있습니다.