leo.dev
infra

배포 길목에 박은 CI 품질 게이트

이 시리즈의 1~4편은 배포가 어떻게 도는지를 고쳤다. 워크플로우 통합, 배포 순서, 자동 롤백, 캐시 전략. 그런데 정작 무엇이 배포되는지는 아무도 검사하지 않고 있었다. 타입 에러가 난 코드, 깨진 테스트가 그대로 ECS로 나갈 수 있었다.

안전망이 사람 손이었다

워크플로우에는 deploy.yml 하나뿐이었다. dev/prod 브랜치에 push가 들어오면 변경을 감지해 곧장 빌드→ECS 배포로 직행한다. 그 경로 어디에도 tsc·eslint·test 게이트가 없었다.

그럼 깨진 코드는 무엇이 걸렀나. 로컬에서 커밋 전에 돌리는 검증뿐이었다. 즉 “내가 push 전에 타입체크를 돌렸나”에 품질이 달려 있었다. 한 번 빼먹으면 그대로 배포다. 비결정적인 사람 손이 유일한 안전망이면, 그건 안전망이 아니다.

PR에 게이트를 걸면 정작 배포는 안 막힌다

CI를 붙이는 표준 답은 “PR에서 검증을 돌린다”다. 그런데 우리 팀 플로우에서는 그게 핵심 경로를 비껴간다.

작업 커밋은 dev직접 push로 들어간다. PR은 릴리스할 때 dev → prod 한 번만 연다. 그러니 pull_request 트리거에만 검증을 걸면, 정작 매일 일어나는 dev 직접 push 배포는 검사 없이 빠져나간다. 별도 ci.yml을 push에도 걸 수는 있지만, 그건 deploy.yml병렬로 돌 뿐 배포를 막지 못한다. 빨간 CI 옆에서 배포는 그냥 나간다.

게이트는 PR이 아니라 모든 배포가 반드시 지나가는 길목에 있어야 한다. 그 길목은 명확했다. dev든 prod든 배포는 전부 deploy.yml의 push 트리거 하나로 들어온다. 그러면 게이트를 거기 박으면 된다.

단일 게이트 — 배포 파이프라인 맨 앞에 verify

deploy.yml 맨 앞에 verify 잡을 두고, 배포의 첫 단계(detect-changes)가 needs: verify로 의존하게 했다. 뒤따르는 배포 잡들은 그 단계에 사슬로 매달려 있어, verify가 실패하면 사슬 전체가 안 돈다. 별도 CI도, 브랜치 보호도 없다. 배포가 한 길목으로 모이니 게이트도 하나면 된다.

deploy.yml
on:
push:
branches: ['prod', 'dev']
paths: ['apps/**', 'packages/**', '.aws/**', ...]
jobs:
verify: # typecheck · lint · test
runs-on: ubuntu-latest
timeout-minutes: 15 # 게이트가 멈춰서 배포를 영영 막지 않게
steps: [ ...turbo typecheck / lint / test ]
detect-changes:
needs: verify # ← verify 통과해야 배포 시작
# deploy-backend → detect-changes, deploy-batch·frontend → deploy-backend

이게 dev 직접 push까지 막는 이빨이다. 워크플로우 안에서 needs로 강제하니, 레포 설정과 무관하게 verify 없이는 배포가 시작되지 않는다. 릴리스 PR(dev → prod)도 결국 prod로의 push로 끝나므로, 같은 verify를 한 번 더 지나간다. 그래서 PR을 막는 별도 장치가 필요 없다.

게이트는 배포를 멈출 수도, 영영 가둘 수도 있다

게이트를 배포 경로에 직접 박으면 새 위험이 생긴다. verify가 어떤 이유로 걸려서 안 끝나면 배포가 영영 못 나간다. 그래서 timeout-minutes: 15로 상한을 뒀다. 게이트는 깨진 걸 막아야지, 멀쩡한 걸 가두면 안 된다.

동시성도 손봤다. 같은 브랜치에 배포가 연달아 트리거되면 마이그레이션·ECS 업데이트가 겹쳐 레이스가 난다. concurrency 그룹으로 같은 브랜치 배포를 직렬화하되, 진행 중인 배포는 취소하지 않고 큐잉했다.

deploy.yml
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false # 마이그레이션·ECS 업데이트 중단은 위험 → 취소 대신 대기

CI 게이트라면 보통 cancel-in-progress: true로 최신만 남기고 이전 실행을 죽인다. 배포는 반대다. 마이그레이션이 절반 돌아간 배포를 중간에 끊는 게 더 위험하니, 새 배포는 앞선 게 끝날 때까지 기다린다.

typecheck가 빌드에 의존하는 이유

세 게이트 중 손이 간 건 typecheck였다. 전용 스크립트가 아예 없었다. 그동안 tsc --noEmit은 로컬에서 손으로 돌리던 명령이라, CI에 넣으려면 각 패키지에 스크립트로 박아야 했다.

// 각 package.json
"typecheck": "tsc --noEmit" // backend, common
"typecheck": "tsc --noEmit -p tsconfig.app.json" // frontend

turbo task로 묶을 때 하나가 걸렸다. 공유 패키지(@repo/common)는 소비처에서 **빌드 산출물(dist)**로 참조된다. 그래서 common을 빌드하지 않은 채 backend/frontend를 타입체크하면 타입을 못 찾는다. task에 dependsOn: ["^build"]를 걸어 의존 패키지 빌드를 선행시켰다.

turbo.json
"typecheck": { "dependsOn": ["^build"], "cache": true }

캐시가 켜져 있어 변경 없는 패키지는 재실행을 건너뛴다. 게이트가 붙어도 CI가 매번 전부 다시 돌지는 않는다.

baseline이 이미 초록이었다

게이트를 켤 때 늘 따라오는 걱정은 “지금 잠재된 실패가 한꺼번에 빨갛게 뜨면 어쩌나”다. 기억으로는 프론트엔드에 tsc 에러가 십수 개 깔려 있었다. 그게 사실이면 차단 게이트를 켜기 전에 baseline부터 정리해야 한다.

켜기 전에 한 번 측정했다. tsc 0에러(3패키지), lint 0에러(경고만 일부), test 933개 전부 통과. 기억이 stale했다. baseline이 이미 초록이라, 비차단(보고용)으로 켜뒀다 천천히 정리하는 단계를 건너뛰고 곧장 차단 게이트로 올릴 수 있었다. 측정 한 번이 “점진 도입” 한 단계를 통째로 없앴다.

그래서

CI를 붙인다는 건 “검증을 돌린다”가 아니라 “통과 못 하면 못 들어가게 한다”다. 그 강제력은 모든 배포가 반드시 지나는 길목에 있어야 효력이 있다. 우리 경우엔 PR이 아니라 deploy.yml의 push 트리거였고, 거기 verify 잡을 박고 배포를 needs로 매달았다. 표준 답(“PR에서 검증”)을 그대로 따랐다면, 정작 매일 나가는 dev push는 게이트 옆으로 빠져나갔을 거다. 게이트는 가장 흔한 배포 경로가 어디인지를 따라가서 그 길목에 둔다.

↑↓ 이동 열기esc 닫기