React SPA의 HTML 캐시 무효화 문제 해결기
2026-05-27저희 사내 관리자 페이지는 React 기반 SPA로, GitHub Actions에서 빌드한 뒤 S3에 업로드하고 CloudFront로 서비스하는 구조였어요.
기존 배포 스크립트의 핵심은 단순했어요.
aws s3 cp \
--recursive \
--region ap-northeast-2 \
build s3://admin-bucket
빌드 결과물인 build 디렉터리를 S3 버킷에 재귀적으로 업로드해요. 새 파일도 S3에 올라가고 GitHub Actions도 실패 없이 끝나요.
그런데 배포 직후 최신 화면이 바로 반영되지 않는 문제가 보고되고 있었어요. 특히 /login이나 /users/123 같은 내부 경로로 직접 진입한 케이스에서 더 자주 보고됐죠. 어떤 사용자는 새 버전을 보고, 어떤 사용자는 이전 버전을 계속 봤어요.
원인 분석
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"이 따로 저장되고요. 본문은 같지만 캐시는 키마다 별도인 거죠.
따라서 새 배포 후 사용자가 보던 페이지는 이런 흐름을 거쳐요.
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만 무효화하는 거였어요.
- 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/... 같은 깊은 경로로 직접 들어왔고, 그 경로들의 엣지 캐시는 무효화 대상에서 빠져 있었던 거죠.
두 번째 시도: /*로 범위 확대
다음으로 시도한 건 무효화 범위를 확대하는 거였어요.
- PATHS: "/index.html"
+ PATHS: "/*"
/login, /users/123 같은 경로별 캐시까지 한 번에 지우려는 의도였죠. 실제로 배포 직후의 문제는 이 방식으로 대부분 해소됐어요.
다만 시간이 지나자 다른 종류의 문제가 보였어요.
첫째, 매 배포마다 전체 무효화를 수행하는 건 비용이나 처리 시간 측면에서 부담이 누적돼요. 둘째, 더 본질적으로는 무효화 직후 새로 채워지는 캐시에 적용되는 정책이 그대로였어요. origin이 명시적 캐시 정책을 보내지 않으면 distribution의 기본 TTL을 따라 다시 같은 기간 동안 캐시되거든요. 즉 매번 같은 패턴이 반복되는 거죠.
그리고 invalidation이 손대지 못하는 영역이 하나 더 있었어요. 바로 사용자의 브라우저 캐시예요.
왜 무효화만으로는 부족했을까
캐시는 사실상 세 계층에 걸쳐 있어요.
Browser Cache -> CloudFront Edge Cache -> S3 Object
CloudFront invalidation이 지우는 건 가운데 계층뿐이에요. 가장 사용자에게 가까운 브라우저 캐시는 invalidation으로 제거할 방법이 없거든요. 사용자의 브라우저가 이전 응답을 "오래도록 신선하다"고 믿고 있으면, CDN을 깨끗하게 만들어도 그 사용자는 한동안 새 응답을 요청조차 하지 않아요.
문제의 뿌리는 origin이 보내는 Cache-Control 헤더에 있었어요. S3에 업로드된 index.html에 명시적인 캐시 정책이 없으면 이런 두 가지가 모두 자유로워져요.
- 브라우저는 자체 휴리스틱으로 응답을 신선하다고 간주할 수 있어요.
- CloudFront는 distribution의 기본 TTL을 따라 응답을 캐시해요.
invalidation은 "지금 엣지에 남아 있는 이전 응답"은 지울 수 있지만, "앞으로 어떻게 캐시될지"와 "이미 사용자 브라우저에 들어가 있는 캐시"에는 무력했어요. 결국 origin에서 캐시 정책 자체를 명시해야 했죠.
해결: S3 origin에서 Cache-Control 직접 지정
배포 스크립트를 이렇게 바꿨어요. 전체 빌드 산출물을 먼저 올린 뒤, index.html과 asset-manifest.json을 다시 업로드하면서 캐시 정책을 명시하는 방식이에요.
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를 거쳐 돌려보내지는 응답에도 그대로 따라붙는다는 거예요. CloudFront가 본문을 index.html로 치환할 때 origin에서 받은 헤더를 그대로 실어 보내거든요. 그래서 경로가 무엇이든 진입점 HTML을 받은 사용자는 다음 요청 때 재검증을 거치게 돼요.
asset-manifest.json도 함께 처리한 이유는 앞에서 짚은 대로 이 파일이 빌드된 JS/CSS 매핑을 담고 있는 또 하나의 진입점이기 때문이에요.
--recursive로 한 번 올린 뒤 같은 파일을 다시 업로드하는 구조는 약간 비효율적이긴 해요. --exclude 옵션으로 첫 단계에서 두 파일을 제외하면 중복 업로드를 피할 수 있거든요. 다만 지금 구조는 "이 파일들은 다른 캐시 정책을 가진다"는 의도를 스크립트만 봐도 드러내는 장점이 있어서 가독성을 우선해 그대로 두기로 했어요.
CloudFront invalidation도 유지했어요.
- 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의 역할은 "이미 엣지에 남아 있는 이전 응답을 제거하는" 안전장치예요. origin에 올바른 Cache-Control이 있으니까 앞으로 새로 채워지는 캐시는 자동으로 재검증되도록 정책이 따라붙거든요. 매 배포마다 전체 무효화의 효과에 의존하는 구조가 아니라, 캐시 동작 자체가 안정된다는 점이 두 번째 시도와의 결정적인 차이예요.
최종 구조
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이 어떤 헤더를 보내고 있는지를 함께 확인하는 게 빠른 진단 경로가 돼요.