AI coding has made CI a bottleneck, so we reworked ours to keep up
AI 코딩으로 CI가 병목이 되자, Linear가 파이프라인을 다시 설계했습니다
AI 에이전트가 코드 작성 속도를 끌어올리면서 Linear에서는 CI(Continuous Integration)가 개발 흐름을 막기 시작했습니다. 테스트 수가 연초보다 거의 4배 늘었지만 PR 대기 시간은 6분 이상에서 5분 조금 넘는 수준으로 줄였고, 테스트당 runner 시간은 절반가량 낮췄습니다.
- 주제
AI 요약
AI 에이전트가 코드를 빠르게 생성하면서 Linear의 검증 단계가 병목이 됐습니다. 모든 PR은 CI를 통과해야 하므로 코드 변경 속도가 빨라질수록 runner 비용과 피드백 대기 시간이 함께 늘어났습니다. Linear는 CI를 개별 작업의 속도만이 아니라 PR이 CI를 기다리는 시간과 runner를 점유하는 시간으로 나눠 살폈습니다. 그 결과 테스트 수가 연초보다 거의 4배 늘었는데도 PR 대기 시간을 6분 이상에서 5분 조금 넘는 수준으로 줄였고, 테스트당 runner 시간은 대략 절반으로 낮췄습니다.
인프라와 도구 교체
GitHub Actions에서 더 빠른 CPU와 저장장치, 캐시 인프라를 제공하는 third-party runner로 작업을 옮겼습니다. 전환 전후 이틀을 같은 조건으로 비교하자 작업 실행 시간이 평균 34% 줄었고, 일부 작업인 tsc는 52% 빨라졌습니다. native TypeScript compiler인 tsgo로 바꾸면서 tsc 검사 주간 중앙값도 73% 줄어 타입 검사가 더 이상 병목이 아니게 됐습니다.
Lint에서는 TypeScript 타입 정보를 요구하던 custom rule을 abstract syntax tree(AST) 기반 정적 분석으로 다시 작성했습니다. 함수 형태와 guard 패턴을 타입 그래프 없이 판별하도록 바꾸자 API lint 시간은 68%, 전체 저장소 lint 시간은 55% 줄었습니다. 메모리 사용량도 크게 감소했습니다. 이후 syntax만 다루는 규칙을 Oxlint로 옮기기도 쉬워졌고, Oxlint는 lint에 사용한 CI runner-minutes를 더 줄였습니다.
필수 경로에 있는 작업 줄이기
각 PR이 어떤 경로를 바꿨는지 확인하고, 같은 입력으로 이미 통과한 테스트가 있는지 확인하는 작은 작업들이 8개 API 테스트 shard보다 먼저 실행되고 있었습니다. 이 작업이 조금만 늦어져도 뒤의 모든 shard가 기다려야 했습니다.
변경 감지 작업에서 전체 working tree를 checkout하던 방식을 바꿨습니다. fetch depth를 제한해 가장 느린 gate 작업을 94초에서 20초로 줄였고, working tree가 필요 없는 작업은 checkout을 없애 27초에서 7초로 낮췄습니다. 경로 차이가 필요한 push와 merge queue 이벤트에는 제한된 이력만 포함한 sparse, blobless checkout을 사용해 11초가량을 추가로 줄였습니다.
Third-party runner와 GitHub 사이의 IP 연결이 간헐적으로 느려지면서 actions/checkout이 멈추는 문제도 생겼습니다. Linear는 자체 composite action을 만들어 backoff를 적용한 재시도를 넣고, GIT_HTTP_LOW_SPEED_LIMIT과 GIT_HTTP_LOW_SPEED_TIME을 설정했습니다. 약 30초 동안 전송이 멈추면 연결을 중단하도록 했고, sticky disk에 영구 Git mirror를 저장하는 checkout cache도 사용했습니다.
테스트가 끝난 뒤 merge를 막던 cache marker 기록은 필수 경로 밖으로 옮겼습니다. API PR과 merge queue 항목마다 merge 경로에서 42초를 줄였고, cache miss 상황에서 API PR에 필요한 검사를 통과하는 데 걸리는 시간은 전체적으로 약 1분 줄었습니다.
반복되는 설정 비용 줄이기
API 테스트 shard마다 Postgres client를 apt로 설치하던 작업을 Node와 client가 포함된 작은 CI base image로 옮겼습니다. 이후 native build header도 이미지에 추가해 설치 중 멈추는 상황과 tail latency를 줄였습니다.
pnpm workspace 전체를 설치하던 API 테스트를 API package와 필요한 의존성만 설치하도록 바꿨습니다. pnpm install 시간은 44~73초에서 16~18초로 줄었습니다. node_modules 캐시도 시험했지만 캐시 복원에 약 28초가 걸렸고, filtered install은 약 7.5초면 끝났습니다. 자주 바뀌는 lockfile 때문에 캐시 적중률도 낮아 캐시를 복원하지 않고 다시 설치하는 편이 빨랐습니다.
세 가지 변경으로 shard당 설정 시간이 110~140초에서 67~73초로 약 44% 감소했습니다. 데이터베이스 스키마가 바뀌지 않은 PR에서는 전체 migration history를 다시 실행하지 않고 생성된 schema snapshot과 bootstrap file을 읽게 해 컨테이너별 설정 시간을 약 12초에서 1~2초로 낮췄습니다.
몇 초짜리 작업 7개가 각각 runner 시작, checkout, 의존성 설치를 반복하던 구조도 두 개의 job으로 합쳤습니다. 두 job 안에서 7개 작업을 동시에 실행한 결과, 6월 사용량 기준으로 월 약 87,000 runner-minutes를 절약했습니다. 전체 CI 사용량의 11.8%에 해당합니다.
테스트 실행 방식 바꾸기
Vitest는 테스트 실행 시간을 기준으로 하지 않고 파일 단위로 작업을 나눴습니다. 큰 테스트 파일 몇 개가 한 shard에 몰리면 다른 shard가 먼저 끝나도 전체 실행은 느린 파일을 기다려야 했습니다. Linear는 큰 파일을 더 작은 파일로 나누고 shard와 runner 조합을 조정했습니다. API 테스트를 3개에서 4개 shard로 늘린 뒤 8개로 확장하자 필수 job이 초기 벤치마크에서 19% 빨라지고 19% 저렴해졌습니다. 일주일 뒤 가장 느린 shard 시간은 5.25분에서 4.33분으로 줄었습니다.
Vitest의 기본 격리 방식은 각 테스트 파일마다 entity, GraphQL, decorator graph를 다시 만들었습니다. Linear는 안전한 파일을 한 worker 안에서 module registry를 공유하도록 isolate: false인 opt-in project를 추가했습니다. 가장 느린 shard는 약 300~379초에서 195초로 줄었고, API shard 전체 runner 시간은 실행당 약 32.8분에서 22분으로 감소했습니다. 월 비용 절감 효과는 약 17%였습니다.
공유 상태를 잘못 다루면 테스트 간 간섭이 생길 수 있어 correctness risk도 가장 큰 변경이었습니다. 모든 파일에 opt-in 주석을 명시하고 teardown을 추가했습니다. fake timer나 공유 상태를 안전하게 분리하지 못한 일부 파일은 기존 격리 project에 남겼습니다. 에이전트가 테스트 대부분을 작성하는 상황에 맞춰 agent skill에도 이 성능 옵션의 제약을 반영했습니다.
Shard 수를 늘리면 설정 비용도 함께 증가하므로 setup을 먼저 줄여야 했습니다. shard당 설정 시간이 110~140초일 때 8개 shard는 테스트보다 설정에만 15~19분의 runner 시간을 쓸 수 있었습니다. 설정 시간을 약 40초로 낮춘 뒤에는 8개 shard가 과거 4개 shard보다 적은 설정 시간으로 테스트를 두 배 더 병렬 실행할 수 있었습니다.
이 작업을 하지 않았다면 현재 테스트 스위트는 약 11분이 걸렸을 것으로 Linear는 추정합니다. 현재도 일주일에 약 2,000개 테스트를 추가하고 있어 새 병목을 계속 찾아야 한다고 설명합니다.
Hacker News 반응
- @azkalam — 설정 비용을 감당할 수 있다면 Bazel은 웜 캐시에서 아주 큰 프로젝트도 약 10초의 빌드 시간을 냅니다.
- @criemen — 5개가 넘는 언어로 작성되고 3대 주요 운영체제용 native binary를 배포하는 꽤 복잡한 소프트웨어의 Bazel 전환을 이끈 적이 있습니다. 완료하는 데 여러 해가 걸렸습니다. 언어 하나를 사용하면서 3개 운영체제에 배포하는 덜 복잡한 프로젝트는 제 지식과 에이전트를 활용해 2주 만에 전환했습니다. Bazel 도입 비용은 크게 낮아졌지만 업계 전체가 아직 그 사실을 모르는 것 같습니다.
- @rokob — 그 프로젝트 중 JavaScript나 TypeScript를 사용한 것도 있었습니까? 여러 compiled language에서는 Bazel이 놀라운 성능을 내는 것을 봤지만 JavaScript 생태계에서는 인상적이지 않았습니다.
- @criemen — 실질적인 규모의 JavaScript나 TypeScript 프로젝트는 없었습니다. 제가 직접 다뤄본 JS/TS 빌드가 Bazel을 검토할 만큼 느렸던 적은 없습니다. Linear가 글에서 설명한 tsgo, Oxlint, 의존성 캐시가 제가 경험한 평균적인 TypeScript 프로젝트에는 더 큰 효과를 냅니다.
- @manquer — nx와 turbo를 모두 써봤는데 병목은 대개 연산이 아니라 네트워크와 디스크 IOPS였습니다. 캐시된 결과도 원격 서버에서 가져와 읽어야 합니다. 이전 단계의 출력물을 원격 캐시에서 가져와 디스크에 쓰고, 다음 단계가 다시 읽습니다. Java나 C++ 생태계에서 Bazel이 주로 언급되는 이유처럼 10초가 현실적인 목표일 수도 있지만, TypeScript 생태계에서는 꽤 큰 monorepo가 1~2분에 실행되는 것만으로도 대부분 매우 만족할 것입니다. 여기서 build가 단순한 transpile이나 compile인지, Linear 글처럼 테스트까지 포함한 전체 작업인지 먼저 정의해야 합니다. virtual DOM이나 실제 브라우저가 필요한 큰 테스트 일부를 10초 이내에 실행하기는 어렵습니다.
- @seniorsassycat — Bazel은 각 작업의 sandbox 설정 비용과, 사람들이 일반 도구를 병행하게 만드는 사용성 문제 같은 trade-off가 많습니다. 웜 캐시라는 표현도 큰 역할을 합니다. 일주일 개발 동안 실제 캐시 적중률이 얼마인지 봐야 합니다. Bazel을 넓게 말하기보다는 어떤 rule을 쓰는지가 실제 성능과 동작을 결정한다고 봅니다. turbo나 nx처럼 package.json 모듈 단위로 tsc, vitest, eslint를 캐시하도록 구성할 수도 있고, 의존성이 바뀔 때만 무효화되는 파일 단위 작업을 사용할 수도 있습니다. 다만 후자는 worker를 쓰지 않으면 batching과 맞바꿔야 합니다.
- @rienbdj — 대부분의 PR은 소수의 target만 건드리므로 실제로는 캐시 적중률이 매우 높습니다.
- @seniorsassycat — 어떤 target을 건드리는지와 캐시를 얼마나 세밀하게 나누는지에 달렸습니다. package.json을 바꾸면 전체가 무효화될 수 있고, core를 바꾸면 전체가 무효화될 수 있습니다. api의 파일 하나를 바꿔도 모든 API 테스트를 실행하는 경우가 있습니다. 한 package를 Bazel로 옮긴 뒤 일주일치 변경을 재생하는 실험에서는 20%가 절약됐습니다. 무시할 수치는 아니지만, 전체 hot build 뒤에 나오는 대표적인 수치와는 다릅니다.
- @d_sc — 파일 기준이 아니라 테스트 실행 시간을 기준으로 load balancing을 하는 Vitest 기능이 있으면 성능이 좋아질 것 같습니다.
- @moofeez — 실제로 시도했지만 추가 복잡성을 감수할 만큼 효과가 크지 않았습니다.
- @classictraffic — GitHub Actions에서 third-party runner로 옮겼다는 부분은 놀랍지 않습니다. GitHub를 이미 쓰는 경우 Actions는 편하지만 꽤 느릴 수 있습니다. 최근 GitHub의 안정성 문제도 크므로 다른 파이프라인으로 옮기는 조직이 더 많아질 것 같습니다.
- @dneri — Actions 작업을 blacksmith.sh로 옮겼고 속도와 비용 모두 만족하고 있습니다. 이런 추세가 계속될 것 같습니다.
- @saithound — 이 글은 제품 개선을 묻는 논쟁의 사례이기도 합니다. type-aware custom lint rule을 AST 기반 정적 분석으로 바꿨지만 새 규칙이 무엇을 감지하는지, 이전 규칙과 결과가 일치하는지 말하지 않습니다. tsc를 tsgo로 바꾼 뒤에도 진단 결과가 같은지 보여주지 않습니다. 테스트 수가 4배가 됐지만 새 테스트 스위트가 예전보다 더 나은지, 제대로 동작하는지 알 수 없습니다. 이전 CI와 새 CI의 pass/fail 일치율도 제시하지 않았습니다.
- @plant-ian — 정말 코드베이스에 일주일마다 테스트 2,000개를 추가하고 있습니까?
- @yieldcrv — 테스트, 특히 unit test는 장식에 가깝다고 생각합니다. 높은 coverage를 쉽게 얻는 데 감탄하기보다 왜 테스트를 작성하는지 봐야 합니다. 에이전트가 테스트를 쓰는 방식은 junior나 mid-level 개발자와 크게 다르지 않고, 사람도 마찬가지입니다. 테스트가 기능을 이끌거나 regression을 드러내기보다는 애플리케이션이 바뀌면 테스트도 함께 수정되는 경우가 많습니다. 반면 end-to-end test는 실제 사용자 경험이 바뀌었는지 확인하므로 유용합니다.
- @dgroshev — CI가 느려진 이유 중 상당 부분은 쓸모없는 테스트가 눈덩이처럼 늘었기 때문일 수 있습니다. PR을 검토할 때 테스트를 마지막으로 자세히 읽은 게 언제인지 생각해 보면 됩니다. LLM이 많이 쓰인 PR의 테스트 중 실제로 유용한 동작을 검증하는 테스트는 얼마나 되고, 내장 기능이나 사소한 동작만 확인하는 테스트는 얼마나 되겠습니까?
- @cyberax — 지난해 CI/CD 흐름을 60초 이하로 고쳤습니다. 전체 lint와 unit test를 포함합니다. GitHub Runner를 self-hosting하고, 매번 build context를 socket으로 보내지 않도록 Podman으로 이미지를 빌드했으며, Docker image를 content-addressable cache로 사용했습니다. 이번 주에 글을 쓸 예정입니다.
- @aliclark — 제 경우 병목은 CI가 아니라 사람이 하는 테스트입니다. 기능이 동작하는지는 확인할 수 있지만, 우리가 원한 동작인지, 고객이 이해하고 실제로 좋아할 방식인지 확인하는 일이 더 어렵습니다.
- @shykes — 계층으로 봐야 합니다. 하위 계층인 CI가 에이전트의 출력량을 따라가지 못하면 사용자 경험 확인 같은 상위 계층의 문제 해결도 훨씬 느리고 불안정해집니다. 프로그램의 안쪽 반복문을 최적화하면 전체 프로그램이 빨라지는 것과 비슷합니다.
- @Sharlin — 아닙니다. 단계 X가 병목이면 단계 Y를 아무리 빠르게 만들어도 상관없습니다.
- @pushpendraw — CI를 빠르게 만들면 다음 병목은 deploy와 rollback으로 옮겨갑니다. 두 작업은 같은 방식으로 확장되지 않습니다.
- @shykes — 맞습니다. 하지만 한 번에 한 문제씩 해결해야 합니다.
- @zackify — 제 CI에는 테스트가 수천 개 있지만 2분 안에 끝납니다. 3분을 넘을 때마다 병렬화를 추가해 계속 빠르게 유지하고 있습니다. 팀에서는 잘 작동합니다.
- @fb03 — commit cadence와 저장소에 추가되는 콘텐츠가 널리 늘어난다면, CI/CD 수요가 많은 회사가 아예 사내에서 CI/CD 장비를 운영하는 방법도 있다고 봅니다. 제 경험에서는 CI/CD runner를 self-hosting하는 방식이 비용을 가장 크게 줄였고, 더 강한 장비를 사용해 실행 시간도 직접 줄였습니다.
원문: Linear / 번역·요약: Trawling