HTTP 캐시 제어 헤더 완전 정리 - 프록시 캐시와 캐시 무효화
한 줄 요약: Cache-Control 지시어로 캐시 유효기간과 공개 범위를 제어하고, 프록시 캐시를 활용해 글로벌 응답 속도를 높이며, must-revalidate로 만료된 캐시의 오사용을 방지한다.
비유로 이해하는 프록시 캐시
편의점 물류 시스템을 생각해보자.
- 원(Origin) 서버: 미국 본사 창고 (거리가 멀어 배송에 0.5초)
- 프록시 캐시 서버: 한국 물류 창고 (가까워서 0.01초)
- 브라우저 캐시: 내 집 냉장고 (즉시 꺼내 쓸 수 있음)
미국 창고에서 직접 가져오면 느리지만, 한국 물류 창고에 자주 팔리는 상품을 미리 채워두면 훨씬 빠르게 받을 수 있다. YouTube 영상, Netflix 콘텐츠가 한국에서 빠른 이유도 이 프록시(CDN) 캐시 덕분이다.
캐시 제어 헤더 전체 목록
Cache-Control 주요 지시어
# 유효 시간 제어
Cache-Control: max-age=3600 # 3600초(1시간) 동안 캐시 유효
Cache-Control: s-maxage=86400 # 프록시 캐시에만 적용되는 max-age
# 캐시 여부 제어
Cache-Control: no-store # 캐시 자체를 금지 (민감한 데이터)
Cache-Control: no-cache # 캐시하되 매번 원 서버에 검증
# 공개 범위 제어
Cache-Control: public # CDN 등 공유 캐시에 저장 허용
Cache-Control: private # 브라우저 캐시에만 저장 (기본값)
# 재검증 강제
Cache-Control: must-revalidate # 만료 후 원 서버에 반드시 재검증
Cache-Control: proxy-revalidate # 프록시 캐시만 재검증 강제
Cache-Control: immutable # 유효기간 내에는 절대 변경되지 않음
지시어 상세 설명
max-age
Cache-Control: max-age=3600
- 리소스를 받은 시점부터 3600초(1시간) 동안 캐시가 유효하다
- 유효기간 내에는 서버에 요청하지 않고 캐시를 바로 사용한다
- 정적 리소스(이미지, CSS, JS)에 적합하다
no-cache
Cache-Control: no-cache
이름에 “no-cache”가 있지만 “캐시 안 함”이 아니다.
sequenceDiagram
participant C as "브라우저"
participant P as "프록시 캐시"
participant S as "원 서버"
C->>P: "1. GET /index.html\nCache-Control: no-cache"
P->>S: "2. 원 서버에 반드시 검증 요청 전달"
S-->>P: "3. 304 Not Modified 또는 200 OK"
P-->>C: "4. 응답 반환"
Note over C: "5. 캐시는 저장됨\n단, 매번 서버 검증 필요"
- 캐시에 저장은 한다
- 하지만 사용할 때마다 원 서버에 변경 여부를 반드시 확인한다
- 자주 바뀌는 동적 콘텐츠 (HTML, API 응답)에 적합하다
no-store
Cache-Control: no-store
- 캐시 자체를 완전히 금지한다
- 저장도 하지 않고, 매번 서버에서 새로 받는다
- 금융 정보, 개인 데이터 등 민감한 정보에 사용한다
must-revalidate
Cache-Control: max-age=3600, must-revalidate
- 캐시 유효기간 내에는 정상적으로 캐시를 사용한다
- 유효기간이 지난 후 원 서버에 재검증을 강제한다
- 원 서버에 접근할 수 없을 때 반드시 오류(504 Gateway Timeout) 를 반환한다
no-cache vs must-revalidate — 네트워크 단절 시 차이
이 두 지시어의 중요한 차이점은 원 서버 접근 불가 상황에서 나타난다.
graph LR
C["브라우저"] -->|"GET /page.html"| P["프록시"]
P -->|"no-cache: 원서버 장애"| P
P -->|"오래된 캐시 반환 가능"| C
P -->|"must-revalidate 장애"| P
P -->|"504 Gateway Error"| C
| 상황 | no-cache | must-revalidate |
|---|---|---|
| 유효기간 내 | 매번 서버 검증 | 캐시 바로 사용 |
| 만료 후 서버 정상 | 서버 검증 후 사용 | 서버 검증 후 사용 |
| 만료 후 서버 장애 | 오래된 캐시 반환 가능 | 504 오류 반환 (절대 오래된 캐시 사용 안 함) |
결론:
- 데이터 정합성이 매우 중요하다 →
must-revalidate - 일시적 서버 장애 시 오래된 데이터라도 보여줘도 된다 →
no-cache
프록시 캐시 (CDN)
프록시 캐시가 없는 경우
graph LR
K1["한국 사용자1"] -->|"GET /video.mp4"| S["미국 원서버"]
S -->|"500MB 전송"| K1
K2["한국 사용자2"] -->|"GET /video.mp4"| S
S -->|"500MB 또 전송"| K2
프록시 캐시 도입 후
graph LR
K["사용자"] -->|"GET /video.mp4"| P["프록시(CDN)"]
P -->|"캐시 없음→원서버 요청"| S["원 서버"]
S -->|"500MB"| P
P -->|"500MB 반환"| K
K -->|"재요청"| P
P -->|"캐시 히트"| K
프록시 캐시 효과:
- 원 서버 부하 감소
- 사용자 응답 속도 향상 (지리적으로 가까운 캐시 서버 사용)
- 네트워크 비용 절감
public vs private
# public: CDN, 프록시 등 공유 캐시에 저장 허용
# 누구나 접근하는 공개 콘텐츠 (이미지, JS, CSS, 공개 API)
Cache-Control: public, max-age=86400
# private: 브라우저 캐시에만 저장 (개인 데이터)
# CDN이 이 응답을 캐시하면 다른 사람에게 개인정보가 노출될 위험
Cache-Control: private, max-age=3600
graph LR
A["브라우저"] -->|"요청"| B["프록시 캐시 (CDN)"]
B -->|"캐시 없으면"| C["원 서버"]
D["public → CDN 저장 가능"] -.-> B
E["private → CDN 저장 불"] -.-> A
s-maxage — 프록시 전용 유효기간
Cache-Control: s-maxage=86400, max-age=3600
s-maxage: 프록시(공유) 캐시에서의 유효기간 (86400초 = 1일)max-age: 브라우저(개인) 캐시에서의 유효기간 (3600초 = 1시간)- 브라우저와 CDN에 서로 다른 유효기간을 지정할 때 사용한다
Age 헤더
Age: 1800
- 원 서버에서 응답이 생성된 후 프록시 캐시에 머문 시간(초)
- 위 예시: 원 서버에서 30분 전에 받아 프록시가 보관 중
- CDN이 자동으로 추가하는 헤더다
레거시 캐시 헤더 — Pragma, Expires
Expires (HTTP 1.0)
Expires: Mon, 01 Jan 2025 00:00:00 GMT
- HTTP/1.0의 캐시 방식이다
- 절대 날짜로 만료 시점을 지정한다
Cache-Control: max-age와 함께 사용하면 max-age가 우선한다- 현재는
Cache-Control: max-age사용을 권장한다
문제점:
- 서버 시간과 클라이언트 시간이 다를 수 있다
- "2025년 1월 1일"이 지나면 갱신하기 전까지 항상 만료 상태
→ max-age=3600처럼 상대적 시간이 훨씬 유연하다
Pragma (HTTP 1.0)
Pragma: no-cache
- HTTP/1.0 하위 호환용이다
- 현재는
Cache-Control: no-cache를 사용한다 - 구형 프록시 호환이 필요할 때만 함께 사용한다
캐시 무효화 — 확실한 설정
특정 리소스가 절대로 캐시되어서는 안 될 때 사용하는 완전한 무효화 설정이다.
Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: 0
각 지시어의 역할:
no-cache: 캐시하더라도 매번 서버 검증no-store: 아예 캐시에 저장 금지must-revalidate: 만료 후 서버 검증 강제Pragma: no-cache: HTTP/1.0 구형 프록시 호환Expires: 0: HTTP/1.0 구형 클라이언트 호환
리소스 유형별 캐시 전략
정적 리소스 — 파일명에 해시 포함
<!-- 배포마다 파일명이 바뀌어 캐시가 자동 무효화됨 -->
<link rel="stylesheet" href="/css/style.a1b2c3d4.css">
<script src="/js/app.e5f6g7h8.js"></script>
<img src="/images/logo.f9g0h1i2.png">
# 파일명이 바뀌면 새로운 URL이므로 캐시가 자동 무효화됨
# 따라서 유효기간을 최대로 설정해도 안전
Cache-Control: public, max-age=31536000, immutable
HTML 파일 — 항상 최신 버전 확인
# HTML은 정적 리소스 파일명을 참조하므로 항상 최신이어야 함
Cache-Control: no-cache
API 응답 — 용도에 맞게 설정
# 공개 API (누구나 같은 응답)
Cache-Control: public, max-age=60
# 사용자별 개인 API
Cache-Control: private, max-age=300
# 실시간 데이터 (항상 최신)
Cache-Control: no-store
# 자주 바뀌지만 캐시 효과 원함
Cache-Control: private, no-cache, max-age=0
캐시 계층 구조
graph LR
A["요청"] --> B["브라우저 캐시"]
B -->|"미스"| C["프록시/CDN"]
C -->|"미스"| D["원 서버"]
B -->|"히트"| Z["즉시 반환"]
C -->|"히트"| Y["CDN 반환"]
style Z fill:#90EE90
style D fill:#FFB6C1
Spring에서의 캐시 제어 전체 설정
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 정적 리소스 (해시 파일명 포함) — 1년 캐시
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS)
.cachePublic()
.immutable());
// 일반 정적 리소스 — 1시간 캐시
registry.addResourceHandler("/assets/**")
.addResourceLocations("classpath:/assets/")
.setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS)
.cachePublic());
}
}
@RestController
public class ApiController {
// 공개 데이터 — CDN 캐시 허용
@GetMapping("/api/products")
public ResponseEntity<List<Product>> getProducts() {
List<Product> products = productService.findAll();
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
.body(products);
}
// 개인 데이터 — 브라우저 캐시만
@GetMapping("/api/my/orders")
public ResponseEntity<List<Order>> getMyOrders() {
List<Order> orders = orderService.findByCurrentUser();
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.MINUTES).cachePrivate())
.body(orders);
}
// 캐시 금지 (실시간 데이터)
@GetMapping("/api/stock/price")
public ResponseEntity<StockPrice> getStockPrice() {
StockPrice price = stockService.getCurrentPrice();
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.body(price);
}
// 민감 데이터 — 완전 무효화
@GetMapping("/api/my/account")
public ResponseEntity<Account> getAccount() {
Account account = accountService.findByCurrentUser();
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore()
.noCache()
.mustRevalidate())
.body(account);
}
}
캐시 제어 헤더 전체 요약
| 헤더 | 역할 | HTTP 버전 |
|---|---|---|
Cache-Control |
캐시 정책 통합 제어 | 1.1 |
Pragma |
구형 프록시 하위 호환 | 1.0 |
Expires |
절대 날짜로 만료 지정 | 1.0 |
Age |
프록시가 보관한 시간 | 1.1 |
| Cache-Control 지시어 | 설명 |
|---|---|
max-age=N |
N초 동안 캐시 유효 |
s-maxage=N |
프록시 캐시 유효기간 (max-age 오버라이드) |
no-cache |
캐시하되 매번 원 서버에 검증 |
no-store |
캐시 완전 금지 |
public |
공유(프록시) 캐시 저장 허용 |
private |
브라우저 캐시만 허용 (기본값) |
must-revalidate |
만료 후 원 서버 재검증 강제. 실패 시 504 |
proxy-revalidate |
프록시에만 재검증 강제 |
immutable |
유효기간 내 절대 변경 없음 (조건부 요청도 안 보냄) |
핵심 포인트 정리
- no-cache는 “캐시 안 함”이 아니다. “캐시하되 매번 서버 검증”이다
- no-store가 진짜 “캐시 안 함”이다. 민감한 데이터에 사용한다
- must-revalidate는 만료 후 원 서버 장애 시 504를 반환한다. no-cache는 오래된 캐시를 반환할 수 있다
- public/private 구분이 중요하다. 개인 데이터에 public을 설정하면 CDN이 다른 사용자에게 노출한다
- 프록시 캐시(CDN) 는 원 서버 부하를 줄이고 지리적으로 가까운 캐시에서 빠르게 응답한다
- 정적 리소스는 파일명에 해시를 넣고
max-age=31536000, immutable로 설정하는 것이 권장 전략이다 - HTML은
no-cache로 설정해 항상 최신 파일명(해시 포함)을 참조하도록 한다
댓글