CI 파이프라인이 느려지는 과정은 대개 조용하다. 처음엔 3분이던 빌드가 어느 날 8분이 되고, 머지 큐가 밀리는 시점이 되어서야 문제가 드러난다. 이때 흔히 저지르는 실수는 감으로 최적화하는 것이다. “테스트가 느릴 거야”라며 병렬화에 손대지만, 정작 범인은 매번 3분씩 걸리는 도커 이미지 빌드일 수 있다.

빌드 시간 최적화의 첫 단계는 코드를 고치는 것이 아니라 측정이다. 어느 단계가 얼마나 걸리는지, 그중 무엇이 실제 병목인지 데이터로 확인해야 한다. 이 글에서는 단계별 소요 시간을 프로파일링하는 방법, 병목을 식별하는 기준, 크리티컬 패스(critical path)를 기준으로 실제 효과 있는 최적화를 고르는 법을 다룬다.

먼저 전체 벽시계 시간을 분해하라

가장 중요한 개념은 벽시계 시간과 누적 시간의 구분이다. 잡 10개가 각각 2분씩 걸려도 완전히 병렬로 돌면 전체는 2분이지만, 순차 의존이 걸리면 20분이 된다. 줄여야 하는 건 사용자가 실제로 기다리는 벽시계 시간이다.

그래서 프로파일링은 각 잡의 개별 소요 시간(어디에 CPU가 쓰이나)과 잡 간 의존 그래프(무엇이 무엇을 기다리나)를 함께 봐야 한다. 전자만 보면 “가장 오래 걸리는 잡”을 고치지만, 그 잡이 병렬 브랜치에 있으면 소용없다. 대부분의 CI는 잡별 타이밍을 API로 노출하므로, 실행별 잡 목록과 시작·종료 시각을 긁어 정렬하면 병목 후보가 한눈에 보인다.

# GitHub Actions: 최근 실행의 잡별 소요 시간을 내림차순으로 출력
run_id=$(gh run list --workflow ci.yml -L 1 --json databaseId --jq '.[0].databaseId')

gh api "repos/{owner}/{repo}/actions/runs/${run_id}/jobs" --paginate 
  | jq -r '.jobs[] | select(.completed_at)
      # 소요 시간(초) = 종료 - 시작
      | [ ((.completed_at|fromdateiso8601) - (.started_at|fromdateiso8601)), .name ]
      | @tsv' 
  | sort -rn | awk -F't' '{ printf "%5d초  %sn", $1, $2 }'

이 출력으로 “어느 잡이 오래 걸리나”는 알 수 있다. 하지만 그 잡이 크리티컬 패스 위에 있는지는 의존 그래프가 있어야 판단할 수 있다.

잡 내부를 스텝 단위로 쪼개기

잡 하나가 6분 걸린다는 사실은 출발점일 뿐이다. 그 6분이 의존성 설치 4분 + 테스트 2분인지, 설치 30초 + 테스트 5분 30초인지에 따라 대응이 달라진다. 병목 잡을 찾았다면 내부를 스텝 단위로 계측해야 한다. 가장 이식성 높은 방법은 스크립트 안에서 각 구간의 경과 시간을 직접 찍는 것으로, CI에 종속되지 않고 로컬에서도 재현된다.

# 구간별 경과 시간을 찍는 간단한 타이머
_last=$(date +%s.%N)
timeit() {
  local now dur; now=$(date +%s.%N)
  # awk 로 소수점 초 차이 계산 (bash 산술은 정수만 됨)
  dur=$(awk -v a="$_last" -v b="$now" 'BEGIN{printf "%.1f", b-a}')
  printf ">>> [%6.1fs] %sn" "$dur" "$1"; _last=$now
}

npm ci --prefer-offline --no-audit;   timeit "deps 설치"
npm run build;                         timeit "번들 빌드"
npm run test:integration;             timeit "통합 테스트"

이 방식으로 한 번만 돌려봐도 “설치가 전체의 60%”라는 사실이 드러나는 경우가 많다. 설치는 대개 캐시로 해결 가능한 손쉬운 절감 대상이고, 통합 테스트가 대부분이라면 병렬화나 테스트 분할이 필요하다. 계측 결과는 버리지 말고 구조화해 남겨 시계열로 추적하는 것이 좋다.

단발성 측정을 믿지 말고 분포를 보라

단발성 측정은 함정이 있다. CI 러너 성능은 실행마다 흔들려서(이웃 부하, 콜드 캐시 여부 등) 한 번의 측정으로 병목을 단정하면 노이즈에 속는다. 신뢰할 판단을 하려면 같은 잡을 여러 번 측정한 분포, 특히 중앙값(median)과 95백분위(p95)를 함께 봐야 한다. 매 실행 시 한 줄씩 남기고 나중에 집계하면 가볍게 시계열을 얻는다.

import json, sys
from collections import defaultdict
from statistics import median

# steps.jsonl 예: {"step": "deps 설치", "dur": 41.2}
buckets = defaultdict(list)
for line in open(sys.argv[1]):
    r = json.loads(line); buckets[r["step"]].append(r["dur"])

def pct(xs, p):                           # 최근접 순위법 - 표본 적은 CI 에 무난
    s = sorted(xs)
    return s[max(0, min(len(s) - 1, round(p / 100 * len(s)) - 1))]

# 중앙값 기준 내림차순 - 만성적으로 느린 스텝이 위로 온다
for step, xs in sorted(buckets.items(), key=lambda kv: median(kv[1]), reverse=True):
    print(f"{step:7.1f}s (p95 {pct(xs, 95):.1f}s)")

이 표를 보면 우선순위가 자명해진다. 중앙값과 p95가 둘 다 높은 스텝은 상시 병목이라 캐싱·병렬화가 필요하고, 중앙값은 낮은데 p95만 높은 스텝은 캐시 미스·네트워크 재시도 같은 안정성 문제다.

크리티컬 패스: 가장 오래 걸리는 잡이 범인이 아니다

여기서부터가 핵심이다. 파이프라인은 의존 그래프(DAG)이고, 전체 소요 시간은 그 그래프의 가장 긴 경로, 즉 크리티컬 패스로 결정된다. 크리티컬 패스 밖의 잡은 아무리 오래 걸려도(다른 잡보다 먼저 끝나는 한) 전체 시간에 영향을 주지 않는다.

예를 들어 lint가 4분, build가 3분, deploy가 build에 의존해 2분이고 lint와 build는 병렬이면, 전체는 build→deploy 경로인 5분이다. 여기서 lint를 1분으로 줄여도 전체는 여전히 5분이다. 가장 오래 걸리는 lint를 고쳤는데 체감은 0이다. 줄여야 할 건 build나 deploy다.

크리티컬 패스는 각 잡의 소요 시간과 의존 관계로 계산한다. 각 잡의 “끝나는 가장 이른 시각”을 위상 정렬 순서로 누적하면, 그 최댓값이 전체 시간이고 그 경로가 답이다.

# 잡: 이름 -> (소요초, 의존하는 잡 목록)
jobs = {
    "lint": (240, []),          "build": (180, []),
    "unit": (150, ["build"]),   "e2e":   (300, ["build"]),
    "deploy": (120, ["unit", "e2e"]),
}
finish, via = {}, {}
def earliest(name):               # 이 잡이 끝나는 가장 이른 시각
    if name in finish: return finish[name]
    dur, deps = jobs[name]
    start, prev = 0, None          # 선행 완료 시각의 최댓값이 시작점
    for d in deps:
        if earliest(d) > start: start, prev = finish[d], d
    finish[name], via[name] = start + dur, prev
    return finish[name]

end, chain = max(jobs, key=earliest), []   # 가장 늦게 끝나는 잡 = 종착점
cur = end
while cur is not None: chain.append(cur); cur = via[cur]
print(finish[end], "초:", " -> ".join(reversed(chain)))
# 출력: 600 초: build -> e2e -> deploy

이 계산을 CI API에서 긁은 실제 타이밍에 적용하면 진짜 병목 경로가 드러난다. 최적화는 이 경로 위의 잡에만 집중한다. 그 밖의 잡은 여유가 있어 느려도 전체에 영향이 없다.

도커 빌드는 별도로 계측하라

가장 은밀한 병목은 도커 이미지 빌드인 경우가 많다. 레이어 캐시가 맞으면 20초, 깨지면 3분이라 변동 폭이 크고, 원인이 COPY 순서 같은 Dockerfile 구조에 숨어 눈에 잘 안 띈다. BuildKit은 각 스텝의 소요 시간과 캐시 적중 여부를 보여준다.

# BuildKit 의 상세 타이밍 출력 (--progress=plain 이 핵심)
DOCKER_BUILDKIT=1 docker build --progress=plain -t app:ci . 2>&1 
  | grep -E '^#[0-9]+ (DONE|CACHED)' 
  | sort -t' ' -k3 -rn
# CACHED 로 표시된 스텝은 재빌드 안 함 → 여기가 많을수록 빠르다
# DONE 42.1s 처럼 큰 값이 나오는 스텝이 실제 빌드 병목

최적화 방향은 정해져 있다. 자주 바뀌는 소스 코드를 Dockerfile 뒤쪽에, 거의 안 바뀌는 의존성 매니페스트를 앞쪽에 두어 레이어 캐시 적중률을 높인다.

# 레이어 순서가 캐시 적중률을 좌우한다
COPY package.json package-lock.json ./   # 1) 매니페스트만 먼저 복사
RUN npm ci --prefer-offline              #    의존성 레이어를 코드와 분리
COPY . .                                 # 2) 소스는 마지막 - 코드만 바뀌면
RUN npm run build                        #    위 레이어는 캐시 재사용

COPY . .를 위로 올리면, 소스 한 줄만 고쳐도 그 아래 npm ci 레이어 캐시가 통째로 무효화되어 매번 의존성을 다시 설치한다. 프로파일링 없이는 이 원인을 찾기 어렵다.

병렬화로 크리티컬 패스를 물리적으로 자르기

크리티컬 패스 위의 잡이 본질적으로 무거운 작업(예: 5분짜리 E2E 테스트)이라면 캐싱으로는 줄지 않는다. 이때는 그 잡을 여러 병렬 샤드로 쪼갠다. 500개 테스트를 한 잡에서 5분에 돌리는 대신 4개 샤드로 나누면 오버헤드를 감안해도 1분 30초 안팎이 된다.

# GitHub Actions: matrix 로 테스트를 4개 샤드로 병렬 실행
jobs:
  test:
    strategy:
      fail-fast: false
      matrix: { shard: [1, 2, 3, 4] }
    steps:
      - uses: actions/checkout@v4
      - run: npm ci --prefer-offline
      # 러너 개수(4)와 현재 인덱스로 테스트를 균등 분배
      - run: npm test -- --shard=${{ matrix.shard }}/4

다만 병렬화에는 대가가 있다. 샤드마다 체크아웃·의존성 설치라는 고정 오버헤드가 붙어, 4개면 준비 작업도 4번 반복된다. 테스트 본체가 5분일 때 30초 오버헤드는 남는 장사지만, 1분인데 8개로 쪼개면 오버헤드가 이득을 다 잡아먹는다. 그래서 샤드를 늘리며 전체 벽시계 시간이 줄어드는지 확인하고 줄지 않는 지점에서 멈춰야 한다. 또한 샤드 간 부하가 고르지 않으면 가장 느린 샤드가 크리티컬 패스가 되므로, 개수로 나누기보다 과거 실행의 테스트별 소요 시간에 맞춰 각 샤드 총합이 비슷해지도록 분배하는 것이 이상적이다.

지속 감시로 회귀를 조기에 잡기

한 번 최적화한 파이프라인도 시간이 지나면 다시 느려진다. 의존성이 늘고 테스트가 추가되고 누군가 캐시를 깨는 변경을 넣기 때문이다. 그래서 빌드 시간은 지속적으로 감시할 지표다. 앞서 축적한 시계열에 임계선을 걸어 크리티컬 패스가 일정 이상 늘면 경고하면 회귀를 조기에 잡는다.

# 최근 실행 시간이 최근 20회 중앙값의 1.3배를 넘으면 경고
current=$(get_last_run_duration)      # 초 단위, 위 gh api 응용
baseline=$(get_median_of_recent 20)  # 최근 20회 중앙값

# 정수 비교 위해 100 곱해 소수 회피 (1.3배 = *130/100)
if [ "$((current * 100))" -gt "$((baseline * 130))" ]; then
  echo "::warning::CI 빌드 시간 회귀: ${current}s (기준 ${baseline}s)"
fi

이 감시는 개별 잡이 아니라 전체 벽시계 시간을 기준으로 거는 게 좋다. 잡 하나가 느려져도 크리티컬 패스 밖이면 문제가 아니고, 여러 잡의 미세한 증가가 겹쳐 경로 전체가 늘어나는 경우를 잡아야 하기 때문이다. 지표를 커밋과 연결해두면 원인 변경을 이등분 탐색으로 좁힐 수 있다.

마무리

CI 빌드 시간 최적화의 핵심은 순서다. 먼저 측정하고, 크리티컬 패스를 찾고, 그 위의 병목만 손댄다. 감으로 “느릴 것 같은” 잡을 고치는 건 크리티컬 패스 밖이면 헛수고이고, 가장 오래 걸리는 잡조차 병렬 브랜치에 있으면 소용없다. 벽시계 시간과 누적 시간을 구분하고, 잡을 스텝 단위로 쪼개 계측하며, 여러 번의 측정으로 분포(중앙값과 p95)를 보는 것이 신뢰할 판단의 토대다.

모든 최적화에는 대가가 있다. 병렬화는 고정 오버헤드를 늘리고 캐싱은 무효화 함정을 낳으니, 최적화 전후의 벽시계 시간을 비교해 실제로 빨라졌는지 확인해야 한다. 프로파일링은 한 번 하고 끝내는 작업이 아니라 파이프라인이 살아 있는 한 계속 돌려야 하는 일이다.

자주 묻는 질문

Q. 가장 오래 걸리는 잡부터 최적화하면 안 되나요?
A. 그 잡이 크리티컬 패스 위에 있을 때만 효과가 있습니다. 병렬 브랜치에서 먼저 끝나는 잡은 아무리 줄여도 전체 벽시계 시간이 변하지 않습니다. 크리티컬 패스를 먼저 계산하고 그 경로 위의 잡을 고르세요.

Q. 테스트 샤드를 늘릴수록 계속 빨라지나요?
A. 아닙니다. 샤드마다 체크아웃·의존성 설치 같은 고정 오버헤드가 반복되어, 어느 지점을 넘으면 오버헤드 총합이 병렬 이득을 상쇄합니다. 샤드 수를 하나씩 늘리며 벽시계 시간을 측정하고, 더 줄지 않는 지점에서 멈추세요.