홈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

소개JSON Formatter개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

소개|JSON Formatter|개인정보처리방침|이용약관

Playwright E2E 테스트: 프론트엔드와 백엔드를 동시에 검증하는 실전 가이드

정기창·2026년 1월 23일
테스트 코드를 작성하다 보면 어느 순간 이런 의문이 들었습니다. "단위 테스트는 다 통과하는데, 왜 실제로 사용하면 버그가 발생하는 걸까?"

곰곰이 생각해보니 당연한 일이었습니다. 단위 테스트는 개별 함수가 올바르게 동작하는지 확인할 뿐, 사용자가 실제로 버튼을 클릭하고, 폼을 입력하고, API 응답을 받아 화면에 표시되는 전체 흐름은 검증하지 못합니다.

그래서 E2E(End-to-End) 테스트가 필요하다는 결론에 이르렀습니다. 이 글에서는 Cypress와 Playwright를 비교하고, 실제 프로젝트에 Playwright를 적용한 과정을 정리해보았습니다.

테스트 피라미드에서 E2E의 위치

테스트 전략을 이야기할 때 흔히 "테스트 피라미드"를 언급합니다. 아래에서 위로 갈수록 테스트 범위는 넓어지고, 실행 속도는 느려집니다.

                    ▲
                   ╱ ╲       E2E Test
                  ╱   ╲      - 전체 시스템, 실제 브라우저
                 ╱     ╲     - 느림, 비용 높음
                ╱───────╲
               ╱         ╲   Integration Test
              ╱           ╲  - 여러 모듈 조합
             ╱─────────────╲ - 중간 속도
            ╱               ╲
           ╱                 ╲  Unit Test
          ╱                   ╲ - 개별 함수/컴포넌트
         ╱─────────────────────╲- 빠름, 비용 낮음

각 테스트가 답하는 질문은 다릅니다:

  • Unit Test: "이 함수가 제대로 동작하나?"
  • Integration Test: "이 모듈들이 함께 동작하나?"
  • E2E Test: "사용자가 목표를 달성할 수 있나?"

결국 E2E 테스트는 "실제 사용자 관점에서 시스템이 제대로 동작하는가"를 검증합니다. 브라우저를 실제로 띄우고, 백엔드 서버와 통신하며, 데이터베이스에 실제 데이터가 저장되는지까지 확인하는 것입니다.

Cypress vs Playwright

E2E 테스트 도구로 가장 많이 거론되는 것이 Cypress와 Playwright입니다. 둘 다 훌륭한 도구지만, 근본적인 아키텍처 차이가 있습니다.

아키텍처 차이

Cypress는 브라우저 내부에서 실행됩니다. 테스트 코드가 애플리케이션과 같은 컨텍스트에서 동작하기 때문에 디버깅 경험이 뛰어납니다. Time-travel 디버깅, 풍부한 에러 메시지 등이 큰 장점입니다.

Playwright는 브라우저 외부에서 실행됩니다. Chrome DevTools Protocol을 통해 브라우저를 제어하기 때문에 더 유연하고 강력한 제어가 가능합니다.

주요 비교

항목 Cypress Playwright
브라우저 지원 Chrome, Edge, Firefox Chrome, Edge, Firefox, Safari
언어 지원 JavaScript/TypeScript JS/TS, Python, Java, C#
병렬 실행 외부 설정 필요 네이티브 지원
다중 탭/창 제한적 완벽 지원
네트워크 모킹 지원 강력한 지원
학습 곡선 낮음 중간
디버깅 경험 매우 우수 우수

Playwright를 선택한 이유

이번 프로젝트에서 Playwright를 선택한 이유는 몇 가지가 있습니다:

  1. 네이티브 병렬 실행: CI/CD 파이프라인에서 테스트 시간은 곧 비용입니다. Playwright는 추가 설정 없이 병렬 실행을 지원합니다.
  2. API 테스트 통합: 브라우저 테스트와 API 테스트를 하나의 프레임워크에서 처리할 수 있습니다. page.waitForResponse()로 특정 API 호출을 가로채서 응답까지 검증할 수 있다는 점이 유용했습니다.
  3. 다중 브라우저 지원: 필요에 따라 여러 브라우저에서 테스트를 실행할 수 있습니다.

다만 Cypress의 디버깅 경험이 더 뛰어난 것은 사실입니다. Time-travel 기능은 테스트 실패 원인을 파악할 때 편리합니다. 팀 상황이나 프로젝트 요구사항에 따라 선택이 달라질 수 있겠습니다.

Playwright E2E 테스트의 동작 원리

Playwright E2E 테스트가 어떻게 동작하는지 이해하면 더 효과적으로 활용할 수 있습니다.

실행 흐름

pnpm test:e2e 실행
       ↓
┌─────────────────────────────────┐
│  1. Playwright가 설정 파일 로드 │
│     (playwright.config.ts)      │
└─────────────────────────────────┘
       ↓
┌─────────────────────────────────┐
│  2. webServer 설정 확인         │
│     → 개발 서버 자동 시작       │
│     → localhost:3003 대기       │
└─────────────────────────────────┘
       ↓
┌─────────────────────────────────┐
│  3. 브라우저 인스턴스 생성      │
│     (Chromium headless 모드)    │
└─────────────────────────────────┘
       ↓
┌─────────────────────────────────┐
│  4. 테스트 케이스 실행          │
│     → 실제 브라우저에서 동작    │
│     → 프론트엔드 렌더링 확인    │
│     → 백엔드 API 호출 및 검증   │
└─────────────────────────────────┘
       ↓
┌─────────────────────────────────┐
│  5. 결과 리포트 생성            │
└─────────────────────────────────┘

핵심은 프론트엔드와 백엔드가 모두 실행된 상태에서 테스트가 진행된다는 점입니다. 단위 테스트와 달리 모킹 없이 실제 시스템을 검증합니다.

설정 예시

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

export default defineConfig({
  testDir: './e2e',
  timeout: 30 * 1000,
  
  // 병렬 실행
  fullyParallel: true,
  
  // 재시도 (flaky 테스트 대응)
  retries: 2,
  
  use: {
    baseURL: 'http://localhost:3003',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  // 브라우저 설정
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],

  // 개발 서버 자동 시작
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:3003',
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
});

webServer 설정이 핵심입니다. 테스트 실행 시 자동으로 개발 서버를 시작하고, 서버가 응답할 때까지 기다린 후 테스트를 진행합니다. 로컬에서 이미 서버가 실행 중이라면 reuseExistingServer: true로 기존 서버를 재사용합니다.

실전: 프론트엔드 렌더링 테스트

E2E 테스트의 첫 번째 목적은 프론트엔드가 의도대로 렌더링되는지 확인하는 것입니다.

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('로그인 페이지', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('로그인 페이지가 정상적으로 로드된다', async ({ page }) => {
    // 제목이 표시되는지 확인
    await expect(page.getByRole('heading', { name: '로그인' })).toBeVisible();
    await expect(page.getByText('글담에 오신 것을 환영합니다')).toBeVisible();
  });

  test('이메일과 비밀번호 입력 필드가 표시된다', async ({ page }) => {
    const emailInput = page.getByLabel('이메일');
    const passwordInput = page.getByLabel('비밀번호');

    await expect(emailInput).toBeVisible();
    await expect(passwordInput).toBeVisible();
  });

  test('회원가입 링크가 작동한다', async ({ page }) => {
    await page.getByRole('link', { name: '회원가입' }).click();
    await expect(page).toHaveURL('/signup');
  });
});

이 테스트들은 단순해 보이지만 중요한 것들을 검증합니다:

  • 페이지 라우팅이 제대로 동작하는가
  • 컴포넌트가 올바르게 렌더링되는가
  • 사용자 인터랙션(클릭)이 의도대로 동작하는가

실전: 백엔드 API 통합 테스트

E2E 테스트의 진정한 가치는 백엔드 API와의 통합을 검증할 때 나타난다고 생각합니다.

// e2e/auth-api.spec.ts
import { test, expect } from '@playwright/test';

// 테스트용 고유 이메일 생성
const generateTestEmail = () =>
  `e2e_${Date.now()}_${Math.random().toString(36).substring(2, 8)}@test.com`;

test.describe('회원가입 API 연동', () => {
  test('실제 회원가입 후 대시보드로 이동한다', async ({ page }) => {
    const testEmail = generateTestEmail();
    const testPassword = 'Test1234!@';

    await page.goto('/signup');

    // 폼 입력
    await page.getByLabel('이름').fill('E2E 테스트 사용자');
    await page.getByLabel('이메일').fill(testEmail);
    await page.getByLabel('비밀번호', { exact: true }).fill(testPassword);
    await page.getByLabel('비밀번호 확인').fill(testPassword);
    await page.getByRole('checkbox').check();

    // API 응답 대기 설정
    const signupPromise = page.waitForResponse(
      (response) =>
        response.url().includes('/saas/auth/signup') && 
        response.request().method() === 'POST'
    );

    // 회원가입 버튼 클릭
    await page.getByRole('button', { name: '회원가입' }).click();

    // API 응답 확인
    const response = await signupPromise;
    expect(response.status()).toBe(201);

    // 응답 데이터 검증
    const responseData = await response.json();
    expect(responseData).toHaveProperty('accessToken');
    expect(responseData.user.email).toBe(testEmail);

    // 리다이렉트 확인
    await expect(page).toHaveURL(/\/(dashboard|$)/);
  });
});

이 테스트는 다음을 검증합니다:

  • 폼 데이터가 올바르게 전송되는가
  • 백엔드 API가 올바른 상태 코드(201)를 반환하는가
  • 응답에 필요한 데이터(accessToken, user)가 포함되어 있는가
  • 인증 후 올바른 페이지로 리다이렉트되는가

page.waitForResponse()를 사용하면 특정 API 호출을 가로채서 응답을 검증할 수 있습니다. 이 기능 덕분에 프론트엔드 동작과 백엔드 응답을 한 번에 확인할 수 있었습니다.

에러 케이스 테스트

성공 케이스만 테스트하면 절반만 검증한 것입니다. 에러 상황도 테스트해야 합니다:

test('잘못된 비밀번호로 로그인 실패', async ({ page }) => {
  await page.goto('/login');

  await page.getByLabel('이메일').fill('test@example.com');
  await page.getByLabel('비밀번호').fill('WrongPassword123!');

  const loginPromise = page.waitForResponse(
    (r) => r.url().includes('/saas/auth/login')
  );
  await page.getByRole('button', { name: '로그인', exact: true }).click();

  const response = await loginPromise;
  expect(response.status()).toBe(401);

  // 에러 메시지 표시 확인
  await expect(
    page.getByText(/이메일 또는 비밀번호가 올바르지 않습니다/)
  ).toBeVisible();
});

API가 401을 반환할 때 프론트엔드가 적절한 에러 메시지를 표시하는지까지 확인합니다. 이런 테스트가 없으면 백엔드는 올바르게 동작하는데 프론트엔드에서 에러 처리가 누락되는 버그를 놓칠 수 있습니다.

도입 시 고려할 점

테스트 데이터 관리

E2E 테스트는 실제 데이터베이스를 사용하기 때문에 테스트 데이터 관리가 중요합니다. 위 예시에서 generateTestEmail()로 매번 고유한 이메일을 생성하는 이유입니다.

const generateTestEmail = () =>
  `e2e_${Date.now()}_${Math.random().toString(36).substring(2, 8)}@test.com`;

테스트 간 격리를 보장하면서도 실제 시스템을 검증할 수 있습니다.

로컬 vs CI 환경 차이

같은 코드라도 환경에 따라 다르게 동작할 수 있습니다:

구분 로컬 (pnpm dev) CI/Docker
서버 Vite Dev Server Nginx (정적 파일)
빌드 없음 (실시간 변환) vite build 결과물
OS macOS Alpine Linux

대소문자 구분(macOS는 무시, Linux는 구분)이나 경로 구분자 차이로 "로컬에서는 되는데 CI에서 안 되는" 문제가 발생할 수 있습니다. E2E 테스트를 CI에서도 실행하면 이런 문제를 조기에 발견할 수 있습니다.

적절한 테스트 범위

모든 것을 E2E로 테스트할 필요는 없습니다. E2E 테스트는 느리고 비용이 높기 때문에 핵심 사용자 플로우에 집중하는 것이 좋겠습니다:

  • 회원가입/로그인 플로우
  • 핵심 기능(글 작성, 결제 등)의 성공 경로
  • 치명적인 에러 케이스

엣지 케이스나 세부 로직은 단위 테스트로 검증하는 것이 효율적입니다.

마무리

E2E 테스트를 도입하면서 몇 가지를 깨달았습니다.

첫째, 단위 테스트만으로는 사용자 경험을 보장할 수 없다는 것입니다. 개별 함수가 올바르게 동작해도 통합 과정에서 문제가 생길 수 있습니다.

둘째, Playwright의 API 응답 검증 기능이 유용했습니다. page.waitForResponse()로 프론트엔드 동작과 백엔드 응답을 한 번에 확인할 수 있어서, 통합 테스트로서의 역할을 제대로 수행할 수 있었습니다.

셋째, 테스트 도구 선택은 프로젝트 상황에 따라 달라집니다. Cypress의 디버깅 경험도 무시할 수 없는 장점입니다. 팀 구성원이 JavaScript에 익숙하고 디버깅 편의성을 중시한다면 Cypress도 좋은 선택이 될 수 있겠습니다.

E2E 테스트가 만능은 아니지만, 테스트 피라미드의 꼭대기에서 "전체 시스템이 사용자 관점에서 동작하는가"를 검증하는 역할은 다른 테스트로 대체하기 어렵다는 생각이 들었습니다.

]]>
PlaywrightE2E 테스트테스트 자동화프론트엔드백엔드Cypress

관련 글

Prettier + husky + lint-staged로 팀 코드 스타일 자동화하기

코드 스타일 논쟁을 없애고 git commit 시 자동으로 포매팅되는 환경을 구축한 경험. Prettier 설정부터 husky + lint-staged 연동, git-blame-ignore-revs까지 실제 적용 과정을 정리했습니다.

관련도 84%

NestJS 마이크로서비스 부하테스트: 전략 설계와 7가지 요구사항

Production 환경을 로컬에서 재현하여 시스템 한계점을 찾는 부하테스트 전략을 설계한 경험. 7가지 요구사항 정의부터 아키텍처 설계까지의 과정을 정리했습니다.

관련도 74%

k6와 실시간 Pool 모니터링으로 시스템 한계점 찾기

k6로 시스템 한계점을 찾는 Breakpoint 테스트와 NestJS Connection Pool 실시간 모니터링 시스템을 구현한 경험. 최적 RPS를 찾기까지의 과정을 정리했습니다.

관련도 73%