프론트엔드 번들 캐시버스팅을 구현해봤다
배포했는데 사용자는 이전 버전을 보고 있다
회사에서 맡고 있는 프로젝트의 배포 방식은 일반적인 S3 + CloudFront + Vite + React 조합이다. 배포 후 CloudFront invalidate를 진행하지만, 사용자가 이미 페이지를 열어둔 상태라면 새로운 배포 버전을 바로 이용할 수 없다.
슬랙에 "배포했습니다"라고 안내해도 PM분이 "Ian, 배포된 건가요?"라고 묻는 경우가 종종 있었다. 그럴 때마다 "새로고침 해보셨어요? 아, 강력 새로고침 해보세요"라는 말을 반복했다.
그 외에 다음과 같은 문제도 있었다.
- API 변경 시 에러: 기존 API를 제거하고 새로운 API로 교체하면, 구 버전 클라이언트가 삭제된 엔드포인트를 호출해 서버 로그에 에러가 쌓였다.
- i18n 키 깨짐: 새로운 기능 배포 시, 번들과 i18n 리소스 로딩 타이밍 차이로 인해 새로 추가된 번역 키가 그대로 노출되는 경우가 있었다.
이런 문제들로 인해 프론트엔드 번들 캐시버스팅 기능 구현이 필수가 되었다.
어떻게 접근했나
처음 계획: 프론트엔드만으로 해결하자
처음에는 단순하게 생각했다. 프론트엔드 코드만으로 캐시버스팅을 구현하면 되지 않을까?
아이디어:
- 빌드할 때
version.json파일 생성 (커밋 해시 포함) - public 폴더에 같이 업로드
- 클라이언트에서 주기적으로 버전 체크
- 버전이 다르면 자동 리프레시
인프라 변경 없이 클라이언트 코드만으로 동작하니 리스크도 낮고, 빠르게 구현할 수 있을 것 같았다.
스펙 문서와 리뷰
이 아이디어를 바탕으로 스펙 문서를 작성했다. 버전 체크 로직, 자동 리프레시, 무한 루프 방지, 보호 경로 처리 등을 정리했다.
문서를 June에게 리뷰 요청했고, 피드백이 왔다:
나중에 롤백도 바로 할 수 있는 구조로 구현되면 더 좋을 것 같아요
맞는 말이었다. 프론트엔드만으로는 버전 체크는 되지만, 이미 배포된 버전을 빠르게 롤백하기는 어렵다. 배포할 때마다 기존 빌드를 덮어쓰는 구조라, 이전 버전으로 돌아가려면 코드를 다시 빌드하고 배포해야 한다.
방향 전환: 빌드 방식과 인프라 개선
리뷰 이후 방향을 수정했다. 롤백까지 고려한 구조로 확장하기로 했다.
핵심 아이디어:
- 커밋 해시별로 빌드를 격리해서 보관
- CloudFront Function으로 활성 버전을 라우팅
- 롤백이 필요하면 활성 버전만 바꾸면 끝
이렇게 하려면 빌드 방식과 인프라 구조를 함께 바꿔야 했다.
왜 CloudFront Function인가?
처음에는 Lambda@Edge도 고려했다. 하지만 우리 프로젝트는 트래픽이 그렇게 많지 않다. Lambda@Edge는 저트래픽 환경에서 콜드 스타트 문제가 심각하다 (100-500ms). 반면 CloudFront Function은:
- 콜드 스타트 없음: 항상 1ms 이내 응답
- 비용 절감: Lambda@Edge 대비 약 67% 저렴
- 일관된 성능: 모든 Edge 로케이션에서 즉시 실행
KeyValueStore와 조합하면 버전 정보를 빠르게 읽고 라우팅할 수 있다. 우리 use case에는 CloudFront Function이 더 적합했다.
결과: 2단계로 나누다
리스크를 줄이기 위해 구현을 2단계로 나눴다:
Phase 0: 클라이언트 레벨 캐시 버스팅
- 인프라 변경 없음 (Low Risk)
version.json기반 버전 체크- 자동 리프레시 + 무한 루프 방지
- 보호 경로 처리 (결제, 주문 등)
- ✅ 이것만으로도 프로덕션 배포 가능
Phase 1: 인프라 구축으로 확장
- CloudFront Function + KeyValueStore
- 커밋 해시별 빌드 파일 보관 (
/builds/<commitHash>/) - 배포 히스토리 관리
- 즉시 롤백 지원 (1-2분)
Phase 0는 원래 계획대로 진행하면 되고, Phase 1은 추가로 인프라를 구축하는 방식이다. Phase 0만 해도 사용자 경험은 충분히 개선되고, Phase 1까지 완료하면 운영 안정성까지 확보할 수 있다.
핵심 구현 내용
Phase 0: version.json으로 시작하기
빌드 시 version.json 생성
빌드 스크립트에서 git 커밋 해시를 읽어서 version.json을 생성한다. 이 파일은 public/ 폴더에 들어가서 번들과 함께 배포된다.
{
"commitHash": "abc1234...",
"builtAt": "2025-12-07T10:00:00Z"
}
버전 체크 타이밍
언제 버전을 체크할까? 세 가지 타이밍을 선택했다:
- 5분마다 polling (포어그라운드일 때만)
- 라우트 이동 시 (60초 디바운스)
- 탭 전환 시 (백그라운드 → 포어그라운드)
너무 자주 체크하면 네트워크 낭비고, 너무 늦으면 사용자가 오래된 버전을 쓴다. 5분이 적당하다고 판단했다.
무한 루프 방지
처음 구현할 때 제일 걱정했던 게 무한 리프레시였다. 만약 버전 체크 로직에 버그가 있거나, 네트워크 이슈로 계속 버전이 다르다고 판단하면 어떻게 될까?
사용자는 페이지 열자마자 → 리프레시 → 또 리프레시 → 무한 반복...
그래서 sessionStorage를 활용한 안전장치를 만들었다:
- 5분 내에 최대 3회까지만 리프레시
- 3회 넘으면 그냥 멈춤 (에러 로그만 남김)
- sessionStorage 사용 = 탭별로 격리 (한 탭이 망가져도 다른 탭은 괜찮음)
보호 경로 처리
가장 중요한 기능이다. 결제나 주문 같은 중요한 작업 중에는 절대 리프레시하면 안 된다.
const PROTECTED_PATHS = ["/checkout/**", "/payment", "/transaction"];
보호 경로에 있을 때 새 버전이 감지되면? 일단 보류(isPending = true)한다. 사용자가 보호 경로를 벗어나면 그때 리프레시한다.
실제로 이 기능 덕분에 "결제하려고 하는데 갑자기 새로고침됐어요" 같은 문의는 0건이었다.
Chunk Loading Error 처리
버전 체크와는 별개로, Vite에서 발생하는 Chunk Loading Error도 처리했다. 사용자가 페이지를 열어둔 상태에서 배포가 되면, 라우트 이동 시 "이전 번들이 새로운 청크를 찾으려다가" 실패하는 경우가 있다.
window.addEventListener("vite:preloadError", (event) => {
event.preventDefault();
// ChunkLoadError의 99%는 버전 불일치
window.location.reload();
});
Vite는 vite:preloadError 이벤트를 제공한다. 이 에러가 발생하면? 무조건 리프레시. 간단하지만 효과적이다.
이 처리 덕분에 사용자가 에러를 보지 않고, 자동으로 최신 버전으로 리프레시된다.
Phase 1: 인프라로 확장하기
빌드 설정 변경 (Vite base)
Phase 1의 핵심은 커밋 해시별로 빌드를 격리하는 것이다. 하지만 여기서 문제가 하나 있다.
Vite는 기본적으로 번들 내부의 asset 경로를 /assets/main.xyz.js 같은 절대 경로로 생성한다. 하지만 우리는 /builds/<commitHash>/assets/main.xyz.js 경로에 파일을 두려고 한다.
해결 방법은? Vite config의 base 옵션에 커밋 해시를 포함한다:
// vite.config.js
import { getCommitHash } from "./scripts/utils/git-utils.cjs";
const commitHash = getCommitHash();
export default {
base: `/builds/${commitHash}/`,
// ...
};
이렇게 하면 빌드된 index.html 안에서:
<!-- Before -->
<script src="/assets/main.xyz.js"></script>
<!-- After -->
<script src="/builds/abc123/assets/main.xyz.js"></script>
번들 내부의 모든 경로가 자동으로 /builds/<commitHash>/ 기준으로 생성된다. 랜덤 해시로 생성되는 번들 파일들도 모두 올바른 경로를 참조하게 된다.
S3 구조 변경
Before (기존 구조):
S3 버킷:
/
├─ index.html ← 배포할 때마다 덮어씀
├─ version.json
└─ assets/
├─ main.xyz.js
└─ main.xyz.css
배포할 때마다 기존 파일을 덮어쓴다. 롤백하려면? 코드를 다시 빌드하고 배포해야 한다.
After (변경된 구조):
S3 버킷:
/builds/abc123/ ← 커밋 해시별 격리 저장
├─ index.html
├─ version.json
└─ assets/...
/builds/def456/ ← 다른 버전
├─ index.html
└─ assets/...
/meta/version.json ← 배포 히스토리 (최근 10개)
여러 버전이 S3에 공존한다. 롤백은? 활성 버전만 바꾸면 끝.
CloudFront Function으로 라우팅
사용자는 여전히 app.example.com/로 접근한다. CloudFront Function이 투명하게 라우팅해준다.
여기서 중요한 점: SPA 라우팅도 처리해야 한다. /products/123 같은 SPA 라우트는 파일이 아니라 index.html을 서빙해야 한다.
import cf from "cloudfront";
const kvsHandle = cf.kvs();
async function handler(event) {
const request = event.request;
const uri = request.uri;
try {
const currentVersion = await kvsHandle.get("current_version"); // "abc123"
if (!currentVersion) return request;
// 1) /api/version 엔드포인트 - 직접 응답
if (uri === "/api/version") {
return {
statusCode: 200,
headers: {
"content-type": { value: "text/plain; charset=UTF-8" },
"cache-control": { value: "no-cache, no-store, must-revalidate" },
},
body: currentVersion,
};
}
// 2) 스킵해야 할 경로들
if (uri.startsWith("/meta/") || uri.startsWith("/builds/")) {
return request;
}
// 3) 핵심: 파일 확장자 체크
const pathWithoutQuery = uri.split("?")[0];
const hasExtension = /\.[a-zA-Z0-9]+$/.test(pathWithoutQuery);
let newUri;
if (hasExtension) {
// 확장자 있음 = 정적 파일 (assets/main.xyz.js, version.json 등)
newUri = `/builds/${currentVersion}${uri}`;
} else {
// 확장자 없음 = SPA 라우트 (/products/123, /dashboard 등)
newUri = `/builds/${currentVersion}/index.html`;
}
request.uri = newUri;
return request;
} catch (error) {
return request; // 에러 나도 원본 요청 그대로 반환
}
}
라우팅 예시:
app.example.com/→/builds/abc123/index.html(확장자 없음)app.example.com/products/123→/builds/abc123/index.html(SPA 라우트)app.example.com/assets/main.xyz.js→/builds/abc123/assets/main.xyz.js(정적 파일)app.example.com/version.json→/builds/abc123/version.json(정적 파일)app.example.com/api/version→ 직접 응답 (S3 안 거침)
파일 확장자 체크
정규식 /\.[a-zA-Z0-9]+$/로 확장자를 체크한다. 확장자가 있으면 정적 파일이고, 없으면 SPA 라우트다. SPA 라우트는 모두 index.html을 서빙해야 React Router가 동작한다.
/api/version은 S3를 거치지 않는다
위 코드에서 /api/version 요청은 CloudFront Function이 직접 응답한다. S3에 갈 필요가 없으니 속도도 빠르고 (no-cache 헤더로 캐싱도 안 함), Phase 0에서 설명한 버전 체크 로직이 정확히 동작한다.
이렇게 하면 사용자는 URL 변화 없이 /builds/<commitHash>/에 있는 번들을 투명하게 사용한다.
롤백 프로세스
문제가 생기면? KeyValueStore의 current_version만 바꾸면 된다:
# 롤백 (1-2분 소요)
aws cloudfront-keyvaluestore put-key \
--key current_version \
--value "이전_커밋_해시"
빌드는 이미 S3에 있으니, 활성 버전만 바꾸면 즉시 롤백된다. 코드 다시 빌드할 필요 없다.
전체 Request Flow
전체 흐름을 다이어그램으로 정리하면:
핵심은:
- 사용자는
app.example.com/로 접근 (URL 변화 없음) - CloudFront Function이 투명하게
/builds/<version>/로 라우팅 /api/version은 KVS에서 직접 응답 (S3 안 거침, 빠름)- 롤백 시 KVS만 변경하면 즉시 반영
회고
처음 해본 인프라 작업
나름 처음으로 인프라 작업을 해본 것이었다. 그래서 배포할 때 좀 쫄렸다.
POC 단계에서는 AWS 콘솔에서 클릭클릭하면서 테스트했는데, 막상 운영 환경에 배포하려니 고려할 게 많았다:
- 프론트엔드 배포와 인프라 배포 순서
- 기존 운영 중인 서비스에 영향 없이 적용하기
- 잘못되면 롤백할 수 있는 계획
다행히 인프라 배포는 Eddy, June이 도와줬다. 혼자였으면 훨씬 더 오래 걸렸을 것 같다.
회사에서는 Pulumi로 인프라를 관리하는데, 이것도 새로웠다. 코드로 인프라를 정의하고 버전 관리하는 게 신기했다.
CloudFront 제약사항과의 싸움
이 작업을 하면서 CloudFront에 대해 많이 배웠다. Viewer Request/Response, Origin Request/Response 개념을 제대로 알게 되었다.
그런데 배포 직전 단계에서 큰 문제를 만났다.
기존 운영 프로젝트의 CloudFront에는 이미 Viewer Response와 Origin Response에 Lambda@Edge가 붙어 있었다. 사실 이걸 알고 있었기 때문에 인프라 아이디어가 나온 거긴 한데... 문제는 AWS 공식 제약사항이었다.
Lambda@Edge와 CloudFront Function을 같은 Distribution에서 섞어 쓸 수 없다.
정확히는, 이미 Lambda@Edge가 붙어 있으면 CloudFront Function을 Viewer Request에 추가할 수 없었다.
처음에는 "뭐야, 안 되는 거였어?" 싶었지만, June이 그렇다면 인프라를 교체하자고 제안해주셔서 Viewer Response에 있던 Lambda@Edge를 CloudFront Function으로 교체했다. 다행히 그 Lambda@Edge는 간단한 헤더 추가 로직이라 CloudFront Function으로 옮기는 게 어렵지 않았다.
이 과정에서 깨달았다: 인프라 작업할 때는 AWS의 제약사항까지 꼼꼼히 파악해야 한다. 콘솔에서 테스트할 때는 몰랐던 제약들이 운영 환경에서 튀어나온다.
인프라 작업, 스릴 있었다
프론트엔드만 하다가 인프라까지 건드려보니 스릴 있었다. CloudFront Distribution 설정 하나 바꾸는데도 긴장되고, 배포하고 나서 제대로 동작하는지 확인할 때 심장이 두근거렸다.
하지만 막상 해보니 할 만했다. 프론트엔드 개발자도 충분히 인프라를 이해하고 다룰 수 있다는 걸 배웠다.물론 동료들의 도움이 컸지만.