SWING은 GitHub Actions 워크플로우 3개의 과정으로 배포하고 있다. 한동안 잘 돌아갔지만, 공유 패키지를 하나 건드린 날 사용자 화면에 잠깐 오류가 났다. 원인을 추적하다 보니 같은 식으로 어긋날 수 있는 데가 여섯 군데 더 있었다. 고치기 전에 무엇이 어떻게 어긋나는지부터 정리했다.
서로를 모른 채 돌던 워크플로우
| 워크플로우 | 트리거 | 배포 대상 |
|---|---|---|
deploy-backend.yml | apps/backend/** 또는 packages/** | ECS (NestJS 서버) |
deploy-frontend.yml | apps/frontend/** 또는 packages/** | S3 + CloudFront (React 앱) |
deploy-batch.yml | apps/backend/** 또는 packages/** | ECS (배치 서버) |
백엔드 배포 순서는 이랬다.
이미지 빌드 → ECR 푸시 → Task Definition 등록 → DB 마이그레이션 → ECS 배포이 구조에서 문제가 7개 나왔다.
문제 1 — FE가 먼저 배포되는 레이스 컨디션
packages/types에 새 필드를 추가하면 packages/** 경로에 걸려 백엔드와 프론트엔드 워크플로우가 동시에 트리거된다. 그런데 둘의 소요 시간이 다르다.
- 프론트엔드: pnpm install → Vite 빌드 → S3 업로드 → CloudFront 무효화 → 약 3~5분
- 백엔드: Docker 빌드 → ECR 푸시 → 마이그레이션 → ECS 안정화 → 약 10~25분
두 숫자는 그때 체감으로 적은 추정이다. 파이프라인을 다 고친 뒤 5편에서 v1 실행 이력 172건으로 실제로 쟀고, 백엔드는 median 15.0분이었다.
프론트엔드가 먼저 끝난다. 새 타입으로 만들어진 프론트엔드가 구 백엔드를 호출하는 상황이 수십 분간 이어진다.
문제 2 — 마이그레이션 성공 후 ECS 배포 실패
[7단계] DB 마이그레이션 (성공)[8단계] ECS 서비스 업데이트 (실패 — 이미지 크래시, 헬스체크 실패 등)DB 스키마는 새 버전인데 ECS는 구 이미지로 계속 돈다. 구 코드가 새 스키마를 만난다.
마이그레이션에서 DROP을 금지하고 ADD만 허용하기로 했다. 신규 컬럼은 구 코드가 무시하면 그만이지만, 삭제된 컬럼을 구 코드가 참조하면 즉시 크래시가 발생한다. 단, ADD-only 방침으로 다 방지할 수 있는 것은 아니다. 기본값 없는 NOT NULL 컬럼을 추가하면 구 코드의 INSERT가 실패한다.
뒤에 붙인 자동 롤백은 이 상태를 없애주지는 않는다. 롤백이 하는 일은 크래시한 배포를 방치하지 않고 직전에 정상이던 이미지로 서비스를 되돌리는 것이다. DB는 새 스키마 그대로라, 구 코드가 새 스키마 위에서 도는 건 마찬가지다. 따라서 구 코드도 멀쩡하게 동작하게 하려면 ADD-only 방침을 유지하면서 구 schema를 살려 놓아야 한다.
문제 3 — 마이그레이션 부분 실패 시 복구 불가
TypeORM 마이그레이션은 파일을 순차 실행한다. 한 파일 안의 DDL이 여러 개면 중간에 실패했을 때 앞부분이 그대로 남는다.
ALTER TABLE employee ADD COLUMN bonus INT; -- 성공ALTER TABLE employee ADD COLUMN grade VARCHAR; -- 실패bonus는 이미 추가됐는데 이 파일은 마이그레이션 테이블에 아무것도 안 남긴다. TypeORM은 up()이 끝난 뒤에야 실행 기록을 넣고, 그 테이블에는 이름과 타임스탬프만 있지 성공·실패를 적는 칸이 없다. 그래서 다시 돌리면 TypeORM이 아직 안 돈 파일로 보고 처음부터 실행하고, 첫 줄에서 bonus가 이미 있다며 실패한다. 손으로 정리하지 않으면 풀기 어렵다.
트랜잭션으로 묶으면 될 것 같지만 MySQL은 DDL 트랜잭션을 지원하지 않는다. 암묵적 커밋 문서가 ALTER TABLE·CREATE TABLE·CREATE INDEX 같은 DDL을 전부 암묵적 커밋을 일으키는 문으로 열거하므로, START TRANSACTION으로 감싸도 각 문이 앞의 트랜잭션을 끝내버린다.
MySQL 8.0이 도입한 atomic DDL을 떠올릴 수도 있는데, 그 문서에 이름의 오해를 짚는 대목이 먼저 나온다.
Atomic DDL is not transactional DDL. DDL statements, atomic or otherwise, implicitly end any transaction that is active in the current session, as if you had done a
COMMITbefore executing the statement. This means that DDL statements cannot be performed within another transaction, within transaction control statements such asSTART TRANSACTION ... COMMIT, or combined with other statements within the same transaction.— Atomic Data Definition Statement Support, MySQL 8.0 Reference Manual
atomic DDL이 보장하는 건 한 문장이 데이터 딕셔너리·스토리지 엔진·바이너리 로그 갱신을 통째로 커밋하거나 통째로 롤백한다는 것이다. 서버가 문 실행 중에 죽어도 반쪽 상태가 안 남는다는 뜻이지, 여러 문을 하나로 묶어주는 기능이 아니다. PostgreSQL 문서를 보면 거기서는 CREATE TABLE이나 ALTER TABLE 같은 문도 트랜잭션 블록 안에 넣을 수 있어서 중간에 실패하면 앞의 DDL까지 함께 되돌아간다. 같은 파일을 MySQL에 올리면 그 보호가 없다.
원자성을 얻을 수 없는 상황이기 때문에 멱등성을 확보해야 한다. 롤백으로 되돌리는 대신 재실행을 안전하게 만들어, 부분 실패라는 상태 자체를 무해하게 만든다.
방법은 두 가지다. 한 파일에 DDL을 하나만 두면 실패했을 때 적용된 것이 없으니, 다시 돌리는 게 곧 처음 돌리는 것이다. DDL이 여럿이어야 하면 hasColumn·hasTable이나 IF NOT EXISTS로 문마다 감싸서, 이미 들어간 것은 다시 돌 때 건너뛰게 한다.
문제 4 — CloudFront 캐시 전파 지연
프론트엔드 배포 마지막은 S3 sync → CloudFront 무효화다. 무효화 요청은 몇 초 안에 모든 엣지로 전달되고 각 엣지가 즉시 처리를 시작하니, 엣지끼리 어긋나는 시간은 길지 않다. 무효화가 닿지 않는 캐시는 그 앞에 있다.
브라우저나 회사 프록시가 이미 받아둔 사본은 캐시가 만료돼야 사라진다. 그동안 일부 사용자는 새 번들을, 일부는 구 번들을 받는다. 새 JS가 새 API 경로를, 구 JS가 구 경로를 호출하면 같은 시간대에 다르게 동작하는 앱이 공존한다.
문제 5 — 순서 없이 같이 뜨는 백엔드와 배치
apps/backend/**가 바뀌면 백엔드와 배치 워크플로우가 동시에 뜬다. 두 워크플로우는 같은 소스를 다른 Dockerfile로 빌드해 별도 ECS 서비스로 독립 배포된다. 마이그레이션은 백엔드 워크플로우에만 있어서 배치는 스키마를 기다리지 않고, 빌드와 배포만 하는 배치 쪽이 대체로 먼저 끝난다.
이렇게 되면 배치서버는 구 스키마를 본다. TypeORM은 SELECT *를 쓰지 않고 엔티티의 컬럼을 전부 이름으로 나열하므로, 새 코드가 아는 컬럼이 아직 없으면 조회가 값을 덜 돌려주는 게 아니라 통째로 실패한다. 그때 배치 앱에는 연차 적립·연차 촉진·일일 알림·구독 이력·반복 업무 생성 잡이 올라가 있었다. 전부 크론이라 응답을 기다리는 사용자가 없으니 화면에 오류가 뜨지도 문의가 들어오지도 않고, 그날 연차가 안 붙었다는 것도 알림이 안 나갔다는 것도 로그를 열어야 알 수 있다는 문제가 있다.
문제 6 — 실패해도 되돌리지 않는 배포
배포가 실패하면 워크플로우는 그냥 끝난다. 배포 스텝이 실패로 기록될 뿐 이전 Task Definition으로 되돌리는 스텝을 뒤에 안 붙여둬서, 서비스는 크래시하는 새 Task Definition을 가리킨 채로 남고, 누가 알아챌 때까지 그 상태가 유지된다. 코드를 되돌려도 마이그레이션된 DB는 같이 안 돌아온다.
손으로 되돌릴 수는 있다. Task Definition은 등록할 때마다 같은 패밀리에 리비전 번호가 하나 늘어나고 이전 리비전이 지워지지 않으니, 직전 번호를 골라 서비스를 갱신하면 된다. 다만 그건 옛 리비전을 폐기하지 않아서 성립하는 것이고, 폐기하면 INACTIVE가 되어 서비스를 그 리비전으로 갱신할 수 없다.
문제 7 — 롤링 배포 중 구/신 컨테이너 공존
wait-for-service-stability: true로 안정화를 기다려도, ECS 기본 방식은 롤링 배포다. 안정화 전까지 일부 요청은 구 컨테이너, 일부는 신 컨테이너로 간다. prod 서비스가 태스크 2개로 도니 실제로 그렇게 겹치는 시간이 있다.
겹치는 게 문제가 되려면 요청 사이에 공유되는 가변 상태가 있어야 한다. 백엔드에 express-session이 없고 인증은 무상태 JWT다. 모듈 레벨에 Set이 몇 개 있지만 전부 허용 콘텐츠 타입·유효 통화 같은 불변 조회표다. 그래서 API 컨테이너가 겹쳐 떠도 어긋날 상태가 없다.
진짜 위험한 쪽은 요청 처리가 아니라 크론이었다. 롤링 중에 구·신 컨테이너가 잠깐 겹치면 같은 크론이 두 번 뜨는데, 이건 워커만의 문제가 아니다. 아웃박스 적체 감시와 탈퇴 사용자 PII 익명화는 API 앱에서 돌고, 그 앱이 prod에서 태스크 두 개다. 그건 MySQL named lock으로 잡은 잡 락이 이미 막고 있다.
API 쪽은 공유 상태가 없고 크론 쪽은 잡 락이 막고 있으니, 새 버전을 통째로 띄웠다가 한 번에 전환하는 블루/그린은 도입하지 않았다.
위험도별 정리
| 문제 | 위험도 | 발생 조건 | 처분 |
|---|---|---|---|
| FE 먼저 배포, BE 구버전 | 높음 | packages/** 변경 시 항상 | 2편 워크플로우 통합 |
| 마이그레이션 성공 + ECS 실패 | 높음 | 배포 실패 시 | ADD-only 규칙 + 3편 자동 롤백 |
| 마이그레이션 부분 실패 | 높음 | DDL 2개 이상인 파일 | 마이그레이션 파일 규칙 (별도 기록) |
| CloudFront 전파 지연 | 중간 | 매 FE 배포 시 | 3편 차등 캐시 |
| 배치/백엔드 무조율 | 중간 | apps/backend/** 변경 시 | 2편 job 의존성 |
| 롤백 없음 | 중간 | 배포 실패 시 | 3편 자동 롤백 |
| 롤링 배포 중 혼재 | 낮음 | 매 BE 배포 시 | 무상태 확인 후 위험 아님으로 판정 |
일곱 개 중 다섯 개는 워크플로우와 배포 설정으로 고쳤는데, 그중 하나는 ADD-only 규칙이 같이 있어야 안전해진다. 하나는 재보니 위험이 아니었고, 하나는 파이프라인 밖에서 막았다. 실행 순서는 위험도를 따랐다.
마이그레이션 부분 실패는 배포 파이프라인을 고쳐서 풀 문제가 아니라 마이그레이션 파일을 쓰는 규칙의 문제이기 때문에 별도로 기록해두는게 나을 것 같다.
다음 글부터는 위험도가 높은 것을 하나씩 잡는다. 먼저 “FE가 먼저 배포되는” 레이스 컨디션이다. 워크플로우 3개를 하나로 합치고 배포 순서를 강제하는 것부터다.