글 목록

React SPA의 HTML 캐시 무효화 문제 해결기

2026년 5월 27일 9분 읽기
배포캐싱

"배포하셨다는데 왜 저는 예전 화면이 그대로 보여요?"

사내 관리자 페이지를 쓰던 운영자에게서 이런 제보가 심심찮게 올라왔습니다. GitHub Actions는 초록불이고, S3에는 새 파일이 분명히 올라가 있고, 옆자리 동료 화면에서는 새 버전이 잘 나옵니다. 그런데 어떤 사람은 계속 이전 버전을 봅니다.

저희 관리자 페이지는 React 기반 SPA로, GitHub Actions에서 빌드한 뒤 S3에 업로드하고 CloudFront로 서비스하는 구조였습니다.

기존 배포 스크립트의 핵심은 단순했습니다.

yaml
aws s3 cp \
--recursive \
--region ap-northeast-2 \
build s3://admin-bucket

빌드 결과물인 build 디렉터리를 S3 버킷에 재귀적으로 업로드합니다. 새 파일도 S3에 올라가고 GitHub Actions도 실패 없이 끝납니다.

문제는 특히 /login이나 /users/123 같은 내부 경로로 직접 들어오는 사용자에게 자주 발생했습니다.

캐시 키는 index.html이 아니라 사용자가 친 주소였다

React SPA에서 모든 페이지의 실제 진입점은 index.html 하나입니다. /login, /users/123 같은 경로는 S3에 실제 파일로 존재하지 않고, JS가 로드된 뒤 클라이언트 측 라우터가 처리하니까요.

이 구조를 S3 + CloudFront로 서비스하기 위해 distribution에는 일반적으로 이런 설정이 들어가 있습니다.

  • 사용자가 /login을 요청 → CloudFront가 S3에 /login 객체를 요청
  • S3에는 해당 객체가 없으므로 403 또는 404를 반환
  • CloudFront의 Custom Error Response가 이 응답을 200 OK로 변환하면서 /index.html을 본문으로 돌려보냄

여기서 한 가지 주의할 점이 있습니다. CloudFront 엣지에 저장되는 캐시 키는 사용자가 요청한 원래 경로 그대로입니다. 즉 사용자가 /login으로 들어왔을 때 엣지에 저장되는 캐시는 "키는 /login, 본문은 그 시점의 index.html"입니다. /users/123으로 들어온 다른 사용자에게는 "키는 /users/123, 본문은 그 시점의 index.html"이 따로 저장됩니다. 본문이 같아도 캐시는 키별로 따로 저장됩니다.

따라서 새 배포 후 사용자가 보던 페이지는 이런 흐름을 거칩니다.

text
1. 새 배포 → S3의 index.html 갱신
2. 사용자가 /login으로 진입
3. CloudFront 엣지에 /login 키로 캐시된 이전 응답이 남아 있음
4. 사용자는 이전 버전 index.html을 받음
5. 이전 index.html이 이전 JS/CSS를 참조 → 화면이 갱신되지 않음

index.html과 함께 asset-manifest.json도 같은 종류의 문제를 가집니다. 이 파일도 파일명이 고정된 채 내용만 바뀌는 진입점이고, 캐시되면 빌드된 JS/CSS 매핑이 이전 상태로 고정되니까요.

첫 번째 시도, /index.html만 지우면 될 줄 알았다

가장 먼저 시도한 방법은 CloudFront에서 /index.html만 무효화하는 것이었습니다.

yaml
- name: Invalidate CloudFront
  uses: chetan/invalidate-cloudfront-action@v2
  env:
    DISTRIBUTION: ${{ secrets.DISTRIBUTION }}
    PATHS: "/index.html"
    AWS_REGION: "us-east-1"
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

SPA의 진입점이 index.html이니까 그 파일만 지우면 될 거라는 직관에서 출발한 접근이었습니다.

하지만 배포 후에도 일부 사용자에게서는 이전 화면이 계속 보였습니다. 원인은 앞 절에서 짚은 캐시 키 구조에 있었죠.

/index.html은 CloudFront 엣지에서 /index.html 키로 저장되는 응답입니다. /login이나 /users/123으로 직접 진입한 사용자에게 돌아간 응답은 본문이 같은 index.html이라도 캐시 키는 각각 /login, /users/123입니다. /index.html 하나만 무효화한다고 해서 이 경로별 캐시까지 함께 지워지지는 않는 겁니다.

관리자 페이지의 진입 패턴상 /로 들어와서 클라이언트 라우팅으로만 이동하는 경우는 오히려 드물었습니다. 운영자 대부분이 북마크나 알림 링크를 통해 /login, /orders/... 같은 깊은 경로로 직접 들어왔고, 그 경로들의 엣지 캐시는 무효화 대상에서 통째로 빠져 있었던 거죠.

두 번째 시도, /*는 증상만 지웠다

다음으로 시도한 건 무효화 범위를 확대하는 것이었습니다.

diff
- PATHS: "/index.html"
+ PATHS: "/*"

/login, /users/123 같은 경로별 캐시까지 한 번에 지우려는 의도였죠. 실제로 배포 직후의 문제는 이 방식으로 대부분 해소됐습니다.

다만 시간이 지나자 다른 종류의 문제가 보였습니다.

첫째, 배포할 때마다 전체 캐시를 무효화하면 비용과 처리 시간의 부담이 쌓입니다. 둘째, 무효화 이후 새로 저장되는 캐시에는 여전히 같은 정책이 적용됐습니다. origin이 명시적 캐시 정책을 보내지 않으면 distribution의 기본 TTL을 따라 다시 같은 기간 동안 캐시되니까요. 즉 매번 같은 패턴이 반복되는 겁니다.

그리고 invalidation이 손대지 못하는 영역이 하나 더 있었습니다. 바로 사용자의 브라우저 캐시였습니다.

무효화가 지우는 건 세 계층 중 가운데 하나뿐이었다

캐시는 사실상 세 계층에 걸쳐 있습니다.

text
Browser Cache -> CloudFront Edge Cache -> S3 Object

CloudFront invalidation이 지우는 건 가운데 계층뿐입니다. 가장 사용자에게 가까운 브라우저 캐시는 invalidation으로 제거할 방법이 없죠. 사용자의 브라우저가 이전 응답을 "오래도록 신선하다"고 믿고 있으면, CDN을 아무리 깨끗하게 만들어도 그 사용자는 한동안 새 응답을 요청조차 하지 않습니다.

문제의 뿌리는 origin이 보내는 Cache-Control 헤더에 있었습니다. S3에 업로드된 index.html에 명시적인 캐시 정책이 없으면 캐시 수명을 다음 기준에 맡기게 됩니다.

  1. 브라우저는 자체 휴리스틱으로 응답을 신선하다고 간주할 수 있습니다.
  2. CloudFront는 distribution의 기본 TTL을 따라 응답을 캐시합니다.

invalidation은 "지금 엣지에 남아 있는 이전 응답"은 지울 수 있지만, "앞으로 어떻게 캐시될지"와 "이미 사용자 브라우저에 들어가 있는 캐시"에는 무력했습니다. 결국 origin에서 캐시 정책 자체를 명시해야 했죠.

origin 응답에 캐시 정책을 명시했다

배포 스크립트를 이렇게 바꿨습니다. 전체 빌드 산출물을 먼저 올린 뒤, index.html과 asset-manifest.json을 다시 업로드하면서 캐시 정책을 명시하는 방식입니다.

yaml
aws s3 cp \
--recursive \
--region ap-northeast-2 \
build s3://admin-bucket

aws s3 cp \
--region ap-northeast-2 \
build/index.html s3://admin-bucket/index.html \
--cache-control "no-cache,max-age=0,must-revalidate" \
--content-type "text/html"

aws s3 cp \
--region ap-northeast-2 \
build/asset-manifest.json s3://admin-bucket/asset-manifest.json \
--cache-control "no-cache,max-age=0,must-revalidate" \
--content-type "application/json"

no-cache,max-age=0,must-revalidate 조합은 응답을 저장하지 못하게 막는 게 아니라, 다음 요청 때 반드시 origin에 재검증을 받도록 만듭니다. SPA 진입점에 적합한 정책이죠. 이 헤더는 S3의 객체 응답에 실립니다. 다만 CloudFront의 캐시 정책과 오류 응답 캐시 설정도 함께 확인해야 의도한 재검증 동작을 얻을 수 있습니다.

특히 /index.html의 객체 메타데이터만 보고 /login의 동작을 단정하면 안 됩니다. Custom Error Response에는 Error Caching Minimum TTL이 관여하고, 일반 캐시 정책의 Minimum TTL도 확인해야 합니다. 실제 배포 도메인의 /, /index.html, 깊은 경로를 각각 요청해 최종 응답 헤더와 빌드 버전을 확인해야 합니다.

asset-manifest.json도 함께 처리한 이유는 앞에서 짚은 대로 이 파일이 빌드된 JS/CSS 매핑을 담고 있는 또 하나의 진입점이기 때문입니다.

--recursive로 한 번 올린 뒤 같은 파일을 다시 업로드하는 구조는 약간 비효율적이긴 합니다. --exclude 옵션으로 첫 단계에서 두 파일을 제외하면 중복 업로드를 피할 수 있죠. 다만 지금 구조는 "이 파일들에는 다른 캐시 정책을 적용한다"는 의도를 드러냅니다. 스크립트를 읽기 쉽다는 점을 우선해 그대로 두기로 했습니다.

CloudFront invalidation도 유지했습니다.

yaml
- name: Invalidate CloudFront
  uses: chetan/invalidate-cloudfront-action@v2
  env:
    DISTRIBUTION: ${{ secrets.DISTRIBUTION }}
    PATHS: "/*"
    AWS_REGION: "us-east-1"
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

이때 invalidation은 엣지에 남아 있는 이전 응답을 제거하는 안전장치 역할을 합니다. 새 응답의 헤더와 distribution의 TTL 설정을 함께 맞춰야 이후 캐시도 의도한 시점에 재검증됩니다. 매 배포마다 전체 무효화의 효과에 의존하는 구조가 아니라, 캐시 동작 자체가 안정된다는 점이 두 번째 시도와의 결정적인 차이입니다.

같은 HTML인지보다 같은 경로로 다시 요청했는지가 중요했다

캐시를 확인할 때 개발자 도구의 Disable cache를 켜면 브라우저 캐시가 관여하는 원래 상황을 바꿉니다. 먼저 옵션을 끈 상태에서 재현하고, 이후 켜서 비교해야 어느 계층이 오래된 응답을 내주는지 좁힐 수 있습니다. 서비스 워커가 있는 앱이라면 HTTP 캐시와 별도로 Cache Storage에서 응답을 만들고 있는지도 살펴봐야 합니다.

브라우저 캐시, CloudFront의 경로별 캐시, S3 원본을 나누어 표시한 구조

그림에서는 S3에 새 HTML이 있어도 엣지 캐시가 이전 응답을 반환합니다. CloudFront 무효화는 이 엣지 캐시를 대상으로 하며, 브라우저에 이미 저장된 응답과 S3의 객체 메타데이터까지 바꾸지는 않습니다. no-cache는 저장 금지가 아니라 재사용 전 검증을 요구합니다. 재검증 결과가 같으면 304를 받고 기존 본문을 재사용할 수 있습니다. 매번 HTML 전체를 다운로드하라는 뜻은 아닙니다.

다음은 실제 도메인으로 바꿔 실행할 수 있는 확인 명령입니다. 각 경로를 반복 요청하고 Cache-Control, Age, ETag, X-Cache를 비교합니다.

bash
curl -sS -D - -o /dev/null https://your-domain.example/
curl -sS -D - -o /dev/null https://your-domain.example/index.html
curl -sS -D - -o /dev/null https://your-domain.example/login

헤더만으로 최신 빌드인지 확정할 수는 없습니다. HTML에 빌드 ID를 넣거나 참조하는 JS 파일의 해시를 함께 비교하면 이전 HTML이 남았는지 확인할 수 있습니다. curl은 브라우저 캐시나 서비스 워커를 거치지 않으므로, 브라우저와 결과가 다를 때 그 차이도 단서가 됩니다.

진입점은 마지막에 바꾸는 편이 안전하다

캐시가 올바르게 동작해도 업로드 순서가 틀리면 새 HTML이 아직 올라오지 않은 JS를 요청할 수 있습니다. 해시가 붙은 JS·CSS를 먼저 업로드하고 HTML을 마지막에 교체하면 이 구간을 줄일 수 있습니다. 이전 HTML을 열어둔 사용자가 동적 import를 할 수 있으므로 이전 해시 파일도 바로 삭제하지 않는 편이 안전합니다.

파일명에 내용 해시가 있는 자산에는 긴 캐시 수명을, 같은 URL로 내용이 바뀌는 HTML에는 재검증 정책을 적용하는 것이 출발점입니다. 오류 응답 치환이 복잡해지면 viewer-request 단계에서 앱 경로만 /index.html로 재작성하는 방법도 검토할 수 있습니다. 이때 API와 정적 파일 경로까지 HTML로 바꾸지 않도록 범위를 분리해야 합니다.

파일 업로드와 캐시 정책 적용을 나눴다

text
1. GitHub Actions에서 React 앱 빌드
2. build 디렉터리 전체를 S3에 업로드
3. index.html을 별도로 재업로드하며 Cache-Control 지정
4. asset-manifest.json을 별도로 재업로드하며 Cache-Control 지정
5. CloudFront 캐시를 "/*" 경로로 무효화

각 단계가 담당하는 역할이 분리돼 있습니다.

  • 2단계: 새 자원을 origin에 올립니다.
  • 3, 4단계: 진입점 파일이 origin 차원에서 "매번 재검증되는" 응답이 되도록 만듭니다. CloudFront와 브라우저 양쪽에 모두 영향을 줍니다.
  • 5단계: 정책을 바꾼 시점 이전에 엣지에 들어가 있던 이전 응답을 정리합니다.

배포 성공 이후의 캐시도 확인해야 했다

이번 문제의 핵심은 "배포가 됐는가?"가 아니라 "사용자가 어떤 경로로 들어와도 최신 진입점을 받는가?"였습니다.

프론트엔드 배포 문제는 종종 코드보다 인프라와 HTTP 캐시 정책에서 발생하는 것 같습니다. 특히 SPA + CDN 구조라면 캐시 키가 어떻게 생성되는지, origin이 어떤 헤더를 보내고 있는지를 함께 확인해보면 진단이 훨씬 빨라질 겁니다.

더 확인할 자료