라이브러리
dequelabs/axe-core · 제작·유지보수 Deque Systems · 라이선스 MPL-2.0 · 버전 4.12.1(package.json 기준) · 런타임 의존성 0개(전부 빌드 시 axe.js 한 파일로 번들) · 규칙 파일 105개(lib/rules/*.json, 활성 100개) · 테스트 파일 654개 · 최신 커밋 9a779f1, 2026-07-27)
axe-core는 브라우저의 DOM을 직접 들여다보는 접근성 자동 검사기다. axe.run() 한 줄을 페이지 위에서 실행하면, 지금 화면에 그려진 요소들을 규칙(rule) 100여 개에 대고 하나씩 확인한다. 각 규칙은 "어떤 요소를 볼지(selector) → 그 요소가 이 규칙 대상이 맞는지(matches) → 실제 조건을 만족하는지(checks)"라는 3단 구조로 짜여 있고, 그 결과가 모여 violations(위반) · passes(통과) · incomplete(불확실, 사람이 봐야 함) · inapplicable(해당 요소 없음) 4개 배열로 반환된다.
중요한 건 axe-core 혼자서는 아무 화면도 보여주지 않는다는 점이다. npm으로 설치해 axe.js를 페이지에 넣고 콘솔이나 테스트 코드에서 axe.run()을 호출해야 결과(JSON)를 받는다. 그 JSON을 예쁘게 그려주는 일은 Lighthouse·axe DevTools·Playwright 리포터 같은 상위 도구들의 몫이다.
axe-core는 체크리스트(규칙 100여 개)를 들고 건물(웹페이지)의 방(DOM 요소) 하나하나를 돈다. "이 문에 손잡이가 있는가", "이 계단에 손잡이가 있는가"처럼 정해진 항목만 확인하고, 각 항목을 합격 / 불합격 / "여기는 사람이 직접 봐야 함" / "이 방에는 해당 없음"으로 표시한다.
점검관은 보고서를 예쁘게 꾸며주지 않는다. 체크 결과 목록만 건네줄 뿐이고, 그걸 그래프로 그리거나 담당자에게 전달하는 건 다른 사람(다른 도구)의 일이다.
axe-core는 2016년 첫 릴리스 이후 꾸준히 쓰여온 프로젝트라 "신생 트렌드"는 아니다. 그런데도 최근 다시 주목받는 이유는 아래 세 갈래로 정리된다.
유럽연합의 웹 접근성 지침(EN 301 549), 미국의 ADA(장애인차별금지법) 관련 소송 증가, 한국의 웹 접근성 품질인증 요구 등으로 기업들이 "우리 사이트가 WCAG를 지키는지"를 실제로 자동화된 코드로 증명해야 하는 상황이 늘었다. axe-core의 규칙 태그 체계에는 wcag2a·wcag21aa·section508·EN-301-549 같은 국제 표준 코드가 직접 박혀 있어, "이 코드가 어떤 법적 기준에 대응하는지"를 그대로 추적할 수 있다.
구글 크롬 개발자도구에 내장된 Lighthouse의 접근성 감사가 실제로는 axe-core 기반 규칙을 가져다 쓰고, Deque 자체 상용 제품(axe DevTools)은 물론 @axe-core/playwright·@axe-core/react·webdriverjs·puppeteer용 공식 래퍼까지 전부 이 저장소 하나의 axe.core 엔진을 감싼 것이다. "접근성 자동 검사"라는 카테고리에서 이 저장소가 사실상 공용 코어 역할을 한다.
AI 코딩 도구가 UI 컴포넌트를 빠르게 찍어내는 지금, "빠르게 만든 코드가 스크린리더 사용자·저시력 사용자에게도 쓸 수 있는가"를 사람이 매번 손으로 확인하기는 현실적으로 어렵다. axe-core처럼 CI 파이프라인에 넣어 자동으로 회귀를 잡는 엔진의 필요성이 오히려 더 커졌다.
Lighthouse·Pa11y·IBM Equal Access 같은 다른 접근성 도구들도 완전히 독자적인 검사 로직을 새로 짜기보다, WCAG 규칙 해석과 DOM 순회라는 어려운 부분은 axe-core 같은 검증된 엔진에 기대는 흐름이 강하다(대안 비교 절에서 상세히 다룬다). 그만큼 "규칙 엔진 자체"가 잘 만들어져 있으면 그 위 생태계 전체가 혜택을 본다.
접근성 문제(이미지에 설명 없음, 색 대비 부족, 폼 입력에 라벨 없음 등)는 전문 지식이 있어야 눈에 띄는 경우가 많고, 사람이 수동으로 훑으면 느리고, 사람마다 결과가 다르고, 매번 반복해야 하는 3중고가 있다. axe-core는 이 중 "기계적으로 판단 가능한 부분"을 명확히 잘라내 자동화한다.
axe.run() 한 줄이면 화면에 보이는 모든 요소를 규칙 100여 개에 대해 검사한다. 사람이 페이지를 열 때마다 눈으로 훑을 필요 없이, 단위 테스트·통합 테스트 안에 끼워 넣어 매 커밋마다 자동으로 돌릴 수 있다. README의 "Getting started" 예시처럼 results.violations.length가 있으면 테스트를 실패시키는 식으로 CI에 바로 연결된다.
많은 접근성 문제는 기계가 100% 확신할 수 없다(예: 이미지의 대체 텍스트가 있긴 한데 그 내용이 적절한지는 사람이 봐야 안다). axe-core는 이런 경우를 억지로 통과·실패로 우기지 않고 incomplete(불확실)라는 별도 칸에 담아 "여기는 사람이 확인하라"고 명시적으로 신호를 준다. 이것이 4번째 결과 분류가 있는 근본 이유다(5번 섹션에서 소스로 자세히 짚는다).
각 규칙 JSON에는 wcag2a·wcag21aa·best-practice·section508·EN-301-549 같은 태그가 붙어 있어, "이 위반이 WCAG 2.1 AA 기준 위반인지, 그냥 권장 사항(모범 사례)인지"를 코드 레벨에서 구분할 수 있다. 회사 정책이 "WCAG 2.1 AA까지만 강제한다"면 runOnly 옵션으로 그 태그만 골라 돌릴 수 있다.
axe-core의 소스는 lib/ 아래 core(실행 엔진), rules(규칙 정의), checks(판정 함수), commons(공용 로직 — 색·DOM·텍스트 등), standards(ARIA/HTML 명세 데이터) 다섯 축으로 나뉜다. 전체 흐름을 한 장으로 보면 이렇다.
이 구조에서 가장 중요한 관찰은, "규칙(rule)"과 "체크(check)"가 분리돼 있다는 점이다. 규칙은 "어떤 요소를, 어떤 체크 조합으로 판정할지"를 정의한 선언(JSON)일 뿐이고, 실제 판정 코드(진짜 로직)는 체크 쪽에 있다. 체크는 여러 규칙에서 재사용된다 — 예를 들어 aria-label 체크는 이름을 요구하는 규칙 여러 개에서 공유된다.
이 섹션이 이 문서에서 가장 두꺼운 이유는, axe-core를 "그냥 쓰는" 사람과 "왜 이렇게 결과가 나오는지 이해하는" 사람의 차이가 전부 여기 있기 때문이다. 실제 소스 파일과 함수명을 하나씩 짚는다.
lib/rules/color-contrast.json가장 유명한 규칙 하나를 실제 파일 그대로 보자(주석은 설명용으로 추가).
{
"id": "color-contrast", // 규칙 고유 ID
"impact": "serious", // 위반 시 심각도
"matches": "color-contrast-matches", // 이 규칙이 적용될 노드를 거르는 함수
"excludeHidden": false,
"tags": [
"cat.color", "wcag2aa", "wcag143", // WCAG 2 AA / 기준 1.4.3
"TTv5", "EN-301-549", "ACT"
],
"actIds": ["afw4f7", "09o5cg"], // W3C ACT 규칙 대응 ID
"metadata": {
"description": "...",
"help": "Elements must meet minimum color contrast ratio thresholds"
},
"all": [],
"any": ["color-contrast"], // any 목록에 체크 1개
"none": []
}
selector 필드가 없으면 lib/core/base/rule.js의 Rule 생성자가 this.selector = spec.selector || '*'로 기본값을 채운다. 즉 규칙 JSON은 "어떤 요소(selector) + 어떤 필터(matches) + 어떤 체크 묶음(any/all/none)"이라는 선언일 뿐이고, 실제 실행은 자바스크립트 코드(Rule.prototype)가 담당한다.
gather → matches → runChecks
lib/core/base/rule.js의 Rule.prototype.gatherAndMatchNodes가 실제 흐름을 보여준다.
Rule.prototype.gatherAndMatchNodes = function (context, options) {
let nodes = this.gather(context, options); // selector 로 후보 수집
return nodes.filter(node => {
return this.matches(node.actualNode, node, context); // matches 함수로 필터
});
};
그 뒤 Rule.prototype.run은 살아남은 노드마다 ['any', 'all', 'none'].forEach(type => ...)로 세 목록을 각각 runChecks에 넘겨 병렬 실행하고, 노드별로 { any: [...], all: [...], none: [...] } 형태의 원시 결과를 만든다. runChecks 내부에서는 각 체크의 check.run(node, option, context, resolve, reject)를 호출하는데, 이 check.run이 바로 다음 항목이다.
lib/core/base/check.js의 체크 실행은 checkHelper가 만든 this(data()·relatedNodes()·async() 메서드 제공) 컨텍스트 안에서 evaluate 함수를 돌린다. evaluate가 true를 반환하면 통과, false면 실패, undefined를 반환하면 "확신 못 함"으로 취급된다 — 이 세 번째 값이 바로 incomplete가 생기는 근원지다.
세 목록의 개별 결과를 노드 하나의 최종 판정으로 합치는 코드가 lib/core/utils/aggregate-checks.js다. 여기서 핵심은 결과값을 우선순위 숫자로 바꿔 최댓값을 취하는 산술이다.
const checkMap = [];
checkMap[PASS_PRIO] = true; // 우선순위 1
checkMap[CANTTELL_PRIO] = null; // 우선순위 2
checkMap[FAIL_PRIO] = false; // 우선순위 3
// none 타입은 pass/fail 을 뒤집는다 (있으면 실패인 규칙이므로)
if (type === 'none') {
if (check.priority === PASS_PRIO) check.priority = FAIL_PRIO;
else if (check.priority === FAIL_PRIO) check.priority = PASS_PRIO;
}
priorities.all = max(all 체크들의 priority, 0 초기값)
priorities.none = max(none 체크들의 priority, 0 초기값)
priorities.any = min(any 체크들의 priority, 4) % 4 // any는 "가장 낮은(가장 잘 통과한)" 것 채택
nodeResult.priority = max(all, none, any) // 셋 중 가장 나쁜(높은) 것이 최종 판정
any는 "하나만 통과해도 됨"이므로 가장 낮은 우선순위(가장 좋은 결과)를 찾고, all과 none은 "하나라도 걸리면 안 됨"이므로 가장 높은 우선순위(가장 나쁜 결과)를 찾는다. 그리고 세 그룹 중에서도 다시 최댓값을 취해 "하나라도 나쁘면 전체가 나쁘다"는 논리를 관철한다. 이 코드 한 파일이 "규칙 하나 = selector + matches + checks 조합"이라는 아키텍처의 실제 심장이다.
lib/core/constants.jsviolations/passes/incomplete/inapplicable이라는 이름이 어디서 나오는지, 소스에 그대로 있다.
const definitions = [
{ name: 'NA', value: 'inapplicable', priority: 0, group: 'inapplicable' },
{ name: 'PASS', value: 'passed', priority: 1, group: 'passes' },
{ name: 'CANTTELL', value: 'cantTell', priority: 2, group: 'incomplete' },
{ name: 'FAIL', value: 'failed', priority: 3, group: 'violations' }
];
lib/core/utils/aggregate-result.js는 각 노드 결과를 이 우선순위에 따라 4개 배열 중 하나로 나눈다. 에러가 났으면 강제로 incomplete, 결과가 NA면 inapplicable, 그 외엔 해당 그룹 배열에 내용이 있는지로 판단한다.
공항 보안 검색대의 4가지 도장을 떠올리면 쉽다. "통과"(passes), "반입 금지 확정"(violations), "다시 열어봐야 함 — 담당자가 눈으로 확인"(incomplete), "이 검색대에서 다룰 물건이 아님, 예를 들어 액체가 아닌 가방"(inapplicable). 검색대는 애매하면 억지로 통과·금지를 결정짓지 않고 "확인 필요" 도장을 따로 찍는다 — 이게 incomplete가 왜 중요한지의 본질이다.
많은 팀이 results.violations.length === 0만 확인하고 results.incomplete는 아예 안 본다. 하지만 axe-core README가 명시하듯 "자동으로 찾을 수 있는 WCAG 문제는 평균 57%뿐"이며, incomplete는 "기계가 확신할 수 없어 일부러 판정을 유보한" 항목이다. 즉 violations가 0이어도 접근성이 완벽하단 뜻이 아니다 — incomplete를 사람이 검토해야 진짜 커버리지가 올라간다.
color-contrast 체크의 핵심 수학은 lib/commons/color/get-contrast.js 한 파일에 있다.
function getContrast(bgColor, fgColor) {
if (!fgColor || !bgColor) return null;
if (fgColor.alpha < 1) {
fgColor = flattenColors(fgColor, bgColor); // 반투명이면 배경과 합성
}
const bL = bgColor.getRelativeLuminance(); // 상대 휘도(밝기)
const fL = fgColor.getRelativeLuminance();
return (Math.max(fL, bL) + 0.05) / (Math.min(fL, bL) + 0.05); // WCAG 공식
}
이건 WCAG 2 명세에 나오는 상대 휘도 대비 공식 그 자체다. 이렇게 구한 비율은 has-valid-contrast-ratio.js에서 글자 크기·굵기에 따라 4.5:1(작은 글자) 또는 3:1(큰 글자·굵은 글자) 기준과 비교된다. 실제로는 이 앞에 get-foreground-color.js·get-background-color.js·flatten-colors.js·stacking-context.js 같은 파일들이 "이 텍스트의 진짜 전경색·배경색이 뭔지"(그림자, 겹친 레이어, 투명도까지 고려)를 먼저 알아내는 훨씬 복잡한 선행 작업을 한다 — 색상 연산 자체는 devDependency인 colorjs.io 라이브러리가 담당한다.
README가 직접 명시한다 — "jsdom에 대한 지원은 제한적이며, 특히 color-contrast 규칙은 jsdom에서 동작하지 않는 것으로 알려져 있다." 실제 컴퓨티드 스타일·레이어링 렌더링이 필요한 체크라 진짜 브라우저 렌더 엔진이 있어야 한다. Node 스크립트에서 axe-core를 돌릴 때 이 규칙만 유독 안 잡히면 당황하지 말 것 — 설계상 한계다. (실제로 최신 커밋도 "jsdom 30이 Node 20 지원을 끊어서 테스트 매트릭스를 손봤다"는 내용이라, 이 jsdom 경계는 지금도 계속 관리 중인 영역이다.)
axe-core는 규칙·체크가 진짜 브라우저 Node 객체를 직접 쥐지 않고, lib/core/base/virtual-node/의 AbstractVirtualNode를 상속한 VirtualNode(진짜 DOM용)·SerialVirtualNode(직렬화된 가짜 DOM용, 다른 프레임에서 넘어온 데이터를 재구성할 때 사용)로 감싸 쓴다. hasClass() 같은 공통 헬퍼를 여기 한 곳에 두고, "노드가 진짜 DOM인지 프레임 너머에서 온 데이터인지"를 규칙 코드가 신경 쓰지 않아도 되게 만든다.
lib/core/base/context.js는 select('frame, iframe', this)로 현재 범위 안의 모든 프레임을 찾고, pushUniqueFrame()이 화면에 보이지 않는(스크린리더에 노출 안 되는) iframe은 건너뛰며 나머지를 context.frames 큐에 쌓는다. 각 프레임은 createFrameContext()로 별도 Context를 만들어 재귀적으로 같은 파이프라인을 탄다. README의 매니페스토에 있는 "무한 깊이의 iframe을 지원한다"는 문장이 정확히 이 코드로 구현돼 있다.
같은 창(window) 안의 프레임은 lib/core/utils/respondable.js가 postMessage 기반으로 부모↔자식 프레임 간에 결과를 주고받는다. 반면 Playwright·Selenium처럼 브라우저를 외부에서 원격 조종하는 자동화 도구는 postMessage 왕복이 번거로우므로, lib/core/public/run-partial.js + finish-run.js라는 별도 경로를 쓴다 — 각 프레임에서 axe.runPartial()을 개별 실행해 부분 결과를 얻고, 최상위에서 axe.finishRun()으로 그 부분 결과들을 한 번에 병합한다. "같은 결과를 얻는 두 가지 경로"가 실제로 코드에 나뉘어 있다는 것 자체가 이 엔진이 다양한 실행 환경(브라우저 내부 vs 자동화 드라이버)을 어떻게 다르게 다루는지 보여주는 좋은 예다.
include/exclude 선택자, 프레임 목록 등). axe.run()에 아무것도 안 주면 문서 전체가 기본 Context가 된다.
package.json을 열어보면 놀라운 점이 하나 있다 — dependencies 필드가 아예 없다. npm으로 axe-core를 설치해도 딸려오는 런타임 패키지가 0개다. 대신 필요한 라이브러리는 전부 빌드 시점에 최종 산출물(axe.js/axe.min.js) 안으로 번들된다.
| 번들 내장 라이브러리 | 역할 |
|---|---|
colorjs.io | 색 대비 계산의 색 공간 연산(휘도, RGB 변환) |
css-selector-parser | 사용자가 준 CSS 선택자를 파싱 |
@deque/dot | doT 템플릿 엔진 — 체크 실패 메시지에 변수 끼워넣기 |
emoji-regex | 텍스트 안의 이모지를 인식(대체 텍스트 판정 등에 사용) |
memoizee | 같은 계산을 반복하지 않도록 메모이제이션(결과 재사용) |
core-js-pure | 구형 브라우저(IE11 등)를 위한 Array.from 등 폴리필 |
이 목록은 lib/core/imports/index.js에 그대로 나열돼 있고, 전부 package.json에는 devDependencies로만 등록돼 있다. 즉 "버전의 진실원"은 이 저장소의 package.json의 version 필드(현재 4.12.1) 하나뿐이며, 이 값이 빌드 스크립트를 통해 axe.js 파일 맨 위 주석(/*! axe v4.12.1 ...*/)과 각 규칙 도움말 URL(dequeuniversity.com/rules/axe/4.12/...)에 그대로 찍힌다.
인터넷에 떠도는 오래된 자료·튜토리얼은 axe-core가 Grunt로 빌드된다고 설명하는 경우가 많다(실제로 예전 버전은 Gruntfile.js를 썼다). 하지만 지금 소스에는 Gruntfile이 없고, build/run-build.mjs 파일 맨 위 주석에 이렇게 적혀 있다: /** axe-core build driver (replaces Gruntfile.js). */. 즉 Grunt는 esbuild 기반의 자체 빌드 스크립트로 완전히 대체됐다. "Grunt task를 어떻게 커스터마이징하나"를 찾으러 왔다면 이 저장소에서는 헛수고다 — build/run-build/ 아래의 커맨드 파서·풀빌드·번역·검증 로직을 봐야 한다.
최종 axe.js는 lib/intro.stub(라이선스 헤더 + IIFE 시작)과 lib/outro.stub(IIFE 종료)으로 모든 소스를 감싼 즉시실행함수(IIFE) 한 덩어리다. <script src="axe.js"> 태그 하나만 넣으면 전역에 window.axe가 생기는 이유가 여기 있다 — 모듈 로더가 없던 시절부터 이어진, 어떤 환경에서도 그냥 붙여넣으면 도는 설계다.
| 영역 | 내용 |
|---|---|
| 언어 | 순수 JavaScript(ESM 소스, esbuild로 번들) + 타입 정의 axe.d.ts(TypeScript 사용자용) |
| 빌드 | esbuild 기반 build/run-build.mjs(Grunt 대체) — build/configure/translate/validate 서브커맨드 |
| 테스트 | @web/test-runner(단위) · Mocha + Selenium/Chromedriver(통합) · jsdom(Node 환경 일부 테스트) |
| 다국어 | locales/*.json 20개 언어(한국어 ko.json 포함) — 규칙 메시지만 번역, 엔진 자체는 언어 무관 |
| 배포 | npm 패키지 axe-core(files: axe.js, axe.min.js, axe.d.ts, locales/) · release.yml이 commit-and-tag-version으로 버전 태깅 |
window.axe)를 노출하는 오래된 표준 패턴이다.| 항목 | 요구사항 |
|---|---|
| 설치 | npm install axe-core --save-dev — 런타임 의존성 0개라 설치가 매우 빠르고 가볍다 |
| 페이지에 삽입 | <script src="node_modules/axe-core/axe.min.js"></script> — 검사하려는 모든 iframe 안에도 넣어야 한다 |
| 실행 | axe.run().then(results => ...) — Promise 기반, 콜백 방식도 지원 |
| Node.js 개발 환경 | engines.node는 >=4로 넓게 열려 있지만, 실제 개발·테스트는 최신 LTS + jsdom 조합을 요구(최근 커밋에서 jsdom 30이 Node 20 지원을 끊어 테스트 매트릭스를 조정) |
| 지원 브라우저 | Edge 40+, Chrome 42+, Firefox 38+, Safari 7+. IE11은 지원 종료(DEPRECATED) 명시. CI에서는 Chrome·Firefox만 매 PR마다 자동 테스트 |
| jsdom(Node 안의 가짜 브라우저) | 제한적 지원 — color-contrast 규칙은 공식적으로 동작 보장 안 됨 |
실무에서는 axe-core를 <script> 태그로 직접 넣는 경우보다, @axe-core/playwright나 @axe-core/webdriverio 같은 공식 래퍼로 시작하는 편이 훨씬 편하다. 이 저장소는 "엔진"만 제공하고, 저 래퍼들이 "브라우저에 스크립트 주입 + 실행 + 결과 리포트"까지 대신 해준다.
가장 흔한 실전 흐름은 "기능 테스트를 짜는 김에 접근성 검사도 같이 돌리는" 방식이다. 아래는 README에 있는 최소 예시를 뼈대로 한 사용 순서다.
axe
.run()
.then(results => {
if (results.violations.length) {
throw new Error('접근성 문제 발견'); // 위반이 있으면 테스트 실패 처리
}
})
.catch(err => {
console.error('실행 중 오류:', err.message);
});
초보자를 위한 한 줄 풀이: "지금 화면에 대고 검사를 돌려서(run), 다 끝나면(then) 위반 목록(violations)이 하나라도 있으면 에러를 던지고, 검사 자체가 실패하면(catch) 그 이유를 콘솔에 찍어라"는 뜻이다. 복사해서 테스트 파일 안에 그대로 붙여도 동작한다.
const { violations, incomplete, passes, inapplicable } = results;
console.log('위반(반드시 고쳐야 함):', violations.length);
console.log('불확실(사람이 확인):', incomplete.length); // 여기를 무시하지 말 것
console.log('통과:', passes.length);
console.log('해당없음:', inapplicable.length);
axe.run(document, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] }, // WCAG 2 A/AA만
exclude: [['#legacy-widget']] // 낡은 위젯은 검사 제외
});
runOnly에 태그를 넘기면, 그 태그가 붙은 규칙 JSON만 골라 Audit이 구성된다(4번 섹션의 태그 체계가 바로 이렇게 실전에서 쓰인다). 회사 정책이 "베스트 프랙티스는 참고만, 법적 기준(WCAG 2 A/AA)만 강제"라면 이렇게 좁혀 CI를 더 엄격하지 않게 통제할 수 있다.
Playwright라면 @axe-core/playwright의 AxeBuilder가 "페이지에 axe.js 주입 → runPartial/finishRun으로 프레임까지 처리 → 결과 반환"을 대신 해준다. 이 저장소 자체를 직접 만질 일은, 웹앱을 만드는 대부분의 팀에게는 거의 없다 — 대신 저 래퍼들의 옵션(포함/제외 선택자, 태그 필터)을 다루게 된다.
README가 직접 못박은 숫자가 있다: "axe-core로 평균 WCAG 이슈의 57%를 자동으로 찾을 수 있다." 나머지 43%는 애초에 기계가 판단할 수 없는 영역(문맥적 의미, 실제 사용성, 논리적 읽기 순서의 자연스러움 등)이다. axe-core는 스스로도 "제로 거짓양성(zero false positives)"을 목표로 내세우지만, 동시에 "발견하지 못하는 진짜 문제(false negative)"가 훨씬 많다는 걸 인정하는 셈이다. 자동 검사 통과는 "최소한의 바닥은 다졌다"는 뜻이지 "접근성 인증"이 아니다.
lib/core/public/run.js는 axe._running 플래그로 동시 실행을 원천 차단한다. 이미 실행 중일 때 다시 axe.run()을 부르면 "Axe is already running..." 에러가 던져진다. 비동기 테스트 코드에서 await를 빼먹고 두 검사를 동시에 걸면 이 에러를 마주친다 — axe-core 버그가 아니라 의도된 안전장치다.
lib/rules/ 안의 JSON 파일은 105개이지만, 이 중 duplicate-id·duplicate-id-active·audio-caption·aria-roledescription·landmark-complementary-is-top-level 5개는 파일 안에 "enabled": false가 박혀 있는 폐지(deprecated) 규칙이다. 기본 실행에서는 100개만 돈다. 오래된 예제 코드에서 저 규칙 이름을 보고 "지금도 기본으로 도는 규칙이겠지" 오해하면 안 된다 — 하위 호환을 위해 코드만 남겨둔 것이다.
실제로는 get-foreground-color·get-background-color가 텍스트 그림자, 겹친 레이어(stacking context), 반투명도까지 역추적해야 색이 확정된다. 이 과정에서 값을 확정 지을 수 없으면(예: 배경이 이미지라 실제 픽셀 색을 알 수 없을 때) 이 체크도 incomplete로 떨어진다 — "색 대비 위반이 안 뜨니 통과"가 아니라 "색을 특정 못 해 사람이 봐야 하는" 경우가 섞여 있을 수 있다.
많은 개발자가 규칙 메시지나 판정 로직을 커스터마이징하고 싶어 dequelabs/axe-core 저장소를 포크해 수정한다. 하지만 실제 서비스에 쓰이는 건 npm에 배포된 빌드된 axe.js이므로, 소스를 고쳤다면 반드시 npm run build로 다시 번들링해 그 결과물을 배포 파이프라인에 반영해야 한다. 소스만 고치고 그대로 두면 아무 변화가 없다.
| 도구 | axe-core와의 관계 · 특징 |
|---|---|
| Lighthouse (a11y 카테고리) | 구글 크롬 개발자도구 내장 감사 도구. 접근성 카테고리의 규칙 다수가 실제로 axe-core를 내부에서 가져다 쓴다(doc/projects.md에 axe-core를 쓰는 커뮤니티 프로젝트로 Lighthouse가 직접 명시돼 있다). 성능·SEO 등 다른 카테고리까지 한 번에 보고 싶을 때 유리하지만, 접근성만 놓고 보면 axe-core의 부분집합에 가깝다. |
| Pa11y | 독자적인 CLI/Node 도구로, 내부적으로 axe-core 또는 HTML_CodeSniffer 엔진을 선택해 쓸 수 있는 구조. "명령줄에서 URL 하나 던지면 리포트가 나온다"는 접근성에 강해, CI에 붙이기 쉬운 경량 래퍼로 자주 쓰인다. |
| IBM Equal Access (accessibility-checker) | IBM이 만든 독자 엔진 + 브라우저 확장. axe-core와 별개의 규칙셋을 직접 구현했고, IBM 자체 접근성 요구사항(트러스티드 접근성 체크리스트)에 특화된 규칙이 강점이다. axe-core보다 규칙 수는 적지만 대기업 내부 정책과의 정합성을 노린 도구. |
| WAVE (WebAIM) | 브라우저 확장/웹 서비스 형태로, 결과를 페이지 위에 아이콘으로 시각적으로 오버레이해 보여주는 게 특징. 자동 판정 엔진은 자체 개발이며, "코드가 아니라 화면으로 바로 이해하고 싶은" 디자이너·기획자에게 특히 친절하다. CI 자동화보다는 수동 점검·교육용으로 강하다. |
axe-core는 테스트 코드·CI 파이프라인에 통합하는 용도로 가장 강하다(같은 검사를 매 배포마다 반복하는 데 최적화). Lighthouse는 "성능까지 한 번에 종합 점검"하고 싶을 때, Pa11y는 "가벼운 CLI로 URL만 검사"하고 싶을 때, IBM Equal Access는 "회사 내부 접근성 정책과 정확히 맞춰야" 할 때, WAVE는 "처음 접근성을 배우며 화면으로 직관적으로 확인"하고 싶을 때 적합하다. 이 넷은 서로 배타적이지 않다 — 실무에서는 CI에는 axe-core 계열, 수동 리뷰에는 WAVE처럼 병행하는 경우가 흔하다.
axe-core는 "규칙(selector+matches+checks) → 우선순위 산술로 4분류 → 상위 도구가 소비"라는 단순하지만 정교한 파이프라인 하나로, 웹 접근성 자동 검사라는 분야의 사실상 표준 엔진이 됐다. 런타임 의존성 0개로 가볍게 배포되고, 105개 규칙·20개 언어·무한 깊이 iframe 지원까지 갖춘 성숙한 프로젝트다.
이 저장소를 직접 들여다볼 가치가 있는 사람은 다음과 같다.
axe.run() 최소 예시만으로도 오늘 바로 시작할 수 있다.runOnly 태그 필터로 회사 정책(WCAG 2.1 AA 등)에 맞춘 CI 게이트를 설계하려는 사람.
반대로, 단순히 "우리 사이트 접근성 검사만 한 번 돌려보고 싶다"면 이 저장소 소스를 직접 읽을 필요 없이 @axe-core/playwright 같은 통합 라이브러리나 axe DevTools 브라우저 확장으로 충분하다. 이 문서는 "그 도구들 안에서 실제로 무슨 일이 벌어지는지" 궁금한 사람을 위한 것이다.
실습은 의존성이 준비된 별도의 학습용 작업 사본에서 진행한다. 명령은 저장소 루트 기준이며, Node.js는 .nvmrc, pnpm은 package.json의 packageManager를 확인해 맞춘다. 먼저 아래 명령으로 로컬 소스를 axe.js로 빌드한다.
pnpm run build
앞의 설치 예시는 웹앱에서 패키지를 사용하는 방법이고, 여기서는 엔진 소스를 직접 빌드한다. 과제 1·2는 doc/examples/jsdom/test/a11y.js의 실행 방식을 응용하며, 과제 3·4의 브라우저 테스트에는 Chrome과 개발 의존성의 chromedriver도 준비되어 있어야 한다.
목표: 같은 요소의 내용을 바꾸면 위반이 통과로 이동하는지 확인한다. 화면 전체의 문제와 섞이지 않도록 button-name 규칙만 실행한다.
doc/examples/jsdom/test/a11y.js에서 DOM 요소를 axe.run()의 첫 인자로 전달하는 부분을 읽는다../axe.js를 불러온다.node <<'NODE'
const { JSDOM } = require('jsdom');
const axe = require('./axe.js');
(async () => {
const { document } = new JSDOM('<main><button></button></main>').window;
const root = document.querySelector('main');
const options = { runOnly: { type: 'rule', values: ['button-name'] } };
const before = await axe.run(root, options);
document.querySelector('button').textContent = '저장';
const after = await axe.run(root, options);
for (const key of ['violations', 'passes', 'incomplete', 'inapplicable']) {
console.log(key, before[key].length, '→', after[key].length);
}
})().catch(error => {
console.error(error);
process.exitCode = 1;
});
NODE
코드 풀이: JSDOM이 메모리 안에 버튼을 만들고, 첫 검사가 끝나면 글자를 넣어 두 번째 검사를 실행한다. 두 호출을 await로 순서대로 기다리므로 동시 실행도 피한다.
완료 기준: violations가 1에서 0으로, passes가 0에서 1로 바뀌고, 글자를 지우면 다시 위반이 나온다. 여기서 배열 길이는 위반 요소 수가 아니라 규칙별 결과 항목 수다.
목표: 같은 규칙을 실행하면서 검사 대상만 바꾸고, 결과 감소의 원인을 설명한다.
doc/API.md의 범위 인자 설명과 doc/context.md를 읽는다. include·exclude는 첫 번째 인자의 범위 설정이고, runOnly는 두 번째 인자의 옵션이다.<main><article><button></button></article><aside><button></button></aside></main>으로 바꾼다.root와 options 선언은 유지하고, before 선언부터 출력 반복문까지를 아래 코드로 교체해 실행한다.const whole = await axe.run(document.querySelector('main'), options);
const context = { include: ['main'], exclude: ['aside'] };
const scoped = await axe.run(context, options);
document.querySelector('article button').textContent = '저장';
const fixed = await axe.run(context, options);
const count = results => results.violations.reduce(
(sum, rule) => sum + rule.nodes.length, 0
);
console.log(count(whole), count(scoped), count(fixed));
코드 풀이: 첫 검사는 버튼 둘을 확인하고, 다음 검사는 보조 영역을 제외한다. 마지막 검사는 포함된 버튼에 이름을 넣은 뒤 같은 범위로 다시 실행하며, nodes.length를 합쳐 위반 요소 수를 센다.
완료 기준: 출력이 2 1 0이고, 마지막에도 aside 안의 버튼은 이름이 없는 상태임을 확인한다. 제외 설정을 없애 다시 실행했을 때 위반 요소가 하나 남는 이유를 설명할 수 있어야 한다.
목표: 규칙 이름, 체크 이름, 실행 함수 이름이 어떻게 연결되는지 소스와 기존 테스트로 확인한다.
lib/rules/button-name.json의 selector·matches·any를 읽고, 후보 요소와 통과 조건을 각각 적는다.lib/checks/shared/button-has-visible-text.json의 evaluate를 따라 lib/checks/generic/has-text-content-evaluate.js를 읽는다. lib/checks/shared/aria-label.json도 같은 방식으로 lib/checks/shared/aria-label-evaluate.js까지 연결한다.test/checks/shared/button-has-visible-text.js에서 빈 버튼, 글자가 있는 버튼, 자식 정보가 없는 SerialVirtualNode, 빈 자식 배열이 있는 노드의 기대값을 먼저 예측한다.test/checks/shared/aria-label.js를 읽어 공백만 있는 이름을 어떻게 처리하는지도 확인한다.pnpm run test:unit --files test/checks/shared/button-has-visible-text.js
pnpm run test:unit --files test/checks/shared/aria-label.js
완료 기준: 두 테스트 파일이 통과하고, 네 입력의 반환값을 순서대로 false·true·undefined·false로 설명한다. 자식 정보가 없을 때와 자식이 없다고 확인됐을 때의 차이를 소스의 예외 처리와 연결해 적는다.
목표: any에서 하나가 통과해도 all의 판단 보류나 none의 금지 조건이 최종 결과를 바꿀 수 있음을 검증한다.
lib/core/utils/aggregate-checks.js와 lib/core/constants.js를 읽고, test/core/utils/aggregateChecks.js를 아래 명령으로 먼저 실행해 기준 상태를 확인한다.test/core/utils/aggregateChecks.js에서 기존 최상위 describe 안에 아래 테스트를 추가한다. 그 범위에 있는 createTestCheckResults와 CANTTELL을 그대로 사용한다.any: [true], all: [true], none: [true] 입력의 기대값을 FAIL로 검증하는 사례도 추가하고 다시 실행한다.pnpm run test:unit --files test/core/utils/aggregateChecks.js
it('any 통과가 all의 판단 보류를 덮지 않는다', () => {
const result = axe.utils.aggregateChecks(
createTestCheckResults({
any: [true],
all: [undefined],
none: [false]
})
);
assert.equal(result.result, CANTTELL);
});
코드 풀이: 입력 배열은 각 체크가 돌려준 값이다. 첫 사례는 필요한 조건 하나를 확정하지 못한 상황이고, 두 번째 사례의 none: [true]는 금지 조건이 발견된 상황이므로 최종 실패가 된다.
완료 기준: 추가한 두 사례가 통과하고, 기대값을 일부러 PASS로 바꾸면 실패한다. 기대값을 복구한 뒤 내부의 CANTTELL이 공개 결과의 incomplete로 연결되는 과정도 설명한다.
매주 작은 입력 하나를 정하고, 예상 결과를 적은 뒤 소스와 테스트로 확인한다. 읽은 파일 수보다 입력·판정·근거를 함께 남기는 것이 목표다.
| 주차 · 배울 개념 | 이 저장소에서 읽을 곳 | 주간 결과물 |
|---|---|---|
| 1주차 HTML 의미와 DOM |
doc/examples/jsdom/test/a11y.jslib/rules/button-name.json |
과제 1의 수정 전·후 기록. 요소, 검사 범위, 규칙별 결과 배열의 차이를 한 문단으로 풀이한다. |
| 2주차 ARIA와 접근 가능한 이름 |
lib/commons/text/accessible-text-virtual.jslib/standards/aria-roles.jslib/standards/aria-attrs.jstest/checks/shared/aria-label.js |
빈 버튼·텍스트 버튼·비어 있지 않은 aria-label·공백뿐인 aria-label의 비교표. 이름을 얻는 경로와 규칙 통과 근거를 구분해 적는다. |
| 3주차 범위와 실행 정책 |
doc/API.mddoc/context.mdlib/core/public/run.jslib/core/base/context.js |
과제 2의 전체 검사·일부 제외·수정 후 결과표. 범위 설정과 runOnly를 구분하고, 제외 항목을 다시 확인할 조건도 적는다. |
| 4주차 선언과 실행의 분리 |
lib/rules/button-name.jsonlib/checks/shared/button-has-visible-text.jsonlib/checks/generic/has-text-content-evaluate.jstest/integration/rules/button-name/ |
규칙 JSON → 체크 JSON → 판정 함수 → 테스트의 연결도. 통합 테스트의 HTML 입력과 JSON 기대 결과에서 통과·위반 사례를 하나씩 연결한다. |
| 5주차 판단 보류와 결과 집계 |
lib/core/utils/aggregate-checks.jslib/core/utils/aggregate-result.jslib/core/constants.jstest/core/utils/aggregateChecks.js |
과제 4의 회귀 테스트와 조합표. any·all·none 각각에 판단 보류가 섞일 때의 결과를 예측하고 검증한다. |
| 6주차 렌더링 의존성과 검사 한계 |
lib/core/base/virtual-node/lib/checks/color/color-contrast-evaluate.jslib/commons/color/get-contrast.jstest/checks/color/color-contrast.js |
색 대비 체크가 필요로 하는 화면 정보와 판단 보류 경로의 설명서. 실제 브라우저 테스트의 입력 하나를 골라 전경색·배경색을 추적하고, 사람이 확인할 항목을 기록한다. |
마지막 주에는 README.md의 jsdom 지원 범위도 다시 읽는다. 이름이나 속성 중심의 검사는 메모리 안에서 연습하기 쉽지만, 색 대비처럼 렌더링 정보가 필요한 검사는 실제 브라우저에서 확인해야 한다. 자동 결과와 수동 확인 근거를 함께 남기면 이후 접근성 회귀를 검토할 때도 사용할 수 있다.
axe.run()에 특정 요소를 넘겨 그 부분을 검사할 수 있다.wcag2a·wcag2aa 같은 태그가 붙어 있어 해당 기준에 연결된 규칙을 선택할 수 있다.lib/standards/aria-roles.js와 lib/standards/aria-attrs.js의 데이터를 판정에 활용한다.lib/commons/text/accessible-text-virtual.js는 관련 요소 참조, aria-label, 요소의 글자 등 여러 경로를 순서에 따라 살펴 이름을 계산한다.lib/rules/button-name.json처럼 대상 선택자와 체크 조합, 태그, 설명을 선언하고 실행 엔진이 이를 사용한다.evaluate가 실행 함수를 가리킨다. 함수의 true·false·undefined 반환값은 any·all·none 조합을 거쳐 최종 판정에 반영된다.selector는 검사 후보를 고르는 선택자이고, matches는 그 후보에 규칙을 적용할지 확인하는 함수다. lib/core/base/rule.js가 두 단계를 연결해 실제 검사할 노드를 추린다.any는 하나 이상의 조건 만족, all은 모든 조건 만족, none은 금지 조건이 발견되지 않기를 요구한다. lib/core/utils/aggregate-checks.js는 여기에 판단 보류까지 반영하므로 단순한 참·거짓 연산만으로 결과를 설명할 수 없다.axe.run(context, options)의 첫 번째 인자에 요소나 선택자, include·exclude 객체를 전달하며 내부 처리는 lib/core/base/context.js에서 볼 수 있다.runOnly에 type: 'rule' 또는 type: 'tag'와 대상 목록을 지정할 수 있다.lib/core/base/virtual-node/에는 실제 노드를 감싸는 VirtualNode와 직렬화된 데이터로 구성하는 SerialVirtualNode가 있다.CANTTELL과 연결된다.lib/core/constants.js는 minor·moderate·serious·critical을 정의하며, 이는 통과·위반을 가르는 결과 분류와 별도로 읽는다.test/checks/의 개별 조건 검사와 test/integration/rules/의 HTML·JSON 사례를 연결해 판정의 근거를 보존한다.