테스트는 하나의 렌즈가 아니다 — 접근성·시각 회귀·부하
2025-11-08
19편에서 유닛부터 E2E 까지 피라미드를 세워 "동작하는가" 는 확인했습니다. 그런데 곧 이런 생각이 들었습니다 — 동작하는 것과, 누구나 쓸 수 있는 것·모양이 안 깨진 것·부하에 버티는 것 은 전부 다른 질문입니다. 테스트는 하나의 렌즈가 아니었습니다. 그래서 세 개의 렌즈를 더 붙였습니다.
💻 이 편의 코드는 github.com/kahnco/go-ddd-shop 의
part-27태그(web/)에 있습니다.
렌즈 1 — 접근성(a11y)
기능이 멀쩡해도 스크린리더 사용자가 못 쓰면 반쪽입니다. axe(axe-core)로 접근성을 자동 스캔합니다. 두 층위로요.
- 컴포넌트:
vitest-axe(jsdom) — 라벨·역할·구조 위반을 빠르게. - 페이지:
@axe-core/playwright(실제 브라우저) — 렌더된 전체 페이지를.
붙이자마자 진짜 위반 을 잡았습니다. 로그인 폼과 수량 입력에 라벨이 없었습니다. placeholder 는 라벨이 아니거든요 — 포커스하면 사라지고, 스크린리더가 제대로 못 읽습니다.
// 전: placeholder 만 → "Form elements must have labels" 위반
<input placeholder="이메일" ... />
// 후: aria-label 추가
<input aria-label="이메일" placeholder="이메일" ... />
// 페이지 스캔
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa"])
.disableRules(["color-contrast"]) // ← 아래 설명
.analyze();
expect(results.violations).toEqual([]);
여기서 두 가지를 정직하게 짚습니다.
- jsdom 은 색 대비를 계산하지 못합니다. 레이아웃·색을 실제로 그리지 않으니까요(canvas 미구현). 그래서 컴포넌트 단계에선
color-contrast를 끄고 구조·라벨 만 봅니다. 대비는 실제 브라우저 axe 의 몫입니다. - 그 색 대비를 우리는 의도적으로 제외 했습니다. 다크 테마의 보조 텍스트(muted gray)가 AA 기준에 아슬아슬한데, 이건 디자인 결정이라 별도로 관리하기로 하고 규칙에서 뺐습니다. 끈 규칙은 부채로 기록 해 둡니다 — 자동 a11y 검사는 문제의 대략 30~40% 만 잡고, 나머지는 수동·스크린리더 테스트의 영역이라는 것도 함께요.
렌즈 2 — 시각 회귀
로직은 그대로인데 CSS 한 줄에 레이아웃이 무너지는 일은 기능 테스트가 못 잡습니다. Playwright 의 toHaveScreenshot 으로 기준(baseline) 이미지 를 만들어 픽셀로 비교합니다.
await expect(page.locator("main")).toHaveScreenshot("home-main.png", {
maxDiffPixelRatio: 0.01, // 앤티앨리어싱 미세차 허용
});
그런데 여기서 시각 회귀의 본질적인 함정 둘을 만났습니다.
- 스냅샷은 플랫폼을 탑니다. 폰트 렌더링이 OS마다 달라, Playwright 는
home-main-chromium-darwin.png처럼 플랫폼별로 기준을 저장합니다. 같은 OS(로컬 mac, 또는 CI linux 컨테이너)에서 비교해야 합니다. - 비결정적 렌더는 시각 회귀를 부순다. 처음 홈 스냅샷 테스트가 계속 깨졌습니다. 원인은 뜻밖에도 백엔드 였습니다 — 카탈로그가 상품을 맵 순회 순서(무작위) 로 돌려줘서, 홈 그리드의 상품 순서가 매번 달라졌습니다. 그래서 프런트에서
product_id로 정렬 했습니다. 화면 순서가 안정되니 시각 테스트도 결정적이 됐고, 덤으로 UX 도 좋아졌습니다.
// 시각 회귀는 결정적 렌더를 요구한다 → 정렬로 안정화
return [...products].sort((a, b) => a.product_id.localeCompare(b.product_id));
교훈: 시각 회귀 테스트는 "화면이 매번 똑같이 그려진다" 를 전제합니다. 그렇지 않은 곳(무작위 순서, 시간, 랜덤 데이터)은 먼저 결정적으로 만들어야 합니다.
렌즈 3 — 부하
기능이 되고 접근성·외관도 좋아도, 부하에서 무너지면 소용없습니다. k6 가 표준이지만 별도 바이너리라, 여기선 Node 네이티브인 autocannon 으로 프로젝트 안에 넣었습니다. 홈 SSR 경로(서버가 카탈로그를 내부 호출하는)를 두드립니다.
const result = await autocannon({ url, connections: 10, duration: 10 });
// 게이트: 비2xx·에러 0, p99 가 예산 이하
if (result.non2xx > 0 || result.errors > 0) process.exit(1);
if (result.latency.p99 > P99_BUDGET_MS) process.exit(1);
로컬 결과는 이랬습니다.
부하: http://localhost:3000/ (연결 10, 10s)
처리량: 774 req/s
지연: p50 12ms · p99 24ms
비2xx: 0 · 에러: 0
✅ 부하 기준 통과
부하 테스트의 핵심은 숫자 자체가 아니라 기준(게이트) 입니다 — 16편의 RED·SLO 를 부하 상황에서 검증하는 셈이죠. "비2xx 0, p99 < 예산" 을 CI 게이트로 두면, 성능 회귀가 배포를 막습니다.
정직하게 — 각 렌즈의 값과 한계
- 자동 a11y 는 만능이 아닙니다. 라벨·대비·역할 같은 기계적 위반은 잡지만, "탭 순서가 자연스러운가", "스크린리더로 흐름이 이해되는가" 는 결국 사람이 봐야 합니다. 끈 규칙(color-contrast)은 부채로 남겨 두었습니다.
- 시각 회귀는 관리비용이 큽니다. 기준 갱신, 플랫폼별 스냅샷, 의도한 변경 때마다 재생성. 그래서 안정적이고 중요한 화면 에만 겁니다(로그인·핵심 그리드).
- 로컬 부하는 상대 비교용 입니다. 인메모리 단일 인스턴스라, 이 숫자가 곧 프로덕션 용량은 아닙니다. 진짜 용량 산정은 프로덕션급 환경에서, 병목(DB·브로커)을 포함해 재야 합니다.
정리 — 렌즈를 바꿔 보기
- 접근성(axe: 컴포넌트+페이지) — 라벨 없는 입력을 잡아 고쳤습니다.
- 시각 회귀(Playwright 스냅샷) — 무작위 순서를 정렬로 결정적으로 만들어 세웠습니다.
- 부하(autocannon) — 비2xx·p99 게이트로 성능을 회귀 대상에 넣었습니다.
핵심 한 줄 — "동작한다" 는 시작일 뿐입니다. 같은 코드를 접근성·외관·성능이라는 다른 렌즈로 다시 보면, 각 렌즈마다 다른 버그와 다른 함정이 드러납니다. 좋은 테스트 전략은 하나의 렌즈를 깊게 파는 게 아니라, 여러 렌즈를 갖추고 상황에 맞게 바꿔 끼우는 것 이었습니다.
이번 편 전체 코드·테스트는 리포의
part-27태그(web/)에 있습니다.