
헤드리스 브라우저를 Docker로 올려 E2E 테스트에 연결한 이야기

배경: E2E 테스트에 "진짜 브라우저"가 필요합니다
FullStackFamily 프론트엔드를 E2E 테스트하려면 실제 브라우저가 필요합니다. 단위 테스트나 빌드 검증만으로는 실제 사용자 시나리오를 검증할 수 없거든요. 버튼 클릭, 페이지 이동, 폼 제출 같은 흐름을 진짜 Chromium 브라우저에서 돌려봐야 합니다.
이 역할을 하는 게 ff-playwright라는 마이크로서비스입니다. Spring Boot + Playwright Java로 만든 헤드리스 브라우저 서비스인데, 그동안 Cloud Run에서만 돌리고 있었거든요.
문제는 로컬 E2E 테스트입니다. 프론트엔드를 Playwright E2E 테스트로 검증하려면 이 서비스가 로컬에서도 떠 있어야 하는데, 그런 환경이 없었습니다. Chromium 바이너리까지 포함된 서비스를 네이티브로 실행하자니 환경 세팅이 번거롭고요.
Docker로 올리기로 했습니다.
전체 구조: 로컬 개발 환경에서의 위치
docker-compose.yml에는 이미 MySQL, Backend, Frontend가 있습니다. 여기에 ff-playwright를 4번째 서비스로 끼워넣었습니다.
┌──────────────────────────────────────────────────────┐ │ docker-compose.yml │ ├───────────────┬──────────────┬────────────────────────┤ │ ff-db │ ff-api │ ff-web │ │ MySQL 8.0 │ Backend │ Frontend │ │ :3308 │ :8080 │ :3000 │ ├───────────────┴──────────────┴────────────────────────┤ │ │ │ ff-playwright (NEW) │ │ Playwright + Chromium 헤드리스 브라우저 │ │ :8081 │ │ │ └──────────────────────── ff-network ────────────────────┘
E2E 테스트 시나리오에서의 호출 흐름은 이렇습니다.
[ff-playwright] 헤드리스 Chromium 제공 │ ▼ [Playwright 테스트] E2E 시나리오 실행 │ ▼ [localhost:3000] 프론트엔드 │ 사용자 시나리오 (클릭, 입력, 이동) ▼ [localhost:8080] 백엔드 │ API 요청/응답 ▼ 테스트 결과 검증
ff-playwright 내부 구조: Semaphore 기반 브라우저 풀
ff-playwright의 핵심은 브라우저 컨텍스트 풀링입니다. 매 테스트마다 Chromium을 새로 띄우면 너무 느리니까요.
서버 시작 시: Playwright.create() → Browser 1개 생성 (Chromium) Semaphore(8, fair=true) 초기화 테스트 요청이 들어오면: ┌─────────────────────────────────┐ │ 1. Semaphore.acquire(30초) │ ← 최대 8개 동시 처리 │ 실패 시 → 503 POOL_EXHAUSTED │ │ │ │ 2. BrowserContext 생성 │ ← synchronized 블록 │ (세션/쿠키 격리) │ │ │ │ 3. Page 생성 → 테스트 대상 접근 │ │ waitFor: networkidle │ │ timeout: 30초 │ │ │ │ 4. 테스트 실행 및 결과 반환 │ │ │ │ 5. finally: Context 닫기 │ │ + Semaphore.release() │ └─────────────────────────────────┘
하나의 Browser 인스턴스를 공유하되, 요청마다 독립적인 BrowserContext를 만드는 구조입니다. 시크릿 모드 탭을 여는 것과 비슷하다고 보면 됩니다. 세션과 쿠키가 격리되니 테스트 간에 간섭이 일어날 일이 없고요.
동시 요청 수는 Semaphore(8)로 제한합니다. fair=true라서 먼저 온 요청이 먼저 처리되고, 8개가 모두 사용 중이면 30초까지 기다립니다. 그래도 자리가 안 나면 503으로 돌려보냅니다.
헬스체크 엔드포인트에서 풀 상태를 확인할 수도 있습니다.
GET /health { "status": "healthy", "service": "ff-playwright", "pool": { "size": 8, "available": 7, "waiting": 0 } }
Docker 이미지 빌드: 멀티스테이지로 3.88GB
Dockerfile은 2단계 멀티스테이지 빌드로 구성했습니다.
Stage 1: gradle:8.5-jdk17 ├── 의존성 다운로드 (캐시 활용) ├── bootJar 빌드 └── Layer 추출 (layertools extract) Stage 2: playwright/java:v1.40.0-jammy ├── Chromium + JDK 17 사전 설치됨 └── 추출된 Layer 복사 → JarLauncher로 실행
베이스 이미지인 mcr.microsoft.com/playwright/java:v1.40.0-jammy에 이미 Chromium이 /ms-playwright 경로에 깔려 있어서 npx playwright install을 따로 할 필요가 없습니다. 대신 이미지가 3.88GB로 꽤 큽니다. Chromium 바이너리만 867MB 정도 되거든요.
Spring Boot의 layertools extract를 쓴 이유도 있습니다. 일반 fat JAR로 실행하면 컨테이너 환경에서 nested JAR 추출이 꼬이는 경우가 있는데, Layer를 미리 풀어놓고 JarLauncher로 직접 실행하면 이걸 피할 수 있습니다.
docker-compose.yml에 추가
기존 docker-compose.yml에 이렇게 추가했습니다.
playwright: build: ../ff-playwright container_name: ff-playwright restart: on-failure ports: - "8081:8080" environment: JAVA_OPTS: "-Xmx2g -Xms256m" TZ: Asia/Seoul healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"] interval: 30s timeout: 10s retries: 3 start_period: 60s networks: - ff-network
설정에서 눈여겨볼 부분이 몇 가지 있습니다.
빌드 컨텍스트가 ../ff-playwright입니다. ff-playwright는 별도 리포지토리라서 상위 경로에 있는데, docker-compose의 build context가 상대 경로를 지원하니까 가능합니다.
포트는 8081로 매핑했습니다. 내부적으로는 8080 포트를 사용하지만, 호스트의 백엔드(8080)와 충돌을 피하고자 8081로 노출했습니다.
start_period는 60초로 잡았습니다. JVM 기동에 Chromium 초기화까지 시간이 걸려서, 헬스체크 시작을 60초 뒤로 미뤘습니다. 이 시간 내에 실패하더라도 비정상(unhealthy) 상태로 판정하지 않습니다.
메모리는 2GB. 프로덕션(Cloud Run)에서는 4GB를 줬지만, 로컬은 동시 요청이 많지 않으니 2GB면 됩니다.
E2E 테스트에서의 활용
실행 순서
E2E 테스트 실행 전에 환경을 이 순서로 셋업합니다.
1. SSH 터널 (MySQL) ./scripts/dev-gcp-start.sh 2. ff-playwright docker compose up -d playwright 3. Backend ./gradlew bootRun 4. Frontend npm run dev 5. E2E 테스트 실행 cd frontend && npm run test:e2e
ff-playwright는 백엔드가 호출하는 쪽이니까, 백엔드보다 먼저 떠 있어야 합니다. docker compose up -d playwright 한 줄이면 끝입니다.
헬스체크 확인
# 컨테이너 상태 docker ps --filter name=ff-playwright # API 헬스체크 curl http://localhost:8081/health # 실제 렌더링 테스트 curl -X POST http://localhost:8081/fetch \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com","waitFor":"networkidle"}'
테스트 실패 시 체크리스트
E2E 테스트가 실패하면 이걸 먼저 확인해보세요.
┌────────────────────────────┬──────────────────────────┐ │ 증상 │ 확인 사항 │ ├────────────────────────────┼──────────────────────────┤ │ 브라우저가 뜨지 않음 │ ff-playwright 실행 여부 │ │ 테스트 타임아웃 │ 프론트엔드 서버 실행 여부 │ │ 503 POOL_EXHAUSTED │ 동시 요청 과다 (풀 소진) │ │ 컨테이너가 unhealthy │ docker logs ff-playwright │ │ 이미지 빌드 실패 │ ../ff-playwright 경로 확인│ └────────────────────────────┴──────────────────────────┘
에이전트 설정: CI/CD와 Sub Agent에도 반영
로컬 환경만 바꿔서는 부족합니다. 팀(여기서는 AI 에이전트)도 알아야 하거든요.
ff-playwright 정보를 등록한 곳은 세 군데입니다.
CLAUDE.md └── 개발 환경 테이블에 Playwright 서비스 추가 └── "로컬 Docker 서비스" 섹션 신설 .claude/agents/frontend-tester.md └── E2E 테스트 단계에 ff-playwright 헬스체크 추가 └── 주의사항에 Docker 실행 필요 명시 .claude/agents/integration-tester.md └── 필수 서비스 테이블에 Playwright 추가 └── 환경 준비 단계에 컨테이너 실행 스크립트 추가
이렇게 해두면 에이전트가 E2E 테스트를 돌릴 때 ff-playwright 컨테이너를 먼저 확인합니다. "테스트가 왜 실패하지?" 하고 삽질하는 일이 줄어들죠.
이미지 크기 이야기: 3.88GB는 괜찮은가
솔직히 3.88GB는 큽니다. 일반 Spring Boot 이미지가 200~300MB인 걸 생각하면 10배가 넘으니까요.
이미지 구성 (대략): ubuntu:jammy 베이스 ~80MB JDK 17 ~225MB Chromium 브라우저 바이너리 ~870MB Playwright 런타임 ~200MB Spring Boot 앱 + 의존성 ~50MB 기타 시스템 라이브러리 ~2.4GB ───────────────────────────────── 합계 ~3.88GB
대부분이 Chromium과 시스템 라이브러리입니다. 실제 브라우저를 돌려야 하니 어쩔 수 없는 부분이고요.
다만 Docker 레이어 캐싱 덕분에 첫 빌드만 오래 걸립니다. 소스 코드만 바뀌면 마지막 레이어만 다시 빌드되어 수 초면 끝나고, Gradle 의존성 다운로드 단계도 별도 레이어로 분리해놨기 때문에 build.gradle이 안 바뀌면 캐시됩니다.
Docker vs 네이티브: 왜 Docker를 골랐나
ff-playwright를 로컬에서 띄우는 방법은 두 가지입니다. Docker 컨테이너로 돌리거나, JDK를 깔고 ./gradlew bootRun으로 직접 실행하거나. 둘 다 해봤는데, 각각 장단이 꽤 뚜렷합니다.
┌──────────────────┬──────────────────────┬──────────────────────┐ │ │ Docker │ 네이티브 (gradlew) │ ├──────────────────┼──────────────────────┼──────────────────────┤ │ 초기 세팅 │ docker compose up │ JDK 17 + Chromium │ │ │ 한 줄이면 끝 │ + 시스템 라이브러리 │ │ │ │ 직접 설치 필요 │ ├──────────────────┼──────────────────────┼──────────────────────┤ │ 시작 속도 │ 느림 (JVM + Chromium │ 빠름 (호스트에서 │ │ │ 컨테이너 기동 ~60초) │ 바로 실행 ~20초) │ ├──────────────────┼──────────────────────┼──────────────────────┤ │ 디스크 │ 3.88GB (이미지) │ ~200MB (JDK 제외) │ ├──────────────────┼──────────────────────┼──────────────────────┤ │ 메모리 │ 컨테이너 오버헤드 있음 │ 호스트에서 직접 실행 │ │ │ (2GB 제한 설정) │ 오버헤드 적음 │ ├──────────────────┼──────────────────────┼──────────────────────┤ │ 디버깅 │ docker logs로 확인 │ IDE 디버거 연결 가능 │ │ │ 디버거 붙이기 번거로움 │ 브레이크포인트 OK │ ├──────────────────┼──────────────────────┼──────────────────────┤ │ 환경 일관성 │ 어디서든 동일 │ OS별로 Chromium 의존성 │ │ │ (Linux 컨테이너) │ 이 달라서 삽질 가능 │ ├──────────────────┼──────────────────────┼──────────────────────┤ │ 정리 │ docker compose stop │ 프로세스 직접 종료 │ │ │ 깔끔하게 정리 │ 포트 물림 주의 │ └──────────────────┴──────────────────────┴──────────────────────┘
네이티브 실행이 속도나 디버깅 면에서는 낫습니다. 그런데 실제로 해보니까 Chromium 의존성 문제가 꽤 귀찮았습니다. macOS에서 Playwright가 쓰는 Chromium은 시스템 라이브러리 버전에 민감한데, OS 업데이트 한 번에 깨지기도 하고요. 팀원마다 "내 로컬에서는 안 되는데?"가 반복되면 시간이 더 들어갑니다.
Docker는 느리고 무겁지만, docker compose up -d playwright 한 줄이면 누구 환경에서든 똑같이 돌아갑니다. E2E 테스트용이라 디버거를 붙일 일도 별로 없고, 한번 띄워놓으면 계속 쓰니까 시작 속도 60초도 큰 문제는 아닙니다.
결국 "한번 세팅하면 신경 안 써도 되는 쪽"을 골랐습니다. ff-playwright 코드를 활발하게 수정 중이라면 네이티브가 나을 수 있지만, 지금처럼 안정된 서비스를 로컬에서 그냥 띄워쓰는 용도라면 Docker가 맞다고 봅니다.
다른 접근법: 테스트 자체를 Docker에 넣는 방식
Playwright + Docker 조합을 다루는 글 중에 Docker for QAs: Playwright Tests On Docker라는 글이 있습니다. Rodrigo라는 QA 엔지니어가 쓴 글인데, 접근법이 우리와 꽤 다릅니다.
이 글의 핵심은 테스트 코드와 브라우저를 통째로 Docker 안에 넣는 것입니다. Playwright 공식 이미지(mcr.microsoft.com/playwright:v1.53.0-jammy)를 베이스로 Node.js와 테스트 코드를 함께 컨테이너에 담고, docker exec로 컨테이너에 들어가서 npx playwright test를 실행합니다. VS Code의 .devcontainer와 연결해서 개발 환경 자체를 컨테이너 안으로 옮기는 방식이고요.
Rodrigo의 방식: ┌───────────────────────────────┐ │ Docker 컨테이너 │ │ │ │ 테스트 코드 (.spec.ts) │ │ + Node.js + Playwright │ │ + Chromium │ │ │ │ → npx playwright test 로 실행 │ │ (전부 컨테이너 안에서 돌아감) │ └───────────────────────────────┘ 우리 방식: ┌──────────────────┐ ┌──────────────────┐ │ 호스트 (로컬) │ │ Docker 컨테이너 │ │ │ │ │ │ frontend/e2e/ │ HTTP │ ff-playwright │ │ npm run test:e2e ─┼──────▶│ (브라우저 서비스) │ │ │ │ Spring Boot + │ │ 테스트 코드는 여기 │ │ Chromium │ └──────────────────┘ └──────────────────┘
차이점을 정리하면 이렇습니다.
┌──────────────────┬────────────────────────┬────────────────────────┐ │ │ dev.to 글 (일반적) │ 우리 (ff-playwright) │ ├──────────────────┼────────────────────────┼────────────────────────┤ │ 테스트 실행 위치 │ Docker 안 │ 호스트 (로컬) │ │ 브라우저 위치 │ Docker 안 │ Docker 안 │ │ 컨테이너 역할 │ 테스트 러너 자체 │ 브라우저 API 서비스 │ │ 테스트 코드 위치 │ 볼륨 마운트로 컨테이너에 │ 호스트의 frontend/e2e/ │ │ 언어 │ Node.js 하나 │ 서비스: Java / 테스트: Node.js │ │ 컨테이너 진입 │ docker exec 필요 │ 필요 없음 (HTTP 호출) │ │ IDE │ .devcontainer 연결 │ 호스트에서 바로 사용 │ │ 프로덕션 재사용 │ 테스트 전용 │ Cloud Run에서도 운영 │ └──────────────────┴────────────────────────┴────────────────────────┘
Rodrigo의 방식이 훨씬 단순합니다. Playwright 공식 이미지에 테스트 코드만 얹으면 되니까요. "팀원 간 환경 차이 없이 동일한 테스트를 돌리고 싶다"가 목적이라면 이쪽이 맞습니다.
우리가 이렇게 한 이유는 ff-playwright가 테스트 러너가 아니라 브라우저를 제공하는 독립 서비스이기 때문입니다. 프로덕션 Cloud Run에서도 같은 이미지를 쓰고 있고, E2E 테스트 외에 다른 용도로도 호출할 수 있어야 합니다. 테스트 코드는 프론트엔드 저장소에 있는 게 자연스럽고, IDE에서 바로 디버깅할 수 있는 게 편하기도 하고요.
어느 쪽이 나은지는 상황에 따라 다릅니다. 테스트 환경 통일이 목적이면 Rodrigo 방식, 브라우저 서비스를 여러 곳에서 재사용해야 하면 우리 방식이 맞습니다.
마무리
정리하면 이런 흐름입니다.
[문제] 프론트엔드 E2E 테스트에 헤드리스 브라우저가 필요 ↓ [해결] ff-playwright를 Docker로 로컬에 올림 ↓ [설정] docker-compose.yml + 에이전트 문서 업데이트 ↓ [결과] docker compose up -d playwright 한 줄로 E2E 테스트 환경 완성
사소해 보일 수 있지만, "로컬에서 전체 스택을 Docker 한 줄로 띄울 수 있다"는 점은 실제로 체감하면 꽤 큰 차이를 만듭니다. E2E 테스트처럼 여러 서비스가 맞물려 돌아가는 시나리오에서는 특히요.
이미지 크기가 3.88GB라 다소 부담스럽지만, 로컬 개발용이라는 점을 고려하면 충분히 납득할 수 있는 수준입니다. 나중에 필요하면 Chromium만 별도 컨테이너로 분리하고 CDP(Chrome DevTools Protocol)로 연결하는 방식도 생각해볼 수 있고요.






댓글
댓글을 작성하려면 이 필요합니다.