본문 바로가기
Case Studies

배포마다 갈리는 서버에서 stale-chunk 404 없애기 — CDN failover

배포 직전 페이지를 열어둔 사용자만 ChunkLoadError로 화면이 깨졌다. 정적 자산이 배포마다 통째로 갈리는 서버에 묶여 있던 게 원인 — S3 누적 + CloudFront OriginGroup 폴백으로 stale-chunk 404를 없앤 과정과, 403 폴백·lifecycle TTL 같은 함정들.

배포만 하면 일부 사용자 화면이 하얗게 깨진다는 제보가 들어왔다. 재현이 안 됐다. 같은 URL을 새로고침하면 멀쩡했다. 콘솔을 열어보니 ChunkLoadError: Loading chunk main failed — 존재하지 않는 JS 파일을 브라우저가 계속 요청하고 있었다. 배포 시각과 제보 시각을 겹쳐보고서야 패턴이 보였다. 배포 직전에 페이지를 열어둔 사람들만 걸리는 문제였다.

원인을 다 파고 나서 보니 허탈할 정도로 구조적인 문제였다. 코드에 버그가 있던 게 아니라, "정적 파일이 어디에 살고 있는가"를 잘못 설계한 거였다. 이 글은 그 문제를 인프라 레벨에서 없앤 과정 — S3에 정적 자산을 누적하고 CloudFront가 서버로 폴백하게 만든 이야기다.

Next.js는 왜 영구 캐시를 걸어도 안전한가

Next.js가 빌드하면 JS/CSS는 이런 이름으로 나온다.

_next/static/chunks/main-a1b2c3d4.js
_next/static/chunks/framework-99887766.js
_next/static/css/app-deadbeef.css

a1b2c3d4 부분이 콘텐츠 해시다. 파일 내용이 한 글자라도 바뀌면 해시가 바뀌고, 그러면 파일 이름 자체가 바뀐다. 반대로 내용이 그대로면 이름도 그대로다.

이 성질 덕분에 이 파일들은 브라우저에 영구 캐시를 걸어도 안전하다. 이름이 곧 내용의 지문이라, 같은 이름이면 같은 내용이라는 게 보장된다. 그래서 응답 헤더에 이렇게 박는다.

Cache-Control: public, max-age=31536000, immutable

1년(31536000초) 동안 재검증 없이 그냥 써도 된다는 뜻이다. 이 얘기만 들으면 "그럼 문제될 게 없잖아"라고 생각하기 쉽다. 문제는 캐시 정책이 아니라, 그 파일이 실제로 어디에 저장돼 있느냐였다.

문제: 서버가 통째로 갈릴 때 파일도 같이 사라진다

우리 배포 파이프라인은 대략 이랬다. 코드를 빌드해서 도커 이미지를 만들고, 그 이미지를 ECS(컨테이너 오케스트레이션 서비스)에 올린다. Next.js를 output: 'standalone' 모드로 빌드하면 서버 실행에 필요한 파일과 정적 자산(.next/static)이 같은 이미지 안에 함께 들어간다. 그리고 배포란 이 이미지를 새 걸로 통째로 갈아 끼우는 것이다.

여기서 문제가 생긴다. HTML은 "어떤 청크를 로드해라"라는 정보를 담고 있다. 배포 전 버전(v1)의 HTML은 main-AAA.js를 참조한다. 코드가 바뀌어 v2로 배포되면 새 HTML은 main-BBB.js를 참조한다. 그런데 v1 HTML을 이미 로드해서 탭에 띄워둔 사용자는 서버가 바뀐 걸 모른다. 그 사람이 나중에 다른 페이지로 이동하거나 코드 스플리팅된 청크를 뒤늦게 요청하면, 여전히 main-AAA.js를 찾는다.

flowchart TD
  t0["t0 — 사용자가 v1 페이지 로드 main-AAA.js 참조, 탭 유지"] --> t1["t1 — v2 배포, 컨테이너 이미지 통째 교체 새 이미지엔 main-AAA.js 없음"]
  t1 --> t2["t2 — 브라우저가 뒤늦게 main-AAA.js 요청"]
  t2 -->|"자산이 서버 안에만 있던 시절"| E["404 → ChunkLoadError"]
  t2 -->|"자산을 S3에 분리한 이후"| S["200 OK"]

새 이미지 안에는 v1의 자산이 없다. 서버는 요청받은 파일이 없으니 정직하게 404를 준다. 브라우저는 이걸 ChunkLoadError로 받아들이고, 사용자는 화면이 깨지는 걸 목격한다. 아무 잘못도 안 했는데 배포 타이밍이 안 좋았다는 이유만으로.

이게 흔히 말하는 버전 스큐(version skew) 문제다. stale-chunk 404라고도 부른다. 배포가 잦을수록, 트래픽이 많을수록 자주 터진다.

근본 원인을 한 문장으로 정리하면: 정적 자산이 "배포마다 통째로 갈리는 서버"와 한 몸으로 묶여 있다. 서버를 갈면 그 안의 옛 자산도 같이 사라진다. 캐시 헤더를 아무리 잘 설정해도, 애초에 파일이 존재하지 않으면 소용없다. 해결하려면 자산을 서버와 물리적으로 분리해서, 배포와 무관하게 계속 살아있는 저장소에 둬야 했다.

해결책: S3에 누적하고, CloudFront가 서버로 폴백한다

아이디어는 두 가지다.

  1. 빌드할 때마다 그 빌드의 _next/staticS3에 쌓는다. 덮어쓰는 게 아니라 누적한다. 해시 파일명이라 이름 충돌이 없다. 옛 청크(main-AAA.js)도 새 청크(main-BBB.js)도 S3 안에서 그냥 공존한다.
  2. CDN(CloudFront)이 /_next/static/* 요청을 S3로 우선 보낸다. S3에 없으면(폴백 조건) 그때만 서버(ECS)로 넘어간다.
flowchart TD
  R["브라우저: GET /_next/static/.../main-AAA.js"] --> CF["CloudFront — /_next/static/* 라우팅 규칙"]
  CF --> Q{"S3 OAC로 잠금에 객체가 있나?"}
  Q -->|"있음 대부분"| OK["200 OK, S3에서 응답"]
  Q -->|"없음 — S3가 403/404 반환"| FB["폴백: ECS 서버로 재시도"]
  FB -->|"현재 빌드에 있으면"| OK2["200 OK, 서버에서 응답"]
  FB -->|"없으면"| NF["최종 404"]

핵심은 폴백이다. S3가 유일한 진실 공급원이 아니라 **"우선순위가 높은 원본"**일 뿐이고, 거기 없으면 항상 서버가 받쳐준다. 이렇게 하면 최초 배포 직후처럼 S3에 아직 파일이 안 올라간 시점, sync가 실패한 시점, TTL로 오래된 파일이 삭제된 시점에도 서버가 안전망 역할을 한다.

빌드 스크립트는 대략 이렇게 생겼다. 도커 이미지를 만든 다음, 실행하지 않고 파일만 뽑아서 S3에 올린다.

# build.sh — CI 빌드 단계 중 정적 자산 업로드 부분
docker build -t "$IMAGE_TAG" .
 
cid=$(docker create "$IMAGE_TAG")             # 컨테이너를 "생성만" 함, 실행 안 함
docker cp "$cid":/app/.next/static ./static-out  # 이미지 안 파일만 꺼냄
docker rm "$cid"
 
if [ -n "$STATIC_ASSETS_BUCKET" ]; then
  aws s3 sync ./static-out \
    "s3://${STATIC_ASSETS_BUCKET}/${STATIC_ASSETS_PREFIX}" \
    --cache-control "public,max-age=31536000,immutable"
fi
 
rm -rf ./static-out

docker create + docker cp는 이미지를 실행하지 않고 안의 파일만 꺼내는 관용적인 기법이다. aws s3 sync는 로컬 폴더와 S3를 차등 동기화하는데, 같은 이름(=같은 해시=같은 내용)이 이미 S3에 있으면 재업로드를 건너뛴다. 그 결과가 자연스러운 "누적"이다. if [ -n "$STATIC_ASSETS_BUCKET" ] 가드는 이 인프라를 켜둔 서비스에서만 이 블록을 태우기 위한 것인데, 이 얘기는 뒤에서 다시 나온다.

403도 잡아야 한다 — 없는 파일이 404가 아니라 403으로 온다

폴백을 설계할 때 처음엔 404만 트리거 조건으로 넣었다. 그런데 테스트해보니 S3에 아직 없는 파일을 요청했을 때 CloudFront가 폴백을 안 타고 그냥 에러를 내려줬다.

원인은 S3 버킷을 완전히 잠가둔 방식에 있었다. 이 버킷은 퍼블릭 접근을 전부 막고(BLOCK_ALL) OAC(Origin Access Control, CloudFront 전용 접근 방식)로만 CloudFront가 읽게 했다. 이때 CloudFront에 준 권한은 s3:GetObject뿐이고 s3:ListBucket은 없다. 그러면 S3는 "그 객체가 원래 없는 것"과 "그 객체에 접근할 권한이 없는 것"을 구분해서 알려주지 않는다. 둘 다 뭉뚱그려서 403 Access Denied로 답한다.

💡 OAC로 잠근 S3 오리진은 존재하지 않는 객체에도 404가 아니라 403을 반환할 수 있다. ListBucket 권한을 주지 않았다면 특히 그렇다. CloudFront OriginGroup의 fallbackStatusCodes에 404만 넣으면, "아직 S3에 없는 최신 파일" 같은 흔한 케이스에서 폴백이 걸리지 않고 그냥 에러로 끝난다. 403도 함께 넣어야 폴백이 의도대로 동작한다.

CDK로 표현하면 대략 이런 모양이다.

behaviors['/_next/static/*'] = {
  origin: new OriginGroup({
    primaryOrigin: S3BucketOrigin.withOriginAccessControl(bucket, {
      originPath: staticAssetsOriginPath(serviceName), // 예: "/svc"
    }),
    fallbackOrigin: ecsOrigin,
    fallbackStatusCodes: [403, 404], // 403 빠뜨리면 폴백이 절반만 동작한다
  }),
  cachePolicy: CachePolicy.CACHING_OPTIMIZED,
  viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
  allowedMethods: AllowedMethods.ALLOW_GET_HEAD,
}

CloudFront OriginGroup은 원래 400, 403, 404, 416, 429, 500, 502, 503, 504 중 원하는 코드를 조합해 폴백 조건으로 지정할 수 있다. S3를 오리진으로 쓸 때는 관용적으로 403과 404를 같이 넣으라고 AWS 문서에도 나와 있다. 처음엔 "S3에 파일이 없으면 404겠지"라고 단순하게 생각했는데, 접근 제어 방식에 따라 에러 코드가 달라진다는 걸 실제로 겪고서야 체감했다.

이 폴백 덕분에 무중단으로 전환할 수 있었다

이 구조를 새로 켤 때 가장 걱정했던 건 "S3 경로를 잘못 연결해서 정적 자산이 전부 404 나면 어떡하지"였다. 실제로 배포 파이프라인에 새 스텝(S3 sync)을 추가하고, CloudFront 라우팅 규칙을 바꾸는 두 가지 변경을 동시에 반영해야 하는 상황이었다.

폴백이 있었기 때문에 이 전환은 사실상 무중단이었다. S3 경로가 아직 비어 있어도, sync 스텝이 실수로 실패해도, CloudFront는 403/404를 보고 즉시 서버로 재시도한다. 서버는 예전처럼 이미지 안에 있는 자산을 그대로 서빙한다. 즉 최악의 경우에도 "S3 도입 전과 동일한 동작"으로 자동 강등될 뿐, 사용자에게 에러가 노출되지 않는다. 새 인프라를 얹을 때 기존 경로를 완전히 대체하지 않고 "우선순위가 높은 추가 경로"로 얹는 이 패턴은, 이후에도 인프라를 바꿀 때 계속 써먹을 만한 감각이었다.

경로 세 개가 어긋나면 전부 404다

이 작업에서 제일 헷갈렸던 지점은 코드가 아니라 경로 매핑이었다. 같은 파일을 가리키는 경로가 세 군데에서 다르게 생겼다.

무엇정의되는 곳
브라우저가 요청하는 URL/_next/static/chunks/main-AAA.jsNext.js가 자동 생성
S3 버킷 안 실제 키svc/_next/static/chunks/main-AAA.js빌드 스크립트의 sync 대상 prefix
CloudFront S3 오리진 경로/svcCloudFront behavior 설정

CloudFront가 S3에 요청을 넘길 때는 오리진 경로 + 요청 경로를 이어붙여서 실제 키를 만든다. /svc + /_next/static/... = svc/_next/static/.... 이 두 값이 어긋나면 CloudFront는 엉뚱한 키를 찾다가 전부 404를 낸다.

문제는 이 두 값이 서로 다른 파일(빌드 스크립트, CDK 인프라 코드)에 따로 하드코딩되기 쉽다는 거였다. 처음엔 각자 문자열을 조립했는데, 서비스 이름을 바꾸거나 새 서비스에 확장할 때 한쪽만 고치고 다른 쪽을 놓치는 실수가 나기 딱 좋은 구조였다. 그래서 이 포맷 자체를 순수 함수 하나로 뽑아 단일 출처(single source of truth)로 만들었다.

const NEXT_STATIC_DIR = '_next/static'
 
// S3 버킷 안 저장 경로. "svc" → "svc/_next/static"
export const staticAssetsPrefix = (serviceName: string) =>
  `${serviceName}/${NEXT_STATIC_DIR}`
 
// CloudFront S3 오리진 경로. "svc" → "/svc"
export const staticAssetsOriginPath = (serviceName: string) =>
  `/${serviceName}`

빌드 스크립트(자산을 올릴 때)와 CloudFront 설정(자산을 읽을 때) 양쪽이 이 헬퍼에서 값을 파생한다. 값을 여러 곳에 prop으로 흘려보내는 대신, 포맷을 정의하는 함수 하나만 공유하고 각자 필요한 곳에서 계산해 쓰는 식이다. 포맷이 바뀌어도 고칠 곳이 한 곳뿐이라, "한쪽만 고쳐서 다 깨지는" 사고를 구조적으로 막는다.

가장 미묘했던 함정: lifecycle TTL과 sync의 의미론이 어긋난다

여기까지는 비교적 직관적이었다. 진짜 까다로웠던 건 S3에 쌓인 자산을 언제까지 보관할지 정하는 부분이었다.

무한정 쌓아두면 스토리지 비용이 계속 늘어나니, S3 lifecycle rule로 일정 기간이 지난 객체를 자동 삭제하려고 했다. 그런데 이 lifecycle expiration이 기준으로 삼는 시점과, aws s3 sync가 실제로 만들어내는 시점 사이에 어긋남이 있었다.

S3 lifecycle expiration은 객체가 업로드(생성)된 시점 기준으로 경과일을 센다. S3 객체는 버전 관리를 켜지 않는 한 "덮어쓰기"가 아니라 매번 새로 생성되는 구조라, 이 시점은 사실상 그 객체의 마지막 PutObject 시각과 같다. 그리고 lifecycle은 그 파일이 얼마나 자주 읽히는지와는 완전히 무관하다. 하루에 수만 번 읽히는 인기 청크든, 아무도 안 읽는 청크든 업로드된 지 N일이 지나면 똑같이 삭제 대상이 된다.

문제는 aws s3 sync의 동작 방식이다. sync는 (파일 크기, 수정 시각)을 비교해서 대상에 이미 같은 파일이 있으면 재업로드를 건너뛴다. 우리 자산은 콘텐츠 해시 파일명이라 같은 이름이면 항상 같은 내용이고, 그래서 sync는 거의 항상 "이미 있네, 스킵"이라고 판단한다. 즉 청크 하나가 배포마다 반복해서 sync 대상에 포함돼도, S3 상의 실제 업로드 시각은 최초로 올라간 그 순간에 고정된다.

⚠️ 정리하면 이렇다. sync가 재업로드를 스킵하는 건 "안 바뀐 파일 잘 걸러냈다"는 뜻이지만, lifecycle 입장에서는 그 파일이 "오래전에 딱 한 번 생성된 뒤 그 이후로 갱신 이력이 없는 파일"로 보인다. 활발히 참조되고 있는 청크도, 최초 업로드 후 TTL이 지나면 lifecycle이 그냥 지워버릴 수 있다. "옛 청크를 보존한다"는 이 작업의 목적과 정면으로 충돌하는 지점이었다.

이 충돌을 어떻게 다뤘는지가 이 작업에서 가장 신경 쓴 결정이었다.

  • 완화 요인: ECS 폴백이 "현재 빌드에 남아 있는 청크"는 이미 받쳐준다. 그래서 진짜 위험한 케이스는 "빌드에서 이미 사라진 옛 청크를, 아직도 그걸 참조하는 아주 오래된 클라이언트가 요청하는 경우"로 좁아진다.
  • 불확실성: 빌드 캐시나 파일 시스템 동작에 따라 로컬 파일의 수정 시각이 원래대로 복원되면 sync가 스킵할 수도, 새로 찍힐 수도 있다. 이 부분의 정확한 동작을 100% 통제할 자신이 없었다.
  • 결정: TTL을 짧게 잡으면 최악의 경우 실사용자에게 404가 노출된다(비용이 크다). TTL을 길게 잡으면 스토리지 비용이 조금 더 든다(월 몇 달러 수준으로, 무시해도 될 정도다). 이 비대칭 때문에 불확실성에 기대는 대신 넉넉하게 90일로 잡았다. 시계가 최악의 경우(최초 업로드 시점)로 고정되더라도, 90일 넘게 같은 HTML을 들고 있는 극단적인 클라이언트까지만 위험군으로 남는다.

숫자 하나를 정하는 데 이렇게까지 고민할 일인가 싶었는데, "자주 읽히는 것과 안 지워지는 것은 별개"라는 S3의 lifecycle 의미론을 모르고 넘어갔으면 조용히 재발했을 버그였다. 캐시 정책이나 TTL 같은 시간 기반 설정은 그게 어떤 이벤트를 기준으로 카운트하는지 반드시 확인하고 넘어가야 한다는 걸 여기서 제대로 배웠다.

전 서비스에 한 번에 켜지 않았다

새 인프라(S3 버킷, CloudFront 라우팅 규칙, CI 빌드 스텝)를 운영 중인 모든 서비스에 동시에 적용하지는 않았다. 배포 빈도가 가장 높아서 이 문제가 가장 자주 터지던 서비스 하나부터 먼저 켜고, 실제 트래픽에서 며칠 관찰한 뒤 문제가 없으면 넓혀가는 순서로 진행했다.

이 스코핑 결정을 코드 여러 곳에 조건문으로 흩어놓지 않고, 한 함수의 가드 하나로 모았다.

function createStaticAssetsBucket(serviceName: string, environment: string) {
  if (serviceName !== TARGET_SERVICE) return undefined
  // ... 버킷 생성 로직
}

이 함수가 undefined를 반환하면 CDK 쪽의 CloudFront behavior, CI의 환경변수 주입, IAM 쓰기 권한 부여가 전부 조용히 스킵되도록(?.grantWrite(role)처럼 optional chaining으로) 연결해뒀다. 나중에 다른 서비스로 확장할 때는 이 가드 한 줄만 고치면, 그 서비스에도 버킷·라우팅 규칙·권한이 자동으로 따라붙는 구조다. "일부 서비스에만 적용한다"는 결정이 코드 여러 군데에 흩어져 있었다면, 확장할 때 하나를 빠뜨려서 그 서비스만 새는 결과를 만들었을 것이다.

마무리

정리하면 이렇다. Next.js의 해시 파일명은 원래 영구 캐시를 걸어도 안전하도록 설계돼 있는데, 그 자산이 "배포마다 통째로 갈리는 서버" 안에 함께 들어 있으면 그 안전성이 무의미해진다. 서버가 갈리는 순간 옛 자산도 같이 사라지기 때문이다. 해결은 자산을 서버에서 물리적으로 떼어내 S3에 계속 누적하고, CloudFront가 S3를 우선 원본으로, 서버를 안전망으로 쓰는 폴백 구조를 만드는 것이었다.

구현하면서 남은 것들은 대체로 "당연해 보이는 가정이 실제로는 안 맞는" 지점들이었다. 없는 파일이 항상 404로 오는 게 아니라 접근 제어 방식에 따라 403으로 올 수 있었고, "많이 쓰이는 파일"과 "최근에 갱신된 파일"이 S3 lifecycle 입장에서는 전혀 다른 개념이었다. 둘 다 문서를 훑기만 해서는 놓치기 쉽고, 실제로 폴백이 안 걸리거나 상상보다 이른 시점에 파일이 사라지는 걸 보고서야 정확히 이해했다.

이 구조는 지금 한 서비스에만 켜져 있다. 나머지 서비스로 넓히는 시점과, 90일 TTL이 실제 트래픽 분포에서 충분한지는 계속 관찰이 필요한 부분이다.