1편에서 꼽은 일곱 문제 중 세 개가 같은 형태였다. 마이그레이션은 성공했는데 ECS 배포가 실패해 DB는 새 스키마인데 코드는 구버전인 상태(문제 2·6), 그리고 프론트 배포 뒤에도 브라우저에 남은 구 번들 때문에 일부 사용자는 새 번들을 일부는 구 번들을 받는 상태(문제 4)다.
셋 다 구버전과 신버전이 동시에 도는 구간이고, 그 구간에서는 어떤 사용자가 어느 버전을 받는지가 정해지지 않는다. 그런 구간이 배포가 실패한 뒤와 성공한 뒤 두 군데 있고, 원인이 달라 처방도 다르다.
배포가 실패한 뒤
새 이미지가 시작 직후 크래시하거나 헬스체크를 통과하지 못하면 마이그레이션만 적용된 채 코드는 안 바뀐다. 자동 복구 수단이 없으면 그 상태가 누군가 알아챌 때까지 유지된다.
배포 전에 현재 실행 중인 Task Definition ARN을 저장해두고, 배포가 실패하면 그 ARN으로 되돌린다. ECS는 Task Definition을 버전으로 관리해서 배포할 때마다 새 버전이 등록되고 이전 버전은 삭제되지 않으므로, ARN만 알면 언제든 되돌릴 수 있다.
Configure AWS credentials → [신규] Save current Task Definition ← 현재 ARN 저장 → Build → Register Task Def → Migration → [수정] Deploy ECS ← continue-on-error: true → [신규] Rollback on failure ← deploy 실패 시에만- name: Save current Task Definition (for rollback) id: save-task-def run: | PREV=$(aws ecs describe-services \ --cluster $CLUSTER --services "$SERVICE" \ --query 'services[0].taskDefinition' \ --output text 2>/dev/null || echo "NONE") echo "previous=$PREV" >> "$GITHUB_OUTPUT"describe-services가 현재 서비스의 Task Definition ARN을 주고, 그걸 $GITHUB_OUTPUT에 저장하면 이후 스텝에서 꺼내 쓸 수 있다. 2>/dev/null || echo "NONE"은 최초 배포처럼 서비스가 아직 없거나 권한 문제로 조회가 실패하는 경우를 처리한다. 이 처리가 없으면 조회 실패 자체가 전체 배포를 중단시킨다.
outcome과 conclusion의 차이
저장까지는 됐는데 배포 스텝이 실패하면 job이 거기서 끝나 롤백 스텝까지 가지도 못한다.
- name: Deploy Amazon ECS task definition id: deploy continue-on-error: true uses: aws-actions/amazon-ecs-deploy-task-definition@v2 with: wait-for-service-stability: truecontinue-on-error가 없으면 이 스텝이 실패하는 순간 job이 끝나버려 롤백 스텝이 실행될 기회가 없다. 붙이면 실패해도 다음 스텝으로 넘어간다.
continue-on-error가 붙은 스텝은 결과를 두 가지로 기록한다.
| 속성 | 값 | 의미 |
|---|---|---|
steps.deploy.outcome | failure | 스텝의 실제 결과 |
steps.deploy.conclusion | success | job에 보고되는 결과 (마스킹됨) |
GitHub 문서의 정의가 이 두 값을 정확히 구분한다. outcome은 continue-on-error가 적용되기 전의 결과이고, conclusion은 적용된 후의 결과다. 문서는 한 문장을 덧붙인다. continue-on-error 스텝이 실패하면 outcome은 failure지만 최종 conclusion은 success다.
그래서 롤백 조건은 반드시 outcome을 봐야 한다. conclusion을 보면 항상 success라 롤백이 절대 안 돈다. 처음에 이걸 모르고 conclusion으로 짰다가 일부러 실패시켜도 롤백이 안 뛰어서 한참 헤맸다. 이름만 보면 conclusion이 최종 결론처럼 읽히는데, 여기서 “최종”은 사실을 뜻하는 게 아니라 마스킹을 거친 뒤를 뜻한다.
- name: Rollback on deployment failure if: steps.deploy.outcome == 'failure' run: | PREV="${{ steps.save-task-def.outputs.previous }}" if [ "$PREV" == "NONE" ] || [ -z "$PREV" ]; then echo "No previous task definition. Skipping rollback." exit 1 fi aws ecs update-service \ --cluster $CLUSTER --service "$SERVICE" \ --task-definition "$PREV" exit 1배포가 성공하면 if 조건에서 걸러져 아예 실행되지 않는다. 실패하면 update-service로 구 Task Definition을 다시 물리고 ECS가 롤링(기존 태스크를 조금씩 교체)으로 구 컨테이너를 띄우기 시작하는데, 워크플로우는 롤백 완료를 기다리지 않고 트리거만 한 뒤 ECS에 맡긴다.
마지막 줄의 exit 1은 롤백을 트리거했다고 배포가 성공한 건 아니라서 남겼다. 워크플로우가 실패로 기록돼야 개발자가 알림을 받고 PR 자동 머지가 막힌다.
ECS가 이미 같은 일을 하고 있었다
ECS에는 배포 실패를 감지해 자동으로 되돌리는 기능이 원래 있고, 우리 서비스에 그게 켜져 있다.
<api-prod> circuit breaker: enable=true rollback=true<api-dev> enable=true rollback=true<worker-prod> enable=true rollback=true<worker-dev> enable=true rollback=trueAWS 문서에 동작이 이렇게 적혀 있다.
When the deployment circuit breaker determines that a deployment failed, it looks for the most recent deployment that is in a
COMPLETEDstate. This is the deployment that it uses as the roll-back deployment. (…) When the deployment circuit breaker does not find a deployment that is in aCOMPLETEDstate, the circuit breaker does not launch new tasks and the deployment is stalled.— How the Amazon ECS deployment circuit breaker detects failures, AWS Documentation
내가 ARN을 저장해 되돌리는 것과 목적지가 같다. 두 장치 다 직전에 정상 완료된 배포로 돌아간다. 워크플로우 롤백을 짤 때 이걸 확인하지 않았다.
판정 주체와 기준의 차이
그렇다고 지울 만한 중복은 아니다. 무엇을 세다가 발동하는지가 서로 다르다.
서킷 브레이커는 태스크가 RUNNING에 도달하지 못하거나 헬스체크가 실패하는 횟수를 세다가 임계값에 닿으면 배포를 FAILED로 만든다. 기본 임계값은 AWS 문서의 0.5 * desired task count인데 최소 3·최대 200으로 고정되므로, 태스크가 하나둘인 서비스에서는 세 번 실패해야 발동한다. 반면 워크플로우 쪽은 wait-for-service-stability 대기가 끝나지 않는 것까지 포함해 배포 스텝이 실패로 끝나면 무조건 돈다.
서버 쪽 장치는 태스크 기동 실패를 세고, 워크플로우 쪽 장치는 배포 스텝의 실패를 본다. 두 기준이 항상 같이 켜지지는 않으니 겹치는 구간이 대부분이어도 서로를 완전히 대체하지는 않는다.
위 인용의 뒷문장이 한계도 같이 말한다. 정상 완료 이력이 없으면 서버 쪽 롤백은 아무것도 못 한다는 뜻인데, 워크플로우 쪽이 PREV == "NONE"을 만나면 롤백을 건너뛰고 exit 1로 실패만 남기는 것과 같은 상황이다. 두 장치가 같은 조건에서 같은 이유로 무력해진다.
코드만 되돌아가는 롤백
워크플로우 쪽이든 서킷 브레이커 쪽이든 되돌리는 건 코드뿐이다. DB 마이그레이션은 적용된 상태 그대로 남는데, 그런데도 안전한 이유는 이 프로젝트의 마이그레이션 정책에 있다.
| 마이그레이션 | 구 코드 + 새 스키마 | 결과 |
|---|---|---|
| ADD COLUMN | 새 컬럼을 모름 | 무시하고 동작 ✅ |
| DROP COLUMN | 없는 컬럼 참조 | 즉시 크래시 ❌ |
| DROP TABLE | 없는 테이블 쿼리 | 즉시 크래시 ❌ |
ADD-only 정책이 없으면 코드 롤백은 오히려 DB 크래시를 부른다. 자동 롤백과 ADD-only 마이그레이션은 서로를 전제하는 쌍이라, 마이그레이션 규칙을 바꿀 땐 롤백이 여전히 안전한지도 같이 봐야 한다.
배포가 성공한 뒤
남은 구간은 배포가 잘 끝난 다음에 생긴다. 매 프론트 배포마다 생기고, 그래서 일곱 문제 중 가장 자주 발생하는 것이었다.
기존 배포의 마지막 두 줄이 원인이었다.
aws s3 sync ./dist s3://$BUCKET --deleteaws cloudfront create-invalidation --paths "/*""/*"는 캐싱된 모든 파일을 무효화하라는 뜻이다. AWS 문서에 따르면 이 요청은 몇 초 안에 모든 엣지로 전달되고 각 엣지가 즉시 처리를 시작하므로, 엣지끼리 어긋나는 시간이 원인은 아니었다. 무효화가 못 건드리는 캐시가 따로 있었다.
Versioning enables you to control which file a request returns even when the user has a version cached either locally or behind a corporate caching proxy. If you invalidate the file, the user might continue to see the old version until it expires from those caches.
— Invalidate files to remove content, Amazon CloudFront Developer Guide
위 sync에 --cache-control을 안 붙였으니 index.html은 아무 캐시 지시자 없이 올라갔다. 브라우저가 알아서 얼마간 들고 있었고, 엣지를 비워도 그 사본은 남아 구 진입점을 계속 로드했다.
HTML과 해시 파일의 캐시 전략
고치려면 무엇을 캐시해도 되고 무엇은 안 되는지부터 나눠야 했다. 기준은 Vite 빌드 결과물의 특성에서 나온다.
dist/├── index.html└── assets/ ├── main-Bx3f9k2a.js ← 파일명에 해시 ├── vendor-Ck7m2pQr.js ← 파일명에 해시 └── ...JS·CSS는 내용이 바뀌면 파일명 해시도 바뀐다. 한 줄 고치면 main-Bx3f9k2a.js가 main-Zp4qR8nT.js가 된다. 반면 index.html은 이름이 고정이고 내용만 바뀌는데, 그 안에 어떤 해시 파일을 로드할지가 적혀 있다.
<script type="module" src="/assets/main-Zp4qR8nT.js"></script>그래서 두 종류를 같은 정책으로 다룰 수 없다. 해시 파일은 캐시해도 안전한데, 내용이 바뀌면 이름이 달라져 브라우저가 알아서 새로 요청하기 때문이다.
index.html은 캐시하면 안 된다. 구 index.html을 받으면 그 안의 구 JS 파일명을 그대로 로드한다.
| 파일 | 파일명 변경 | 안전한 전략 |
|---|---|---|
assets/*-해시.js | 내용 바뀌면 이름도 바뀜 | 영구 캐시 (immutable) |
index.html | 이름 고정, 내용만 바뀜 | 캐시 금지 (no-cache) |
파일마다 다른 헤더를 붙이는 명령
# 1. 해시 파일 — 1년 영구 캐시aws s3 sync ./apps/frontend/dist s3://$BUCKET \ --delete --exclude "index.html" \ --cache-control "max-age=31536000,immutable"
# 2. index.html — 항상 새로aws s3 cp ./apps/frontend/dist/index.html s3://$BUCKET/index.html \ --cache-control "no-cache,no-store,must-revalidate"
# 3. 무효화는 index.html 하나만aws cloudfront create-invalidation \ --distribution-id $DIST_ID --paths "/index.html"1단계. --exclude "index.html"로 해시 파일만 올린다. immutable은 RFC 8246이 정의한 확장 지시자로, 원 서버가 신선도 수명 동안 이 리소스의 표현을 갱신하지 않겠다는 선언이다. RFC에는 클라이언트 행동까지 규정돼 있다.
Clients SHOULD NOT issue a conditional request during the response’s freshness lifetime (e.g., upon a reload) unless explicitly overridden by the user (e.g., a force reload).
The immutable extension only applies during the freshness lifetime of the stored response. Stale responses SHOULD be revalidated as they normally would be in the absence of the immutable extension.
— RFC 8246, Section 2, HTTP Immutable Responses
새로고침을 눌러도 재검증 요청조차 안 나간다는 뜻이라, 해시 파일에는 정확히 맞는 계약이다. 파일명이 내용에서 뽑은 해시라 “이 이름의 파일은 절대 안 바뀐다”가 참이다. 다만 뒷문장이 범위를 좁힌다. 이 지시자는 신선도 수명 안에서만 유효하므로, max-age=31536000을 같이 준 것이 실질적인 유효 기간이 된다.
2단계. sync 대신 cp를 쓴다. sync는 크기와 수정시간이 같으면 건너뛸 수 있는데, index.html은 매 배포마다 새 Cache-Control 헤더와 함께 반드시 덮어써야 한다.
3단계. 무효화를 "/*"에서 "/index.html" 하나로 좁혔다. 해시 파일은 무효화가 필요 없다. 구 main-Bx3f9k2a.js는 여전히 유효한 파일이고 새 main-Zp4qR8nT.js는 완전히 새 이름이라, CloudFront가 처음 요청받으면 S3에서 가져와 캐시한다. 덤으로 무효화 대상이 줄어 전파가 빠르고 비용도 준다.
결과는 이렇다.
변경 전: 브라우저가 구 index.html을 들고 있으면 구 JS를 계속 로드 → 그 사본이 만료될 때까지 혼재
변경 후: index.html은 no-store → 브라우저가 저장하지 않고 매번 새로 받음 → 배포와 동시에 모두가 새 진입점 (JS/CSS는 캐시돼 빠름)index.html을 브라우저가 저장하지 않으니 매번 원본을 받고, 배포 완료 시점에 모두가 새 진입점을 받는다.
no-cache라는 이름의 오해
no-cache라는 이름이 오해를 부른다. MDN 정의로 no-cache는 저장을 막는 게 아니라 재사용 전 재검증을 강제하는 지시자다. 저장 자체를 막는 건 no-store고, must-revalidate는 만료된 응답을 재검증 없이 쓰지 말라는 뜻이라 사실상 no-cache와 겹친다.
여기 붙인 값은 세 지시자를 모두 나열한 no-cache,no-store,must-revalidate인데, 이 조합에서는 제일 강한 no-store가 적용되고 no-cache와 must-revalidate는 판정을 바꾸지 못한다. 브라우저가 index.html을 저장하지 않으니 If-None-Match로 물어 304 Not Modified를 받는 절약이 성립하지 않고, 매 로드마다 본문을 통째로 다시 받는다. 정확성은 지켜지지만 필요 이상으로 강한 설정이라, no-cache 하나만 두면 저장은 하되 매번 재검증하므로 같은 정확성에 304 왕복 절약이 따라온다. 파일이 수 KB라 체감 차이가 작아서 그대로 뒀다.
두 구간의 처분과 남은 중복
배포 실패 구간은 워크플로우가 저장한 ARN으로 되돌리고, ECS 서킷 브레이커가 서버 쪽에서 같은 목적지로 한 번 더 받친다. 두 장치가 겹친다는 건 이번에 확인했다. 코드 롤백이 안전한 건 마이그레이션이 ADD-only라서다.
캐시 구간은 없어졌다. index.html은 매 로드마다 원본까지 가고 해시 에셋은 1년 immutable이라, 배포가 끝나는 시점에 모든 엣지의 사용자가 같은 진입점을 받는다.
“ARN 저장 + 실패 시 롤백” 스텝 쌍은 지금 두 벌이다. 5편에서 워커 배포를 백엔드와 병렬로 떼면서 같은 스텝을 워커 잡에도 복사했고, composite action으로 뽑아 한 벌로 만들 수 있는데 안 했다. 한쪽만 고치면 어긋난다는 걸 알고 남겨둔 것이라, 다음에 이 스텝을 손대는 순간이 뽑아낼 시점이다.
여기까지가 1편에서 꼽은 일곱 개 중 파이프라인을 고쳐서 풀 수 있는 것들이다. 남은 두 개는 여기서 안 풀린다. 마이그레이션 부분 실패는 배포 순서가 아니라 마이그레이션 파일을 쓰는 규칙의 문제고, 롤링 중 컨테이너 혼재는 애초에 이 시스템에서 위험이 아니었다. 둘 다 1편에 처분을 적어뒀다.
배포가 어떻게 도는지는 고쳤는데 무엇이 배포되는지는 여전히 아무도 검사하지 않는다. 4편에서 배포 길목에 품질 게이트를 박는다.