Turborepo 2.9는 이전 버전인 2.8보다 최대 96% 빠릅니다. 코딩 에이전트와 Vercel 샌드박스, 그리고 사람의 검토를 적절히 결합해 이 성과를 달성한 과정을 소개합니다.
Turborepo는 저희 레포지토리에서 태스크 그래프 계산 속도가 81~91% 빨라졌으며, 이 수치는 레포 규모에 따라 달라집니다. 패키지가 1,000개 이상인 모노레포에서 turbo run은 이제 거의 즉각적으로 실행됩니다. 첫 번째 태스크 시작까지 걸리는 시간(Time to First Task)은 11배 단축됐습니다.
오픈소스 Turborepo 프로젝트 몇 개로 변경 사항을 검증하고, Vercel 고객들에게 카나리(canary) 릴리스를 레포에 직접 적용해 달라고 요청한 결과, 레포의 규모와 복잡도에 따라 최대 96%까지 성능이 개선되는 것을 확인했습니다.
이 성과를 이끌어낸 과정은 충분히 공유할 가치가 있습니다. 단 하나의 최적화 기법으로 이뤄낸 결과가 아니기 때문입니다. AI 에이전트와 Vercel 샌드박스, 그리고 평범하고 전형적인 엔지니어링 방식을 8일 동안 조합한 작업의 산물입니다.
turbo run를 실행하면 가장 먼저 모노레포의 구조, 스크립트, 의존성을 분석해 태스크 그래프를 구성합니다. 이 그래프가 실행 순서를 결정하고, 병렬 처리를 가능하게 하며, 동일한 작업을 반복하지 않도록 캐싱을 지원합니다.
태스크 그래프 구성은 레포 작업이 실제로 시작되기 전에 치러야 하는 오버헤드입니다. 레포가 클수록 비용도 커집니다. 저희의 1,000개 패키지 모노레포에서는 M4 Pro Max 기준으로 약 10초가 소요됐는데, 저는 이를 도저히 받아들일 수 없었습니다.
별다른 가이드 없이 에이전트가 무엇을 해낼 수 있는지 확인하고 싶었습니다. 잠자리에 들기 전 폰으로 백그라운드 코딩 에이전트 8개를 실행했고, 각각 속도가 느릴 것으로 의심되는 Rust 코드베이스의 서로 다른 부분을 타깃으로 지정했습니다.
프롬프트마다 관심 있는 코드베이스 영역을 바꿔 지정했습니다. 기준점을 잡는 차원에서, 모호한 지시만으로 에이전트가 어디까지 해낼 수 있는지 지켜보고 싶었습니다.
아침에 결과를 확인해보니 8개 중 3개가 실제로 배포할 수 있는 성과물을 만들어냈습니다.
PR #11872는 HashMap 전체를 복사(clone)하는 대신 참조 기반 해싱으로 메모리 할당 부담을 줄여, 실제 실행 시간을 약 25% 단축했습니다.
PR #11874는 Rust 의존성 크레이트인 twox-hash를 더 빠른 해싱 알고리즘을 사용하는 xxhash-rust로 교체했습니다. 거의 1:1로 대체 가능한 전환이었고, 약 6%의 성능 향상을 이끌어냈습니다.
PR #11878은 아직 처리하지 못한 채 남아 있던 TODO 주석에서 출발했습니다. 불필요하게 사용 중이던 플로이드-워셜(Floyd-Warshall) 알고리즘을 다중 소스 깊이 우선 탐색(DFS)으로 교체해야 했습니다. turbo run의 핫 패스(hot path)와는 직접적인 관련이 없었지만, 애초에 프롬프트에서 어떤 핫 패스를 최적화하라고 명시하지 않았으니, 납득할 만한 결과입니다.
이 결과들은 분명 의미 있는 성과입니다. 그러나 8개의 채팅 세션과 코드 출력 결과를 전부 검토하면서, 적절한 컨텍스트 설계 없이 자율적으로 실행되는 최신 에이전트가 오늘날 어떤 한계를 드러내는지도 분명하게 파악할 수 있었습니다.
에이전트는 Turborepo 코드베이스 자체에서 개선 사항을 벤치마킹할 수 있다는 사실을 끝내 파악하지 못했습니다. Turborepo는 Turborepo를 직접 dogfooding하기 때문에, 바이너리를 빌드한 다음 소스 코드에 바로 실행해 엔드-투-엔드 결과를 얻을 수 있었는데도 말입니다.
에이전트는 문제를 추상적으로 다시 생각하기보다, 처음 떠올린 아이디어에 집착하며 억지로 동작하게 만들려 했습니다. 채팅 로그를 보면 나름대로 재고하려는 시도가 보이기는 했지만 실제로 방향을 바꾸지는 못했습니다.
에이전트는 실제 성능과는 거의 무관한 마이크로벤치마크를 만들어놓고 최대한 높은 수치를 끌어내려 했습니다. 그렇게 벤치마크에서 97% 개선이라는 결과를 뽑아냈지만, 실제 환경에서의 개선율은 고작 0.02%에 불과했습니다.
에이전트는 단 한 번도 회귀 테스트를 작성하지 않았습니다.
에이전트는 단 한 번도 turbo CLI의 --profile 플래그를 사용하지 않았습니다.
에이전트가 자율적으로 실행되는 방식으로도 어느 정도 성과는 있었지만, 이 방식이 지속 가능하지 않다는 것은 분명했습니다. 더 철저한 테스트와 검증 루프가 필요했고, 제가 더 직접적으로 관여해야 했습니다.
제가 처음으로 한 정석적인 엔지니어링 작업은 프로파일을 뽑는 것이었습니다. 놀랍죠, 그렇죠.
가장 큰 레포에서 turbo run build --profile을 실행하고, 그 트레이스를 Perfetto에서 열어봤습니다.
플레임 그래프(flame graph)는 많은 정보를 담고 있지만 다루기가 번거롭습니다. 플레임 그래프를 분석하며 성과를 하나씩 쌓아가는 작업을 즐기긴 하지만, Turborepo는 출시해야 할 기능이 많습니다. 사용자들에게는 제가 가진 최선의 도구를 활용해 효율적이고 효과적으로 일할 의무가 있습니다.
Turborepo의 프로파일은 Chrome Trace Event Format의 JSON 파일로 출력됩니다.
LLM이 이론상 이 파일을 읽고 파싱할 수는 있지만, 직접 보면 바로 이해가 될 겁니다. 함수 식별자는 여러 줄에 걸쳐 분리돼 있고, 무관한 메타데이터가 타이밍 데이터와 뒤섞여 있으며, grep으로 검색하기도 불편합니다. 에이전트를 이 파일로 테스트해봤더니 grep 호출을 거듭하며 여러 줄에 흩어진 함수 이름을 조각조각 끌어모으려 했고, 노이즈를 걸러내는 데도 실패했습니다. 에이전트가 이 파일 앞에서 헤매는 모습은 제가 직접 씨름하는 것과 다르지 않았습니다.
코딩 에이전트와 작업할 때 제가 자주 활용하는 원칙이 있습니다. 내가 다루기 불편한 것은 에이전트에게도 불편하다는 것입니다. 이는 작업량의 문제라기보다 인터페이스의 문제입니다. 내가 읽기 어려운 것은 에이전트도 읽기 어렵습니다. 물론 이 원칙이 모든 상황에 들어맞지는 않지만, 곧 실질적인 효과로 이어지는 것을 보게 될 것입니다.
일주일 전, Jarred Sumner의 트윗에서 Bun이 새 플래그 --cpu-prof-md를 추가했다는 소식을 봤습니다. 프로파일을 마크다운으로 출력하는 기능인데, 에이전트가 가장 잘 활용할 수 있는 형식이라는 제 생각과 딱 맞아떨어졌습니다.
#11880에서 새로운 turborepo-profile-md 크레이트를 추가해, 모든 트레이스와 함께 .md 파일이 자동으로 생성되도록 했습니다. self-time 기준으로 정렬된 핫 함수 목록, total-time 기준의 콜 트리, caller/callee 관계까지, 모두 한 줄로 표현되어 grep 검색이 가능한 형태로 정리됩니다.
에이전트 출력 품질의 차이는 극명했습니다. 모델도, 코드베이스도, 데이터도, 에이전트 환경도 동일했습니다. 포맷만 바뀌었는데, 최적화 제안의 수준이 눈에 띄게 향상됐습니다. 프로파일 데이터가 드디어 저와 에이전트 모두 한눈에 파악할 수 있는 형식이 된 것입니다.
마크다운 프로파일이 준비되자, 자연스럽게 일정한 작업 흐름이 자리를 잡았습니다.
에이전트를 Plan Mode로 실행하고, 프로파일을 생성한 뒤 마크다운 출력에서 병목 지점을 찾도록 지시
제안된 최적화 방안을 검토하고, 추진할 가치가 있는 것을 선별
선별된 제안을 에이전트가 구현
hyperfine 엔드-투-엔드 벤치마크로 검증
PR 제출
반복
이 루프를 통해 나흘 동안 20개가 넘는 성능 개선 PR이 만들어졌습니다. 성과는 크게 세 가지 유형으로 나뉩니다. 각각 예시를 들어 설명하겠습니다.
병렬화가 가장 큰 비중을 차지했습니다. git 인덱스 빌드, 파일시스템 glob 탐색, 락파일 파싱, package.json 파일 로딩은 모두 순차적으로 실행되고 있었는데, 이를 동시에 처리할 수 있었습니다. PR #11889, #11902, #11927, #11918에서 이 핫 패스들을 병렬화했습니다.
불필요한 할당 제거는 파이프라인 전반에 걸친 중복 복사와 clone을 없애는 작업이었습니다. SCM 작업의 참조 기반 해싱(#11916), glob 제외 필터 사전 컴파일(#11891), 요청마다 새로운 HTTP 클라이언트를 생성하는 방식을 공유 클라이언트로 전환(#11929)한 것이 주요 변경 사항입니다.
시스템 콜 감소는 패키지별 git 서브프로세스 호출을 레포 전체를 대상으로 한 단일 인덱스 조회로 통합하고(#11887), git 서브프로세스를 libgit2 라이브러리 호출로 대체하며(#11938), 더 나아가 libgit2 자체를 더 빠른 gix-index으로 교체하는(#11950) 과정이었습니다.
거듭 말하지만, 전형적이고 평범한 소프트웨어 엔지니어링의 영역입니다. Ralph Wiggum 루프처럼 에이전트에게 전부 맡겨보려는 시도도 했지만, 실수가 너무 잦았습니다. 모델과 환경, 루프의 조합이 충분히 안정적이지 않았고, 제가 파악하기도 전에 너무 많은 코드를 바꿔버릴 수 있었습니다. 사이드 프로젝트였다면 감수했을 수도 있지만, Turborepo는 세계에서 가장 큰 레포지토리들을 지탱하고 있습니다. 빠르게 그리고 책임감 있게 움직여야 합니다.
이 단계에서 가장 흥미롭게 발견한 패턴은, 코드베이스 자체가 에이전트에게 가장 강력한 피드백 메커니즘으로 작용한다는 점이었습니다.
에이전트가 작업 중인 코드에서 성능 문제를 발견하면 함께 수정했습니다. 그런 다음 "동일한 방식으로 개선할 수 있는 부분이 더 있나요?"라고 물으면, 에이전트가 코드베이스 전체에서 같은 패턴을 찾아냈습니다. 변경 규모에 따라 해당 PR에 바로 반영하거나, 나중에 처리할 항목으로 기록해두었습니다.
기존 코드에 지저분한 패턴이 있으면, 에이전트는 새 코드도 같은 방식으로 작성했습니다. 한 곳을 수정해주면, 이후에는 그 수정된 방향을 따랐습니다. 이후 대화에서는 메모리나 컨텍스트가 이어지지 않더라도, 에이전트가 병합된 개선 사항을 소스에서 직접 확인하고 이전 패턴을 반복하지 않았습니다.
시간이 지나면서 에이전트가 요청하지 않았는데도 자발적으로 테스트를 작성하는 것을 보게 됐습니다. 제가 직접 작성했을 법한 추상화를 만들어내기도 했는데, 이전에는 없던 모습이었습니다. 예전에 에이전트가 제대로 처리하지 못했던 코드 영역을 다시 살펴보면, 모델이나 환경을 전혀 바꾸지 않았는데도 더 나은 코드를 출력했습니다.
결국 자신의 소스 코드가 최고의 강화 학습 데이터인 셈입니다.
주말이 끝날 무렵, Turborepo는 가장 큰 레포에서 약 85% 빨라진 상태였습니다. 작업을 시작할 때 막연하게 95% 개선을 목표로 잡았었는데, 남은 성과가 손에 닿을 듯 느껴졌습니다.
문제는 측정이었습니다. 맥북에서만 벤치마크를 실행해왔는데, hyperfine 보고서의 노이즈가 점점 심해지고 있었습니다. 코드가 빨라질수록 시스템 노이즈의 영향이 더 커집니다. 시스템 콜, 메모리, 디스크 I/O 모두 자체적인 편차를 가지고 있습니다.
내가 한 변경이 실제로 2% 빨라진 것인지, 아니면 우연히 조용한 실행을 만난 것인지 자신 있게 판단할 수 없었습니다. 더 통제된 환경이 필요했습니다.
Vercel 샌드박스는 직접 넣은 것만 존재하는 일회성 Linux 컨테이너입니다. 백그라운드 데몬도, CPU를 잡아먹는 Slack 알림도, 네트워크 요청을 보내는 백그라운드 프로그램도 없습니다. 머신의 모든 자원이 실행 중인 작업에만 집중됩니다.
벤치마킹 워크플로 전체를 자동화하는 bash 스크립트를 작성했습니다. 전체 gist의 축약 버전을 아래에 붙여두겠습니다.
스크립트 마지막 부분에서 프로파일을 노트북으로 다시 내려받는 것을 볼 수 있습니다. 이렇게 하면 에이전트가 벤치마크 결과와 마크다운 프로파일을 로컬에서 바로 확인할 수 있고, 변경 사항이 실질적인 개선인지 노이즈인지 확신을 갖고 판단할 수 있었습니다.
샌드박스의 깨끗한 신호 덕분에, 노이즈가 많은 노트북 환경에서는 보이지 않던 저수준 변경의 실질적인 효과를 확인할 수 있었습니다.
스택 할당 git OID (#11984)
git 인덱스의 모든 파일은 40자리 SHA-1 해시를 힙(heap) 할당 String로 저장하고 있었습니다. 가장 큰 레포에서 new_from_gix_index 하나만으로도 40바이트짜리 힙 할당이 10,000건 이상 발생하고 있었습니다.
OidHash는 Deref<Target=str>을 구현하므로 기존 코드를 수정하지 않아도 되고, Copy 덕분에 clone이 힙 할당 대신 스택의 40바이트 memcpy으로 처리됩니다. 프로파일 데이터 기준으로 new_from_gix_index self-time이 15%, get_package_file_hashes_from_index이 17% 감소했습니다.
레포 규모 | 적용 전 | 적용 후 | 변화 |
|---|---|---|---|
~1,000개 패키지 | 1.463s ± 0.052s | 1.466s ± 0.027s | 동일 속도, 편차 48% 감소 |
~125개 패키지 | 658.6ms ± 144.6ms | 592.1ms ± 62.9ms | 10% 빠름, 편차 57% 감소 |
6개 패키지 | 96.8ms ± 46.7ms | 75.0ms ± 18.4ms | 22% 빠름, 편차 61% 감소 |
세 가지 규모 모두에서 가장 눈에 띄는 변화는 실행 간 편차의 감소였는데, 이는 할당 부담이 줄어들고 성능 예측 가능성이 높아졌다는 이론과 일치합니다.
시스템 콜 제거 (#11985)
캐시를 가져올 때마다 시스템 콜 세 번이 발생하고 있었습니다. stat(.tar)가 ENOENT를 반환하면, stat(.tar.zst), 그다음 open(.tar.zst) 순서로 이어지는 구조였습니다. 이상한 패턴이었습니다.
원인을 파헤쳐보니, .tar 폴백은 Turborepo의 Golang 시절(2021~2022년)에 생성된 캐시 아티팩트를 처리하기 위해 남겨진 코드였습니다. 현재 버전에서는 비압축 캐시 항목을 생성하지 않고, 캐시 항목도 계속 교체되기 때문에 불필요한 코드였습니다.
가장 큰 레포에서 962회의 캐시 조회를 기준으로, fetch self-time이 200.5ms에서 129.6ms로 35% 감소했습니다.
clone 대신 move (#11986)
방문자 디스패치 루프는 약 1,700개 태스크 각각에 대해 사전 계산된 맵에서 (String, HashMap<String, String>)를 깊은 복사(deep clone)로 가져오고 있었습니다. 각 태스크 ID는 디스패치 스트림에서 정확히 한 번만 등장하므로, HashMap::remove()을 사용하면 복사 없이 값을 이동시킬 수 있습니다.
8일 후, 가장 큰 레포에서 첫 번째 태스크 시작까지 걸리는 시간이 8.1초에서 716밀리초로 줄었습니다.
레포 규모 | v2.8.0 | v2.9.0 | 개선율 |
|---|---|---|---|
~1,000개 패키지 | 8.1s | 0.716s | 91% 향상 |
132개 패키지 | 1.9s | 0.361s | 81% 향상 |
6개 패키지 | 0.676s | 0.132s | 80% 향상 |
에이전트 없이는 최소 두 달은 걸렸을 작업이라고 생각합니다. 그러나 이 글을 통해 에이전트가 알아서 모든 것을 해준 게 아니라는 점이 분명히 전달됐으면 합니다. 무엇을 프로파일링할지, 어떤 제안을 추진할지, 언제 도구를 바꾸고 전략을 수정할지, 모든 결정은 제가 내렸습니다. 하지만 저의 엔지니어링 경험에 에이전트를 위한 더 나은 도구와 깨끗한 벤치마킹 환경을 결합함으로써, 6개월 전이라면 불가능했을 속도로 작업을 진행할 수 있었습니다.
이 모든 성능 개선 사항은 이제 안정 버전에서 사용할 수 있습니다. Turborepo 2.9 릴리스 포스트에서 최신 내용을 확인해 보세요.