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 브라우저 확장으로 충분하다. 이 문서는 "그 도구들 안에서 실제로 무슨 일이 벌어지는지" 궁금한 사람을 위한 것이다.