E2E 테스트 구조 설계와 VRT 적용기: 배포에 자신감을 더한 올라핀테크의 실험
2026.07.21
올라핀테크 프론트엔드팀이 복잡한 서비스 플로우 검증을 위해 Playwright 기반 E2E 테스트를 도입한 과정입니다. 견고한 구조 설계부터 CI 병렬화 최적화(Dynamic Shard), 시각적 회귀 테스트(VRT) 적용, AI를 활용한 시나리오 작성 자동화까지 실무 고민과 생생한 해결책을 담았습니다.
배포에 자신감을 더한 이야기 — E2E 테스트 도입기
배포 버튼을 누르기 직전, 이런 생각이 든 적 있으신가요?
“이 기능 건드렸는데, 혹시 저쪽 플로우 깨진 거 아니야?”
자금관리 서비스 올라는 온라인 셀러의 미정산 매출채권을 즉시 유동화해주는 선정산 서비스입니다. 계약 체결, 서류 첨부, 외부 인증, 전자 서명까지 전 과정이 비대면으로 이어지고, 셀러분들께서 최대한 한 번에 작업을 완료하실 수 있도록, 상태에 따른 다양한 분기 처리 로직을 갖추고 있습니다.
해당 분기 처리들은 초기에는 수동 확인으로 충분했지만, 기능마다 엮이는 요소가 생기면서 상황이 달라졌습니다. 코드 한 줄을 바꿨음에도 다른 페이지까지 확인해봐야 하는 상황이 반복되었어요. 더 나아가 복잡한 조건을 통과해야 하는 영역이 늘어나면서 수동 QA로는 커버할 수 없게 되었습니다.
이 글에서는 이러한 문제를 해결하기 위한 방법으로 채택된 E2E 테스트와 설계 구조, CI 최적화, 그리고 E2E 구조 위에서 자연스럽게 확장된 시각적 회귀 테스트(VRT)와 AI 워크플로우까지 순서대로 다루겠습니다.
❓ 왜 E2E 테스트인가
단위 테스트는 빠르지만 모듈 간 연결을 검증하지 못합니다. 통합 테스트는 API 흐름까지 확인하지만, 브라우저에서 사용자가 실제로 플로우를 끝까지 완료할 수 있는지는 E2E 테스트 없이 확인할 수 없습니다. 세 가지 모두 역할이 다르고, 서비스 상황에 따라 필요한 비중도 달라집니다.
플로우가 복잡해질수록 수동 QA의 한계가 드러났다
올라 서비스의 계약 기능은 계약 필요 여부 판단, 서류 첨부, 외부 인증, 전자 서명이 순서대로 이어지는 다단계 플로우입니다. 각 단계를 완료해야 다음으로 진행되고, 단계마다 유저 상태에 따라 분기가 달라집니다.
▲ 올라 서비스 계약단계 화면
“사용자가 실제로 이 플로우를 끝까지 완료할 수 있는가”는 단위 테스트도, 통합 테스트도 답하지 못합니다. 브라우저 레이어에서의 사용자 플로우는 E2E 테스트가 아니면 검증하기 어렵습니다.
다음 상황이 생길 수 있습니다.
“A 상태 유저의 플로우는 검사했었는데 B 상태 유저의 플로우랑 연관되어 있는지는 확인을 못했네요…”
매 배포마다 팀이 직접 플로우를 클릭하며 확인했습니다. 초반에는 그것만으로도 충분했지만, 도메인이 늘고 분기가 복잡해질수록 어려워졌습니다. 수동으로 감당할 수 있는 한계가 왔습니다.
E2E 테스트를 사용하면 브라우저가 사용자처럼 특정 플로우를 처음부터 끝까지 직접 실행해줍니다. 작은 수정에도 치명적인 문제가 생길 수 있는 개발 세계를 조금 더 안정감 있게 해줍니다.
🧱 어떻게 만들었나
E2E 테스트는 설계가 나쁘면 유지 비용이 단위 테스트보다 훨씬 높아집니다. 도입 초기에는 구조가 정해지지 않았고, 반복적인 리팩토링을 거쳐 지금의 구조에 도달했습니다.
구조 설계
//...
├── shared/
| └── sequences/ <- "공통 flow"
└── domain
├── specs/
│ └── sign-in.spec.ts <- "무엇을 검증하는가"
└── flows/
└── sign-in-flows.ts <- "어떻게 행동하는가"
specs: 유저의 행동
import { 자격_증명_입력, 임시_비밀번호_로그인_플로우, 로그인_성공_플로우 } from '../flows/sign-in-flows'
// sign-in.spec.ts
test.describe('사용자 로그인 플로우', () => {
// ...
test('로그인 성공', async ({ page, next }) => {
await 자격_증명_입력(page)
await 로그인_성공_플로우(page, next)
})
test('임시 비밀번호로 로그인 성공', async ({ page, next }) => {
await 자격_증명_입력(page)
await 임시_비밀번호_로그인_플로우(page, next)
})
})
specs은 유저의 행동을 의미합니다. 유저가 서비스를 이용하기 위해 취해야 하는 행동은 생각보다 단순하며, 코드의 복잡한 세부사항을 알 필요도 없습니다. 저희는 그 점에 착안하여 유저의 행동을 함수화했고, 그 내부적으로 일어날 상황들은 코드로 구체화하기로 결정했습니다.
결과적으로 가독성도 챙기고, 테스트 코드의 세부사항도 효과적으로 캡슐화할 수 있었습니다.
flows: specs의 구현체
// sign-in-flows.ts
export async function 자격_증명_입력(page: Page) {
const businessNumberInput = page.getByPlaceholder('-제외 10자리 입력')
const passwordInput = page.getByPlaceholder('비밀번호를 입력해주세요')
await expect(businessNumberInput).toBeEditable()
await expect(passwordInput).toBeEditable()
await businessNumberInput.fill('123412341234')
await passwordInput.fill('시크릿')
//...
}
flows는 테스트 코드의 복잡한 실제 구현을 담당합니다. 간단히 specs의 구현체라고도 볼 수 있습니다.
테스트 케이스에 복잡한 코드가 그대로 드러나게되면 테스트 코드의 유지보수성이 심각하게 떨어지게 되며, 이를 숨기기 위해 specs의 세부사항을 구현하는 flows 계층이 탄생하게 되었습니다.
이렇게 구조가 잡히면 테스트가 실패했을 때 specs 문제인지 flows 문제인지도 즉시 파악할 수 있습니다.
공통 선행 동작은 sequences로 뺀다
로그인처럼 여러 테스트에서 반복되는 선행 동작은 shared/sequences 에 분리했습니다.
export const userLoginSuccessSequence = async (page: Page) => {
await 유저_로그인_성공_검증(page)
//...
}
여러 flow가 결합된 형태라 단일 flow로 보기는 어렵고, 여러 spec에서 공통으로 반복되는 함수들을 여기에 정의합니다. 위 함수를 예시로 들면 로그인이 선행 조건인 spec의 beforeEach에서 이 sequence로 세팅하게 됩니다.
test.beforeEach(async ({ page }) => {
await userLoginSuccessSequence(page)
await page.goto(Routes.contractNew(1))
})
API 모킹으로 엣지 케이스를 재현한다
대부분의 경우 실제 서버 없이 테스트를 실행합니다. 네트워크 요청을 가로채고 원하는 응답을 즉시 반환합니다.
export async function setSignInApiMocks(next: NextFixture) {
apiMockHandlers(next, [
{
match: signatureMatch(API_SIGNATURE.AUTH.SIGN_IN),
responseBody: SIGN_IN_FIXTURE.SUCCESS.data,
},
//...
])
}
실서버로 재현하기 어려운 상태를 fixture로 즉시 만들 수 있습니다. 선정산 조회 기능만 해도 조회 완료, 조회 실패, 최소 신청 금액 미달, 2차 인증 필요 등 수많은 상태가 있습니다. apiMockHandlers에 fixture 데이터를 교체하는 것만으로 각 상태를 자유롭게 재현합니다.
이 fixture 데이터는 이전 Storybook 도입기에서 소개드렸던 zod 기반 fixture를 그대로 활용합니다.
// fixture.ts
export const SIGN_IN_FIXTURE = {
SUCCESS: { data: { ... } },
SUCCESS_TEMP_PW_LOGIN: { data: { ... } },
PASSWORD_RESET_REQUIRED: { data: { ... } },
EXCEED_LOGIN_ATTEMPTS: { data: { ... } },
}
같은 fixture 데이터로 Storybook 컴포넌트 시각화와 E2E 시나리오를 함께 관리합니다. “테스트 데이터가 어디 있지?”라는 질문이 사라졌습니다.
🚀 CI가 너무 느리다
구조가 자리를 잡으면서 테스트 수가 빠르게 늘었고, CI 실행 시간도 함께 길어졌습니다. 반응형 분기에 따라 동작이 달라지는 화면이 많아 모바일 테스트 환경도 필요했습니다. 환경이 하나 추가될 때마다 총 테스트 개수만큼 시간이 늘어났습니다.
Dynamic Shard: 테스트 수에 비례한 병렬화
Playwright의 shard는 테스트를 나눠 병렬 실행하는 기능입니다. 하지만 shard 개수를 고정하면 문제가 생깁니다. 테스트가 적을 때는 빈 shard가 생기고, 많아지면 shard가 부족해집니다. shard도 Runner 리소스를 사용하기 때문에 낭비 없이 운용해야 했습니다.
테스트 개수를 먼저 세고, 그 수에 맞게 shard를 자동으로 생성하는 방식으로 해결했습니다.

▲ Dynamic Shard를 통한 CI 플로우
테스트 개수를 측정한 뒤, 15개당 shard 1개 기준으로 총 shard 수를 계산합니다.
- name: Calculate Shards
run: |
TOTAL_TESTS=$(pnpm test:e2e --list | grep 'Total:' | awk '{print $2}')
SHARD_COUNT=$(( (TOTAL_TESTS + 14) / 15 ))
echo "TOTAL_TESTS=$TOTAL_TESTS" >> "$GITHUB_ENV"
echo "SHARD_COUNT=$SHARD_COUNT" >> "$GITHUB_ENV"
이후 jq로 shard 인덱스 목록을 JSON 배열로 만들어 다음 job에 넘깁니다.
- name: Generate Shards Matrix JSON
id: set-matrix
run: |
jq -cn --argjson count "${{ env.SHARD_COUNT }}" \\
'[range(1; $count + 1) | {"shard-index": ., "total-shards": $count}]' \\
> matrix.json
echo "matrix=$(cat matrix.json)" >> "$GITHUB_OUTPUT"
각 shard job은 이 matrix를 받아 독립적으로 병렬 실행됩니다.
- name: Generate Shards Matrix JSON
id: set-matrix
run: |
jq -cn --argjson count "${{ env.SHARD_COUNT }}" \\
'[range(1; $count + 1) | {"shard-index": ., "total-shards": $count}]' \\
> matrix.json
echo "matrix=$(cat matrix.json)" >> "$GITHUB_OUTPUT"
빌드는 한 번만, 나머지는 공유
Next.js 빌드는 시간이 오래 걸리고 자원을 많이 소모합니다. shard마다 빌드를 반복하면 병렬화 이득이 줄어듭니다. 빌드를 별도 job으로 분리하고 artifact로 공유하는 방식으로 해결했습니다.
# build job
- uses: actions/upload-artifact@v4
with:
name: tests-build
path: .next
# e2e-test job
- uses: actions/download-artifact@v4
with:
name: tests-build
path: .next
두 전략을 함께 적용했습니다.
- Dynamic Shard: 테스트 개수에 비례한 병렬 실행
- 단일 빌드 공유: Next.js 빌드를 shard마다 반복하지 않음

▲ 최적화 전후 CI 빌드 비교
실패하면 shard 리포트를 하나로 합친다
각 shard는 blob 리포트를 아티팩트로 업로드합니다.
- name: Upload Report Blob
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report-${{ matrix.shard-index }}
path: blob-report
retention-days: 1
실패 시에만 동작하는 merge-reports job이 이를 하나의 HTML 리포트로 합쳐, 어느 시나리오에서 무슨 이유로 실패했는지 한 화면에서 확인할 수 있습니다.
merge-reports:
if: ${{ always() && cancelled() == false && needs.e2e-test.result == 'failure' }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: test-report-*
merge-multiple: true
- run: pnpm exec playwright merge-reports --reporter html ./blob-reports

▲ 실패시 결과로 들어오는 playwright 보고서
당시 동일 코드 기준 순수 CI 시간이 약 30% 단축되었습니다. shard 추가 비용(matrix 생성, 아티팩트 공유)이 있었지만 병렬화 이득이 훨씬 컸습니다. 결과적으로 실행해야 하는 테스트가 세 배 이상 증가한 현 시점에서도 당시와 동일한 속도를 유지하고 있습니다.
🖼 시각적 회귀 테스트(VRT): E2E 위에서 확장하다
E2E 구조가 자리를 잡으면서 자연스럽게 다음 질문이 생겼습니다. “플로우는 검증했는데, 화면이 올바르게 보이는지는 어떻게 확인하지?”
시각적 회귀 테스트(Visual Regression Test, 이하 VRT)가 그 답이었습니다. E2E에서 이미 페이지 진입과 API 모킹 구조를 갖춰두었기 때문에, VRT를 얹는 작업은 생각보다 단순했습니다. 기존 flows 함수로 화면에 진입하고, toHaveScreenshot()으로 스냅샷을 찍으면 됩니다.
test('선정산 페이지 @vrt', async ({ page }) => {
await 선정산_페이지_진입(page)
await takeVrtSnapshot(page, '선정산', Routes.factoring())
})
고정된 화면을 찍기 위한 함수 고정, MutationObserver를 통한 스냅샷 시점 제어 등의 추가 세팅은 필요합니다. 관련해서는 생략하고 넘어가겠습니다.
CI에 넣기 어려운 이유
VRT를 CI에 강제하지 않는 방향을 선택했습니다. 허용 범위를 어디로 잡아도 해결되지 않는 두 가지 문제가 있기 때문입니다.
문제 1: OS·브라우저 업데이트에 취약하다
로컬(macOS)과 Linux 기반 CI는 폰트 힌팅, 서브픽셀 렌더링 방식이 다릅니다.

▲ 서로 다른 OS 환경의 픽셀 렌더링 방식 차이를 보여주는 테스트 보고서 화면
같은 OS와 브라우저여도 버전이 업데이트되면 UI 변화 없이 픽셀 차이가 발생할 수 있습니다. VRT를 쓰는 한 피할 수 없는 문제이고, 이 이유만으로 CI가 실패하는 건 비효율적입니다.
문제 2: 허용 범위를 넓히면 실제 변화를 놓친다
전체 화면 기준으로 threshold를 넉넉하게 잡으면 의미 있는 UI 변화도 그냥 통과합니다.

▲ 변경 전: 정상 상태

▲ 변경 후: 날짜 컴포넌트가 왼쪽 정렬로 깨진 상태
예를 들어 접근성 개선 작업 중 정산 달력 페이지의 날짜 컴포넌트가 왼쪽 정렬로 잘못 설정되었다고 해봅시다. 육안으로는 찾기 어렵지만 VRT는 즉시 감지합니다.

▲ VRT 감지: diff 하이라이트

▲ VRT 감지: 픽셀 차이 상세
하지만 전체 화면 기준으로 이 차이는 0.01%에 불과합니다. threshold를 넉넉하게 설정하면 이런 변화를 그대로 통과시키게 됩니다.
- 허용 범위를 좁히면 → 환경 차이에 계속 터짐
- 허용 범위를 넓히면 → 실제 변화를 놓침
결국 VRT는 CI 게이트가 아니라, 개발자가 변경을 직접 검토할 때 쓰는 보조 도구로 한정하기로 했습니다. 다음 방식으로 운영합니다.
- 스냅샷을 레포에 커밋해서 팀이 공유
- 로컬에서
pnpm test:vrt로 원할 때 실행 - 변경이 생기면
pnpm test:vrt-update로 스냅샷 업데이트
VRT 테스트는 @vrt 태그로 일반 E2E와 구분합니다. test:e2e 스크립트는 --grep-invert=@vrt로 VRT를 자동으로 제외합니다.
"test:e2e": "playwright test --grep-invert=@vrt",
"test:vrt": "playwright test --grep @vrt",
"test:vrt-update": "playwright test --grep @vrt --update-snapshots",
💡 반복을 없애다 — AI Skill로 만든 워크플로우
새 도메인에 E2E 시나리오를 추가할 때마다 같은 작업을 반복했습니다. spec/flow 분리, mock handler 연결, fixture 작성 — 결정의 내용은 같고 대상만 달랐습니다.
이런 반복 패턴은 AI를 활용하기 좋은 조건입니다. 견고한 구조가 이미 설계되어 있기에 AI에게 가르치기 좋은 환경이었습니다. AI가 잘 알아들을 수 있도록 해당 구조를 AI 친화적으로 문서화했습니다.
e2e-flow-generator: E2E 시나리오 생성 Skill
Skill이란 AI 코딩 도구에게 “이 저장소에서 이 작업은 이렇게 한다”를 알려주는 문서입니다. 절차, 파일 구조, 네이밍 규칙, 코드 패턴까지 담아두면, 도구가 이 문서를 읽고 저장소 규칙에 맞는 코드를 스스로 생성합니다.
docs/ai/skills/e2e-flow-generator.md에 E2E 시나리오 추가 절차를 문서화했습니다. Skill 문서에는 다음 내용이 담겨 있습니다.
- 생성할 파일 목록과 경로 규칙 (
e2e/tests/{도메인}/specs/,flows/) - spec 파일과 flow 파일의 역할 구분
- mock handler 연결 방식과 fixture 참조 패턴
data-testid보완이 필요한 경우 함께 검토하는 기준
“이 페이지 E2E 추가해줘” 한 마디면, 저장소 컨벤션에 맞는 spec/flow/mock 파일이 생성됩니다. 새 팀원이 E2E 구조를 파악하는 시간도 함께 줄었습니다.
도구별 설정 파일이 아닌 docs/를 단일 원천으로
팀원마다 쓰는 AI 도구가 다릅니다. Claude Code, Cursor, GitHub Copilot 등 각자 선호하는 도구가 있고, 도구별 설정 파일(.claude, .cursor, .github 등)을 각각 레포에 커밋하면 관리 포인트가 늘어나고, 도구가 바뀔 때마다 파일도 바뀌어야 합니다.
처음에는 도구별로 따로 관리했는데, 같은 내용이 여러 파일에 중복됐습니다.
docs/ai/skills/를 단일 원천으로 삼고, AGENTS.md에 Skill 목록을 등록했습니다. AI 도구는 저장소 접근 시 이 파일을 자동으로 참조합니다.
// AGENTS.md
//...
## 사용 가능한 스킬
- e2e-flow-generator: Playwright E2E 시나리오를 새로 추가할 때 사용합니다.
(파일: docs/ai/skills/e2e-flow-generator.md)
- //...
어떤 도구를 쓰든 같은 문서를 읽고, 도구별 로컬 설정은 각자 관리합니다.
E2E 외에도 Skill은 다양한 작업에 쓰입니다.
add-domain-api: schema, fixture, query-key, hook까지 저장소 패턴에 맞게 생성git-commit-message: 변경사항을 분석해 커밋 메시지 제안github-pr-generator: 브랜치 차이를 분석해 PR 제목과 본문 초안 작성
팀이 AI 도구를 쓸 때 “어떻게 쓸지”를 반복해서 설명하지 않아도 됩니다. 문서가 그 역할을 합니다.
🌅 E2E 테스트 도입 후 달라진 것
테스트가 기능 명세가 되었다
특정 시나리오 하나만 봐도 서비스 정책이 보입니다. 테스트 파일 자체가 명세서가 됩니다.
test('계약 성공 플로우', async ({ page, next }) => {
await 계좌_작성_플로우(page, next)
await 다음_스텝_이동(page)
// ...
await 신분증_첨부_플로우(page, next)
await 정산계좌_사본_첨부_플로우(page, next, 1)
// ...
})
배포 확신이 생겼다
QA를 아무리 철저히 해도 배포 직전 불안감은 남습니다. 그리고 그 예감이 맞을 때가 있습니다. 이제 수십 개 시나리오가 통과되면 그 불안이 크게 덜어집니다. 리팩토링 PR을 올릴 때의 긴장이 줄었고, 민감한 기능을 다룰 때 어느 정도 방어막이 있다는 확신이 생겼습니다.
마치며
테스트는 코드 품질 도구이기 전에 팀의 언어입니다. 어떤 케이스를 중요하게 생각하는지, 어떤 사용자 행동을 보장하려 하는지가 코드에 담깁니다. 글에서 보신 것처럼, 이제는 AI를 활용해 작성 비용도 크게 낮아졌습니다.
E2E 테스트에는 완성 상태가 없습니다. 서비스가 변하는 한 테스트도 함께 바뀌어야 합니다. 커버리지를 넓히고, 엣지 케이스를 발견할 때마다 시나리오로 추가하고, 필요하다면 구조도 바꿔야 합니다. 지금까지 그래왔듯, 상황에 맞는 이유 있는 선택을 이어가겠습니다.
배포가 불안했던 날들은 아직 끝나지 않았지만, 이제는 그 불안을 코드로 줄일 수 있다는 걸 압니다. 올라핀테크 프론트개발팀은 앞으로도 더 안정적인 서비스를 만들어가겠습니다.
감사합니다.
“올라핀테크 팀은 앞으로도 문제를 해결한 과정을 꾸준히 기록하고 나누겠습니다!”
write. 프론트개발팀 이승현
올라핀테크 팀의 이전 이야기👇
- 감춰진 UI를 ‘보이게’ 만든 스토리 — 올라핀테크 프론트개발팀의 스토리북 도입기
- 프로모션 혜택 (쿠폰, 포인트 무료)이 정말 전 환에 도움이 될까? — 올라핀테크 마케팅팀
- 유저가 원하는 말 한마디 찾기 — 올라핀테크 프로덕트디자인팀
- 데이터독을 떠나 월 1,500달러를 아끼기까지: Grafana 기반 자체 모니터링 스택 구축기