왜 빌드 시간이 문제가 되는가

빌드가 느려지면 단순히 기다리는 시간만 늘어나는 것이 아니다. 개발자는 컨텍스트를 잃고, CI 큐가 쌓이며, 배포 주기가 늘어난다. 하루 30번 빌드하는 팀에서 빌드가 3분 느려지면 하루 90분이 사라진다. 문제는 대부분의 팀이 "빌드가 느리다"는 체감만 있고, 어느 단계가 느린지는 모른다는 점이다. 그래서 캐시를 켜거나 러너를 키우는 등 감으로 대응하다가 효과를 못 본다. 최적화의 출발점은 항상 측정이다.

먼저 측정한다: 단계별 타이밍 수집

어떤 도구든 단계별 소요 시간을 남기는 것이 첫 단추다. 셸 레벨에서라면 각 단계 앞뒤로 타임스탬프를 찍어 누적 시간을 뽑을 수 있다.

#!/usr/bin/env bash
set -euo pipefail

step() {
  local name="$1"; shift
  local start end
  start=$(date +%s.%N)
  "$@"
  end=$(date +%s.%N)
  printf '%-20s %6.1fs\n' "$name" "$(echo "$end - $start" | bc)" >> build-timing.log
}

step "deps-install"  npm ci
step "typecheck"     npm run typecheck
step "bundle"        npm run build
step "test"          npm test

sort -k2 -nr build-timing.log | head

정렬해서 상위 항목만 보면 어디에 시간이 몰리는지 즉시 드러난다. 감이 아니라 숫자로 우선순위를 정하는 것이 핵심이다.

빌드 도구 내장 프로파일러 활용

셸 타이밍은 큰 그림용이고, 단계 내부의 병목은 도구 자체의 프로파일러로 파고들어야 한다. 대부분의 현대 빌드 도구는 이를 지원한다.

도구프로파일링 명령결과 형태
Gradle--profile / --scanHTML 리포트, 태스크별 시간
Bazel--profile=out.jsonChrome trace (chrome://tracing)
Webpack--profile --json모듈별 빌드 시간 JSON
Cargo--timings크레이트별 컴파일 타임라인

예를 들어 Bazel의 트레이스를 열면 어떤 액션이 직렬로 실행되며 CPU를 놀리는지 시각적으로 확인된다. 병목은 대개 "느린 단계"가 아니라 "병렬화되지 못한 단계"인 경우가 많다.

흔한 병목 유형과 대응

의존성 설치

매번 전체 의존성을 새로 받는다면 락파일 해시 기반 캐시를 걸어야 한다. GitHub Actions에서는 다음과 같이 캐시 키를 락파일에 묶는다.

- name: Cache node modules
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-

- name: Install
  run: npm ci --prefer-offline

직렬 실행

타입체크·린트·테스트가 서로 독립적이라면 별도 잡으로 쪼개 병렬로 돌린다. 단일 잡 안에서 순차로 묶으면 가장 느린 단계가 전체를 지배한다.

불필요한 재빌드

변경되지 않은 모듈까지 다시 빌드한다면 증분 빌드와 원격 캐시(remote cache)를 검토한다. 특히 모노레포에서는 영향받은 패키지만 빌드하도록 의존성 그래프 기반 필터링이 효과가 크다.

캐시는 만능이 아니다: 주의점

캐시를 켜면 무조건 빨라질 것 같지만 함정이 있다. 캐시 키가 너무 넓으면 오래된 아티팩트를 물어 오작동하고, 너무 좁으면 히트율이 0에 수렴한다. 반드시 캐시 적중률을 로그로 측정하라. 적중률이 낮은 캐시는 저장·복원 오버헤드만 더해 오히려 느려진다.

또한 원격 캐시는 네트워크 왕복이 들어가므로, 캐시 다운로드가 재빌드보다 느린 작은 태스크는 캐싱에서 제외하는 것이 낫다. "무엇을 캐시하지 않을지"를 정하는 것도 최적화의 일부다.

회귀 방지: 빌드 시간을 지표로 관리한다

한 번 최적화해도 몇 주 뒤 새 의존성이나 무거운 테스트가 들어오면 원상복구된다. 빌드 타이밍을 CI에서 상시 기록하고, 임계치를 넘으면 경고하도록 만들어야 한다.

import json, sys

THRESHOLD_SEC = 300  # 5분

with open("build-timing.json") as f:
    data = json.load(f)

total = sum(step["duration"] for step in data["steps"])
if total > THRESHOLD_SEC:
    slowest = max(data["steps"], key=lambda s: s["duration"])
    print(f"::warning::빌드 {total:.0f}s (임계 {THRESHOLD_SEC}s 초과), "
          f"최대 병목: {slowest['name']} {slowest['duration']:.0f}s")
    sys.exit(1)

빌드 시간을 코드 커버리지처럼 추적 가능한 지표로 다루면, 성능 저하가 배포 전에 드러난다. 결국 빌드 최적화는 한 번의 이벤트가 아니라 지속적인 측정과 관리의 문제다.