
Playwright E2E 테스트: 새벽 3시에 배포했는데 회원가입이 안 됐다
새벽 배포 후 회원가입 플로우가 깨진 걸 유저가 먼저 발견했다. Playwright로 E2E 테스트를 구축하면서 배운 실전 패턴과 CI 연동 방법.

새벽 배포 후 회원가입 플로우가 깨진 걸 유저가 먼저 발견했다. Playwright로 E2E 테스트를 구축하면서 배운 실전 패턴과 CI 연동 방법.
서버를 끄지 않고 배포하는 법. 롤링, 카나리, 블루-그린의 차이점과 실제 구축 전략. DB 마이그레이션의 난제(팽창-수축 패턴)와 AWS CodeDeploy 활용법까지 심층 분석합니다.

롤링 배포, 블루/그린 배포와 카나리 배포는 무엇이 다를까요? 1%의 사용자에게만 먼저 배포하여 위험을 감지하는 카나리 배포의 원리와 Kubernetes/Istio 구현 전략을 정리합니다.

금요일 오후 5시에 배포하는 것이 두려운가요? 기능을 코드에 포함시켜 배포하되, 실제 사용자에게는 보이지 않게 만드는 Feature Flag 기술. 롤백 없이 기능을 끄는 킬 스위치부터, A/B 테스트, 카나리 배포, 그리고 기술 부채 관리까지 안전한 DevOps를 위한 필수 전략을 정리합니다.

많은 회사가 'DevOps 엔지니어'를 채용하지만, 정작 DevOps가 무엇인지 오해하는 경우가 많습니다. 단순히 Jenkins를 돌리고 AWS를 관리하는 것이 DevOps일까요? 개발(Dev)과 운영(Ops)의 벽을 허물고, 비즈니스 가치를 빠르게 전달하기 위한 DevOps의 철학(The Three Ways), 문화, 그리고 CI/CD 파이프라인의 핵심을 파헤칩니다.

새벽 2시 40분에 배포했다. 급하게 고친 인증 로직 패치였다. 로컬에서 돌렸을 때는 됐다. 단위 테스트도 전부 통과했다. "이 정도면 됐다"고 생각하고 잠들었다.
아침 7시에 일어났더니 슬랙에 메시지가 쌓여 있었다.
"회원가입이 안 되네요? 이메일 입력하고 다음 버튼 누르면 아무 반응이 없어요."
"저도 동일한 문제입니다. 가입을 못 하고 있어요."
"혹시 서비스 점검 중인가요?"
유저가 버그를 나보다 먼저 발견했다. 그것도 가장 중요한 플로우에서. 회원가입이 안 되는 서비스는 사실상 동작하지 않는 서비스다. 모든 유저 여정의 시작점이 막혀 있었던 것이다.
배포 후 4시간 동안 아무도 가입을 못 했다. 새벽이라 다행이었다고 위로하기에는, 그게 더 부끄러웠다.
원인을 찾는 건 어렵지 않았다. 이메일 인증 스텝 이후에 세션 토큰을 쿠키에 저장하는 로직이 있었는데, 내가 패치하면서 쿠키 옵션에서 sameSite 값을 잘못 건드렸다. 결과적으로 다음 스텝으로 넘어갈 때 세션이 사라졌다.
단위 테스트는 통과했다. 쿠키를 저장하는 함수만 따로 보면 오류가 없었으니까. 함수 자체는 올바르게 동작했다. 세션 저장도 됐다. 하지만 브라우저가 실제로 다음 요청에서 그 쿠키를 보내는지는 단위 테스트로 확인이 안 된다.
자동차 비유로 정리하면 이렇다. 단위 테스트는 엔진을 분리해서 돌려보는 것이다. 오일이 도는지, 피스톤이 움직이는지 각 부품을 하나씩 확인한다. 다 정상이다. 그런데 실제로 조립해서 도로에 나갔더니 기어가 안 맞아서 출발이 안 된다. 부품이 멀쩡해도 전체를 조립해서 달려보는 시험이 따로 필요하다.
E2E(End-to-End) 테스트가 바로 그것이다. 실제 브라우저를 띄우고, 실제 사용자처럼 클릭하고 타이핑하면서, 전체 플로우가 처음부터 끝까지 작동하는지 확인한다.
E2E 테스트 도구가 몇 가지 있다. Cypress, Selenium, Puppeteer. 그 중에서 Playwright를 택했다.
선택 이유를 한 줄로 요약하면: 기다려주기 때문이다.
Playwright는 기본적으로 자동 대기(auto-wait) 를 한다. 버튼을 클릭하라고 하면 그 버튼이 클릭 가능한 상태가 될 때까지 기다린다. 텍스트를 확인하라고 하면 그 텍스트가 DOM에 나타날 때까지 기다린다. 직접 sleep(1000) 같은 하드코딩된 대기를 넣을 필요가 없다.
Selenium로 E2E 테스트를 짠 적이 있는 팀들이 자주 하는 불평이 있다. "테스트가 너무 자주 깨진다." 대부분의 원인은 타이밍이다. 페이지가 느릴 때, 서버 응답이 늦을 때, 애니메이션이 채 끝나지 않았을 때 — 테스트는 무정하게 다음 단계로 진행하고 실패한다.
Playwright의 자동 대기는 이 문제를 기본으로 해결한다. 불안정한(flaky) 테스트의 가장 흔한 원인을 없애준다.
또 하나. 로케이터(Locator) API가 직관적이다.
// CSS 선택자를 외우지 않아도 된다
await page.getByRole('button', { name: '다음' }).click();
await page.getByLabel('이메일').fill('user@example.com');
await page.getByText('가입이 완료되었습니다').isVisible();
getByRole, getByLabel, getByText — 사용자가 화면을 보는 방식으로 요소를 찾는다. div.auth-form > .step-2 > button:nth-child(2) 같은 CSS 선택자를 짜지 않아도 된다. 코드가 UI 구조 변경에 훨씬 덜 깨진다.
바로 그 사건의 원흉, 회원가입 플로우를 테스트로 작성하면 이렇다.
// tests/auth/signup.spec.ts
import { test, expect } from '@playwright/test';
test.describe('회원가입 플로우', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/signup');
});
test('이메일로 정상 가입', async ({ page }) => {
// 1단계: 이메일 입력
await page.getByLabel('이메일').fill('newuser@example.com');
await page.getByRole('button', { name: '다음' }).click();
// 인증 코드 발송 확인
await expect(page.getByText('인증 코드를 발송했습니다')).toBeVisible();
// 2단계: 인증 코드 입력 (테스트 환경에서는 고정 코드 사용)
await page.getByLabel('인증 코드').fill('123456');
await page.getByRole('button', { name: '확인' }).click();
// 3단계: 프로필 입력
await page.getByLabel('닉네임').fill('테스트유저');
await page.getByLabel('비밀번호').fill('SecurePass123!');
await page.getByRole('button', { name: '가입 완료' }).click();
// 가입 완료 확인 — 여기서 세션이 살아있는지가 핵심
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('환영합니다, 테스트유저')).toBeVisible();
});
test('이미 가입된 이메일로 시도', async ({ page }) => {
await page.getByLabel('이메일').fill('existing@example.com');
await page.getByRole('button', { name: '다음' }).click();
await expect(
page.getByText('이미 가입된 이메일입니다')
).toBeVisible();
// 페이지가 1단계에 머물러 있어야 한다
await expect(page.getByLabel('이메일')).toBeVisible();
});
});
이 테스트가 있었다면 새벽 배포 전에 실패했을 것이다. await expect(page).toHaveURL('/dashboard') 에서. 세션이 사라져서 가입 완료 후 대시보드로 이동하지 못했을 테니.
테스트가 3개, 5개일 때는 괜찮다. 20개가 넘어가면 문제가 생긴다. 같은 요소 선택자가 여러 테스트에 흩어져 있다. UI가 바뀌면 20군데를 다 수정해야 한다.
페이지 오브젝트 패턴(Page Object Pattern)은 이 문제를 해결한다. 페이지와의 상호작용을 클래스로 캡슐화한다.
// tests/pages/SignupPage.ts
import { Page, expect } from '@playwright/test';
export class SignupPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/signup');
}
async fillEmail(email: string) {
await this.page.getByLabel('이메일').fill(email);
await this.page.getByRole('button', { name: '다음' }).click();
}
async fillVerificationCode(code: string) {
await this.page.getByLabel('인증 코드').fill(code);
await this.page.getByRole('button', { name: '확인' }).click();
}
async fillProfile(nickname: string, password: string) {
await this.page.getByLabel('닉네임').fill(nickname);
await this.page.getByLabel('비밀번호').fill(password);
await this.page.getByRole('button', { name: '가입 완료' }).click();
}
async expectSuccess() {
await expect(this.page).toHaveURL('/dashboard');
}
async expectEmailError(message: string) {
await expect(this.page.getByText(message)).toBeVisible();
}
}
// tests/auth/signup.spec.ts — 페이지 오브젝트 사용 후
import { test } from '@playwright/test';
import { SignupPage } from '../pages/SignupPage';
test('이메일로 정상 가입', async ({ page }) => {
const signupPage = new SignupPage(page);
await signupPage.goto();
await signupPage.fillEmail('newuser@example.com');
await signupPage.fillVerificationCode('123456');
await signupPage.fillProfile('테스트유저', 'SecurePass123!');
await signupPage.expectSuccess();
});
테스트 코드가 시나리오를 읽는 것처럼 보인다. 구현 세부사항은 SignupPage 클래스 안에 감춰져 있다. 이메일 입력 필드의 label 텍스트가 바뀌어도 SignupPage.ts 한 군데만 고치면 된다.
도서관 비유로 정리하면 이렇다. 페이지 오브젝트는 도서관 사서 같다. "경제학 책 찾아줘"라고 하면 사서가 어디 가서 어떻게 찾는지는 신경 쓸 필요가 없다. 나는 원하는 것만 말하면 된다. 서가 배치가 바뀌어도(UI가 변경돼도) 사서(페이지 오브젝트)만 업데이트하면 되고, 내 요청 방식(테스트 시나리오)은 그대로다.
로컬에서 테스트가 통과하는 건 충분하지 않다. 배포 전에 자동으로 돌아야 의미가 있다. GitHub Actions에 Playwright를 연동하는 설정은 이렇다.
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test
env:
BASE_URL: ${{ secrets.STAGING_URL }}
TEST_EMAIL: ${{ secrets.TEST_EMAIL }}
TEST_VERIFICATION_CODE: ${{ secrets.TEST_CODE }}
# 실패했을 때 스크린샷과 트레이스를 아티팩트로 저장
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
마지막 스텝이 핵심이다. 테스트가 실패하면 Playwright가 자동으로 스크린샷과 비디오를 남긴다. upload-artifact로 GitHub Actions에 첨부해두면, 실패 원인을 보러 로컬 환경을 재현할 필요가 없다. 아티팩트를 다운받아서 Playwright의 HTML 리포트로 열면, 어느 스텝에서 무엇이 보이는 상태로 실패했는지 영상으로 확인할 수 있다.
playwright.config.ts에서 실패 시 아티팩트 수집을 활성화해두어야 한다.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, // CI에서는 불안정 테스트를 2회 재시도
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
screenshot: 'only-on-failure', // 실패 시에만 스크린샷
video: 'retain-on-failure', // 실패 시에만 비디오
trace: 'on-first-retry', // 첫 재시도 시 트레이스 수집
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
retries: 2 설정이 있다. CI 환경은 로컬보다 불안정하다. 네트워크 지연, 리소스 경합 등으로 타이밍 문제가 생길 수 있다. 2회 재시도를 허용하되, 3번 모두 실패하면 진짜 문제가 있는 것이다. 이 설정 덕분에 "CI에서는 가끔 깨지는 테스트"를 많이 줄였다.
E2E 테스트에서 가장 골치 아픈 건 가끔 실패하는 테스트다. 10번 돌리면 9번 성공하고 1번 실패한다. 실패를 재현하기도 어렵다.
원인은 대부분 두 가지다.
첫째, 타이밍. 앞서 말한 것처럼 Playwright의 자동 대기가 대부분을 해결한다. 하지만 직접 만든 커스텀 컴포넌트, 특히 애니메이션이 포함된 모달이나 드로어는 여전히 문제가 생길 수 있다. 이때는 명시적으로 상태를 기다린다.
// 나쁜 방법: 임의의 시간 대기
await page.waitForTimeout(1000);
// 좋은 방법: 상태를 기다린다
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('dialog')).not.toHaveAttribute('aria-hidden', 'true');
둘째, 테스트 간 상태 공유. 테스트 A가 데이터베이스에 유저를 만들고, 테스트 B가 "해당 이메일이 없을 때"를 테스트하는데 A가 먼저 돌아서 유저가 이미 존재한다. 이런 상황이다.
해결책은 테스트 격리다. 각 테스트는 자기 데이터를 직접 만들고, 테스트가 끝나면 정리해야 한다.
test.beforeEach(async ({ request }) => {
// API로 테스트용 유저 생성
await request.post('/api/test/seed-user', {
data: { email: 'testuser@example.com', code: '123456' },
});
});
test.afterEach(async ({ request }) => {
// 테스트 후 정리
await request.delete('/api/test/cleanup-user', {
data: { email: 'testuser@example.com' },
});
});
테스트 환경에서만 쓰이는 시드(seed)/클린업(cleanup) API 엔드포인트를 만들어두면 관리가 편하다. 프로덕션에서는 NODE_ENV 체크로 비활성화한다.
병렬 실행도 언급해야 한다. playwright.config.ts의 fullyParallel: true는 테스트를 동시에 여러 개 돌린다. 테스트가 완전히 격리되어 있으면 속도를 크게 줄일 수 있다. 격리가 안 되어 있으면 병렬 실행에서 데이터 충돌이 나서 불안정 테스트의 원인이 된다. 격리와 병렬화는 함께 가야 한다.
단위 테스트와 E2E 테스트는 다른 걸 잡는다. 단위 테스트는 부품이 올바른지 확인한다. E2E 테스트는 조립된 전체가 실제로 달리는지 확인한다. 둘 다 필요하다.
Playwright의 자동 대기가 불안정 테스트의 가장 큰 원인을 제거한다. waitForTimeout 대신 상태 기반 대기를 쓰면 된다.
페이지 오브젝트 패턴은 테스트가 10개를 넘어가면 필수다. 중복을 없애고, UI 변경에 한 곳만 수정하면 되도록 만든다.
CI 연동 없이는 반쪽짜리다. 배포 전에 자동으로 돌아야 의미가 있다. 실패 시 스크린샷/비디오 아티팩트는 디버깅 시간을 크게 줄여준다.
테스트 격리가 병렬화의 전제조건이다. 각 테스트가 독립적으로 데이터를 관리하면 병렬로 빠르게 돌릴 수 있다.
새벽 배포 후 유저가 버그를 먼저 발견하는 경험은 다시 하고 싶지 않았다. E2E 테스트를 구축하면서 배포가 훨씬 덜 무서워졌다. 소방 훈련을 반복하면 실제 화재에 덜 당황하듯이. 테스트는 그 훈련이다.