왜 실행 시간이 문제가 되는가

CI는 병합 속도의 상한을 결정한다. 파이프라인이 느리면 개발자는 컨텍스트를 잃고, 여러 PR이 큐에 쌓이며, 실패한 잡이 러너를 붙잡아 다른 잡의 대기 시간까지 늘린다. 특히 타임아웃이 없거나 지나치게 관대하면, 네트워크 행(hang)이나 무한 루프가 러너를 최대 실행 시간까지 점유해 청구 비용과 큐 지연을 동시에 키운다. 핵심은 "빠르게 만들기"가 아니라 예측 가능한 상한을 두는 것이다.

시간 예산을 먼저 정한다

최적화 이전에 목표 상한(SLO)을 숫자로 정한다. 흔한 기준은 아래와 같다. 이 예산을 초과하는 단계는 개선 대상이거나 병렬화·캐시 대상이다.

단계목표 상한초과 시 조치
린트/포맷2분변경 파일만 검사
단위 테스트8분샤딩·병렬화
통합/E2E15분선택적 실행
전체 파이프라인20분(p95)경보 및 회고

계층별 타임아웃을 건다

타임아웃은 잡, 스텝, 명령 세 층위에 모두 걸어야 한다. 잡 전체에만 걸면 어느 스텝이 매달렸는지 알 수 없고, 명령에만 걸면 큐·설치 지연을 놓친다.

jobs:
  test:
    timeout-minutes: 12          # 잡 전체 상한
    steps:
      - uses: actions/checkout@v4
      - name: Install deps
        timeout-minutes: 4       # 스텝 상한
        run: npm ci
      - name: Unit tests
        timeout-minutes: 8
        run: timeout 420 npm test  # 명령 상한(초)

timeout 420은 러너 종료 신호가 늦게 전달되는 상황에서도 프로세스를 강제로 끊는 마지막 방어선이다.

느린 테스트를 격리한다

p95를 망치는 것은 대개 소수의 꼬리(tail) 테스트다. 실행 시간을 수집해 상위를 분리하면, 빠른 피드백 루프와 느린 검증을 나눌 수 있다.

import json, sys

# pytest --durations 출력이나 리포트 JSON을 읽는다고 가정
data = json.load(open("durations.json"))
slow = [t for t in data if t["duration"] > 5.0]
slow.sort(key=lambda t: -t["duration"])

for t in slow[:10]:
    print(f'{t["duration"]:6.1f}s  {t["name"]}')

# 예산 초과 시 CI 실패로 되돌린다
budget = 300.0
total = sum(t["duration"] for t in data)
if total > budget:
    print(f"budget exceeded: {total:.0f}s > {budget:.0f}s")
    sys.exit(1)

느린 테스트에 태그를 붙여 PR에서는 스킵하고 야간 파이프라인에서만 돌리는 식으로 예산을 지킨다.

타임아웃 이후를 설계한다

타임아웃은 끊는 것으로 끝이 아니다. 왜 끊겼는지 남기지 않으면 같은 문제가 반복된다.

  • 진단 수집: 종료 직전 스레드 덤프·열린 커넥션·디스크 사용량을 기록한다.
  • 재시도 분리: 인프라성 실패(네트워크)만 1회 재시도하고, 테스트 실패는 재시도하지 않는다. 무분별한 재시도는 플레이키(flaky)를 은폐한다.
  • 취소 전파: 새 커밋이 오면 이전 실행을 취소해 러너를 즉시 회수한다.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true       # 오래된 실행 자동 취소

주의점

타임아웃을 너무 빡빡하게 잡으면 콜드 캐시·러너 혼잡 시 정상 실행이 오탐으로 죽는다. 상한은 관측된 p95의 약 1.5~2배로 두고, 값을 코드로 관리해 변경 이력을 남긴다. 또 전역 재시도로 상한 초과를 덮지 말 것. 재시도는 실패를 늦출 뿐 예산을 지켜주지 않는다. 마지막으로 예산과 실제 p95를 대시보드로 지속 추적해, 상한이 현실과 벌어지면 숫자를 조정하는 회고 루프를 유지한다.