- 들어가며 — 빌드 진행 바를 바라보는 시간
- 빌드는 하나의 동작이 아니다
- 컴파일 vs 링킹 — 서로 다른 병목
- 콜드 빌드 vs 웜 빌드
- 증분 빌드 — 안 바뀐 것은 다시 안 한다
- 컴파일러 캐시 — ccache와 sccache
- 태스크 캐시 — Turbo, Nx, Bazel
- 모노레포의 문제 — 무엇을 다시 빌드할 것인가
- 병렬성의 한계
- CI 캐싱 — 매번 처음부터 하지 않기
- 빌드를 프로파일링하기 — 추측 대신 측정
- 실무 체크리스트
- 마치며
- 참고 자료
들어가며 — 빌드 진행 바를 바라보는 시간
개발자의 하루에서 빌드를 기다리는 시간은 생각보다 큽니다. 코드 한 줄을 고치고 결과를 보기까지 30초, 1분, 때로는 몇 분씩 기다립니다. 이 짧은 대기가 하루에 수십 번 쌓이면 집중이 끊기고 흐름이 깨집니다. "빌드가 느리다"는 불평은 흔하지만, 그 느림이 정확히 어디서 오는지 아는 개발자는 의외로 적습니다.
빌드가 느린 데는 반드시 구조적인 이유가 있습니다. 무엇을 컴파일하는지, 무엇을 링크하는지, 무엇을 캐시할 수 있는지, 무엇을 병렬로 돌릴 수 있는지가 모두 속도를 결정합니다. 이 글은 빌드의 각 단계를 분해해 어디서 시간이 새는지 짚고, 증분 빌드와 캐시, 모노레포 전략, CI 캐싱, 그리고 빌드를 실제로 측정하는 법까지 정리합니다. 막연히 "느리다"는 느낌을 "여기가 병목이다"라는 진단으로 바꾸는 것이 목표입니다.
빌드는 하나의 동작이 아니다
먼저 "빌드"라는 한 단어가 사실 여러 단계의 파이프라인이라는 점부터 짚어야 합니다. C/C++ 같은 컴파일 언어를 예로 들면 크게 다음과 같은 단계를 거칩니다.
소스 코드 (.c, .cpp)
|
| 1. 전처리 (헤더 포함, 매크로 전개)
v
전처리된 소스
|
| 2. 컴파일 (소스 -> 오브젝트 파일)
v
오브젝트 파일 (.o)
|
| 3. 링킹 (오브젝트 + 라이브러리 -> 실행 파일)
v
실행 파일
각 단계는 성격이 다르고, 느려지는 이유도 다릅니다. 그래서 "빌드가 느리다"를 진단할 때 첫 질문은 항상 "어느 단계가 느린가"입니다. 컴파일이 느린 것과 링킹이 느린 것은 완전히 다른 문제이고, 해법도 다릅니다.
컴파일 vs 링킹 — 서로 다른 병목
컴파일은 각 소스 파일을 독립적으로 기계어 오브젝트 파일로 번역하는 단계입니다. 핵심 성질은 파일 단위로 독립적이라는 것입니다. a.cpp를 컴파일하는 것과 b.cpp를 컴파일하는 것은 서로 무관하므로, 여러 파일을 여러 코어에서 병렬로 컴파일할 수 있습니다. 컴파일이 느린 대표적 원인은 다음과 같습니다.
- 거대한 헤더: C++에서 헤더는 각 소스 파일마다 다시 전처리됩니다. 무거운 헤더를 수백 개 파일이 포함하면, 같은 내용을 수백 번 파싱합니다.
- 템플릿과 메타프로그래밍: C++ 템플릿은 인스턴스화될 때마다 코드를 생성하므로 컴파일러 부담이 큽니다.
- 최적화 수준:
-O2,-O3같은 높은 최적화는 훨씬 많은 분석을 요구해 느립니다.
링킹은 컴파일된 모든 오브젝트 파일과 라이브러리를 하나로 엮어 최종 실행 파일을 만드는 단계입니다. 컴파일과 결정적으로 다른 점은 링킹이 본질적으로 전역 작업이라는 것입니다. 모든 오브젝트 파일이 준비되어야 시작할 수 있고, 심벌을 해석하고 배치하는 과정이 대체로 단일 스레드로 진행됩니다. 그래서 링킹은 병렬화가 어렵고, 큰 프로젝트에서 종종 마지막 병목이 됩니다.
이 차이가 실무에서 중요한 이유는 이렇습니다. 코어를 늘리면 컴파일은 빨라지지만 링킹은 잘 빨라지지 않습니다. 그래서 대형 프로젝트는 더 빠른 링커(예: lld, mold 같은 병렬 링커)를 도입하거나, 링킹 자체를 줄이는 전략(증분 링킹, 동적 링킹)을 씁니다. "코어를 아무리 늘려도 빌드가 그만큼 안 빨라진다"면 링킹이 범인인 경우가 많습니다.
콜드 빌드 vs 웜 빌드
빌드 속도를 이야기할 때 반드시 구분해야 하는 것이 콜드(cold)와 웜(warm)입니다.
- 콜드 빌드: 아무것도 없는 상태에서 전체를 처음부터 빌드하는 것. 방금 저장소를 받았거나, 빌드 산출물을 모두 지운 뒤의 첫 빌드입니다.
- 웜 빌드: 이전 빌드의 산출물이 남아 있는 상태에서, 바뀐 부분만 다시 빌드하는 것.
콜드 빌드는 원래 느립니다. 모든 것을 처음부터 하기 때문입니다. 정말 중요한 것은 웜 빌드입니다. 개발 중에는 코드를 조금 고치고 다시 빌드하는 일을 하루에 수십 번 반복하는데, 이 반복 빌드가 빠른지가 생산성을 좌우합니다. 웜 빌드를 빠르게 만드는 핵심 기술이 바로 다음에 볼 증분 빌드와 캐시입니다.
한 가지 흔한 실수는 콜드 빌드 시간만 보고 빌드 시스템을 평가하는 것입니다. 실제로 개발자가 하루 종일 겪는 것은 웜 빌드이므로, "한 파일만 고쳤을 때 다시 빌드하는 데 얼마나 걸리는가"가 훨씬 중요한 지표입니다.
증분 빌드 — 안 바뀐 것은 다시 안 한다
증분 빌드(incremental build)의 원리는 단순합니다. 바뀌지 않은 것은 다시 만들지 않는다. 빌드 시스템은 각 산출물이 어떤 입력에 의존하는지(의존성 그래프)를 알고 있고, 입력이 바뀌지 않았으면 이전 산출물을 그대로 씁니다.
전통적인 make가 이를 파일 타임스탬프로 판단합니다. 소스 파일의 수정 시각이 오브젝트 파일보다 나중이면 "바뀌었다"고 보고 다시 컴파일합니다.
a.cpp (수정: 10:05) --> a.o (생성: 10:03)
=> a.cpp가 더 최신이므로 a.o를 다시 컴파일
b.cpp (수정: 09:50) --> b.o (생성: 10:03)
=> b.o가 더 최신이므로 b.o는 건너뜀
증분 빌드가 제대로 동작하려면 의존성 그래프가 정확해야 합니다. 여기서 흔한 함정이 헤더 의존성입니다. a.cpp가 config.h를 포함하는데, config.h가 바뀌면 a.cpp도 다시 컴파일해야 합니다. 이 관계를 빌드 시스템이 모르면 "헤더를 고쳤는데 반영이 안 되는" 버그가 생깁니다. 그래서 컴파일러는 각 오브젝트 파일이 어떤 헤더에 의존하는지를 기록한 의존성 정보를 함께 생성하고, 빌드 시스템이 이를 읽어 그래프를 완성합니다.
타임스탬프 방식의 한계도 있습니다. 파일을 열었다 저장만 해도(내용이 같아도) 타임스탬프가 바뀌어 불필요한 재빌드가 일어납니다. 그래서 현대 빌드 시스템은 타임스탬프 대신 내용 해시로 변경을 판단하는 방향으로 갑니다. 내용이 실제로 같으면 다시 빌드하지 않는 것입니다. 이것이 다음에 볼 캐시와도 연결됩니다.
컴파일러 캐시 — ccache와 sccache
증분 빌드가 "이 프로젝트 안에서 안 바뀐 것을 건너뛰는" 것이라면, 컴파일러 캐시는 한 걸음 더 나아갑니다. 한 번 컴파일한 결과를 저장해 두고, 같은 입력이 또 오면 컴파일 없이 저장된 결과를 꺼냅니다.
대표적인 도구가 ccache입니다. ccache는 컴파일러를 감싸서, 컴파일 요청이 오면 입력(전처리된 소스, 컴파일 옵션, 컴파일러 버전 등)의 해시를 계산합니다. 같은 해시를 이전에 본 적이 있으면 저장된 오브젝트 파일을 즉시 반환합니다.
컴파일 요청
|
v
입력 해시 계산 (소스 내용 + 옵션 + 컴파일러)
|
+--> 캐시에 있음 (hit) --> 저장된 .o 즉시 반환 (컴파일 안 함)
|
+--> 캐시에 없음 (miss) --> 실제 컴파일 후 결과를 캐시에 저장
이 방식의 강점은 콜드 빌드에서도 효과를 낸다는 것입니다. 빌드 산출물을 다 지워도 ccache 캐시는 남아 있으므로, 다시 빌드할 때 대부분을 캐시에서 꺼내 씁니다. 브랜치를 이리저리 오가며 같은 파일을 반복 컴파일하는 상황에서 특히 큰 도움이 됩니다.
sccache는 ccache의 아이디어를 확장합니다. 로컬 디스크뿐 아니라 원격 저장소(예: 클라우드 스토리지)에도 캐시를 둘 수 있어서, 팀 전체 또는 CI가 캐시를 공유합니다. 한 사람이 컴파일한 결과를 다른 사람이 캐시 히트로 받는 것입니다. Rust 생태계에서 특히 널리 쓰입니다.
캐시가 올바르려면 캐시 키가 정확해야 합니다. 컴파일러 버전, 옵션, 소스 내용, 포함된 헤더까지 모두 키에 반영되어야, 조건이 다른데 잘못된 캐시를 꺼내는 사고를 막습니다. 캐시의 정확성은 "무엇을 키에 넣느냐"에 달려 있습니다.
태스크 캐시 — Turbo, Nx, Bazel
컴파일러 캐시가 "컴파일 한 번"을 캐시한다면, 상위 레벨 빌드 도구는 "빌드 태스크 전체"를 캐시합니다. 여기서는 자바스크립트 모노레포와 대규모 다언어 프로젝트에서 널리 쓰이는 세 도구를 봅니다.
Turborepo와 Nx는 주로 자바스크립트/타입스크립트 모노레포를 위한 도구입니다. 이들의 핵심은 각 태스크(빌드, 테스트, 린트 등)의 입력을 해시해 결과를 캐시하는 것입니다. 패키지 A를 빌드했는데 그 입력(소스, 의존성, 설정)이 하나도 안 바뀌었으면, 다시 실행하지 않고 이전 출력을 그대로 복원합니다. 콘솔 로그까지 캐시해서 "마치 방금 실행한 것처럼" 재생합니다.
turbo run build
|
v
각 패키지의 입력 해시 계산
|
+--> 해시 동일 (cache hit) --> 저장된 출력 복원, 실행 생략
|
+--> 해시 변경 (cache miss) --> 실제 빌드 후 출력 캐시
Bazel은 구글이 만든 대규모 빌드 시스템으로, 이 아이디어를 극한까지 밀어붙입니다. Bazel의 철학은 재현 가능성(hermeticity)입니다. 모든 빌드 액션의 입력을 완전히 명시하고(선언된 입력 외에는 아무것도 못 건드리게), 그 입력의 해시로 출력을 결정론적으로 캐시합니다. 입력이 같으면 출력이 반드시 같다는 것을 보장하므로, 로컬과 CI, 나아가 팀원 전체가 원격 캐시를 안전하게 공유할 수 있습니다. Bazel은 심지어 빌드 액션 자체를 원격 머신들에 분산 실행하기도 합니다.
세 도구의 공통 원리는 하나입니다. 입력을 해시하고, 같은 입력에는 저장된 출력을 재사용한다. 컴파일러 캐시와 같은 발상을, 파일 단위가 아니라 태스크나 타깃 단위로 올린 것입니다. 규모가 커질수록 이 "다시 안 하기"의 가치가 커집니다.
모노레포의 문제 — 무엇을 다시 빌드할 것인가
모노레포(monorepo)는 여러 프로젝트를 하나의 저장소에 두는 방식입니다. 코드 공유와 일관성 관리에 유리하지만, 빌드 관점에서 고유한 문제를 낳습니다. 작은 변경 하나가 무엇을 다시 빌드하게 만드는가?
수백 개의 패키지가 서로 의존하는 모노레포에서, 공유 라이브러리 하나를 고치면 그것에 의존하는 모든 패키지가 영향을 받습니다. 순진하게 전부 다시 빌드하면 작은 변경에도 엄청난 시간이 듭니다. 반대로 아무것도 다시 안 하면 변경이 반영되지 않습니다. 핵심은 "영향받은 것만 정확히" 다시 빌드하는 것입니다.
shared-utils (변경됨)
/ | \
app-web app-api lib-auth
|
app-admin
shared-utils를 고치면:
영향받는 것 -> app-web, app-api, lib-auth, app-admin (다시 빌드)
영향 없는 것 -> 나머지 패키지 (건너뜀)
이것을 "영향받은 집합만 빌드/테스트하기(affected)"라고 부릅니다. Nx의 affected 명령이나 Turborepo의 필터가 이를 자동화합니다. 의존성 그래프를 분석해, 바뀐 파일에서 도달 가능한 패키지만 골라 빌드하고 테스트합니다. 이 덕분에 거대한 모노레포에서도 "내가 건드린 부분과 그 영향권"만 검증하면 되므로, CI 시간을 극적으로 줄일 수 있습니다.
모노레포의 진짜 어려움은 이 의존성 그래프를 정확히 유지하는 것입니다. 그래프가 실제보다 넓으면 불필요하게 많이 빌드하고, 좁으면 영향받은 것을 놓쳐 깨진 빌드가 통과합니다. 그래서 모노레포 빌드 도구의 품질은 "의존성 그래프를 얼마나 정확히 아느냐"로 판가름 납니다.
병렬성의 한계
"코어를 더 주면 빌드가 빨라진다"는 대체로 맞지만 무한하지 않습니다. 병렬 빌드에는 근본적인 한계가 있습니다.
첫째, 의존성 사슬입니다. B가 A의 산출물에 의존하면, A가 끝나기 전에는 B를 시작할 수 없습니다. 이런 사슬이 길면 코어가 아무리 많아도 그 사슬을 순서대로 지나가야 합니다. 이 최장 의존 사슬을 임계 경로(critical path)라고 하며, 빌드 시간의 하한을 결정합니다. 아무리 병렬화해도 임계 경로보다 빨라질 수는 없습니다.
둘째, 암달의 법칙입니다. 빌드에 병렬화할 수 없는 부분(예: 앞서 본 링킹)이 있으면, 그 부분이 전체 속도를 잡아끕니다. 병렬 가능한 부분을 아무리 빠르게 해도, 순차 부분은 그대로 남아 전체 개선에 상한이 생깁니다.
코어 1개: ████████████████████ (100초)
코어 4개: █████ + 순차 부분 (병렬 부분은 1/4로 줄지만
순차 부분은 그대로)
=> 순차 부분(링킹 등)이 클수록 병렬화 이득이 작아진다
셋째, 자원 경합입니다. 코어를 늘려도 디스크 I/O, 메모리 대역폭이 한계면 그것이 새 병목이 됩니다. 특히 오브젝트 파일을 많이 쓰고 읽는 빌드는 디스크가 병목이 되기 쉽습니다. 그래서 병렬도를 코어 수만큼 무작정 올리는 것이 항상 최선은 아니고, 실제로 측정해 최적점을 찾아야 합니다.
CI 캐싱 — 매번 처음부터 하지 않기
로컬에서는 이전 빌드 산출물이 남아 웜 빌드가 가능하지만, CI는 다릅니다. CI는 대개 깨끗한 환경에서 시작하므로, 아무 대책이 없으면 매번 콜드 빌드입니다. 그래서 CI를 빠르게 하는 핵심은 캐시를 세션 간에 보존하는 것입니다.
CI 캐싱에는 크게 두 층이 있습니다.
- 의존성 캐시:
node_modules, 패키지 매니저 캐시, 컴파일된 서드파티 라이브러리 등 잘 안 바뀌는 것들. 이를 매번 새로 받거나 빌드하면 큰 시간 낭비이므로, 잠금 파일(lockfile)의 해시를 키로 캐시합니다. 의존성이 안 바뀌면 통째로 복원합니다. - 빌드 캐시: 앞서 본 ccache/sccache의 캐시, Turbo/Nx/Bazel의 태스크 캐시를 CI 세션 간에 공유합니다. 원격 캐시를 쓰면 여러 CI 작업과 개발자가 같은 캐시를 나눠 씁니다.
CI 캐싱에서 가장 중요한 것은 캐시 키 설계입니다. 키가 너무 좁으면(예: 커밋 해시) 매번 캐시 미스가 나서 무용지물이고, 너무 넓으면 바뀐 것을 놓쳐 오래된 결과를 씁니다. 실무에서 흔한 패턴은 잠금 파일 해시를 주 키로 하고, 브랜치나 운영체제를 보조 키로 두어 적절히 재사용하는 것입니다.
또 하나 유의할 점은 캐시 복원과 저장 자체에도 시간이 든다는 것입니다. 캐시가 너무 크면 내려받고 압축 푸는 데 걸리는 시간이 캐시로 아낀 시간을 잡아먹을 수 있습니다. 그래서 "무엇을 캐시할 가치가 있는가"를 따져, 재생성이 비싼 것 위주로 캐시하는 균형이 필요합니다.
빌드를 프로파일링하기 — 추측 대신 측정
지금까지 빌드가 느려지는 여러 원인을 봤지만, 실제 프로젝트에서 어느 것이 범인인지는 추측하면 안 됩니다. 측정해야 합니다. 빌드 프로파일링의 핵심 질문은 이것입니다. 시간이 어디에 쓰이는가?
몇 가지 실용적 접근이 있습니다.
- 단계별 시간 측정: 전처리, 컴파일, 링킹 중 어디가 오래 걸리는지 나눠 봅니다. 링킹이 대부분이면 코어를 늘려도 소용없다는 신호입니다.
- 파일별 컴파일 시간: 어떤 소스 파일이 유독 오래 걸리는지 찾습니다. 대개 무거운 헤더나 템플릿을 많이 쓰는 소수의 파일이 전체 시간을 지배합니다.
- 빌드 그래프 시각화: 많은 현대 빌드 도구가 각 태스크의 시작-종료 시간을 타임라인으로 보여줍니다. 여기서 임계 경로와 병렬화가 안 되는 구간이 눈에 들어옵니다.
- 캐시 적중률 확인: ccache나 태스크 캐시의 히트율을 봅니다. 히트율이 낮으면 캐시 키가 잘못됐거나 캐시가 제대로 공유되지 않는 것입니다.
컴파일러 자체도 프로파일링 옵션을 제공합니다. 예를 들어 어떤 컴파일러는 각 컴파일 단계(파싱, 템플릿 인스턴스화, 최적화)에 걸린 시간을 리포트로 뽑아줍니다. 이를 보면 "이 파일은 템플릿 인스턴스화에 시간의 절반을 쓴다" 같은 구체적 진단이 가능합니다.
프로파일링의 원칙은 하나입니다. 빌드를 빠르게 만들기 전에, 느린 곳을 정확히 찾으십시오. 대부분의 빌드에서 시간은 소수의 병목에 집중되어 있습니다. 그 병목을 데이터로 찾아 겨냥하는 것이, 막연히 이것저것 최적화하는 것보다 훨씬 효과적입니다.
실무 체크리스트
느린 빌드를 마주했을 때 순서대로 확인할 항목을 정리합니다.
- 어느 단계가 느린가? 컴파일인지 링킹인지부터 나눕니다. 링킹이면 더 빠른 링커를 검토합니다.
- 웜 빌드가 느린가, 콜드 빌드가 느린가? 웜이 느리면 증분 빌드의 의존성 그래프가 부정확할 수 있습니다.
- 컴파일러 캐시를 쓰고 있는가? ccache/sccache 도입만으로 반복 빌드가 크게 빨라지는 경우가 많습니다.
- 모노레포라면 영향받은 것만 빌드하는가? 전부 재빌드하고 있지 않은지 확인합니다.
- 병렬도가 적절한가? 코어 수, 디스크, 메모리 중 무엇이 병목인지 측정합니다.
- CI가 매번 콜드로 도는가? 의존성과 빌드 캐시를 세션 간에 보존하는지 봅니다.
- 캐시 키가 올바른가? 너무 넓거나 좁지 않은지, 히트율로 검증합니다.
- 측정했는가? 추측 전에 프로파일링으로 병목을 데이터로 확인합니다.
마치며
빌드가 느린 것은 운명이 아니라 진단 가능한 문제입니다. "빌드"는 컴파일과 링킹이라는 성격이 다른 단계로 이뤄지고, 각 단계는 증분 빌드와 캐시로 상당 부분 건너뛸 수 있습니다. 웜 빌드를 빠르게 유지하는 것이 콜드 빌드 시간을 줄이는 것보다 대개 더 중요하며, 모노레포에서는 "영향받은 것만" 다시 빌드하는 정밀함이 핵심입니다. 병렬성은 강력하지만 임계 경로와 순차 구간이라는 한계가 있고, CI에서는 캐시를 세션 간에 보존하는 것이 결정적입니다.
무엇보다 중요한 원칙은 측정입니다. 빌드 시간은 대개 소수의 병목에 집중되어 있으므로, 그 병목을 프로파일링으로 정확히 찾아 겨냥하면 적은 노력으로 큰 개선을 얻습니다. 다음에 빌드 진행 바를 바라보게 되거든, "왜 느린가"를 막연한 불평이 아니라 구체적인 질문으로 바꿔 보시기 바랍니다. 답은 대부분 데이터 안에 있습니다.
참고 자료
- ccache 공식 문서: https://ccache.dev/
- sccache (Mozilla): https://github.com/mozilla/sccache
- Bazel 공식 사이트: https://bazel.build/
- Turborepo 문서: https://turborepo.com/docs
- Nx 문서: https://nx.dev/
- mold — 빠른 링커: https://github.com/rui314/mold
- Amdahl's law (Wikipedia): https://en.wikipedia.org/wiki/Amdahl%27s_law
현재 단락 (1/113)
개발자의 하루에서 빌드를 기다리는 시간은 생각보다 큽니다. 코드 한 줄을 고치고 결과를 보기까지 30초, 1분, 때로는 몇 분씩 기다립니다. 이 짧은 대기가 하루에 수십 번 쌓이면...