← all posts
DEV 2026.05.02 · 14 min read Intermediate

CI/CD 파이프라인의 다섯 가지 신호

배포 추적부터 장애 복구까지, 파이프라인이 침묵하는 순간 팀이 잃는 것을 추적한다.


배포는 코드의 생애주기에서 가장 위험한 순간이다. 그런데 많은 팀이 그 순간을 조용히 지나간다 — 알림도 없고, 기록도 없고, 대응 절차도 없다. 파이프라인이 침묵할 때 팀이 실제로 무엇을 잃는가?

침묵하는 배포

배포가 성공했다. 그런데 팀원들이 모른다. 10분 뒤 응답 지연이 감지됐는데, 모니터링 팀은 “최근 변경 사항이 있었나?”를 묻는다. 답을 찾는 데 30분이 걸린다.

이것은 가상의 시나리오가 아니다. 배포 알림이 없는 팀에서 반복적으로 일어나는 일이다. Slack Webhook 한 줄로 막을 수 있는 30분짜리 원인 추적이 매번 반복된다.

배포 알림의 구조는 단순하다. slackapi/slack-github-action에 JSON 페이로드를 넘기면 커밋, 담당자, 환경 정보가 채널에 도착한다. 더 중요한 것은 실패 알림이다 — if: failure() 조건은 성공 메시지보다 훨씬 더 즉각적인 가치를 가진다.

GitHub Deployment API는 이 알림을 한 단계 넘어선다. 배포 이력이 레포지토리에 기록되면 “이 버그 언제부터 있었지?”라는 질문에 PR 날짜로 추측하는 대신 정확한 배포 타임라인을 제시할 수 있다. Datadog이나 Grafana에 Annotation을 추가하면 메트릭 그래프에 수직선이 생긴다 — 그 선 이후로 에러율이 올라갔다면, 배포가 원인임을 증명할 수 있다.

간헐적 실패의 정체

“로컬에서는 성공하는데 CI에서만 실패한다”는 말은 CI 디버깅의 시작점이다. 그 원인은 대개 네 가지 범주 중 하나다.

Flaky Failures
├── Network-related (20-30%)  — DNS 타임아웃, 레지스트리 rate limit
├── Timing-related (40-50%)  — Race condition, 비동기 미대기
├── Resource-related (15-25%) — 메모리 부족, 포트 충돌
└── Cache-related (10%)       — Stale cache, 캐시 충돌

GitHub Actions에는 두 가지 디버그 레벨이 있다. ACTIONS_STEP_DEBUG=true는 step별 환경 변수와 네트워크 요청을 보여준다. ACTIONS_RUNNER_DEBUG=true는 그보다 더 깊은 Runner 초기화 과정까지 노출한다. 두 값 모두 Repository Secrets에서 설정하면 다음 실행부터 즉시 활성화된다.

더 극단적인 방법은 mxschmitt/action-tmate다. 실패한 Runner에 SSH로 직접 접속해 ps aux, df -h, npm test를 직접 실행할 수 있다. 환경 차이가 원인일 때 가장 빠른 진단 도구다.

트레이드오프

ACTIONS_RUNNER_DEBUG를 항상 켜두면 로그가 10배 이상 커지고 읽기 어려워진다. Secret 값을 기본 false로 두고, 조사가 필요할 때만 true로 전환하는 것이 권장된다. 재시도 로직도 마찬가지다 — 네트워크 작업(npm install, docker pull)은 재시도가 유효하지만, 테스트 재시도는 flaky를 숨긴다.

파이프라인 속도와 누적 손실

10분 파이프라인과 45분 파이프라인은 같은 코드를 검증하지 않는다. 45분짜리는 개발자가 피드백을 기다리다 컨텍스트를 잃는 파이프라인이고, 급한 버그 수정도 배포를 망설이게 만드는 파이프라인이다.

최적화의 첫 번째 단계는 병목 식별이다. 대부분의 경우 npm install이 매번 5분을 소모하고 있다 — 캐시 한 줄로 막을 수 있는 5분이.

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

캐시 키는 package-lock.json 해시에 묶어야 한다. 소스 파일 해시에 묶으면 커밋마다 캐시 미스가 발생한다. 테스트 병렬화는 그다음 단계다 — Jest의 --shard=1/4 옵션으로 20분 테스트를 4개 Job에서 각 5분씩 처리한다. Docker 레이어 캐시는 Dockerfile의 작성 순서가 결정한다. 자주 바뀌는 소스 코드는 마지막에, 의존성 설치는 앞에 두어야 캐시가 살아남는다.

누적 효과는 크다. 팀 10명, 하루 5번 빌드라면 45분 파이프라인은 매달 수백 시간을 소모한다.

환경 분리와 Drift

“Staging에서 성공했는데 Production에서 실패했다”는 말의 가장 흔한 원인은 환경이 달랐기 때문이다 — 메모리, CPU, 환경 변수 어딘가에서.

환경은 최소 세 개다. Dev는 빠른 피드백을 위해 자동 배포. Staging은 Production과 동일한 리소스 설정으로 — 메모리 한도, 레플리카 수, 로그 레벨까지. Production은 승인자를 요구한다.

GitHub Environments는 이 구조를 선언적으로 관리한다. 각 환경에 별도 Secret을 두면 DATABASE_URL이 환경마다 다른 값을 가지면서도 워크플로우 파일은 동일하다. environment: production 한 줄이 승인 게이트를 만든다.

Drift는 조용히 쌓인다. 핫픽스로 Production에만 수동 설정이 추가되거나, Kubernetes 스케일링 정책이 환경마다 달라진다. 정기적으로 Staging과 Production의 Deployment 정의를 비교하는 스크립트가 없다면, 이 차이는 다음 장애 때까지 감지되지 않는다.

장애는 설계다

배포 후 장애는 피할 수 없다. 중요한 것은 얼마나 빨리 감지하고 복구하는가다.

자동 롤백의 구조는 단순하다. 배포 직후 헬스 체크 — 실패하면 이전 이미지로 즉시 교체. kubectl rollout undo는 한 줄이다. 이 한 줄이 없으면 15-30분 다운타임이, 있으면 1-2분 다운타임이 된다.

데이터베이스 마이그레이션은 롤백할 수 없다는 제약이 있다. ALTER TABLE 이후 컬럼을 드롭하면 데이터가 사라진다. 해결책은 마이그레이션을 배포와 분리하는 것이다 — 배포 성공을 확인한 뒤 별도로 마이그레이션을 실행하고, 마이그레이션은 항상 순방향으로만 설계한다.

Postmortem은 의식(儀式)이다. 장애 후 “왜?”를 다섯 번 반복하고, Action Item에 책임자와 마감일을 붙이고, 3개월마다 Game Day로 대응 절차를 실제로 테스트한다. Game Day는 Production에서 수행해야 효과가 있다 — Dev 환경에서의 시뮬레이션은 실제와 너무 다르다.

정리

  • 배포 알림과 GitHub Deployment API는 팀의 상황 인식을 만든다. 실패 알림이 성공 알림보다 더 중요하다.
  • 간헐적 실패는 “그냥 다시 돌리면 된다”는 생각이 쌓이는 기술 부채다. ACTIONS_STEP_DEBUG와 tmate로 근본 원인을 찾는다.
  • 캐시와 병렬화로 45분 파이프라인을 10분으로 줄이는 것은 가능하다. 병목은 항상 측정 가능하다.
  • Staging과 Production의 환경 차이는 조용히 쌓인다. 정기적인 Drift 감지가 없으면 다음 장애 때 드러난다.
  • 자동 롤백은 선택이 아니다. 수동 대응을 기다리는 매 분이 피해다.