레시피 목록

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

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

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

사내 관리자 페이지를 쓰던 운영자에게서 이런 제보가 심심찮게 올라왔습니다. 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.htmlasset-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를 거쳐 돌려보내지는 응답에도 그대로 따라붙는다는 겁니다. CloudFront가 본문을 index.html로 치환할 때 origin에서 받은 헤더를 그대로 실어 보내니까요. 그래서 경로가 무엇이든 진입점 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의 역할은 "이미 엣지에 남아 있는 이전 응답을 제거하는" 안전장치입니다. origin에 올바른 Cache-Control이 있으니 앞으로 새로 채워지는 캐시는 자동으로 재검증되도록 정책이 따라붙으니까요. 매 배포마다 전체 무효화의 효과에 의존하는 구조가 아니라, 캐시 동작 자체가 안정된다는 점이 두 번째 시도와의 결정적인 차이입니다.

최종 구조

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이 어떤 헤더를 보내고 있는지를 함께 확인해보면 진단이 훨씬 빨라질 겁니다.