---
title: "로컬에선 되는데 배포하면 안 된다 — CloudFront가 내 API GET을 캐싱하고 있었다"
description: "로컬에서는 정상인데 배포 환경에서만 데이터가 갱신되지 않는 현상의 원인을 분석합니다. API 응답 헤더에 캐시 제어 설정이 없어 CloudFront가 GET 요청을 임의로 캐싱하며 발생한 문제로, Cache-Control 헤더 설정과 CDN 캐시 정책 수정을 통해 이를 해결하는 과정을 상세히 정리했습니다"
date: 2026-06-11
updated: 2026-06-11T04:47:02.385Z
tags: [cloudfront, http-cache, react-query, cdn, debugging]
canonical: https://blog.wooncloud.com/posts/12
---

![Minimalist isometric illustration: three stacked l](/images/posts/12/thumb-2a5c23e0-6c8f-428e-bc1a-d4091ec72bca.jpg)


데이터를 바꿔도 화면이 안 바뀌었다. 파일을 옮기고, 올리고, 폴더를 만들어도 목록은 그대로. 그런데 서버에는 분명히 반영돼 있고, **재로그인하면 정상**으로 보였다. 더 이상한 건 **로컬에서는 멀쩡한데 배포 서버에서만** 그랬다는 점이다.

결론부터 말하면 범인은 코드가 아니라 **CDN(CloudFront)이 인증 API의 GET 응답을 캐싱**하고 있던 것이었다. 며칠치 의심을 거쳐 찾은 과정을 정리한다. 스택은 React SPA + `@tanstack/react-query` 프론트, Express API, 그 앞단 CloudFront다.

## 증상 정리

- 목록을 바꾸는 동작(이동/업로드/폴더 생성) 후 **화면이 갱신되지 않음**
- 서버 DB에는 변경이 **정상 반영됨** (재조회하면 있음)
- **F5 새로고침해도 안 보임**
- **재로그인(전체 리로드)하면 정상**으로 보임
- **로컬 개발 환경에서는 재현 안 됨**, 배포 서버에서만 발생

이 다섯 줄이 사실 답을 거의 다 품고 있었는데, 처음엔 보이지 않았다.

## 1차 용의자: react-query 캐시

이 앱은 react-query를 "수동 갱신" 모델로 쓰고 있었다.

```js
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: Infinity,
      refetchOnWindowFocus: false,
    },
  },
});
```

`staleTime: Infinity` + 포커스 refetch off라, 화면 갱신은 전적으로 mutation 후의 명시적 `invalidateQueries`에 의존한다. 그러니 "화면이 안 바뀐다"면 1순위 의심은 무효화 키 불일치다.

```js
// 활성 쿼리 키
queryKey: ["items", parentId];
// 무효화
queryClient.invalidateQueries({ queryKey: ["items"] }); // prefix 매칭
```

키는 정상적으로 prefix 매칭됐고, `invalidateQueries`의 기본 `refetchType: "active"`는 활성 쿼리를 refetch한다. 정적으로는 "동작해야 맞다". 여기서 한참 막혔다.

## 멀티탭도 아니었다

"여러 탭을 띄워서 그런가?"라는 가설도 나왔다. 하지만 코드를 뒤져봐도 탭 간 상태를 동기화하는 장치(`BroadcastChannel`, service worker, `storage` 이벤트 구독, react-query의 `broadcastQueryClient`)가 하나도 없었다. react-query 캐시는 탭마다 독립된 인메모리다. 탭 간 간섭은 원인이 아니었다.

## 결정적 전환: "로컬에서는 된다"

정적 분석이 계속 "동작해야 한다"고 말하는데 현실은 아니니, 실제로 돌려봤다. 로컬에서 브라우저 자동화로 mutation을 실행하고 네트워크를 캡처했다.

```text
PATCH /api/items/123/move   -> 200
GET   /api/items?parentId=… -> 200   (자동 refetch)
```

refetch는 정확히 발생했고, 응답에는 변경된 데이터가 들어 있었고, **화면도 리로드 없이 갱신됐다.** 로컬에서는 완벽히 동작했다.

이 순간 프레임이 바뀐다. **"로컬은 되는데 배포하면 안 된다"는 코드 버그가 아니라 환경 차이다.** react-query, 무효화 로직, 컴포넌트 렌더 — 전부 결백했다. 차이는 배포 서버 앞단에만 있는 무언가였다.

## 범인: CloudFront가 GET을 캐싱

배포 서버 API의 응답 헤더를 직접 봤다.

```bash
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을 반복해 봤다.

```bash
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 cloudfront
```

`Hit from cloudfront`. **CloudFront가 GET 응답을 캐싱하고 있었다.** origin이 `Cache-Control`을 주지 않으면 CloudFront는 기본 TTL로 GET/HEAD를 캐싱할 수 있다([AWS 문서](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html)).

원인 체인이 맞춰졌다.

1. origin(Express)이 API 응답에 `Cache-Control`을 안 보냄
2. → CloudFront가 기본 TTL로 `GET /api/*`를 캐싱
3. → 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`** (정답)

```js
// 모든 API 응답에, 라우트보다 먼저 등록
app.use("/api", (req, res, next) => {
  res.set("Cache-Control", "no-store");
  next();
});
```

`no-store`는 "저장하지 마라"는 가장 강한 지시다([MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)). CDN과 브라우저 양쪽에 적용된다.

**2) Client — fetch에도 `cache: "no-store"`** (이중 방어)

```js
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` 정책으로 바꾸는 게 가장 확실하다([관리형 정책 문서](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html)). 그리고 이미 쌓인 캐시는 무효화로 비운다.

```bash
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의 존재와 캐시 적중을 바로 알려준다.

코드가 결백하다는 걸 증명하는 데 가장 오래 걸렸다. 하지만 그 증명이 끝나는 순간, 남은 건 헤더 한 줄이었다.
