로컬에선 되는데 배포하면 안 된다 — CloudFront가 내 API GET을 캐싱하고 있었다
로컬에서는 정상인데 배포 환경에서만 데이터가 갱신되지 않는 현상의 원인을 분석합니다. API 응답 헤더에 캐시 제어 설정이 없어 CloudFront가 GET 요청을 임의로 캐싱하며 발생한 문제로, Cache-Control 헤더 설정과 CDN 캐시 정책 수정을 통해 이를 해결하는 과정을 상세히 정리했습니다
![]()
데이터를 바꿔도 화면이 안 바뀌었다. 파일을 옮기고, 올리고, 폴더를 만들어도 목록은 그대로. 그런데 서버에는 분명히 반영돼 있고, 재로그인하면 정상으로 보였다. 더 이상한 건 로컬에서는 멀쩡한데 배포 서버에서만 그랬다는 점이다.
결론부터 말하면 범인은 코드가 아니라 CDN(CloudFront)이 인증 API의 GET 응답을 캐싱하고 있던 것이었다. 며칠치 의심을 거쳐 찾은 과정을 정리한다. 스택은 React SPA + @tanstack/react-query 프론트, Express API, 그 앞단 CloudFront다.
증상 정리
- 목록을 바꾸는 동작(이동/업로드/폴더 생성) 후 화면이 갱신되지 않음
- 서버 DB에는 변경이 정상 반영됨 (재조회하면 있음)
- F5 새로고침해도 안 보임
- 재로그인(전체 리로드)하면 정상으로 보임
- 로컬 개발 환경에서는 재현 안 됨, 배포 서버에서만 발생
이 다섯 줄이 사실 답을 거의 다 품고 있었는데, 처음엔 보이지 않았다.
1차 용의자: react-query 캐시
이 앱은 react-query를 "수동 갱신" 모델로 쓰고 있었다.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
refetchOnWindowFocus: false,
},
},
});staleTime: Infinity + 포커스 refetch off라, 화면 갱신은 전적으로 mutation 후의 명시적 invalidateQueries에 의존한다. 그러니 "화면이 안 바뀐다"면 1순위 의심은 무효화 키 불일치다.
// 활성 쿼리 키
queryKey: ["items", parentId];
// 무효화
queryClient.invalidateQueries({ queryKey: ["items"] }); // prefix 매칭키는 정상적으로 prefix 매칭됐고, invalidateQueries의 기본 refetchType: "active"는 활성 쿼리를 refetch한다. 정적으로는 "동작해야 맞다". 여기서 한참 막혔다.
멀티탭도 아니었다
"여러 탭을 띄워서 그런가?"라는 가설도 나왔다. 하지만 코드를 뒤져봐도 탭 간 상태를 동기화하는 장치(BroadcastChannel, service worker, storage 이벤트 구독, react-query의 broadcastQueryClient)가 하나도 없었다. react-query 캐시는 탭마다 독립된 인메모리다. 탭 간 간섭은 원인이 아니었다.
결정적 전환: "로컬에서는 된다"
정적 분석이 계속 "동작해야 한다"고 말하는데 현실은 아니니, 실제로 돌려봤다. 로컬에서 브라우저 자동화로 mutation을 실행하고 네트워크를 캡처했다.
PATCH /api/items/123/move -> 200
GET /api/items?parentId=… -> 200 (자동 refetch)refetch는 정확히 발생했고, 응답에는 변경된 데이터가 들어 있었고, 화면도 리로드 없이 갱신됐다. 로컬에서는 완벽히 동작했다.
이 순간 프레임이 바뀐다. "로컬은 되는데 배포하면 안 된다"는 코드 버그가 아니라 환경 차이다. react-query, 무효화 로직, 컴포넌트 렌더 — 전부 결백했다. 차이는 배포 서버 앞단에만 있는 무언가였다.
범인: CloudFront가 GET을 캐싱
배포 서버 API의 응답 헤더를 직접 봤다.
curl -sI "https://api.example.com/api/health"
# etag: W/"1fa-..."
# x-cache: Miss from cloudfront
# via: 1.1 ....cloudfront.net (CloudFront)두 가지가 눈에 들어왔다. (1) via/x-cache — 앞단에 CloudFront가 있다. 로컬엔 없는 레이어다. (2) 응답에 Cache-Control이 없다. ETag만 있다.
같은 GET을 반복해 봤다.
for i in 1 2 3 4 5; do
curl -sI "https://api.example.com/api/health" | grep -i x-cache
done
# x-cache: Hit from cloudfront
# x-cache: Hit from cloudfront
# x-cache: Hit from cloudfront
# x-cache: Hit from cloudfront
# x-cache: Hit from cloudfrontHit from cloudfront. CloudFront가 GET 응답을 캐싱하고 있었다. origin이 Cache-Control을 주지 않으면 CloudFront는 기본 TTL로 GET/HEAD를 캐싱할 수 있다(AWS 문서).
원인 체인이 맞춰졌다.
- origin(Express)이 API 응답에
Cache-Control을 안 보냄 - → CloudFront가 기본 TTL로
GET /api/*를 캐싱 - → mutation 후 refetch GET이 CDN 캐시(stale) 를 받음 → 화면 안 바뀜
증상이 전부 들어맞는다
- F5해도 안 됨 — 같은 세션이면 캐시 키가 같아 CloudFront가 같은 stale을 반환. (브라우저의 강제 재검증은 CDN 캐시를 못 뚫는다)
- 재로그인하면 됨 — 새 세션 쿠키 → 캐시 키 변경(또는 그 사이 TTL 만료) → cache miss → origin 최신
- 로컬은 정상 — CDN이 없으니 항상 origin 직결
- 간헐적 — 캐시는 URL별 + TTL이라, 그 순간 그 URL이 Miss 상태면 최신이 보이고 Hit 상태면 stale. "되다 안 되다" 한다
처음의 다섯 줄짜리 증상이 사실 전부 이 하나를 가리키고 있었다. 특히 "재로그인하면 되는데 F5는 안 됨" 은 세션 기반으로 키가 잡히는 CDN 캐시의 전형적인 지문이다.
ETag가 있는데 왜 안 막혔나
여기서 헷갈리기 쉽다. ETag가 있으면 브라우저는 조건부 요청(If-None-Match)으로 재검증하고, 내용이 바뀌면 ETag도 바뀌니 stale을 안 받는다. 브라우저 레벨에서는 맞는 말이다.
하지만 CDN이 Cache-Control 없이 기본 TTL로 시간 기반 캐싱을 하면 이야기가 다르다. TTL 안에서는 재검증 없이 저장본을 그냥 반환한다. ETag 재검증이 끼어들 틈이 없다. CDN 캐싱은 코드의 ETag와 별개 레이어에서 일어난다.
보안 함정도 숨어 있다
per-user 인증 응답이 CDN에 캐시된다는 건 또 다른 위험이다. 만약 캐시 키에 인증 토큰/쿠키가 포함돼 있지 않다면, A 사용자의 응답이 B 사용자에게 그대로 서빙될 수 있다. 사용자별로 다른 데이터를 주는 API는 CDN 캐시 대상이 되어선 안 된다.
해결: 세 겹으로 막는다
원인이 명확하니 픽스는 단순하다. 다만 한 군데만으로는 부족하다.
1) Origin — API 응답에 Cache-Control: no-store (정답)
// 모든 API 응답에, 라우트보다 먼저 등록
app.use("/api", (req, res, next) => {
res.set("Cache-Control", "no-store");
next();
});no-store는 "저장하지 마라"는 가장 강한 지시다(MDN). CDN과 브라우저 양쪽에 적용된다.
2) Client — fetch에도 cache: "no-store" (이중 방어)
const res = await fetch(url, {
cache: "no-store",
...options,
});CDN이 origin 헤더를 무시하더라도 브라우저 레벨에서는 항상 최신을 받게 한다. react-query 인메모리 캐시를 쓰니 HTTP 캐시는 어차피 불필요하다.
3) CDN — API 경로의 캐싱 자체를 끈다 (코드만으론 부족할 수 있음)
CloudFront cache policy가 MinTTL > 0이면 origin의 no-store를 무시하고 계속 캐싱한다. 그래서 API 경로의 behavior를 관리형 CachingDisabled 정책으로 바꾸는 게 가장 확실하다(관리형 정책 문서). 그리고 이미 쌓인 캐시는 무효화로 비운다.
aws cloudfront create-invalidation \
--distribution-id <DIST_ID> \
--paths "/api/*"남는 교훈
- "로컬은 되는데 배포하면 안 된다" 는 코드보다 인프라 레이어(CDN/프록시/LB)를 먼저 의심하라는 신호다. 정적 분석이 "동작해야 한다"고 우길수록 환경 차이를 봐야 한다.
- API 응답엔 항상 명시적인
Cache-Control을 줘라. 안 주면 CDN이 알아서 추측한다 — 보통 캐싱하는 쪽으로. - "재로그인하면 됨 / F5는 안 됨 / 간헐적" 조합은 세션 키 기반 CDN 캐시의 지문이다.
- 진단은 실제 브라우저 재현 +
curl -I로 응답 헤더 확인이 빠르다.via,x-cache,age헤더가 CDN의 존재와 캐시 적중을 바로 알려준다.
코드가 결백하다는 걸 증명하는 데 가장 오래 걸렸다. 하지만 그 증명이 끝나는 순간, 남은 건 헤더 한 줄이었다.