메뉴
HN
Hacker News • 4일 전

AI 코딩 시대, CI가 병목이 되어 개선한 Linear의 사례

IMP
6/10
핵심 요약

AI 코딩 에이전트로 코드 생산 속도는 빨라졌지만 검증을 담당하는 CI가 병목이 되면서 Linear는 인프라 업그레이드, 타입 체커 없는 린트, 게이팅 작업 최적화 등을 통해 대기 시간을 6분에서 5분으로 줄이고 테스트당 러너 시간을 절반으로 감축했습니다. 테스트 규모가 4배 가까이 늘어난 상황에서도 달성한 결과로, TypeScript 기반 팀뿐 아니라 다양한 언어·툴체인에 적용 가능한 실전 CI 최적화 가이드입니다.

번역된 본문

팀에서 보내온 글입니다. AI 코딩 시대, CI가 병목이 되어 우리는 이를 개선했다 — Mufeez Amjad, 2026년 9월 21일.

올해 초 Linear를 열어보니 Tuomas CTO가 나에게 "CI 비용이 너무 높다"는 이슈를 할당해두었다. 그깟 김에 CI도 더 빠르게 만들어달라는 요청이었다. AI 에이전트 덕분에 코드를 배포하는 속도는 비약적으로 빨라졌지만, 그 변경사항을 검증하는 속도는 같은 폭으로 따라가지 못했다. 모든 PR은 여전히 CI를 통과해야 하므로, 개발이 빨라질수록 CI가 병목이 되어 인프라 비용을 끌어올리고 개발자와 에이전트는 피드백을 더 오래 기다리게 된다.

Linear에서 CI 성능을 개선하면서 우리가 최적화한 지표는 PR이 CI에서 대기하는 시간과 소비하는 러너 시간이었다. 테스트 스위트가 연초 대비 거의 4배로 늘어났음에도, PR 대기 시간을 6분 이상에서 5분 조금 넘게 줄이고 테스트당 러너 시간을 대략 절반으로 줄였다.

크게 보면 네 가지 방향으로 CI를 개선했다:

  1. 인프라와 툴링 업그레이드
  2. 다른 작업을 게이팅하는 잡 최적화
  3. 반복되는 셋업 감소
  4. 테스트 실행 효율화

Linear의 코드베이스는 주로 TypeScript지만, 이런 최적화 대부분은 언어와 툴체인을 가리지 않고 적용할 수 있다.

인프라와 툴링 업그레이드 가장 초기의 성과 일부는 CI 자체를 거의 최적화하지 않고도 얻었다. 워크로드를 GitHub Actions에서 더 빠른 CPU, 고성능 스토리지, 더 나은 캐시 인프라를 갖춘 서드파티 러너로 옮긴 것만으로 같은 파이프라인을 더 빠른 머신에서 돌릴 수 있었다. 전환 전후 이틀씩을 동일 조건으로 비교한 결과, 잡이 평균 34% 빨라졌고 tsc 같은 일부 워크로드는 52% 줄었다.

별도로 툴체인 현대화도 효과가 컸다. 네이티브 TypeScript 컴파일러인 tsgo로 전환하자 tsc 체크의 주간 중앙값이 73% 감소해, 타입 체킹이 병목에서 완전히 벗어날 정도였다.

타입 체커 없는 린트 린팅도 초기 개선 대상이었다. 우리의 커스텀 린트 규칙 일부는 제약을 강제하거나 자동 수정을 적용하기 위해 TypeScript 타입 정보에 의존했다. 그 때문에 모든 린트 실행이 해당 규칙을 평가하기 전에 전체 타입 그래프를 빌드해야 했고, 린팅은 가장 메모리를 많이 쓰는 CI 잡 중 하나였다.

우리는 이 규칙들을 추상 구문 트리(AST) 기반 정적 분석을 사용하도록 다시 작성해, 타입 정보 없이도 함수형 구조와 가드 패턴을 식별하도록 했다. 덕분에 ESLint가 TypeScript를 완전히 떼어낼 수 있었고, API 린트 시간이 68%, 전체 저장소 린트 시간이 55% 줄었다. 메모리 사용량도 크게 감소했다. 타입 정보 의존을 제거한 것은 이후 Oxlint로 전환하는 작업도 훨씬 쉽게 만들어주었는데, 순수하게 구문만 다루는 규칙은 포팅이 간단했기 때문이다. Oxlint 자체도 린팅에 쓰이는 CI 러너 시간(분)을 줄여주었다.

다른 작업을 게이팅하는 잡 최적화 기반 인프라와 개별 체크가 빨라지자, 이번에는 CI를 하나의 시스템으로 바라보며 시야를 넓혔다. 그러자 다른 모든 작업 앞에 놓인 작은 잡들이 눈에 들어왔다. 모든 실행은 PR이 어떤 경로를 건드렸는지, 그리고 같은 입력에 대해 이 테스트들이 이미 통과했는지 확인하는 것으로 시작한다. 우리는 이 확인을 잡 수준에서 게이팅하여 스킵된 작업이 러너를 예약하지 않도록 하지만, 그만큼 이들이 중요 경로(크리티컬 패스)에 직접 놓이게 된다. 8개의 API 테스트 샤드는 이들이 끝나야 시작할 수 있으므로, 작은 지연도 불균형하게 큰 영향을 준다.

각 잡에 필요한 것만 가져오기 우리 워크플로 중 일부는 변경 감지 잡으로 시작해 다음에 무엇을 실행할지 결정한다. 예를 들어 diff에 데이터베이스 마이그레이션이 포함되어 있는지 확인하고, 그 신호를 사용해 관련 데이터베이스 CI 체크를 스케줄링한다. 이런 잡들은 아주 작은 일부만 필요로 하는데도 전체 워킹 트리를 체크아웃하고 있었다. fetch 깊이를 제한하자 이런 게이트 중 가장 느렸던 잡이 94초에서 20초로 줄었고, 워킹 트리가 아예 필요 없는 잡에서는 체크아웃을 완전히 제거해 27초에서 7초로 줄였다.

원문 보기
원문 보기 (영어)
Now From the team AI coding has made CI a bottleneck, so we reworked ours to keep up Mufeez Amjad · September 21, 2026 Earlier this year, I opened Linear to find that Tuomas, our CTO, had assigned an issue to me, titled “CI costs are high.” While I was at it, he also wanted me to make CI faster. Agents have made it exponentially faster to ship code, but validating those changes hasn’t quite kept up at the same rate. Every PR still has to pass through CI, so as development accelerates, CI becomes a bottleneck, driving up infrastructure costs and leaving developers and agents waiting longer for feedback. In our pursuit to make CI more performant at Linear, we optimized for how long a PR waits on CI and how much runner time it consumes. Despite our test suites almost quadrupling since the start of the year, we brought pull request wait time down from more than 6 minutes to just over 5, while cutting runner time per test roughly in half. Broadly, we improved CI in four ways: Upgraded infrastructure and tooling Optimized the jobs that gate other work Reduced repeated setup Made test execution more efficient Linear’s codebase is primarily TypeScript, but many of these optimizations apply across languages and toolchains. Upgraded infrastructure and tooling ⁠ Some of our earliest gains required almost no optimization of CI itself. Moving our workloads off GitHub Actions to third-party runners with faster CPUs, higher-performance storage, and better cache infrastructure gave us faster machines to run the same pipeline on. In a like-for-like comparison of the two days either side of the switch, jobs ran 34% faster on average, with some workloads like tsc dropping 52%. Separately, modernizing our toolchain also paid off. Switching to tsgo , the native TypeScript compiler, cut the weekly median of the tsc check by 73%, large enough to move the bottleneck off of typechecking entirely. Lint without the type checker ⁠ Linting was another early target. A handful of our custom lint rules depended on TypeScript type information, either to enforce a restriction or apply an autofix. That meant every lint run had to build the full type graph before evaluating those rules, making linting one of our most memory-intensive CI jobs. We rewrote the rules to use static analysis over the abstract syntax tree, identifying function-like constructs and guard patterns without type information. That let ESLint drop TypeScript entirely, reducing API lint time by 68%, and full-repository lint time by 55%. Memory usage dropped substantially as well. Removing the dependency on type information also made our later move to Oxlint much easier because rules that operate purely on syntax are straightforward to port. Oxlint itself reduced the CI runner-minutes spent on linting. Optimize the jobs that gate other work ⁠ With the underlying infrastructure and individual checks running faster, we zoomed out to look at CI as a system. That drew our attention to the small jobs that sat in front of everything else. Every run starts by checking which paths a PR touched and whether these tests have already passed for the same inputs. We gate on those checks at the job level so skipped work never reserves a runner, but that also puts them directly on the critical path. None of the eight API test shards can start until they finish, making even small delays disproportionately important. Fetch only what each job needs ⁠ Several of our workflows start with a change-detection job that decides what runs next; for instance, it checks whether a diff contains a database migration and outputs a signal used to schedule the relevant database CI checks. These jobs were checking out the full working tree even though they needed only a small subset of it. We capped the fetch depth, which took the slowest of these gates from 94 seconds to 20, and removed checkout entirely from the jobs that never needed a working tree, reducing time spent on those from 27 seconds to 7. For commit push and merge-queue events, where we do have to diff paths, we found that a sparse, blobless checkout with limited history was enough, saving another 11 odd seconds. Make checkout more resilient ⁠ After we swapped the underlying runner infrastructure, we noticed that our checkout times (with actions/checkout ) in our jobs had gotten longer and would sometimes hang. Because the third-party runners sit outside GitHub’s network, they rely on a direct IP link to reach GitHub. The provider traced the hangs to intermittent degradation on that link. Several of our workflows begin with a checkout, so a stalled fetch could delay the entire CI run. To be resilient to the network instability, we replaced actions/checkout with a composite action of our own that retried with backoff, and sets GIT_HTTP_LOW_SPEED_LIMIT and GIT_HTTP_LOW_SPEED_TIME so a stalled connection aborts after about 30 seconds instead of hanging and also uses the checkout cache, which keeps a persistent git mirror on a sticky disk. The result was far fewer runs where a critical-path job sat idle waiting for checkout to finish. Minimize what’s on the critical path ⁠ Not every job on the critical path needed to be there. We were writing cache markers as part of the final check before merging, which meant a pull request could sit in the merge queue even after its tests had passed. We moved that write into a job that runs once the test shards finish but gates nothing, shaving 42 seconds from the merge path for every API pull request and merge-queue entry. Together, these changes took roughly a minute off the required check for API pull requests on cache misses, while also reducing runner starts. Reduce repeated setup ⁠ From there, we turned to the setup cost repeated across every job, like booting a runner, installing packages, and provisioning build dependencies. That overhead means a job that does only seconds of useful work can end up consuming whole minutes of infrastructure time. Here are a few steps we took to work around that issue: Preinstall shared dependencies in the CI image ⁠ Our API test shards each spent 7 to 8 seconds installing the same Postgres client with apt on every run. We moved it into a small CI base image containing Node and the client, so each shard could start from an environment that was ready to run. We later added the required native build headers to the image after discovering that downloading them during setup could occasionally hang, shortening the tail. Install only the dependencies each job needs ⁠ Linear’s codebase is a monorepo managed as a pnpm workspace. Our API test workflow was installing the entire workspace even though it only needed the API package and its dependencies. Restricting the install to our API package cut pnpm install from 44-73 seconds to 16-18 seconds. We applied the same pattern to API-adjacent jobs, which were each installing the full repository and uploading a dependency cache that later runs almost never hit. Don’t cache when it’s faster to rebuild ⁠ We also tested caching node_modules and found it was faster to rebuild. The cache key depended on a frequently changing lockfile, and even a cache hit took about 28 seconds to restore, compared with roughly 7.5 seconds for a filtered install. The cache was adding save time and variability without giving us any discernible advantage. Together, these three changes reduced per-shard setup time by roughly 44%, from 110-140 seconds to 67-73 seconds. Beyond this, there were other forms of repeated setup we could avoid altogether. Avoid replaying unchanged setup ⁠ Some setup work only needs to be repeated when its inputs change. Our API containers, for example, were replaying the full database migration history on every run, even when a PR hadn’t changed the schema. For those cases, we switched to loading a generated schema snapshot and bootstrap file instead, cutting database setup from roughly 12 seconds to 1-2 seconds per container. Batch short checks into fewer jobs ⁠ Seven independen