E2E 6분을 1.3분으로 — dev-kit 연재 ② 시험 속도
시험 하나마다 0.5·1·3초 한도를 걸고, 넘는 시험은 한도를 올리지 않고 원인을 고쳤습니다. 해셔 한 줄, CPU 시간 측정, 이벤트 기다리기로 시험을 빠르고 흔들리지 않게 만든 기록입니다.

E2E 6분을 1.3분으로
dev-kit 연재 ② — 시험 하나에 0.5·1·3초 한도를 걸고, 넘으면 원인을 고친다
왜 만들었나
1편에서 작업자 에이전트의 토큰은 대략 「턴 수 × 그때의 컨텍스트」라고 했습니다. 시험이 느리면 작업자는 기다리고, 시험이 흔들리면 다시 돌리며 원인을 찾느라 턴을 씁니다. 그래서 시험 속도는 곧 토큰 문제이기도 했습니다.
해법은 단순합니다. 시험 하나마다 시간 한도를 걸고, 넘으면 한도를 올리지 않고 원인을 줄입니다. 이 규칙과 붙여 쓸 코드 조각을 dev-kit의 test-speed 플러그인으로 묶었습니다. 백엔드·로직 시험은 test-budgets, Playwright E2E는 fast-stable-e2e 스킬이 맡습니다.
글의 숫자는 모두 한 프로젝트(Django + PostgreSQL + Playwright)에서 잰 값입니다.
숫자로 본 차이
시험 종류별 한도
| 종류 | 범위 | 시험 하나 | 거는 방법 |
|---|---|---|---|
| 로직 | 순수 함수만(네트워크·DB 없음) | 0.5초 | 백엔드와 같은 pytest 훅 |
| 백엔드 | HTTP·DB 같은 서버 경계 | 1초(CPU 시간) | conftest 훅 + junit에 cpu_seconds 기록 |
| E2E | 브라우저로 화면을 끝까지 | 3초(beforeEach·afterEach 포함) | Playwright timeout 3000, retries 0 |
| 프런트 단위 | 컴포넌트·훅 | 정하지 않음 | 실제 경과 시간밖에 못 재서, 분포를 재 보고 정한다 |
- 한 시험이 두 종류에 걸치면 더 넓은 쪽으로 칩니다(예: 로직 + DB면 백엔드). 한도에서 빠지는 것은 PR CI가 돌리지 않는 `slow` 마커가 붙은 시험뿐입니다.
한도를 처음 켜는 순서
- 재기만훅이 실제로 도는지도 확인
훅을 붙이되 한도는 끄고, junit에 시험별 CPU 시간을 남긴다
- 분포 보기기계가 바쁠 때와 한가할 때 비교
한도와 목표를 넘는 시험을 센다
- 고치기PR마다 남은 건수
원인 한 곳이 여러 건을 없애는지부터
- 켜기켜자마자 실패하면 모든 PR이 막힘
CI는 현재 최댓값보다 조금 위에서 시작해 단계적으로 1.0까지
- E2E 3초재시도 0도 같은 PR에서
리포트로 분포를 보고 넘는 시험부터 고친 뒤
- 문서화저장소의 testing.md
한도, 환경변수, 올리는 규칙, junit 보는 법
규칙은 에이전트가 읽는 스킬 문서로
부딪힌 문제와 해결
1. 한도 근처 시험이 빌드마다 번갈아 떨어졌다
붐비는 CI 러너에서 실제 경과 시간(wall-clock)으로 재면 같은 시험도 실행마다 시간이 달랐습니다. 한도 근처에 있는 시험은 빌드마다 떨어졌다 붙었다 했습니다.
→ 백엔드는 CPU 시간으로 잽니다. 프로세스가 CPU를 실제로 쓴 시간이라, 기계가 바빠 기다린 시간은 빠지고 일의 양에 가깝습니다. time.process_time()에 자식 프로세스의 user+sys 시간을 더해, 서브프로세스를 띄우는 시험도 측정에서 빠지지 않게 했습니다. 그래도 같은 코어를 나눠 쓰는 다른 하이퍼스레드가 바쁘면 같은 일에 CPU 시간이 1.6~2배 듭니다(load average 15에서 0.6초짜리가 1.1초). 그래서 한가한 기계의 목표는 한도의 절반인 0.5초로 두고, CI에서는 환경변수로 한도에 1.5배를 곱합니다. 훅이 실제로 도는지는 한도를 0.001초로 주고 시험 하나가 실패하는지로 확인합니다.
2. 한도를 넘은 시험이 340건이었다
한도를 처음 걸자 340건이 걸렸습니다. 하나씩 고쳤다면 한참 걸렸을 겁니다.
→ 원인은 대부분 세 가지였습니다. 비밀번호 해시, 서브프로세스 기동, 필요보다 큰 데이터입니다. 가장 큰 것은 비밀번호 해시였습니다. 로그인과 계정 생성마다 PBKDF2가 돌아 시험 하나가 0.5~1초를 썼습니다. 시험 설정에서만 가장 빠른 해셔로 바꾸는 한 줄로 340건이 59건이 됐고, 전체 시험은 106초에서 66초가 됐습니다. 운영 해셔가 그대로인지 확인하는 시험은 따로 하나 둡니다. 같은 원인이 수십 건이면 시험을 하나씩 고치지 않고 원인 한 곳을 고칩니다.
3. 빌드마다 다른 시험이 떨어졌다
어느 빌드에서는 A 시험이, 다음 빌드에서는 B 시험이 한도를 넘었습니다. 두 시험은 아무 관계가 없었습니다.
→ 원인은 첫 요청 때 한 번 드는 초기화 비용이었습니다. URL 라우터를 첫 요청에서 만드는 프레임워크에서는 pytest 워커마다 처음 요청을 보낸 시험 하나가 그 비용(0.5초 넘게)을 혼자 떠안습니다. 어느 시험이 먼저일지는 실행마다 달랐습니다. 세션 범위 픽스처에서 시험 전에 미리 한 번 요청해 두게 했습니다.
4. 첫 시험의 준비가 7.5초였다
어떤 시험은 junit time이 7.5초인데 CPU 시간은 0.02초였습니다. time은 setup·teardown을 포함하고 CPU 시간은 시험 본문만 재기 때문에, 두 값이 크게 다르면 본문이 아니라 준비가 느린 것입니다. 워커마다 시험 DB를 처음부터 마이그레이션하고 있었습니다.
→ 템플릿 DB를 한 번만 마이그레이션하고 CREATE DATABASE … TEMPLATE으로 복제합니다(워커당 약 0.5초). 오래된 스키마가 남을 수 있는 --reuse-db는 쓰지 않습니다. 병렬 실행도 -n 4에서 -n auto로 바꿔 1,192건이 84초에서 57초가 됐습니다. 대신 시험끼리 데이터가 섞이지 않게 시험 DB 이름을 워커별로 나눴습니다.
5. E2E가 6분대였고 10개가 떨어졌다
E2E는 화면이 바뀔 때까지 expect.poll 12곳, waitForFunction 27곳, while 루프로 기다리고 있었습니다. 폴링은 간격이 짧으면 흔들리고, 길면 느리고, 다시 보는 간격만큼 결과를 늦게 알게 됩니다. Playwright의 expect도 처음 0.27초, 그 뒤로 0.5초마다 다시 확인합니다.
→ 화면을 바꿀 수 있는 이벤트가 올 때마다 한 번 다시 확인하는 헬퍼로 바꿨습니다. MutationObserver와 SSE·WebSocket 메시지 리스너가 신호입니다. 헬퍼 파일 밖에서 폴링을 쓰면 lint가 막습니다.
가장 큰 원인은 따로 있었습니다. 시험 안에서 연 브라우저 컨텍스트(BrowserContext)를 닫지 않아, 끝난 시험의 페이지가 실시간 연결을 붙든 채 남아 있었습니다. 서버는 그 연결에 계속 응답했고, 시험이 쌓일수록 느려졌습니다. 컨텍스트를 시험 뒤에 닫자 전체 E2E가 로컬 1.3분, CI 45초가 되었고 110개가 모두 통과했습니다.
6. 금방 사라지는 상태를 놓쳤다
「보내는 중」처럼 잠깐 뜨는 표시는 버튼을 누른 뒤에 확인하면 이미 사라진 뒤였습니다. 버튼 개수를 세고 차례로 누르는 시험은, 세는 순간과 누르는 순간 사이에 버튼이 사라지면 없는 버튼을 끝없이 기다렸습니다.
→ 사라지는 상태는 arm으로 버튼을 누르기 전에 감시를 걸어 두고 누른 뒤에 기다립니다. 버튼은 미리 세지 않고 「보이는 첫 버튼을 누르거나, 다음 화면이 오면 끝」으로 짭니다.
남은 한계
- 0.5·1·3초는 한 프로젝트에서 정한 값이지 보편 상수가 아닙니다. 새 저장소는 분포를 재고 정해야 합니다.
- CPU 시간에는 DB 서버가 질의를 처리하는 시간이 잡히지 않습니다. 느린 질의는 DB 로그와
EXPLAIN으로 따로 봅니다. - Windows에서는 자식 프로세스의 CPU 시간을 세지 못하고, 시간 측정 단위가 약 15ms라 값이 거칩니다.
- 프런트 단위 시험(vitest)은 실제 경과 시간밖에 잴 수 없어 한도를 정하지 않았습니다.
- 시험 DB 템플릿 복제 코드는 Django + PostgreSQL용입니다. 다른 스택에서는 같은 생각(한 번 만들고 복제)을 새로 구현해야 합니다.
3편에서는 모델이 실수하기 전에 Claude Code 훅이 먼저 막는 작업 환경을 다룹니다.



