leo.dev
infra

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

앞의 세 편에서 배포가 도는 방식을 고쳤다. 워크플로우를 하나로 합치고, 순서를 정하고, 자동 롤백과 캐시를 붙였다. 그런데 무엇이 배포되는지는 아무도 안 보고 있었다. 타입 에러가 난 코드도, 실패하는 테스트를 안은 코드도 그대로 ECS로 나갈 수 있었다.

검증이 내 기억에 달려 있었다

워크플로우는 deploy.yml 하나였다. devprod에 push가 들어오면 변경을 감지해 곧장 빌드와 ECS 배포로 간다. 그 경로 어디에도 tsc·eslint·test가 없었다.

그럼 그런 코드는 뭐가 걸렀나. 내가 커밋 전에 로컬에서 돌리는 것뿐이었고, 한 번 빼먹으면 그대로 배포됐다. 빼먹은 날에만 검증이 사라지니까 평소에는 아무 문제도 없어 보였다.

PR에 걸면 비껴가는 경로

CI를 붙이는 표준 답은 PR에서 검증을 돌리는 것이다. 그런데 이 레포에서는 그게 핵심 경로를 비껴간다.

작업 커밋은 dev에 직접 push로 들어가고, PR은 릴리스할 때 dev → prod 한 번만 연다. 이 레포에 커밋을 쓰는 사람이 나 하나라서 그렇다. 리뷰어가 없는 PR은 머지를 한 번 더 승인하는 절차라, 브랜치를 파는 비용만 남고 얻는 게 없다. 리뷰어가 보는 관문도 그래서 없고, 기계 검증이 유일한 안전망인데 그게 0개였다.

pull_request 트리거에 검증을 걸어봐야 매일 나가는 dev 직접 push가 그 트리거를 안 탄다. 별도 ci.yml을 push에 걸 수도 있지만, 그건 deploy.yml과 병렬로 돌 뿐이라 빨간 CI 옆에서 배포가 그냥 나간다.

그래서 배포가 어디로 모이는지부터 봤다. dev든 prod든 결국 deploy.yml의 push 트리거 하나였고, 게이트는 거기 붙였다.

배포 잡 맨 앞에 매단 verify

deploy.yml 맨 앞에 verify 잡을 두고, 배포의 첫 단계(detect-changes)가 needs: verify로 의존하게 했다. 뒤따르는 배포 잡들이 전부 그 단계를 needs로 걸고 있어 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을 막는 별도 장치를 두지 않았다.

게이트는 도는데 초록불이 사실이 아닌 경우

게이트를 박고 나서 두 번 더 걸렸다. 워크플로우는 성공으로 끝나는데 그 성공이 사실이 아닌 경우다.

게이트와 운영의 타임존 차이

이 레포의 DB는 KST 자정을 저장하고, mysql2가 timezone: '+09:00'으로 읽어 날짜 컬럼을 KST 자정을 가리키는 시점(instant)으로 준다. 여기서 toISOString()으로 날짜를 뽑으면 UTC로 환산돼 하루가 밀린다.

DB=2026-07-15 → instant 2026-07-14T15:00:00Z
toISOString().slice(0, 10) → '2026-07-14' 하루 일찍

이 패턴 때문에 연차 캘린더, 이슈 에픽 시작·마감일, 반복 업무 종료일, 연차 계획 날짜, 구독 계약 종료일이 전부 하루 일찍 나가고 있었다.

이게 로컬에서는 안 잡힌다. 내 맥의 TZ가 KST라 로컬 시간대 포맷터가 섞인 코드도 테스트도 맥에서는 통과하고, 그러면 초록불이 “이 코드가 맞다”가 아니라 “내 노트북에선 맞다”가 된다.

운영 쪽 TZ는 환경변수 한 줄(TZ=Asia/Seoul)이 정본인데, 로컬 스크립트나 새로 띄운 워커처럼 그 줄이 빠진 환경은 Node 기본값인 UTC로 떨어진다. 실제로 존재하는 시간대가 둘이라, 게이트는 코드가 더 잘 틀리는 UTC 쪽에 맞췄다.

deploy.yml
verify:
env:
# 프로덕션은 env로 KST지만, 그 한 줄이 빠진 환경은 UTC로 떨어진다.
# 게이트는 더 가혹한 쪽에서 돈다. ubuntu-latest 기본값도 UTC지만 그건
# 암묵적이라, 러너가 바뀌거나 누가 TZ를 주입해도 흔들리지 않게 명시한다.
TZ: UTC

지금도 러너 기본값이 UTC라 이 줄이 당장 바꾸는 건 없다. 그런데 러너 이미지가 바뀌거나 누가 워크플로우에 TZ를 주입하면 기본값은 언제든 달라질 수 있고, 그때 이 줄이 없으면 게이트가 조용히 다른 시간대에서 돈다.

반대편도 같은 이유로 이중화했다. 운영 TZ가 환경변수 한 줄에 걸려 있는 상태 자체가 얇아서, 이미지의 ENV TZ=Asia/Seoul을 기본값으로 넣었다. 그 줄이 유실돼도 컨테이너는 KST로 뜬다. 코드가 TZ에 의존해선 안 된다는 원칙과는 별개의 방어다.

테스트 0개가 통과로 나온 스위트

turbo test가 초록이길래 테스트가 다 돈다고 믿었다. 그런데 e2e 스위트 두 개는 turbo test 대상이 아니라 별도 스크립트였고, 그 둘이 부팅조차 못 한 채 “테스트 0개”로 방치돼 있었다. 0개가 돌면 실패가 아니라 통과로 나온다.

고치고 나니 0개가 28개가 됐다. 그 28개는 그동안 아무것도 안 지키고 있었다.

deploy.yml
# e2e는 turbo test 대상이 아니라 별도 스크립트다. CI에서 안 돌리면 아무도 안 돌려
# 조용히 썩는다. DB·Docker 불필요, 수 초면 끝난다.
- run: pnpm --filter @repo/swing-backend test:e2e

가드레일이 “위반 0건”을 보고했을 때와 같은 함정이다. 0은 “안전하다”와 “안 보고 있다”를 구분하지 못하고, 워크플로우는 두 경우 모두 성공으로 끝난다.

두 달 뒤에 다시 세어본 verify

여기까지가 5월에 한 일이다. 두 달 뒤인 7월 말에 verify 잡이 실제로 무엇을 도는지 다시 셌더니, 같은 함정이 한 군데 더 열려 있었다.

게이트는 turbo typecheck · turbo lint · turbo test · e2e 네 가지를 돌린다. 그런데 turbo test가 실행하는 백엔드 jest의 설정이 이렇다.

jest.config.js
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',

Testcontainers로 실제 MySQL을 띄우는 통합 테스트는 test/integration/ 아래에 있고 파일명이 *.int-spec.ts다. rootDirsrc라 스캔 범위 밖이고, 이름도 .spec.ts가 아니라 -spec.ts로 끝나 정규식에 안 걸린다. 둘 중 하나만 고쳐도 여전히 안 잡히는 상태였다. 실제로 세어보면 이렇다.

turbo test가 잡는 파일 109개 그중 int-spec 0개
test:int 설정이 잡는 파일 21개 CI 스텝 없음

통합 테스트 21개가 CI에서 한 번도 안 돌고 있었다. 내가 로컬에서 pnpm test:int를 칠 때만 돌았던 것이다. e2e가 같은 이유로 빠져 있던 걸 알아채고 별도 스텝을 붙여 살렸으면서, 통합 테스트는 그때 같이 안 봤다.

이게 여태 안 아팠던 이유는 이 테스트들이 지키는 대상에 있다. 동시성 직렬화, 락 획득 판정, 타임스탬프 시간대 왕복처럼 mock으로는 물어볼 수 없는 것들이라 실 DB가 필요하고, 그래서 Docker를 띄우는 데 시간이 든다. 배포 길목에 넣으면 게이트가 무거워진다는 판단이 어딘가에 있었을 텐데, 기록이 없으니 판단이었는지 그냥 빠뜨린 건지 나도 모르겠다.

어느 쪽이든 “동시성 회귀는 통합 테스트가 막는다”에서 막는 주체가 테스트가 아니었다. 테스트는 있고 통과도 하지만 돌리는 건 사람이었으니, 앞에서 걷어냈다고 쓴 상태가 이 21개에는 그대로 남아 있었다.

게이트가 배포를 영영 막는 경우

게이트를 배포 경로에 직접 박으면서 새 위험이 하나 생겼다. verify가 어떤 이유로 걸려서 안 끝나면 배포가 영영 못 나간다. 잘못된 걸 막으려고 넣은 게 멀쩡한 배포까지 못 나가게 하면 곤란하니 timeout-minutes: 15로 상한을 뒀다.

같은 시점에 concurrency 설정도 넣었다. 같은 브랜치 배포를 concurrency 그룹으로 직렬화하되 진행 중인 배포는 취소하지 않고 큐잉하게 했는데, 그 결정의 근거는 2편에 적었다.

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를 타입체크하면 타입을 못 찾는다.

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

^ 하나가 이 문제를 푼다. Turborepo 문서에 그 뜻이 적혀 있다.

Prefixing a string in dependsOn with a ^ tells turbo that the task must wait for tasks in the package’s dependencies to complete first.

Configuring turbo.json, Turborepo Documentation

접두사가 없으면 같은 패키지 안의 태스크를 기다리고, 붙이면 의존 패키지 쪽을 기다린다. @repo/common이 먼저 빌드되고 그 다음에 소비처가 타입체크되는 순서가 이 한 글자에서 나와서, CI 스크립트에 순서를 손으로 적지 않았다. turbo는 캐시가 기본값이라 변경 없는 패키지는 재실행을 건너뛰고, 그래서 게이트가 붙어도 매번 전부 다시 돌지는 않는다.

켜기 전에 잰 baseline

게이트를 켤 때 제일 걱정한 건 잠재된 실패가 한꺼번에 빨갛게 뜨는 것이었다. 기억으로는 프론트엔드에 tsc 에러가 십수 개 깔려 있었고, 그게 사실이면 차단 게이트를 켜기 전에 baseline부터 정리해야 했다.

그래서 켜기 전에 한 번 쟀다. 그 시점 기준으로 tsc 0에러(3패키지), lint 0에러(경고만 일부), test 전부 통과였다. 기억이 틀렸던 것이다. 비차단으로 켜뒀다 천천히 정리하는 단계를 건너뛰고 곧장 차단 게이트로 올렸다.

거짓 초록불이 나온 곳

이 작업에서 초록불이 거짓이 되는 방식을 세 번 만났는데, 매번 다른 층이었다.

초록불이 거짓인 이유실제로 겪은 것
위치게이트를 안 지나는 경로가 있다PR 트리거는 매일 나가는 dev push를 못 잡는다
환경검사 환경이 실행 환경과 다르다맥은 KST, env가 빠진 런타임은 UTC. 날짜가 하루 밀렸다
실효게이트가 아무것도 안 검사한다e2e 두 스위트가 “테스트 0개”로 방치

셋 다 게이트 자체는 멀쩡히 돌고 있었다. 워크플로우는 성공으로 끝나고 배지도 초록이라 아무도 이상하다고 느끼지 않는다. 게이트를 붙이는 것보다 그 게이트가 거짓말을 하는지 확인하는 데 시간을 더 썼다.

실효 쪽이 제일 끈질겼다. 파일명 규칙 하나가 어긋나면 그 파일들이 실행 목록에서 조용히 빠지는데, 남은 것들이 통과하는 한 초록불은 정상으로 뜬다. e2e에서 한 번 겪고 고쳐놓고도 통합 테스트에 같은 모양이 남아 있는 걸 이번에야 봤다. e2e를 고칠 때 정규식에 안 걸리는 파일이 또 있는지 세어봤으면 그때 같이 나왔을 텐데, 그 생각을 못 했다.

통합 테스트를 게이트에 붙이는 비용

아래는 전부 7월 말에 이어서 한 작업이다. 게이트가 무거워진다는 판단을 근거 없이 물려받고 있었으니, 실제로 얼마나 무거운지부터 재봤다.

로컬에서 통합 스위트 전체를 돌리면 244.6초였다. 4분을 배포 길목에 붙이는 건 부담스러운데, 그 4분이 어디에 쓰이는지 쪼개보니 테스트가 아니었다.

컨테이너 기동 10.4초
스키마 로드 2.2초
TRUNCATE 115개 2.3초

파일 하나가 시작할 때마다 MySQL 컨테이너를 새로 띄우고 있었다. 21개 파일이면 기동에만 218초로, 전체 244.6초의 89%다. 4분을 감당할 수 있냐고 묻고 있었는데 물어볼 건 왜 컨테이너를 21번 띄우냐였다.

컨테이너를 한 번만 띄우기

jest에는 스위트 전체보다 앞서 한 번 도는 훅이 있다. 문서globalSetup이 모든 테스트 파일보다 앞서 한 번 실행된다고 적혀 있으니, 컨테이너를 여기서 띄우면 21번이 1번이 된다.

대신 값을 넘기는 경로가 하나 막혀 있다.

Any global variables that are defined through globalSetup can only be read in globalTeardown. You cannot retrieve globals defined here in your test suites.

Configuring Jest — globalSetup, Jest Documentation

그래서 컨테이너 핸들은 globalThis에 두고(teardown이 읽어 내린다), 테스트가 실제로 필요로 하는 접속 정보는 process.env로 넘긴다. env는 워커로 상속되므로 각 파일은 이미 떠 있는 컨테이너에 붙기만 한다.

global-setup.ts
const container = await new MySqlContainer('mysql:8.0').start();
globalThis.__IT_MYSQL__ = container; // teardown 전용
process.env.IT_DB_HOST = container.getHost();
process.env.IT_DB_PORT = String(container.getPort());

격리를 스키마 재로드로 하기

컨테이너를 공유하면 앞 파일이 남긴 행이 다음 파일에 보인다. 후보가 두 가지였고, 앞에서 4분을 쪼갠 값에 둘 다 나와 있다. 테이블 115개를 TRUNCATE하면 2.3초, 전부 DROP하고 schema.sql을 다시 로드하면 2.2초다.

값이 사실상 같아서 싼 쪽을 고를 이유가 없었고, 되돌리는 범위가 넓은 재로드로 갔다. 재로드는 행뿐 아니라 테이블 정의까지 초기 상태로 돌리니 어떤 테스트가 ALTER를 쳤거나 임시 테이블을 남겼어도 다음 파일에 안 넘어간다. TRUNCATE는 행만 지운다.

대신 --runInBand가 전제가 된다. 파일들이 하나의 DB를 공유하는데 병렬로 돌리면 한 파일의 스키마 재로드가 다른 파일이 쓰는 중인 테이블을 지운다.

244.6초 → 80.5초 (로컬, 21개 파일 동일)

게이트에 몇 분이 늘었나

CI에는 verify와 별도 잡으로 붙였다. 직렬로 이으면 늘어난 시간이 그대로 게이트 길이가 되는데, 나란히 두면 둘 중 긴 쪽만 게이트 길이가 된다.

deploy.yml
int-test:
name: Integration Tests (real MySQL)
timeout-minutes: 20
env:
TZ: UTC
steps:
- run: pnpm turbo build --filter=@repo/common # 이 잡은 turbo 그래프를 안 타서 직접 빌드한다
- run: pnpm --filter @repo/swing-backend test:int
detect-changes:
needs: [verify, int-test] # ← 둘 다 통과해야 배포 시작

같은 시점에 verify 쪽도 손댔다. 러너는 매번 새 머신이라 Turborepo 캐시가 항상 콜드였고, 변경 없는 패키지까지 매 배포가 처음부터 다시 돌고 있었다. .turbo를 lockfile 해시 + 커밋 SHA를 키로 actions/cache에 얹었다.

실측은 이렇다.

붙이기 전 verify 단독 중앙값 4.4분 (표본 10, 2026-07-28~29)
붙인 뒤 verify 0.6분 · int-test 2.3분 → 게이트 2.5분

숫자 하나에는 조건이 붙는다. verify 0.6분은 turbo 캐시가 전부 적중한 배포다. turbo.json이나 lockfile을 건드려 전역 해시가 바뀌면 verify는 4.9분으로 돌아가고, 그때는 int-test가 임계경로에서 빠져 게이트 길이에 아무것도 더하지 않는다. 두 경우를 각각 한 번씩만 관측했으니 중앙값을 말하려면 표본을 더 쌓아야 한다.

어느 쪽이든 게이트에 실 MySQL 테스트 21개가 붙었는데 게이트가 예전보다 길어지진 않았다. 병렬 잡과 캐시 복원이 늘어난 몫을 흡수했다.

7월 말 기준 게이트가 잡는 범위

배포가 deploy.yml의 push 트리거 하나로 모이고 첫 단계가 needs: [verify, int-test]를 걸고 있어 게이트의 위치가 고정돼 있다. 두 잡 다 TZ: UTC로 못 박혀 있고, verify는 typecheck·lint·test·e2e 네 가지를, int-test는 Testcontainers로 띄운 실 MySQL 위에서 통합 테스트를 전부 돌린다(그때 21개). 상한은 각각 15분과 20분이다.

파일명 규칙에서 새는 건 없다. 7월 말 기준 e2e 두 스위트 28개와 통합 21개가 전부 실행 목록에 올라 있고, 통합 쪽은 컨테이너를 프로세스당 하나만 띄워 로컬 244.6초가 80.5초가 됐다. 캐시가 적중하는 배포 기준으로 게이트는 2.5분이다.

↑↓ 이동 열기esc 닫기