본문으로 건너뛰기

Playwright로 구축하는 비주얼 회귀 테스트 파이프라인: 스크린샷 기준 이미지 관리부터 CI 디자인 리뷰 자동화까지

Development21분 읽기본문 10,521

핵심 요약

이 글에서 확인할 내용

유닛 테스트가 "통과"해도 디자인은 무너진다 프론트엔드 실무자들은 테스트 커버리지가 80%를 넘어도 프로덕션 배포 다음 날 디자이너에게 "헤더 폰트가 바뀌었어요"라는 메시지를 받는 경험을 한 번쯤 합니다. Button 컴포넌트의 로직은 제대로 동작하는데, CSS 변수 하나가 바뀌면서 브랜드 컬러가 슬그머니 회색으로 바뀌어 있는 상황입니다.

  • 1. 비주얼 회귀 테스트가 필요한 이유
  • 2. Playwright 설치와 스크린샷 API 기초
  • 3. 결정론(Determinism): 비주얼 테스트의 사활이 걸린 원칙

Playwright visual regression CI design review automation

유닛 테스트가 "통과"해도 디자인은 무너진다

프론트엔드 실무자들은 테스트 커버리지가 80%를 넘어도 프로덕션 배포 다음 날 디자이너에게 "헤더 폰트가 바뀌었어요"라는 메시지를 받는 경험을 한 번쯤 합니다. Button 컴포넌트의 로직은 제대로 동작하는데, CSS 변수 하나가 바뀌면서 브랜드 컬러가 슬그머니 회색으로 바뀌어 있는 상황입니다.

문제의 본질은 우리가 검증하는 대상과 사용자가 경험하는 대상이 다르다는 데 있습니다. 우리는 함수의 반환값, 상태 전이, DOM 트리의 구조를 테스트합니다. 그러나 사용자는 화면에 그려진 픽셀을 봅니다. 그 사이에는 CSS 캐스케이드, 폰트 메트릭, 브라우저 렌더링 엔진, 뷰포트 크기라는 거대한 변환 계층이 있고, 이 계층은 기존 테스트가 전혀 관측하지 못하는 사각지대입니다. 로직 테스트가 초록불이어도 이 사각지대에서 회귀가 일어나면 사용자에게는 명백한 결함으로 보입니다.

비주얼 회귀 테스트(Visual Regression Testing)는 바로 이 간극을 메웁니다. 코드가 아닌 픽셀을 기준으로 UI가 의도치 않게 변경됐는지 자동으로 검증하는 방식입니다. 통합 테스트 전반을 먼저 다룬 Vitest + MSW로 React 통합 테스트 설계하기TDD로 프론트엔드 개발하기도 함께 참고하면 좋습니다. 이 글에서 우리는 Playwright를 사용해 실제로 동작하는 비주얼 회귀 파이프라인을 구축하고, 실무에서 이 방식이 무너지는 지점—거짓 실패, 환경 차이, 기준 이미지 관리 실패—을 어떻게 방어하는지까지 정리합니다.


1. 비주얼 회귀 테스트가 필요한 이유

유닛 테스트와 통합 테스트는 로직과 데이터 흐름을 검증합니다. expect(price).toBe(9900) 같은 assertion은 숫자가 맞는지 확인하지만, 그 숫자가 화면에서 어떤 크기로 렌더링되는지는 전혀 알 수 없습니다. 9,900이라는 값이 잘린 컨테이너 밖으로 삐져나가든, 배경색과 같은 색으로 렌더링되어 보이지 않든 assertion은 통과합니다.

비주얼 버그는 다양한 경로로 발생합니다.

  • CSS 충돌: 전역 스타일시트에 새 규칙이 추가되면서 특정 컴포넌트의 margin이 덮어씌워집니다. 특히 특이성(specificity) 관리가 되지 않는 코드베이스에서 자주 나타나며, 이 문제 자체는 CSS Cascade Layers로 디자인 시스템 특이성 관리하기에서 구조적으로 다룹니다.
  • 의존성 업데이트: 디자인 시스템 패키지를 패치 버전으로 올렸는데 내부 토큰 값이 바뀌어 있습니다.
  • 폰트 로딩 순서 변경: 폴백 폰트가 레이아웃을 밀어냅니다.
  • 브라우저 기본 스타일 차이: Chromium 업데이트 이후 <button> 기본 아웃라인 두께가 달라집니다.
테스트 유형로직 버그스타일 버그레이아웃 버그
유닛 테스트OXX
통합 테스트OX
E2E 기능 테스트OXX
비주얼 회귀 테스트XOO

비주얼 회귀 테스트는 앞의 테스트들을 대체하는 것이 아니라 보완합니다. 로직은 유닛·통합 테스트가, 사용자 흐름은 E2E가, 최종 시각 결과는 비주얼 회귀가 책임지는 역할 분담입니다. 이 경계를 흐리면—예컨대 비주얼 스냅숏으로 텍스트 내용까지 검증하려 들면—테스트가 과도하게 취약해지고 거짓 실패가 폭증합니다. 비주얼 회귀는 어디까지나 "의도하지 않은 시각적 변화"를 잡는 안전망으로 좁게 정의하는 편이 오래갑니다.


2. Playwright 설치와 스크린샷 API 기초

pnpm add -D @playwright/test
pnpm exec playwright install --with-deps chromium

Playwright 공식 스크린샷 문서에 따르면 page.screenshot() API는 전체 페이지, 특정 엘리먼트, 뷰포트 영역 세 가지 모드를 지원합니다. 비주얼 회귀에서는 대부분 엘리먼트 단위 스크린샷을 기본으로 삼습니다. 전체 페이지 스크린샷은 관련 없는 영역의 변화까지 실패로 잡아내 diff가 노이즈로 가득 차기 때문입니다.

import { test, expect } from '@playwright/test';

test.describe('Button 컴포넌트 비주얼 회귀', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:6006/iframe.html?id=ui-button--primary');
    await page.waitForLoadState('networkidle');
  });

  test('Primary 버튼이 기준 이미지와 일치한다', async ({ page }) => {
    const button = page.locator('[data-testid="btn-primary"]');
    await expect(button).toHaveScreenshot('button-primary.png', {
      maxDiffPixels: 50,
      threshold: 0.1,
    });
  });

  test('Hover 상태 버튼', async ({ page }) => {
    const button = page.locator('[data-testid="btn-primary"]');
    await button.hover();
    await page.waitForTimeout(300);
    await expect(button).toHaveScreenshot('button-primary-hover.png');
  });
});

toHaveScreenshot()은 처음 실행 시 기준 이미지를 자동 생성하고, 이후 실행부터는 픽셀 단위로 비교합니다. 한 가지 중요한 특성은 이 assertion이 자동 재시도(auto-retry)와 안정화(stabilization) 를 내장한다는 점입니다. Playwright는 스크린샷을 연속으로 찍어 두 프레임이 동일해질 때까지 기다린 뒤 비교를 수행합니다. 덕분에 폰트 로딩이나 짧은 트랜지션 정도는 명시적 waitFor 없이도 상당 부분 흡수됩니다. 다만 이 안정화는 만능이 아니며, 무한 애니메이션이나 커서 깜빡임처럼 결코 정지하지 않는 요소는 뒤에서 다룰 방식으로 직접 고정해야 합니다.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/visual',
  snapshotDir: './tests/visual/__snapshots__',
  updateSnapshots: process.env.UPDATE_SNAPSHOTS === 'true' ? 'all' : 'none',
  use: {
    baseURL: 'http://localhost:6006',
    viewport: { width: 1280, height: 720 },
    reducedMotion: 'reduce',
  },
  expect: {
    toHaveScreenshot: {
      threshold: 0.1,
      maxDiffPixels: 100,
    },
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

설정에서 viewport를 명시적으로 고정한 점에 주목합니다. 뷰포트 크기는 반응형 레이아웃을 결정하는 1차 변수이므로, 이 값이 실행 환경마다 다르면 기준 이미지 자체가 무의미해집니다. reducedMotion: 'reduce'prefers-reduced-motion 미디어 쿼리를 활성화해 CSS 트랜지션·애니메이션을 상당 부분 비활성화하는 첫 번째 방어선입니다.


3. 결정론(Determinism): 비주얼 테스트의 사활이 걸린 원칙

비주얼 회귀 테스트가 실패하는 이유는 크게 둘입니다. 하나는 우리가 잡고 싶은 진짜 회귀이고, 다른 하나는 실행할 때마다 결과가 미세하게 달라지는 비결정성(non-determinism) 입니다. 후자를 제거하지 못하면 팀은 곧 실패한 테스트를 습관적으로 무시하게 되고, 그 순간 파이프라인은 죽습니다. 따라서 비주얼 테스트 엔지니어링의 본질은 "같은 입력에는 언제나 같은 픽셀"을 보장하는 결정론을 확보하는 일입니다.

결정성을 깨뜨리는 대표적 원인과 처방은 다음과 같습니다.

  • 애니메이션·트랜지션: reducedMotion 설정에 더해, 스크린샷 직전 모든 애니메이션을 강제로 0초로 만드는 스타일을 주입합니다.
  • 시간·날짜: "3분 전", "오늘"처럼 현재 시각에 의존하는 렌더링은 page.clock.setFixedTime()으로 시계를 고정합니다.
  • 네트워크·동적 데이터: API 응답을 page.route()로 고정합니다. 실제 서버에서 매번 다른 순서·개수의 데이터가 오면 스크린샷은 결코 안정되지 않습니다.
  • 폰트 로딩: 폰트가 늦게 로드되면 폴백 폰트로 먼저 그려졌다가 교체되며 레이아웃이 흔들립니다. 스크린샷 전에 document.fonts.ready를 기다리는 것이 안전합니다.
  • 랜덤성·커서: Math.random() 기반 요소나 텍스트 입력 커서(caret)의 깜빡임도 diff를 유발합니다. 커서는 caret-color: transparent로 숨길 수 있습니다.
test.beforeEach(async ({ page }) => {
  // 폰트가 모두 로드될 때까지 대기
  await page.goto('/stories/card');
  await page.evaluate(() => document.fonts.ready);
  // 애니메이션·트랜지션·커서 전면 정지
  await page.addStyleTag({
    content: `
      *, *::before, *::after {
        animation-duration: 0s !important;
        animation-delay: 0s !important;
        transition-duration: 0s !important;
        transition-delay: 0s !important;
        caret-color: transparent !important;
      }
    `,
  });
});

이렇게 결정성을 확보하고 나면, 이후에 발생하는 diff는 대부분 진짜 회귀입니다. 즉 결정론을 잘 잡을수록 우리가 보게 되는 실패의 신호 대 잡음 비율(signal-to-noise)이 극적으로 좋아집니다.


4. 기준 이미지(Baseline) 생성과 버전 관리 전략

Git에 직접 커밋하는 방식이 가장 단순합니다. Git LFS를 사용하면 저장소 크기 증가를 완화할 수 있습니다.

git lfs install
git lfs track "tests/visual/__snapshots__/**/*.png"
git add .gitattributes

기준 이미지 업데이트는 반드시 의도적인 행위여야 합니다. UPDATE_SNAPSHOTS=true 환경 변수를 명시적으로 설정해야만 갱신되도록 구성합니다. 여기서 핵심 원칙 하나를 세워야 합니다. 기준 이미지는 "코드의 일부"이며, 그 변경은 코드 변경과 똑같은 리뷰 절차를 밟아야 한다는 것입니다. 개발자가 로컬에서 아무 생각 없이 --update-snapshots를 돌려 커밋하면, 회귀가 곧바로 새로운 "정답"으로 굳어져 안전망이 스스로를 무력화합니다.

기준 이미지 변경 PR은 디자이너와 공동 리뷰를 필수로 지정합니다.

# .github/CODEOWNERS
tests/visual/__snapshots__/  @frontend-team @design-team

기준 이미지가 바뀌는 PR은 diff 이미지를 리뷰어가 눈으로 확인하고 "이 변화는 의도된 것"이라고 승인할 때만 병합되어야 합니다. 이 규율은 대규모 리팩터링에서 특히 중요한데, 변경 범위를 통제 가능한 단위로 쪼개는 전략은 거대한 리팩터링 PR을 안전하게 쪼개는 법에서 더 자세히 다룹니다. 스냅숏이 수백 장 바뀌는 PR은 사실상 아무도 제대로 리뷰하지 못하므로, 디자인 토큰 변경 같은 광범위한 수정은 별도 PR로 분리하는 편이 안전합니다.


5. 픽셀 비교 임계값과 안티앨리어싱 처리

비주얼 회귀 테스트에서 가장 골치 아픈 문제는 거짓 실패(False Positive) 입니다. 안티앨리어싱, 서브픽셀 렌더링, OS 폰트 힌팅 차이로 인해 픽셀 값이 미세하게 다르게 나타납니다. Playwright는 이를 완화하기 위해 두 개의 손잡이를 제공합니다. threshold는 개별 픽셀이 "다르다"고 판정되는 색 차이의 허용 범위(0~1의 YIQ 색 공간 거리)이고, maxDiffPixels는 그렇게 다르다고 판정된 픽셀이 몇 개까지 허용되는지의 절대 개수입니다. 이 둘을 함께 조절해야 합니다. threshold만 높이면 미묘한 색 회귀를 놓치고, maxDiffPixels만 키우면 작은 영역의 큰 변화를 놓칩니다.

컴포넌트 유형thresholdmaxDiffPixels
아이콘 · SVG0.0510
텍스트 버튼0.1580
차트 · 그래프0.2200
전체 페이지0.1500

임계값에는 정답이 없습니다. 위 표는 출발점일 뿐이며, 실제로는 팀의 렌더링 환경에서 몇 차례 반복 실행해 "실패하지 않는 최소한의 여유"를 찾아 조정해야 합니다. 임계값을 무작정 키우는 것은 유혹적이지만, threshold를 0.3 이상으로 올리면 브랜드 컬러가 눈에 띄게 바뀌어도 통과하는 지경이 되어 테스트의 존재 의의가 사라집니다. 임계값은 "거짓 실패를 없애는" 도구이지 "진짜 실패를 감추는" 도구가 아니라는 점을 팀 규약으로 못 박아 두는 편이 좋습니다.

특정 영역만 노이즈가 심하다면 임계값을 전역으로 키우는 대신 그 영역을 마스킹하는 편이 정확합니다. 광고 배너, 사용자 아바타, 실시간 그래프처럼 본질적으로 매번 달라지는 요소는 mask 옵션으로 가려 비교에서 제외합니다.

test('로딩 스켈레톤 비주얼 테스트', async ({ page }) => {
  await page.goto('/stories/skeleton');

  // CSS 애니메이션을 강제로 정지
  await page.addStyleTag({
    content: `
      *, *::before, *::after {
        animation-duration: 0s !important;
        transition-duration: 0s !important;
      }
    `,
  });

  const skeleton = page.locator('[data-testid="skeleton-card"]');
  await expect(skeleton).toHaveScreenshot('skeleton-card.png', {
    // 매번 달라지는 영역은 마스킹해 비교에서 제외
    mask: [page.locator('[data-testid="live-avatar"]')],
  });
});

6. Docker 환경에서 폰트·OS 렌더링 일관성 확보

비주얼 회귀 테스트의 가장 큰 함정은 로컬 맥북에서는 통과하고 CI(Linux)에서는 실패하는 현상입니다. 원인은 명확합니다. macOS와 Linux는 폰트 렌더링 엔진(각각 CoreText와 FreeType)이 다르고, 설치된 폰트 자체도 다릅니다. 같은 "맑은 고딕" 텍스트라도 안티앨리어싱 방식과 자간이 미묘하게 달라 픽셀이 일치하지 않습니다. 해결책은 기준 이미지를 생성하는 환경과 CI에서 비교하는 환경을 동일한 Docker 이미지로 통일하는 것입니다.

FROM mcr.microsoft.com/playwright:v1.49.0-jammy

WORKDIR /app

RUN apt-get update && apt-get install -y \
    fonts-noto-cjk \
    fonts-noto-color-emoji \
    && rm -rf /var/lib/apt/lists/*

RUN fc-cache -fv

COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile

COPY . .

ENV CI=true
ENV UPDATE_SNAPSHOTS=false

CMD ["pnpm", "exec", "playwright", "test", "--reporter=html"]
# 로컬에서 Docker로 기준 이미지 생성
docker run --rm \
  -v $(pwd)/tests/visual/__snapshots__:/app/tests/visual/__snapshots__ \
  -e UPDATE_SNAPSHOTS=true \
  playwright-vrt \
  pnpm exec playwright test --update-snapshots

폰트 버전을 고정하고, Docker 이미지 태그도 고정하는 것이 중요합니다. mcr.microsoft.com/playwright:v1.49.0-jammy처럼 정확한 버전을 박아 두어야 하며, latest 태그는 절대 쓰지 않습니다. 베이스 이미지가 조용히 바뀌면 어느 날 갑자기 모든 스냅숏이 깨지고, 그 원인을 추적하는 데 반나절이 날아갑니다. 한국어 UI를 다룬다면 fonts-noto-cjk 같은 CJK 폰트를 반드시 이미지에 포함해야 합니다. 그렇지 않으면 한글이 두부(□)로 렌더링되어 기준 이미지부터 잘못 만들어집니다.


7. GitHub Actions 워크플로우 통합과 아티팩트 업로드

name: Visual Regression Test

on:
  pull_request:
    branches: [main]

jobs:
  visual-test:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.49.0-jammy

    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - run: pnpm install --frozen-lockfile

      - name: Install CJK fonts
        run: |
          apt-get update -qq
          apt-get install -y --no-install-recommends fonts-noto-cjk
          fc-cache -fv

      - name: Build Storybook
        run: pnpm build-storybook

      - name: Run visual tests
        run: |
          pnpm dlx serve storybook-static --port 6006 &
          sleep 5
          pnpm exec playwright test --reporter=html,json
        env:
          CI: true
          UPDATE_SNAPSHOTS: false

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-visual-report-${{ github.run_id }}
          path: playwright-report/
          retention-days: 14

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: visual-diff-${{ github.run_id }}
          path: tests/visual/__snapshots__/**/*-diff.png
          retention-days: 7

GitHub Actions 아티팩트 문서에 따르면 actions/upload-artifact@v4는 워크플로우 실행당 최대 10GB를 저장할 수 있으며, 기본 보존 기간은 90일입니다. 컨테이너를 container: 필드로 지정해 테스트가 로컬 기준 이미지를 만든 것과 동일한 이미지 안에서 돌게 한 점이 이 워크플로우의 핵심입니다. if: always()로 리포트를 항상 올리고, if: failure()로 실패 시에만 diff 이미지를 별도 아티팩트로 올려 리뷰어가 빠르게 원인 이미지를 내려받게 합니다. CI 파이프라인 전반의 설계 원칙이 궁금하다면 GitHub Actions로 CI/CD 파이프라인 구축하기를 함께 참고할 수 있습니다.


8. PR 리뷰에 diff 이미지 자동 첨부하기

name: Visual Regression Comment

on:
  workflow_run:
    workflows: [Visual Regression Test]
    types: [completed]

jobs:
  comment-on-pr:
    runs-on: ubuntu-latest
    if: github.event.workflow_run.conclusion == 'failure'

    permissions:
      pull-requests: write
      actions: read

    steps:
      - name: Comment on PR
        uses: actions/github-script@v7
        with:
          script: |
            const prNumber = context.payload.workflow_run.pull_requests[0]?.number;
            if (!prNumber) return;

            const runId = ${{ github.event.workflow_run.id }};
            const artifactUrl =
              `https://github.com/${{ github.repository }}/actions/runs/${runId}`;

            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: prNumber,
              body: `## 비주얼 회귀 테스트 실패\n\nUI 픽셀 차이가 감지되었습니다.\n\n[리포트 보기](${artifactUrl})`,
            });

이 워크플로우는 workflow_run 이벤트를 사용해 비주얼 테스트 워크플로우가 완료된 직후 트리거됩니다. 여기서 workflow_run을 쓰는 이유가 있습니다. 포크(fork)에서 올라온 PR의 워크플로우는 보안상 쓰기 권한이 제한되어 PR에 코멘트를 달 수 없는데, workflow_run은 기본 브랜치의 신뢰된 컨텍스트에서 실행되므로 pull-requests: write 권한을 안전하게 획득할 수 있기 때문입니다. 자동 코멘트의 목적은 단순합니다. 개발자가 CI 로그를 뒤지지 않고도 "무엇이, 어떻게 달라졌는지"를 곧바로 눈으로 확인하게 만들어, 비주얼 실패를 방치하지 않고 즉시 판단하도록 유도하는 것입니다.


본문 중간 핵심 흐름을 시각화한 Development 일러스트

9. 컴포넌트 단위 vs 페이지 단위 테스트 전략

컴포넌트 단위: Storybook Story 활용. 안정성·속도 모두 우수. 각 Story는 이미 상태가 고정된 격리 환경이므로 결정성을 확보하기 쉽고, 하나가 깨져도 원인이 명확합니다.

페이지 단위: 실제 라우트, 네트워크 모킹 필요, 동적 콘텐츠 고정 필요. 실제 사용자가 보는 조합된 화면을 검증한다는 장점이 있지만, 그만큼 변동 요인이 많아 유지 비용이 높습니다.

test('상품 목록 페이지 비주얼 회귀', async ({ page }) => {
  // 날짜·시간 고정
  await page.clock.setFixedTime(new Date('2026-01-15T09:00:00'));

  await page.route('**/api/products**', async (route) => {
    await route.fulfill({
      contentType: 'application/json',
      body: JSON.stringify({
        data: [
          { id: 'p1', name: '무선 키보드', price: 89000 },
          { id: 'p2', name: '기계식 마우스', price: 65000 },
        ],
      }),
    });
  });

  await page.goto('/products');
  await page.waitForLoadState('networkidle');

  await expect(page).toHaveScreenshot('product-list-page.png', {
    fullPage: false,
    maxDiffPixels: 300,
  });
});

경험적으로 컴포넌트 단위 80%, 페이지 단위 20% 비율이 유지 비용 대비 효과가 좋습니다. 컴포넌트 단위 테스트로 디자인 시스템의 원자적 요소들을 촘촘히 방어하고, 페이지 단위 테스트는 핵심 랜딩·결제·목록처럼 비즈니스적으로 중요한 화면 몇 개에만 집중하는 식입니다. 이 네트워크 모킹은 통합 테스트에서 쓰는 MSW 접근과 사상이 같으며, 그 설계는 Vitest + MSW 통합 테스트 글과 함께 보면 일관된 그림이 그려집니다.


10. 크로스 브라우저·디바이스 매트릭스 설계

같은 마크업도 렌더링 엔진에 따라 다르게 그려집니다. Chromium과 WebKit은 폰트 렌더링, border-radius 안티앨리어싱, 폼 컨트롤 기본 스타일에서 차이를 보입니다. Playwright는 하나의 설정으로 여러 브라우저·뷰포트를 프로젝트로 선언할 수 있습니다.

// playwright.config.ts (projects 확장)
projects: [
  { name: 'desktop-chrome', use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 720 } } },
  { name: 'desktop-safari', use: { ...devices['Desktop Safari'] } },
  { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
],

주의할 점은 스냅숏 파일명이 프로젝트·플랫폼별로 분리된다는 것입니다. Playwright는 기준 이미지 파일명에 {projectName}과 OS 접미사를 붙이므로, 브라우저 3종 × 뷰포트 2종을 돌리면 컴포넌트 하나당 기준 이미지가 6장으로 늘어납니다. 매트릭스를 무작정 키우면 기준 이미지 관리 비용과 CI 실행 시간이 곱셈으로 증가하므로, "실제로 지원을 약속한 조합"만 골라 최소한으로 유지하는 절제가 필요합니다. 모바일 반응형이 중요한 서비스라면 데스크톱 1종 + 모바일 1종처럼 대표 조합으로 압축하는 편이 현실적입니다.


11. Percy·Chromatic 등 SaaS와의 비용·기능 비교

Chromatic 공식 문서에 따르면 Chromatic은 Storybook과 네이티브로 통합되어 Story 단위로 스크린샷을 찍고 클라우드에서 비교합니다.

항목셀프 호스팅 PlaywrightPercyChromatic
기준 이미지 저장Git LFS / S3클라우드클라우드
디자이너 리뷰 UIPR 코멘트내장내장
월 무료 한도무제한5,000장5,000장
커스터마이징높음중간낮음

소규모 팀이나 Storybook을 적극 사용하는 팀이라면 Chromatic이 초기 설정 비용을 낮춰줍니다. 디자인 시스템 규모가 크거나 보안상 코드 외부 업로드가 제한되는 환경이라면 셀프 호스팅이 현실적입니다. SaaS의 가장 큰 가치는 사실 스크린샷 비교 엔진 자체가 아니라, 클라우드에서 렌더링 환경을 완벽히 통일해 주고 디자이너용 승인 UI를 제공한다는 점에 있습니다. 우리가 6장에서 Docker로 힘겹게 확보한 환경 일관성을 SaaS는 기본으로 제공합니다. 따라서 선택은 "환경 통일과 리뷰 UI를 직접 운영할 여력이 있는가" 대 "월 구독료를 내고 그 부담을 위탁할 것인가"의 트레이드오프로 귀결됩니다.


12. 실패 트리아지: diff가 떴을 때 무엇을 보는가

비주얼 테스트가 실패하면 팀은 세 갈래 중 하나를 선택해야 합니다. Playwright는 실패 시 actual, expected, diff 세 장의 이미지를 리포트에 남기므로, 이 셋을 겹쳐 보며 판단합니다.

  1. 의도된 변경: 디자인이 실제로 바뀌었고 그게 맞다면, 기준 이미지를 갱신(--update-snapshots)하고 diff를 리뷰에 첨부해 승인받습니다.
  2. 진짜 회귀: 의도하지 않은 스타일·레이아웃 변화라면 코드를 고칩니다. 이것이 우리가 이 파이프라인을 만든 이유입니다.
  3. 거짓 실패: diff가 전면에 얇게 흩뿌려진 노이즈 형태라면 환경 비결정성입니다. 3·6장으로 돌아가 애니메이션·폰트·환경을 다시 점검하고, 임계값이 아니라 원인을 고칩니다.

여기서 팀이 흔히 저지르는 실수는 거짓 실패를 만날 때마다 반사적으로 threshold를 조금씩 올리는 것입니다. 이 습관이 누적되면 어느새 임계값이 회귀를 통과시킬 만큼 헐거워져, 테스트는 "언제나 초록불이지만 아무것도 못 잡는" 장식품이 됩니다. 거짓 실패는 임계값이 아니라 결정성 확보로 없애야 한다는 원칙을 반복해서 지켜야 합니다.


13. 이 방식을 쓰지 말아야 할 때

비주얼 회귀 테스트는 강력하지만 공짜가 아닙니다. 다음 상황에서는 도입을 미루거나 범위를 좁히는 편이 낫습니다.

  • 디자인이 매주 크게 흔들리는 초기 프로덕트: 기준 이미지가 매번 무의미해져 갱신 비용만 발생합니다. 디자인 시스템이 어느 정도 안정된 뒤 도입하는 편이 좋습니다.
  • 콘텐츠 위주의 동적 화면: 뉴스 피드처럼 매 순간 내용이 바뀌는 화면은 마스킹할 영역이 너무 많아 검증 가치가 떨어집니다.
  • 환경 통일에 투자할 여력이 없는 팀: 6장의 Docker 통일 없이 도입하면 거짓 실패에 지쳐 곧 테스트를 꺼 버리게 됩니다. 이럴 때는 차라리 SaaS로 시작하는 편이 낫습니다.

핵심은 비주얼 회귀를 "모든 화면을 픽셀까지 봉인하는 도구"가 아니라, "변하면 안 되는 안정된 UI 자산을 지키는 안전망"으로 좁게 적용하는 것입니다.


14. 자주 묻는 질문(FAQ)

Q. 기준 이미지가 너무 자주 바뀝니다. 어떻게 해야 하나요?
대부분 결정성 문제이거나 대상 범위가 너무 넓은 경우입니다. 먼저 폰트·애니메이션·시간·네트워크를 고정했는지 점검하고, 그래도 잦다면 전체 페이지 대신 컴포넌트 단위로 대상을 좁히세요.

Q. 스냅숏 PNG가 저장소를 무겁게 만듭니다.
Git LFS로 바이너리를 분리하거나, 기준 이미지를 S3 같은 외부 스토리지에 두고 CI에서 내려받는 방식을 씁니다. 매트릭스(브라우저×뷰포트)를 지원 약속 범위로 줄이는 것도 효과가 큽니다.

Q. 로컬과 CI 결과가 다릅니다.
거의 항상 렌더링 환경 차이입니다. 기준 이미지 생성과 CI 실행을 반드시 같은 Docker 이미지에서 수행하고, 절대 macOS에서 생성한 기준 이미지를 Linux CI와 비교하지 마세요.

Q. 접근성(a11y)까지 이걸로 검증할 수 있나요?
아니요. 비주얼 회귀는 "보이는 픽셀"만 봅니다. 색 대비, 포커스 순서, 스크린리더 레이블 같은 접근성은 별도 도구로 검증해야 하며, 이 주제는 웹 접근성과 Core Web Vitals에서 다룹니다.


실전 운영 체크리스트

안티패턴: 절대로 피해야 할 것들

  • --update-snapshots 플래그를 CI 파이프라인에 상시 활성화하기
  • 전체 페이지를 fullPage: true로 스크린샷하기
  • 로딩 중 상태를 스크린샷하기 (waitForLoadState('networkidle') 없이)
  • 기준 이미지를 macOS에서 생성하고 Linux CI에서 비교하기
  • threshold0.5 이상으로 설정하기

운영 점검 포인트

  • Docker 이미지 통일: 기준 이미지 생성 환경과 CI 환경이 동일한지 확인.
  • Git LFS 설정: PNG 파일이 LFS로 트래킹되는지 점검.
  • CODEOWNERS 등록: 디자인 팀이 필수 리뷰어로 지정.
  • 동적 콘텐츠 고정: page.clock 또는 API 모킹.
  • 애니메이션 비활성화: reducedMotion: 'reduce' + 인라인 스타일 주입.
  • 매트릭스 절제: 지원을 약속한 브라우저·뷰포트 조합만 유지.

운영 관점 정리

비주얼 회귀 테스트 파이프라인은 "한 번 설정하면 끝"이 아닙니다. 디자인 시스템이 진화하고 컴포넌트가 추가될수록 기준 이미지도 함께 관리해야 합니다. 핵심은 업데이트를 어렵게 만드는 것이 아니라 업데이트를 명시적이고 추적 가능하게 만드는 것입니다.

그리고 이 모든 구성의 밑바탕에는 결정론이 있습니다. 같은 입력에 언제나 같은 픽셀이 나오도록 환경을 통제하지 못하면, 아무리 정교한 CI 워크플로우도 거짓 실패의 늪에서 신뢰를 잃습니다. 반대로 결정성만 확실히 잡으면 나머지는 자연스럽게 따라옵니다. Playwright의 toHaveScreenshot() API, Docker 기반 렌더링 환경 통일, GitHub Actions 아티팩트 업로드, PR 자동 코멘트까지 이 글에서 다룬 구성 요소들이 맞물리면, 디자이너와 개발자가 같은 diff 이미지를 보며 대화하는 협업 구조가 자연스럽게 만들어집니다.