CI 46분이 8.5분이 되기까지 — 러너 교체와 코드 개선을 갈라서 잰 기록

정기창·

릴리스마다 도는 CI 게이트가 46분 걸렸습니다. 지금은 8분 30초 남짓입니다. 숫자만 보면 코드를 대단히 고친 것 같지만, 줄어든 37분 가운데 34.5분은 CI 러너를 바꾼 효과였습니다. 코드로 줄인 몫은 2.5분 남짓입니다.

이 연재는 그 2.5분에 대한 기록입니다. 무엇을 줄였는지보다, 줄었다는 것을 어떻게 믿을 수 있었는지를 먼저 적습니다. 실행할 때마다 속도가 1.5배씩 달라지는 러너 위에서 10초, 20초의 차이를 재는 일이 생각보다 어려웠기 때문입니다.

출발점 — 46분은 어디에 쓰이고 있었나

대상은 NestJS 로 만든 API·worker 와 Next.js web 이 함께 있는 pnpm 모노레포입니다. B2B 커머스 운영 시스템이고, 릴리스 PR 마다 아래 단계를 차례로 도는 게이트가 있습니다. 한 잡 안에서 순서대로 돌기 때문에 단계 시간이 그대로 더해집니다.

단계하는 일2코어 시절 중앙값
타입 검사패키지별 tsc --noEmit1.3분
linteslint0.7분
단위 테스트vitest, 패키지 병렬16.8분
web 빌드next build1.8분
e2e진짜 MySQL·S3 위의 API 테스트, 4샤드22.2분
worker DB 테스트진짜 MySQL1.1분
전체설치·준비 포함45.7분

릴리스 게이트가 성공한 12회의 중앙값입니다. 러너는 GitHub 의 표준 2 vCPU 였고, 테스트 러너는 워커 1개로 돌고 있었습니다. 시간의 85%가 e2e 와 단위 테스트에 몰려 있었습니다.

먼저 러너를 바꿨습니다

가장 큰 레버는 코드가 아니었습니다. 같은 게이트를 GitHub Actions 의 8코어 larger runner 로 옮기자 45.7분이 약 11.2분이 됐습니다. 단위 테스트와 e2e 가 코어 수만큼 병렬로 돌 수 있게 됐기 때문입니다.

예전에 무과금 원칙으로 CI 를 굴려 본 적이 있습니다(연재 「CI를 공짜로 굴리려다 노트북 두 대를 켰습니다」). 이번에는 반대로 돈을 내고 시간을 샀습니다. 큰 러너는 분 단위로 과금되므로 측정 한 번도 공짜가 아니었습니다. 이후의 모든 측정을 「몇 번 돌려야 믿을 수 있나」와 「몇 번까지 돌려도 되나」 사이에서 정해야 했습니다.

러너를 바꾸자 코드 개선의 기대치도 달라졌습니다. 2코어에서 한 감사는 「단위 테스트에서 몇 분 절약」 같은 큰 숫자를 약속했지만, 8코어에서는 같은 개선이 수십 초로 줄어듭니다. 그래서 측정 러너를 8코어로 고정하고, 8코어에서 다시 잰 효과로만 판단하기로 했습니다.

판정 규칙 — 흔들림보다 크게 줄어야 넣습니다

규칙은 단순했습니다. 8코어 CI 에서 그 단계가 흔들림(±5%)보다 크게 줄 때만 변경을 넣습니다. 흔들림 안이면 효과가 있어 보여도 넣지 않고, 사유를 기록합니다. 예외는 둘이었습니다. 타입 검사를 TypeScript 7 로 바꾸는 일과, AI 에이전트가 읽을 테스트 작성 지침을 남기는 일은 크기와 무관하게 하기로 했습니다.

또 하나의 원칙은 「지워서 얻는 시간은 작다」였습니다. 감사에서 순수한 중복 테스트를 지웠을 때 얻는 시간을 따져 보니 web 4초, API 6~10초, e2e 10초 안팎이었습니다. 그래서 1초를 위해 의도가 담긴 테스트를 지우지는 않기로 했습니다. 테스트가 느린 이유는 대개 테스트의 «수»가 아니라, 테스트마다 반복되는 «준비 비용»이었습니다.

같은 코드가 실행마다 1.5배 다르게 돌았습니다

측정을 시작하자마자 문제가 생겼습니다. 같은 코드인데 lint 가 어떤 실행에서는 21초, 어떤 실행에서는 34초가 걸렸습니다. 큰 러너는 실행마다 새 VM 을 받는데, 그 VM 의 속도가 고르지 않았습니다.

그래서 러너 속도를 재는 지표를 따로 정했습니다. 이번 변경과 무관한 단계, 즉 lint 와 web 빌드의 시간입니다. 이 두 값으로 실행을 등급으로 나누고, 같은 등급끼리만 비교했습니다.

등급lintweb 빌드정상 대비
정상21~25초40~49초1배
중간28초57~58초약 1.3배
아주 느림31~34초68~70초1.4~1.6배

등급은 시간대로 예측할 수 없었습니다. 같은 순간 3초 간격으로 시작한 두 실행이 서로 다른 등급을 받은 적도 있습니다. 어느 날 밤에는 네 번 연속으로 아주 느린 러너가 걸렸습니다. 같은 등급 안에서도 하루 사이에 코드가 늘어 lint 와 web 빌드가 1~3초 오르는 것이 보였습니다.

이 규칙이 없었다면 틀린 결론을 냈을 것입니다. 실제로 한 번 틀렸습니다. e2e 개선 효과를 처음에는 −36%라고 적었는데, 비교 기준 둘 중 하나가 다른 등급의 표본이었습니다. 같은 등급끼리 다시 비교하니 −38%였습니다. 방향은 같았지만 그것은 운이었다는 생각이 들었습니다.

측정용 실행은 동시에 띄우지 않고 차례로 돌렸습니다. 같은 브랜치와 같은 러너의 조합은 동시성 그룹에 묶여, 뒤 실행이 앞 실행을 취소하기 때문입니다. 순차로 돌리면 실행 하나에 9~13분이 걸리므로, 표본 하나가 곧 그만큼의 시간이었습니다.

무엇을 바꿨고, 얼마나 줄었나

코드에서 바꾼 것은 넷입니다. 각각은 이 연재의 2~4편에서 자세히 다룹니다.

단계바꾼 것8코어에서 줄어든 시간
타입 검사TypeScript 7 을 타입 검사에만 사용26초 → 6초
단위 테스트(API·worker)상태를 남기지 않는 테스트 파일은 모듈을 다시 읽지 않게약 −18초
e2e정리 DDL 축소와 DB 연결 재사용336초 → 218초
단위 테스트(web)DOM 이 필요 없는 테스트 123개를 node 환경으로단독으로 −10초

모두 들어간 뒤, 같은 등급(정상) 러너에서 전후 세 번씩 잰 결과입니다.

단계개선 전개선 후차이
게이트 전체670초535초−135초(−20%)
타입 검사26초6초−20초
단위 테스트154초133초−20초
e2e336초218초−118초(−35%)
그 밖의 단계154초177초+23초

개선한 세 단계만 더하면 158초가 줄었습니다. 게이트 전체로는 135초입니다. 그 밖의 단계가 23초 늘었기 때문입니다. 개선 전 표본은 아침에, 개선 후 표본은 그날 밤에 쟀는데, 그사이 저장소에 코드와 테스트가 쌓였습니다. e2e 테스트만 해도 4,197건에서 4,287건으로 늘었습니다. 그래서 이 숫자는 실제 효과보다 조금 작게 잡힌 값입니다.

표본은 전후 세 번씩이라 많지 않습니다. 다만 개선 후 세 번은 529~539초로 좁게 모였고, 개선 전 세 번(660~686초)과 겹치지 않았습니다. 다음 날 0시에 실제로 돈 릴리스 게이트도 8분 32초로 이 숫자를 따라왔습니다.

더해지지 않는 개선

가장 기억에 남은 것은 효과가 더해지지 않은 일입니다. API·worker 단위 테스트 개선은 단독으로 단위 단계를 18초 줄였습니다. web 단위 테스트 개선은 단독으로 10초를 줄였습니다. 둘을 합치면 28초를 기대하게 됩니다. 실제로 둘 다 들어간 뒤의 단위 단계는 20초 줄었습니다. web 개선이 더한 몫은 3초 남짓이었고, 흔들림 범위 안이었습니다.

단위 테스트 단계는 패키지들을 병렬로 돌리고, 가장 늦게 끝나는 패키지가 단계 시간을 정합니다. 가장 늦는 쪽은 web 이었습니다. API·worker 가 CPU 를 덜 쓰자 web 이 코어를 더 받아 먼저 빨라졌고, 그 뒤 web 자체를 가볍게 한 효과는 이미 일부가 반영된 상태였던 것으로 봅니다. 이것은 추론이고, 패키지별 CPU 사용을 따로 재서 확인하지는 않았습니다.

병렬로 도는 단계에서는 따로 잰 개선을 더하면 안 된다는 것을 숫자로 확인했습니다. 합친 효과는 합친 상태에서 다시 재야 했습니다.

넣지 않은 것들

규칙 때문에 뺀 것도 있습니다. 판단이 가장 어려웠던 것은 web 테스트의 setup 파일을 가벼운 쪽과 무거운 쪽으로 나누는 변경이었습니다. 8코어에서 7초(4.5%)가 줄었습니다. 표본이 서로 겹치지 않아 실제 효과로 보였지만, 5% 기준 바로 안쪽이라 넣지 않았습니다.

후보8코어 효과결정
web setup 분리−7초(4.5%)보류, 패치 보관
긴 입력은 붙여넣기로, 중복 렌더는 합치기CPU −2.7초보류
e2e DB 준비를 한 번으로(globalSetup)상한 2.9초(1.4%)하지 않음
리허설 성격의 e2e 두 파일 건너뛰기가장 늦는 샤드에 없어 0초보류

마지막 줄은 기준을 정하는 일이 곧 판단이라는 것을 보여 줍니다. 8코어 4샤드에서는 0초지만, 샤드를 나누지 않는 경로에서는 54초가 줄어듭니다. 어느 경로를 기준으로 삼을지는 측정이 아니라 결정의 문제였습니다.

AI 가 읽는 자리에 테스트 지침을 남겼습니다

이 저장소의 테스트는 이제 대부분 AI 코딩 에이전트가 씁니다. 이번 작업도 Claude Code 세션 여러 개가 단계를 나눠 맡았습니다. 그래서 줄인 시간을 지키는 장치로, 에이전트가 작업 전에 읽는 문서(web 패키지의 CLAUDE.md)에 「빠르게 쓰는 테스트」 지침을 남겼습니다.

  • DOM 이 필요 없는 테스트는 첫 줄에 node 환경 지시자를 둡니다.
  • 긴 입력은 글자 단위 입력 대신 붙여넣기를 씁니다. 13글자를 한 글자씩 입력하면 131ms, 붙여넣으면 35ms 였습니다. 입력 중간의 상태를 재는 테스트만 예외입니다.
  • 큰 화면은 한 번 렌더하고, 질의 범위를 within() 으로 좁힙니다.
  • 시간만 보고 기존 테스트를 고쳐 쓰지 않습니다. 의도가 담긴 테스트는 지우지 않습니다.

지침마다 근거 수치를 함께 적었습니다. 근거 없는 규칙은 다음 에이전트가 「왜」를 몰라 쉽게 어긴다는 생각이 들었기 때문입니다.

남은 것과 한계

정리하면, 46분이 8.5분이 된 이야기의 대부분은 러너였습니다. 코드로 줄인 2.5분은 작아 보이지만, 같은 러너 위에서 다시 20%를 줄인 몫입니다. 그리고 릴리스 게이트뿐 아니라 각자 로컬에서 돌리는 push 훅에도 그대로 적용됩니다. 로컬 push 훅의 e2e 는 381초에서 205초로 줄었습니다(12코어 로컬, 4샤드).

이 기록이 증명하지 않는 것도 적어 둡니다. 표본은 전후 세 번씩이고, 개선 전후 사이에 저장소가 자랐습니다. 러너 등급을 lint 와 web 빌드로 가른 것은 실용적인 근사이지 VM 성능을 직접 잰 것이 아닙니다. 그리고 이것은 한 저장소의 사례라, 같은 변경이 다른 저장소에서 같은 크기로 줄어든다는 보장은 없습니다.

연재 목차

  1. CI 46분이 8.5분이 되기까지 — 러너 교체와 코드 개선을 갈라서 잰 기록(이 글)
  2. TypeScript 7을 타입 검사에만 쓰기 — 빌드와 lint는 5.x에 남겨 둔 이유
  3. vitest 격리를 파일마다 고르기 — 상태를 남기는 스펙만 격리했습니다
  4. e2e 테스트 시간의 39%는 ALTER TABLE 이었습니다 — MySQL 하네스의 정리 비용 줄이기
  5. 밤에만 실패하는 테스트 — 같은 커밋이 낮에는 통과했습니다
CIGitHub Actions테스트 최적화성능 측정vitestClaude Code