테스트는 하나의 렌즈가 아니다 — 접근성·시각 회귀·부하

2025-11-08

19편에서 유닛부터 E2E 까지 피라미드를 세워 "동작하는가" 는 확인했습니다. 그런데 곧 이런 생각이 들었습니다 — 동작하는 것과, 누구나 쓸 수 있는 것·모양이 안 깨진 것·부하에 버티는 것 은 전부 다른 질문입니다. 테스트는 하나의 렌즈가 아니었습니다. 그래서 세 개의 렌즈를 더 붙였습니다.

💻 이 편의 코드는 github.com/kahnco/go-ddd-shoppart-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/)에 있습니다.