이 시리즈의 1~4편은 파이프라인을 고쳤다. 이번엔 구조를 갈아엎었다. 백엔드 이미지를 한 번만 빌드해 재사용하고, backend와 worker 배포를 병렬로 돌리고, 로드밸런서가 내려가는 태스크의 기존 연결을 기다려주는 시간(드레이닝)을 300초에서 60초로 줄였다. 커밋 메시지에는 “백엔드 배포 약 21분 → 약 12분”이라고 적었다.
적고 나서 보니 그건 목표지 결과가 아니다. 얼마나 빨라졌는지는 잰 다음에만 말할 수 있다.
무엇을 바꿨나
| v1 | v2 | |
|---|---|---|
| 백엔드 이미지 빌드 | deploy-backend 잡 안에서 빌드+배포 결합 | build-migrate 잡에서 1회만 빌드·push |
| worker 배포 | backend 뒤에 직렬, Dockerfile.worker를 매번 자체 빌드(약 5분) | backend와 병렬, 동일 이미지 재사용 + task-def command 오버라이드 |
| 배포 잡 러너 | ARM(빌드 때문에) | 빌드 없는 배포 잡은 ubuntu-latest, 빌드 잡만 ARM |
| ALB 드레이닝 | 300초 | 배포마다 60초로 자가복구 |
제거를 노린 병목이 세 가지 있었다. ECS 안정화 대기, worker 직렬 배포, 중복 이미지 빌드.
전후 비교가 가능했던 이유
개편하면서 워크플로우 파일 경로를 바꾸지 않았다. GitHub Actions는 실행 이력을 워크플로우 파일 단위로 쌓으므로, 신·구 파이프라인의 run이 같은 이력에 남는다. 도입 커밋 시각을 경계로 자르면 전후 비교가 된다. v1은 7주간 172건, v2는 도입 후 2일간 18건이었다.
지표는 두 가지로 나눴다. run 하나의 전체 소요시간만 보면 프론트 전용 배포와 verify 게이트에 희석돼 개편의 효과가 안 보인다.
- 백엔드 임계경로: 빌드·backend·worker 배포 잡들의
min(started_at) → max(completed_at). 서버 코드가 실제 반영되기까지의 시간이다. - 전체 wall-clock: 백엔드를 배포한 run의 전 잡 기준 같은 계산.
실패 run은 시간 통계에서 뺐다. 조기 중단된 run은 짧게 나와 평균을 왜곡한다. 실패율은 별도로 셌다.
첫 측정, 임계경로 −45%
| 지표 | v1 | v2 (18 run) |
|---|---|---|
| 백엔드 임계경로 median | 15.0분 (max 29.0) | 8.2분 (max 21.9, −45%) |
| 전체 wall-clock median | 15.3분 | 13.8분 (−10%) |
| 실패율 | 11.6% | 11.1% |
어디서 왔는지는 잡별로 분해하면 보인다. v1의 Deploy Backend는 빌드와 배포가 결합돼 median 8.8분이었고, worker가 그 뒤에 직렬로 4~5분을 더 썼다. v2는 빌드(3.4분)와 배포(4.9분)를 분리하고 worker(3.4분)를 병렬로 돌린다. 임계경로가 3.4 + max(4.9, 3.4) ≈ 8.3분으로 접히는 구조다.
전체 wall-clock이 10%밖에 안 준 것도 구조로 설명된다. verify 게이트(약 3.8분)가 배포 앞에 직렬로 있고, ECS 서비스 안정화 대기가 배포 잡 안에 남아 있다. 이 두 가지가 하한을 만든다. 4편에서 배포 길목에 박은 게이트가 이번엔 wall-clock의 하한이 됐는데, 이건 감수하기로 한 비용이다.
표본이 적었다. v2 쪽이 2일치 18건뿐이라 이 숫자를 결론이라 부르기 어려웠다. 열흘 뒤 다시 쟀다.
재측정, 결론 유지
v2 표본이 44건(성공 36, 실패 3, 취소 5)으로 늘었다.
| 지표 | v1 | v2 첫 측정 | v2 재측정 |
|---|---|---|---|
| 백엔드 임계경로 median | 15.0분 | 8.2분 (−45%) | 8.9분 (max 22.8, −41%) |
| 전체 wall-clock median | 15.3분 | 13.8분 (−10%) | 14.2분 (−8%) |
| 실패율 | 11.6% | 11.1% | 7.7% (3/39, 취소 제외) |
임계경로 단축은 −45%에서 −41%로 물러섰지만 결론은 그대로다. 실패율은 “사실상 불변”에서 “개선”으로 바뀌었다. 병렬화로 속도를 얻으면 안정성을 잃는 게 보통의 트레이드오프인데, 여기선 잃지 않았다. 다만 실패 3건짜리 표본이라 이 항목은 아직 단정하지 않는다.
표의 숫자보다 그것을 재면서 드러난 두 가지가 더 컸다. 측정 방법에서 나온 구멍과 남은 병목의 위치다.
측정 방법에서 나온 구멍
측정은 GitHub REST API의 워크플로우 잡 목록에서 잡별 started_at·completed_at을 받아 계산한다. gh api repos/{owner}/{repo}/actions/runs/{id}/jobs 한 줄이다.
이 엔드포인트에 재실행을 다루는 파라미터가 있는데, 그걸 안 준 게 구멍이 됐다.
Filters jobs by their
completed_attimestamp.latestreturns jobs from the most recent execution of the workflow run.allreturns all jobs for a workflow run, including from old executions of the workflow run.— List jobs for a workflow run, GitHub REST API Documentation
기본값이 latest다. 잡 하나만 재실행하면 그 잡은 재실행 시각으로 갱신되고 나머지는 원래 시각 그대로 남으므로, 한 run 안에 몇 시간 떨어진 타임스탬프가 섞인다. min(start) → max(end)는 그 사이를 통째로 소요시간으로 센다.
그래서 이 방식은 잡 재실행이 낀 run에서 대기 시간을 소요시간으로 오인한다. 재측정 표본에서 2건이 걸렸다.
- 한 run은 실패 후 16.5시간 뒤에 재실행됐다. wall-clock이 989분으로 계산됐다.
- 다른 run은 실패한 배포 잡 하나만 85분 뒤에 잡 단위로 재실행됐다. 임계경로가 103분으로 계산됐다.
둘 다 배포가 989분, 103분 걸린 게 아니다. 내가 재실행하기까지의 시간이 span에 들어간 것이다.
보완 규칙을 넣었다. 연속한 잡 사이의 idle gap이 15분을 넘으면 그 run을 시간 통계에서 제외한다. 위 표에는 이 규칙이 반영돼 있다.
첫 측정의 2일 표본에는 재실행 run이 우연히 없었다. 그래서 이 구멍은 첫 측정에서 드러날 수 없었다. 표본을 열흘 더 쌓지 않았으면 989분과 103분짜리 run이 들어오지 않았고, 그러면 이 규칙을 지금도 안 넣고 있었을 것이다.
남은 병목의 위치
Deploy Backend Service의 median은 5.4분인데, 10분을 넘긴 run이 33건 중 8건(24%)이고 최대 18.9분이다. dev·prod 모두에서 산발적으로 나오고 특정 날짜에 몰리지 않는다.
첫 측정의 결론은 “다음 병목은 ECS 안정화 대기”였다. 재측정은 그 병목이 median이 아니라 꼬리에 산다는 걸 보여줬다.
보통의 배포는 이미 충분히 빠르고, 4건 중 1건꼴로 헬스체크 안정화가 오래 걸리는 run이 전체 체감을 끌어내린다. 같은 기간에 겪은 일시적 헬스체크 실패로 난 롤백과 원인이 같다고 추정했고, 인과는 아직 못 이었다.
3차 측정, 그리고 재현되지 않은 기준선
한 달 뒤 다시 쟀다. v2 표본이 44건에서 115건(시간 통계 대상 86건)으로 늘었다.
| 지표 | v1 | v2 2차 | v2 3차 |
|---|---|---|---|
| 백엔드 임계경로 median | 15.0분 | 8.9분 | 8.9분 (max 26.6) |
| 전체 wall-clock median | 15.3분 | 14.2분 | 14.6분 |
| 실패율 | 11.6% | 7.7% (3/39) | 3.7% (4/109) |
| Deploy Backend 10분 초과 | — | 24% (8/33) | 23% (20/86) |
임계경로는 표본이 2.6배가 돼도 8.9분 그대로다. 실패율은 계속 내려갔다. 2차에서 “실패 3건짜리 표본이라 아직 단정하지 않는다”고 유보했던 항목인데, 109건 기준 3.7%면 이제 말할 수 있다. 병렬화로 속도를 얻으면서 안정성을 잃지 않았다.
꼬리는 안 줄었다. 2차에서 다음 개선 대상으로 지목한 “10분 넘는 24%“가 3차에서 23%다. 표본이 2.6배가 됐는데 비율이 그대로라는 건 우연한 몰림이 아니라 구조라는 뜻이고, 최댓값은 18.9분에서 19.2분으로 오히려 늘었다. 지목만 하고 손대지 않았으니 당연한 결과다.
4차 — 꼬리가 사라진 이유
열흘 뒤 다시 쟀더니 꼬리가 없어져 있었다.
| 표본 | median | max | 10분 초과 | |
|---|---|---|---|---|
| 3차 시점까지 | 23건 | 5.8분 | 21.4분 | 8건 (35%) |
| 그 이후 | 21건 | 5.0분 | 7.4분 | 0건 |
파이프라인은 안 건드렸다. 그 사이에 있었던 건 Fargate Spot 장애를 수습하면서 용량 공급자를 온디맨드 base=1로 바꾼 것, 곧 Spot 대신 온디맨드 태스크를 최소 한 개는 늘 띄우게 한 변경뿐이다. 10분을 넘긴 8건이 전부 그 변경 이전에 몰려 있고 이후로는 한 건도 없다.
3차에서 “다음 개선 대상”으로 지목한 병목은 배포 파이프라인의 문제가 아니었다. ECS 안정화 대기가 느린 게 아니라 태스크가 헬스체크를 통과하지 못해 교체되고 있었고, 그건 인프라 구성 쪽이었다.
측정만으로는 이걸 구분할 수 없었다는 게 남는다. 잡 소요가 길다는 사실은 잡 안에서 무엇이 오래 걸리는지를 말해주지 않는다. 3차까지의 데이터는 “꼬리가 있다”까지만 말했고, 그 꼬리가 파이프라인 밖에서 온다는 건 다른 사고를 파다가 알았다.
안 남긴 측정 스크립트
3차에서는 v1 기준선 자체가 다시 안 나왔다.
1·2차는 측정 방법을 산문으로만 적었다. “빌드·backend·worker 배포 잡들의 min(started_at) → max(completed_at)”. 3차 때 그 설명대로 다시 짰더니 v1 기준선이 재현되지 않았다. 발표한 15.0분 대신 13.5분이 나왔다. 판정 변형을 세 가지 돌려봐도 12.9~13.5 사이라 15.0이 안 나온다.
어디가 어긋났는지는 좁혀진다. 같은 스크립트로 계산한 다른 v1 값들은 소수점까지 일치했다.
| v1 지표 | 발표치 | 재계산 |
|---|---|---|
| 전체 wall-clock median | 15.3분 | 15.3분 ✅ |
| 실패율 | 11.6% | 11.6% ✅ |
| 백엔드 임계경로 median | 15.0분 | 13.5분 ❌ |
셋 중 임계경로만 다르다. 그리고 임계경로만 합성 지표다. 나머지 두 지표는 run 하나에서 바로 나오는데, 임계경로는 “여러 잡 중 어느 것을 묶을 것인가”를 먼저 정해야 값이 나온다.
그 합성 규칙이 명세의 일부인데 산문은 그걸 다 담지 못했다. 실패한 잡을 포함하는지, run 성공을 요구하는지, Deploy Batch를 백엔드로 세는지가 안 적혀 있다.
발표치가 틀렸다고는 말 못 한다. 재현할 수 없을 뿐이고, 재현할 수 없으면 검증도 안 된다. 개선 폭은 −41%에서 −34%로 내려가지만 방향과 크기는 유지된다.
그래서 판정 규칙을 코드로 고정해 레포에 커밋했다(.github/scripts/measure-deploy.mjs). 다음 측정은 이 파일을 고쳐서 하고, 고치면 커밋에 남는다.
파이프라인보다 많이 손댄 측정 쪽
네 번 재는 동안 손댄 건 파이프라인보다 측정 쪽이 많았다. 1차 뒤에는 커밋 메시지에 적어둔 “약 21분 → 약 12분”을 지웠고, 2차 뒤에는 재실행이 낀 run을 거르는 규칙을 넣었고, 3차 뒤에는 판정 규칙을 산문에서 스크립트로 옮겼다.
현재 상태는 이렇다. 백엔드 임계경로 median 8.9분, 실패율 3.7%, 그리고 Deploy Backend의 꼬리는 용량 공급자를 고친 뒤 21건 중 0건으로 사라졌다. 측정 스크립트는 레포에 있다.