E2E 테스트는 로컬에서는 잘 도는데 CI에 붙이는 순간 무너지는 경우가 유독 많다. 어제까지 초록불이던 파이프라인이 오늘은 같은 코드로 빨간불이 뜨고, 재실행하면 통과한다. 이 “가끔 실패(flaky)”가 쌓이면 팀은 E2E 결과를 신뢰하지 않게 되고, 결국 실패해도 무시하거나 아예 CI에서 떼어낸다. 애써 만든 테스트가 관상용으로 전락하는 것이다.

Playwright는 자동 대기(auto-waiting), 브라우저 격리, 워커 기반 병렬 실행, 재시도와 트레이스를 기본으로 갖춘 도구지만, 이 기능들을 CI 환경에 맞게 쓰지 않으면 여전히 느리고 불안정하다. 이 글에서는 Playwright를 CI에 안정적으로 붙이는 방법을 병렬 실행을 중심으로 정리한다. 워커와 샤딩의 차이, 상태 격리, 재시도 전략, 실패 진단, 그리고 병렬화가 오히려 독이 되는 경우까지 다룬다.

병렬의 두 축: 워커와 샤드

Playwright의 병렬 실행은 서로 다른 두 층위로 나뉜다. 헷갈리면 튜닝 방향을 잘못 잡게 되므로 먼저 구분해야 한다. 워커(worker)는 한 머신 안에서 뜨는 여러 프로세스이고, 샤드(shard)는 여러 머신에 테스트를 쪼개 나눠주는 단위다.

워커는 CPU 코어 수에 좌우된다. 한 러너에 4코어가 있으면 워커 4개가 각각 독립된 브라우저 컨텍스트로 테스트 파일을 병렬 처리한다. 기본적으로 파일 단위로 병렬화되므로, 같은 파일 안의 테스트는 순차 실행된다. 파일 하나에 테스트를 몰아넣으면 병렬성이 죽는다는 뜻이다.

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

export default defineConfig({
  // CI 러너 코어 수에 맞춰 워커 지정. 미지정 시 코어의 절반을 쓴다.
  // CI에서는 명시하는 편이 재현성이 좋다.
  workers: process.env.CI ? 4 : undefined,

  // 한 파일 안의 테스트도 병렬로 돌리려면 명시적으로 켠다.
  // 단, 파일 내 테스트가 상태를 공유하지 않을 때만 안전하다.
  fullyParallel: true,

  // CI에서 실수로 test.only 를 커밋하면 빌드 실패시킨다.
  forbidOnly: !!process.env.CI,
});

샤드는 워커만으로 부족할 때 등장한다. 테스트가 500개인데 한 머신에서 다 돌리면 15분이 걸린다면, 4대에 나눠 각 머신이 125개씩 처리해 4분 안에 끝낼 수 있다. 샤딩은 --shard=1/4처럼 “몇 분의 몇”을 지정하는 방식이며, Playwright가 테스트를 해시 기반으로 균등 분배한다.

CI에서 워커 수를 정하는 실전 기준

흔한 오해가 “워커를 많이 띄울수록 빠르다”는 것이다. 실제로는 반대가 되기 쉽다. 브라우저 인스턴스는 메모리를 상당히 먹고, 워커가 코어보다 많으면 컨텍스트 스위칭과 메모리 압박으로 각 테스트가 느려진다. 특히 CI 러너는 표기된 vCPU가 공유 코어인 경우가 많아, 로컬 4코어와 CI 4vCPU가 같은 성능을 내지 않는다.

더 위험한 것은 메모리 부족(OOM)이다. 워커가 너무 많아 러너 메모리가 부족하면 브라우저 프로세스가 조용히 죽고, 테스트는 “타임아웃”이나 “페이지 닫힘” 같은 엉뚱한 에러로 실패한다. flaky의 흔한 숨은 원인이다. 워커 수는 코어 수와 메모리 상한 둘 다로 제한해야 한다.

  • 워커 수 ≤ vCPU 수가 기본. 2vCPU 러너라면 워커 2개가 안전한 출발점이다.
  • 브라우저 인스턴스당 대략 300~500MB를 잡고, 러너 메모리를 워커 수로 나눠 여유가 있는지 확인한다.
  • 느려도 안정적인 편이 낫다. 워커를 줄이면 flaky가 줄어드는 경우가 많다.
# .github/workflows/e2e.yml
name: e2e
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest   # 기본 2vCPU / 7GB
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      # 브라우저 바이너리는 캐시하거나 --with-deps 로 시스템 의존성까지 설치
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test --workers=2
        env:
          CI: 'true'

샤딩으로 여러 머신에 분산하기

테스트 수가 수백 개를 넘어 한 머신의 워커 병렬만으로 시간이 부족해지면 샤딩을 도입한다. GitHub Actions의 매트릭스와 결합하면 각 샤드가 별도 러너에서 동시에 돈다. 핵심은 각 샤드가 완전히 독립된 실행이라는 점이다. 서로 상태를 공유하지 않으므로, 테스트끼리 순서 의존이 있으면 샤딩에서 즉시 깨진다.

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false           # 한 샤드 실패로 나머지를 죽이지 않는다
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      # 각 러너는 전체의 1/4 만 실행. shard 값은 매트릭스에서 주입
      - run: npx playwright test --shard=${{ matrix.shard }}/4
      # 샤드별 blob 리포트를 아티팩트로 올려 나중에 병합
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: blob-report-${{ matrix.shard }}
          path: blob-report/
          retention-days: 1

샤딩을 쓰면 리포트가 조각난다. 각 러너가 자기 몫만 알기 때문이다. Playwright의 blob 리포터는 이를 위해 존재한다. 각 샤드가 --reporter=blob으로 blob을 남기고, 테스트 잡이 끝난 뒤 별도 잡에서 모든 blob을 내려받아 npx playwright merge-reports --reporter html로 병합하면 하나의 통합 HTML 리포트가 만들어진다. 이 병합 잡은 일부 샤드가 실패해도 결과를 봐야 하므로 if: !cancelled()로 항상 돌게 둔다.

상태 격리: flaky의 진짜 뿌리

병렬 실행이 불안정한 근본 원인은 대개 코드가 아니라 공유 상태다. 테스트 A가 만든 사용자 계정을 테스트 B가 지우고, 두 워커가 같은 이메일로 회원가입을 시도하고, 앞선 테스트가 남긴 장바구니가 다음 테스트를 오염시킨다. 순차 실행에서는 우연히 순서가 맞아 통과하던 것이, 병렬이 되면 경합으로 터진다.

원칙은 각 테스트가 자기 데이터를 스스로 만들고 정리하는 것이다. 고정된 시드 데이터에 의존하지 말고, 테스트 실행 시점에 유일한(unique) 식별자로 데이터를 생성한다. 워커 인덱스나 타임스탬프를 섞으면 병렬 워커 간 충돌을 원천 차단할 수 있다.

import { test as base } from '@playwright/test';

// 워커 스코프 픽스처: 워커마다 고유한 테스트 계정을 딱 한 번 만든다.
type Fixtures = { account: { email: string; password: string } };

export const test = base.extend<{}, Fixtures>({
  account: [async ({}, use, workerInfo) => {
    // workerInfo.workerIndex 로 워커 간 이메일 충돌을 막는다
    const email = `user-w${workerInfo.workerIndex}-${Date.now()}@example.test`;
    const account = await createTestUser(email, 'pw-secret');

    await use(account);

    // 워커 종료 시 정리. 실패해도 다음 실행을 오염시키지 않는다.
    await deleteTestUser(account.email);
  }, { scope: 'worker' }],
});

백엔드가 공유 데이터베이스 하나뿐이라 격리가 어렵다면, 테스트 데이터에 네임스페이스를 붙이는 방법이 현실적이다. 모든 테스트 리소스에 실행 고유 접두사를 달고, 조회·정리도 그 접두사로만 한정하면 같은 DB에서도 병렬 실행끼리 서로 밟지 않는다.

자동 대기를 믿고 sleep을 걷어내라

flaky의 두 번째 흔한 원인은 임의의 대기다. “여기서 로딩이 좀 걸리니까” 하며 넣은 waitForTimeout(2000)은 로컬에선 충분해도 부하가 걸린 CI 러너에선 부족해 실패한다. 반대로 넉넉히 잡으면 테스트 전체가 느려진다. 어느 쪽이든 손해다.

Playwright의 로케이터는 자동으로 조건을 기다린다. 요소가 나타나고, 보이고, 안정되고, 클릭 가능해질 때까지 기본 타임아웃 안에서 재시도한다. 고정 시간 대기 대신 “무엇을 기다리는지”를 표현하는 web-first assertion을 쓰면 CI 속도와 상관없이 안정적이다.

// 나쁜 예: 부하 걸린 CI 에서 2초가 부족하면 그대로 실패한다
await page.click('#submit');
await page.waitForTimeout(2000);
expect(await page.textContent('.status')).toBe('완료');

// 좋은 예: '완료'가 나타날 때까지 기다린다. 빠르면 빨리, 느리면 타임아웃까지.
await page.getByRole('button', { name: '제출' }).click();
await expect(page.getByTestId('status')).toHaveText('완료');
// 네트워크 응답 자체를 기다리는 것이 더 정확할 때도 있다
await page.waitForResponse(r => r.url().includes('/api/order') && r.ok());

선택자도 안정성에 직결된다. CSS 클래스나 DOM 구조에 의존하면 리팩터링 한 번에 무더기로 깨진다. 역할(role)·레이블 기반 로케이터나 data-testid를 우선하면 마크업이 바뀌어도 테스트가 살아남는다.

재시도는 안전망이지 해결책이 아니다

Playwright는 CI에서 실패한 테스트를 자동 재시도하도록 설정할 수 있다. 이는 드물게 발생하는 인프라성 흔들림(순간적 네트워크 지연 등)을 흡수하는 안전망으로는 유용하다. 하지만 재시도를 늘려 flaky를 덮는 것은 부채를 쌓는 일이다. 재시도로 통과한 테스트는 근본 원인이 남아 있고, 언젠가 재시도로도 안 되는 순간 터진다.

export default defineConfig({
  // CI 에서만 2회 재시도. 로컬은 0회로 두어 flaky 를 즉시 드러낸다.
  retries: process.env.CI ? 2 : 0,

  // 실패한 테스트만 재시도 시 트레이스를 남긴다. 항상 켜면 용량이 커진다.
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
});

중요한 건 재시도를 가시화하는 것이다. Playwright는 “재시도 후 통과한(flaky)” 테스트를 리포트에 별도로 표시한다. 이 목록을 무시하지 말고 주기적으로 검토해야 한다. flaky 테스트에는 대개 공유 상태나 임의 대기 같은 실제 결함이 숨어 있으며, 이를 방치하면 재시도 예산을 갉아먹다가 어느 날 파이프라인 전체를 마비시킨다.

실패를 재현하지 못하면 고칠 수 없다

CI에서 E2E가 깨졌을 때 가장 힘든 건 로컬에서 재현이 안 되는 것이다. 헤드리스 환경, 다른 화면 크기, 느린 러너, 없는 폰트가 원인이지만 로그만으로는 알 수 없다. Playwright의 트레이스(trace)가 이 지점을 결정적으로 해결한다. 트레이스에는 각 단계의 DOM 스냅샷, 네트워크 요청, 콘솔 로그, 액션 타임라인이 통째로 담긴다. 위 설정처럼 trace: 'on-first-retry'로 두면 실패해 재시도된 테스트만 트레이스를 남기고, 이 파일을 아티팩트로 올려두면 CI 실패를 로컬에서 그대로 되감아 볼 수 있다.

# CI 아티팩트로 내려받은 trace.zip 을 로컬에서 재생
npx playwright show-trace trace.zip

# 실패를 로컬에서 재현할 때는 CI 와 조건을 맞춘다
# 헤드리스로, 실패한 그 테스트만, 재시도 없이 단독 실행
npx playwright test tests/checkout.spec.ts --headed=false 
  --grep "결제 완료" --retries=0 --workers=1

스크린샷과 비디오도 실패·재시도 시점에만 남기는 편이 실용적이다. trace: 'on'이나 항상 비디오를 켜는 설정은 대량 테스트에서 아티팩트 용량과 실행 시간을 크게 늘린다.

병렬화가 오히려 독이 되는 경우

모든 테스트를 병렬화하는 것이 항상 옳지는 않다. 몇몇 시나리오는 본질적으로 순차여야 하거나, 병렬로 돌리면 외부 시스템을 압도한다. 이럴 때는 병렬성을 의도적으로 낮추는 것이 정답이다.

  • 공유 자원을 건드리는 테스트: 전역 설정을 바꾸거나 결제 게이트웨이의 샌드박스 계정을 쓰는 테스트는 동시 실행 시 서로 간섭한다. test.describe.serial()로 묶어 순차화한다.
  • 외부 API 레이트 리밋: 테스트마다 서드파티 API를 호출하는데 워커 8개가 동시에 때리면 429가 쏟아진다. 워커 수를 제한하거나 해당 호출을 목(mock)으로 대체한다.
  • DB 마이그레이션·시딩: 스키마 준비 같은 전역 준비는 globalSetup에서 병렬 실행 전에 한 번만 수행한다.
// 결제 흐름처럼 순서·공유 상태에 민감한 묶음은 직렬로 고정
import { test } from '@playwright/test';

test.describe.serial('결제 파이프라인', () => {
  test('장바구니 담기', async ({ page }) => { /* ... */ });
  test('배송지 입력', async ({ page }) => { /* ... */ });
  // 앞 단계가 실패하면 뒤 단계는 건너뛴다 — 상태 의존을 명시적으로 표현
  test('결제 승인', async ({ page }) => { /* ... */ });
});

목킹을 얼마나 할지도 균형의 문제다. E2E의 가치는 실제 통합을 검증하는 데 있으므로 전부 목으로 바꾸면 의미가 옅어진다. 반대로 불안정하거나 비용이 큰 외부 의존성(결제, 이메일 발송, 외부 검색)은 목으로 대체해 테스트를 결정론적으로 만드는 편이 낫다. 자사 백엔드는 실제로, 통제 밖의 서드파티는 목으로 — 이 선을 그어두면 병렬 실행의 안정성과 검증 가치를 함께 지킬 수 있다.

마무리

Playwright를 CI에 안정적으로 붙이는 일은 특정 옵션 하나로 끝나지 않는다. 워커는 코어와 메모리 양쪽으로 제한해 OOM을 피하고, 테스트가 늘어나면 샤딩과 blob 리포트 병합으로 여러 머신에 분산한다. 무엇보다 flaky의 뿌리인 공유 상태를 워커 스코프 픽스처와 네임스페이스로 격리하고, 임의의 waitForTimeout을 자동 대기 기반 assertion으로 걷어내는 것이 핵심이다. 재시도는 인프라성 흔들림을 흡수하는 안전망일 뿐 flaky를 덮는 수단이 아니며, 트레이스로 실패를 재현 가능하게 만들어야 비로소 고칠 수 있다. 병렬화는 강력하지만 만능은 아니어서, 공유 자원과 외부 레이트 리밋 앞에서는 의도적으로 직렬화하는 판단이 필요하다. 결국 목표는 “빠른 테스트”가 아니라 신뢰할 수 있는 테스트다. 팀이 초록불을 믿게 되는 순간, E2E는 비로소 제 값을 한다.

자주 묻는 질문

Q. 워커를 늘렸는데 테스트가 더 느려졌습니다. 왜인가요?
A. 워커 수가 러너의 vCPU를 넘으면 컨텍스트 스위칭과 메모리 경합으로 각 테스트가 느려집니다. 브라우저 인스턴스는 수백 MB의 메모리를 쓰므로, 워커가 많으면 스와핑이나 OOM으로 오히려 전체 시간이 늘어납니다. 워커 수를 vCPU 수 이하로 낮추고, 더 빠르게 만들고 싶다면 워커 대신 샤딩으로 머신을 늘리는 방향이 맞습니다.

Q. 특정 테스트만 CI에서 계속 flaky합니다. 재시도 횟수를 늘리면 되나요?
A. 임시방편은 되지만 근본 해결은 아닙니다. 대개 원인은 공유 상태 오염이나 임의의 waitForTimeout, 또는 안정되지 않은 요소를 조작하는 선택자입니다. 해당 테스트의 트레이스를 show-trace로 열어 실패 순간의 DOM과 네트워크를 확인하고, 자동 대기 기반 assertion과 워커 스코프 격리로 고치는 것이 정석입니다. 재시도는 인프라성 흔들림용 안전망으로만 두세요.