# 접근성 E2E — @axe-core/playwright로 스캔

> 실제 브라우저에 그려진 화면을 axe-core 엔진으로 스캔해 접근성 위반을 자동으로 잡아냅니다. @axe-core/playwright로 E2E에 붙이고, 글씨 색을 살짝 흐리게 바꿔 color-contrast 위반이 실패로 잡히는 과정을 확인합니다.

**Published:** 2026-07-27 | **Updated:** 2026-07-27

---


> 「프론트엔드 테스트 제대로 하기」 시리즈의 **15편**입니다. [전체 목차 보기](/series/프론트엔드-테스트-제대로-하기/) · [용어집](/posts/frontend-testing-glossary/)

버튼 하나에 이름이 붙었는지는 그 버튼만 봐도 압니다. 그런데 **글씨 색이 배경과 충분히 대비되는지, 페이지에 언어가 지정됐는지, id가 중복되진 않았는지**는 요소 하나만 봐선 알 수 없어요. 화면 전체를, 그것도 실제로 그려진 상태로 봐야 하죠. 이건 사람이 매번 눈으로 하기엔 지치는 일이고, 그래서 **자동 검사 엔진**이 필요합니다.

**axe**가 바로 그 엔진입니다. 렌더된 페이지를 통째로 훑어 접근성 위반을 자동으로 잡아줘요 — 대비 부족, 이름 없는 버튼, 잘못된 ARIA 같은 것들을요. 브라우저 확장(axe DevTools)으로 한 번씩 돌려본 분도 많을 텐데, 이 글은 그걸 **Playwright E2E에 넣어 매번 자동으로** 돌게 만드는 방법입니다. 몇 줄이면 됩니다.

시리즈로 따라오셨다면 — [쿼리 단위의 접근성](/posts/frontend-testing-a11y-queries/)에 이어 오늘은 페이지 단위, 두 번째 안전망이에요. 검색으로 처음 오셨어도 코드는 자급자족합니다(Playwright 기초는 [입문 편](/posts/frontend-testing-playwright-intro/) 참고).

> 실습 코드: [frontend-testing-lab](https://github.com/IsaacEryn/frontend-testing-lab/tree/step-15) — 이 편 기준 스냅샷은 `step-15` 태그에 고정돼 있습니다.

## 오늘 다루는 것

- axe-core가 어떤 엔진이고 어떻게 동작하는지
- @axe-core/playwright 통합과 위반을 테스트 실패로 게이트하기
- 검사 범위 좁히기·넓히기(태그·포함·제외)
- 자동 검사가 잡는 것, 못 잡는 것, 그리고 "검토 필요"

{{< img src="images/contents/axe-violations.png" alt="axe 접근성 스캔이 페이지의 위반 항목들을 목록으로 보고한 화면" >}}

---

## axe-core가 뭔가요

방금 "axe"라고 뭉뚱그렸는데, 정확히는 **axe-core**입니다. 접근성 컨설팅 회사 Deque가 만든 **오픈소스 접근성 검사 엔진**이에요. 여기가 중요한데 — 크롬 확장 axe DevTools도, 구글 Lighthouse의 접근성 점수도, 수많은 CI 접근성 검사도 **속을 열어보면 다 이 엔진 하나**를 씁니다. 우리가 오늘 붙일 것도 그거예요. 한 번 배워두면 도구가 바뀌어도 결과 읽는 법은 그대로입니다.

동작 방식이 이 엔진의 성격을 말해줍니다. axe-core는 HTML **소스가 아니라, 브라우저가 실제로 그려낸 결과**를 봅니다. 렌더된 DOM 트리에 계산된 스타일(computed style — CSS가 다 적용된 최종 색·크기·표시 여부)을 얹어, 약 100개의 규칙으로 하나씩 대조하죠. 그래서 CSS로 옅어진 글씨 색도, JavaScript가 나중에 주입한 ARIA 속성도 그대로 잡힙니다. 소스만 봐선 절대 알 수 없는 것들이에요.

그럼 우리가 설치할 **@axe-core/playwright**는 뭐냐 — 엔진(axe-core)을 Playwright가 띄운 페이지에 **주입해 실행하고, 결과를 받아오는 얇은 어댑터**입니다. 엔진은 브라우저 안에서 돌고, 우리는 그 결과를 Node 쪽 테스트에서 받아 검사하는 구조죠.

각 규칙은 **태그**로 묶여 있습니다. `wcag2a`·`wcag2aa`·`wcag21aa`·`wcag22aa`는 WCAG(웹 접근성 표준)의 버전·수준을, `best-practice`는 표준은 아니지만 권장되는 관행을 뜻해요. 뒤에서 이 태그로 검사 범위를 조절합니다.

---

## 페이지 전체 스캔

설치는 한 줄, 사용도 몇 줄이에요. (repo `e2e/dashboard.spec.ts`의 실제 테스트)

```bash
npm i -D @axe-core/playwright
```

```ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'

test('접근성 위반이 없다', async ({ page }) => {
  await page.goto('/')
  await page.getByText('Alice Kim').waitFor()   // 로딩 완료 후 스캔

  const results = await new AxeBuilder({ page }).analyze()  // 페이지 전체 스캔
  expect(results.violations).toEqual([])   // 위반 0이어야 통과 — 실패 시 위반 내용이 diff로
})
```

코드를 뜯어보면, 핵심은 두 줄입니다. `new AxeBuilder({ page }).analyze()`가 지금 페이지에 엔진을 주입해 전체를 스캔하고, `expect(results.violations).toEqual([])`가 그 결과의 **위반 배열이 비어 있어야** 통과라고 못 박아요. `toEqual([])`로 둔 데는 이유가 있는데, 실패하면 **위반 내용 전체가 diff로 출력**되거든요 — 어떤 규칙을, 어느 요소가 어겼는지까지 전부. (배열이 커지면 diff도 커져 읽기 벅찰 수 있는데, 그 얘기는 곧 실패 장면에서 다시 합니다.)

돌려보면 이렇게 끝납니다.

```bash
Running 1 test using 1 worker
  ✓  1 [chromium] › e2e/dashboard.spec.ts:16:1 › 접근성 위반이 없다 (712ms)
  1 passed (2.1s)
```

---

## 검사 범위 좁히기·넓히기

기본은 "페이지 전체를 모든 규칙으로"입니다. 실무에선 이걸 조절할 일이 생기죠. `AxeBuilder`는 `.analyze()` 앞에 메서드를 체이닝해서 범위를 바꿉니다.

**태그로 규칙을 좁히기** — 특정 WCAG 수준만 보고 싶을 때.

```ts
new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze()
// 'wcag2a'·'wcag2aa' = WCAG의 A·AA 수준 규칙만 검사
```

**특정 영역만 검사하거나 제외하기** — CSS 셀렉터로 범위를 지정합니다. 제3자 위젯(광고·지도·챗봇처럼 내가 못 고치는 iframe)은 검사에서 빼는 게 실용적이에요. 남의 코드 위반까지 내 테스트를 빨갛게 만들 필요는 없으니까요.

```ts
new AxeBuilder({ page })
  .include('main')                 // 본문 영역만
  .exclude('.third-party-widget')  // 이 요소(및 하위)는 제외
  .analyze()
```

**특정 규칙만 끄기** — 알려진 오탐이나, 팀이 의도적으로 예외로 둔 규칙이 있을 때.

```ts
new AxeBuilder({ page }).disableRules(['color-contrast']).analyze()
```

끄는 건 신중히 하세요. `disableRules`로 규칙을 빼면 그 위반은 영영 안 보입니다 — 오탐이 확실할 때만, 이유를 주석으로 남기고요.

---

## 무엇을 잡나

대비(contrast) 부족, 이름 없는 컨트롤, 잘못된 ARIA, 라벨 없는 입력, 언어 미지정, id 중복 같은 **규칙 기반 위반**을 잡아요. 전편의 role 쿼리가 "이 버튼에 이름이 있나"를 요소 단위로 본다면, axe는 이렇게 **페이지를 가로지르는** 문제를 봅니다. 눈으로는 몇 번을 봐도 "옅은 게 디자인이지" 하고 넘길 대비 문제를 숫자로 잡아내고요. CI에 넣으면(다음 장에서 함께 합니다) 이런 회귀가 배포 전에 걸립니다.

그런데 axe 결과가 위반(violations)만 있는 건 아니에요. 검사 결과는 네 갈래로 나뉩니다.

{{< img src="images/contents/axe-result-buckets.png" alt="axe 검사 결과가 네 갈래로 나뉘는 다이어그램 - 렌더된 화면(DOM과 계산된 스타일)을 axe-core 엔진이 약 100개 규칙으로 검사해 violations(위반, 규칙을 확실히 어긴 것으로 테스트 실패로 게이트), incomplete(검토 필요, axe가 판단 못 해 사람이 확인해야 하는 것, 예를 들어 배경 이미지 위 텍스트), passes(통과), inapplicable(해당 없음, 그 규칙을 적용할 요소가 페이지에 없음)으로 분류한다. axe는 소스가 아니라 실제 그려진 결과를 검사하므로 CSS로 바뀐 색과 JS로 주입된 ARIA까지 반영한다" >}}

우리가 위에서 게이트한 건 이 중 **violations**입니다. 눈여겨볼 건 **incomplete(검토 필요)**\ 예요. axe가 규칙을 적용하려다 **자동으로는 판단 못 한** 항목입니다. 대표적인 게 배경 이미지 위의 텍스트 — 배경이 단색이 아니라 사진이면 대비를 계산할 기준색을 못 정하거든요. 그래서 "위반"이 아니라 "사람이 봐주세요"로 빼둡니다. `results.violations`만 보면 이걸 놓치니, 접근성을 진지하게 챙긴다면 `results.incomplete`도 로그로 남겨 한 번씩 훑는 걸 권해요.

말로 하면 심심하니, violations가 직접 잡히는 장면을 봅시다.

---

## 일부러 부숴봅시다

목록의 이메일 글씨 색을 회색에서 **더 옅은 회색으로** 한 줄 바꿔봤습니다 — 눈으로는 "좀 연해졌네?" 수준의, 리뷰에서 그냥 통과될 변화예요.

```css
small { color: #b0b0b0; }   /* 원래는 #595959 */
```

axe 테스트는 곧장 빨간불입니다.

```bash
  ✘  1 [chromium] › e2e/dashboard.spec.ts:16:1 › 접근성 위반이 없다 (707ms)

    Error: expect(received).toEqual(expected) // deep equality
    - Expected  -   1
    + Received  + 128
    - Array []
    + Array [
    +   Object {
    +     "id": "color-contrast",
    +     "impact": "serious",
    +     "help": "Elements must meet minimum color contrast ratio thresholds",
    +     "helpUrl": "https://dequeuniversity.com/rules/axe/4.12/color-contrast...",
    +     "nodes": Array [
    +       Object {
    +         "failureSummary": "Fix any of the following:
    +   Element has insufficient color contrast of 2.16
    +   (foreground color: #b0b0b0, background color: #ffffff,
    +   font size: 10.0pt, font weight: normal).
    +   Expected contrast ratio of 4.5:1",
    +         "html": "<small>alice@example.com</small>",
    +         "target": Array [ "li:nth-child(1) > small" ],
    ...
```

읽어볼 게 많아요. 어긴 규칙(`color-contrast`)과 심각도(`serious`), 고치는 법 문서 링크(`helpUrl`), 문제가 된 바로 그 요소(`<small>alice@example.com</small>`)와 셀렉터까지. 압권은 failureSummary입니다 — **현재 대비율 2.16, 기준 4.5:1**. "좀 연한 것 같다"는 느낌이 아니라 숫자로 말해주죠. 색 하나 바꿨을 뿐인데 diff가 128줄인 이유도 있어요 — 목록의 다섯 행 **전부**가 각각 위반 노드로 잡혔거든요. 실패 출력만 따라가도 고칠 곳이 나옵니다. 에러 메시지가 이렇게 친절한 검사 도구는 흔치 않아요.

---

## 자동화의 한계

axe는 만능이 아니에요. "맥락에 맞는 대체텍스트인지", "포커스 순서가 논리적인지"는 여전히 사람이 봐야 합니다. 자동 검사는 접근성의 '바닥'을 지켜줄 뿐, '천장'은 사람이 올려요.

---

> [role 쿼리 계약](/posts/frontend-testing-a11y-queries/) + 이번 편의 axe 스캔 = **두 겹의 접근성 안전망**. 여기에 곧 CI 게이트까지 얹으면, 접근성이 '가끔 점검'에서 '상시 보증'으로 바뀝니다.

{{< img src="images/contents/a11y-two-nets.png" alt="두 겹의 접근성 안전망 다이어그램 - 첫 번째 그물은 요소 단위의 role 쿼리 계약으로 이름 없는 버튼과 끊긴 레이블을 테스트 실패로 잡고, 두 번째 그물은 페이지 단위의 axe 스캔으로 색 대비와 잘못된 ARIA, 언어 미지정을 잡습니다. 두 그물을 통과해도 대체텍스트의 적절성이나 포커스 순서 같은 맥락 판단은 사람의 몫으로 남는다는 안내가 아래 붙어 있습니다" >}}

> axe를 처음 붙이면 위반이 우수수 쏟아져 당황할 수 있어요. 미루던 건강검진 결과지를 한꺼번에 받아든 기분이지만 — 정상입니다. 하나씩 고치다 보면 어느새 초록불이에요.

---

## 한 장 요약

- **axe-core** = Deque의 오픈소스 접근성 검사 엔진. 크롬 확장·Lighthouse·CI가 다 쓰는 그것 — 소스가 아니라 **렌더된 화면(DOM+계산된 스타일)**\ 을 약 100개 규칙으로 검사
- **@axe-core/playwright**는 그 엔진을 페이지에 주입·실행하는 어댑터 — 로딩 완료를 기다린 뒤 `new AxeBuilder({ page }).analyze()` → `expect(results.violations).toEqual([])`
- `toEqual([])`의 보너스: 실패 시 위반 내용 전체(규칙·요소·도움말·대비율)가 diff로 출력
- 범위 조절: `withTags`(수준), `.include()`/`.exclude()`(영역·제3자 위젯 제외), `disableRules`(오탐 — 신중히)
- 결과는 네 갈래 — violations(위반)·**incomplete(검토 필요)**·passes·inapplicable. incomplete는 axe가 판단 못 한 것이니 사람이 훑기
- 자동 검사는 접근성의 **바닥**을 지킬 뿐 — 맥락 판단(대체텍스트의 적절성, 포커스 순서 등)은 여전히 사람 몫

---

## 두 겹의 안전망을 걸고

접근성까지 자동으로 지키게 됐습니다. 다음 편은 눈에 보이는 회귀를 잡는 시각적 회귀 테스트입니다.

> **레벨업**: 페이지 전체의 접근성 위반을 자동으로 걸러낼 수 있습니다.

> **다음 편**: 시각적 회귀 테스트 — 스크린샷 비교

> 낯선 용어가 있었다면 — [용어집](/posts/frontend-testing-glossary/)에 전부 한 줄씩 정리돼 있어요.

