HTTP 검증 헤더와 조건부 요청 - Last-Modified 상세
한 줄 요약: 검증 헤더(Last-Modified)와 조건부 요청(If-Modified-Since)을 활용하면 캐시가 만료된 후에도 서버 데이터가 변경되지 않았다면 304 응답으로 바디 전송 없이 캐시를 재사용할 수 있다.
비유로 이해하는 캐시 재검증
도서관 책 반납 시스템을 생각해보자.
- 책을 빌렸을 때 “최종 개정일: 2024년 1월 1일”이라는 스탬프가 찍혀있다
- 반납 기한이 지나서 다시 빌리러 갔다
- 사서에게 묻는다: “2024년 1월 1일 이후로 이 책이 개정됐나요?”
- 개정이 없다면 → “그냥 계속 보세요. 반납 기한만 연장할게요” (304 Not Modified)
- 개정됐다면 → “새 판본 드릴게요” (200 OK + 새 데이터)
HTTP 조건부 요청도 동일하다. 캐시가 만료됐을 때 서버에 전체 데이터를 다시 요청하는 대신, “혹시 변경됐나요?”라고 먼저 확인한다.
캐시 시간 초과 후의 두 가지 상황
캐시 유효기간이 지나서 서버에 다시 요청하면 두 가지 상황이 발생한다.
상황 1: 서버에서 기존 데이터를 변경함
→ 새 데이터를 다시 다운로드해야 한다 (200 OK + 전체 바디)
상황 2: 서버에서 기존 데이터를 변경하지 않음
→ 기존 캐시를 재사용하면 된다 (304 Not Modified, 바디 없음)
문제: 브라우저 입장에서는 캐시가 만료됐을 때 서버 데이터가 변경됐는지 알 수 없다.
해결: 검증 헤더(Last-Modified)를 사용해 서버에 변경 여부를 확인한다.
검증 헤더 — Last-Modified
첫 번째 요청: 서버가 Last-Modified를 응답에 포함
sequenceDiagram
participant C as "브라우저"
participant S as "서버"
C->>S: "1. GET /star.jpg HTTP/1.1"
S-->>C: "2. 200 OK\nCache-Control: max-age=3600\nLast-Modified: Mon, 01 Jan 2024 10:00:00 GMT\nContent-Length: 1048576\n\n[1MB 이미지 데이터]"
Note over C: "3. 캐시 저장:\n- 데이터: star.jpg (1MB)\n- 유효시간: 1시간\n- Last-Modified: 2024-01-01 10:00:00 GMT"
서버는 응답에 Last-Modified 헤더를 포함해 리소스의 마지막 수정 시각을 알려준다. 브라우저는 이 값을 캐시 메타데이터로 저장한다.
조건부 요청 — If-Modified-Since
캐시 만료 후 재요청
sequenceDiagram
participant C as "브라우저 (캐시 만료됨)"
participant S as "서버"
Note over C: "캐시 유효기간 초과\n저장된 Last-Modified: 2024-01-01 10:00:00"
C->>S: "1. GET /star.jpg HTTP/1.1\nIf-Modified-Since: Mon, 01 Jan 2024 10:00:00 GMT"
Note over S: "2. 요청 검증:\nstar.jpg 수정일 = 2024-01-01 10:00:00\nIf-Modified-Since = 2024-01-01 10:00:00\n→ 동일! 변경 없음"
S-->>C: "3. 304 Not Modified\nCache-Control: max-age=3600\n(Body 없음)"
Note over C: "4. 캐시 메타데이터 갱신\n기존 캐시 데이터 재사용\n유효기간 리셋"
브라우저는 캐시에 저장된 Last-Modified 값을 If-Modified-Since 헤더에 담아 서버에 보낸다. 서버는 이를 비교해 변경 여부를 확인한다.
304 Not Modified 응답의 핵심
HTTP/1.1 304 Not Modified
Cache-Control: max-age=3600
Last-Modified: Mon, 01 Jan 2024 10:00:00 GMT
304 응답의 특징:
- Body가 없다 → 헤더만 전송 (약 0.1KB)
- 기존 1MB 파일 대신 0.1KB 헤더만 네트워크로 전송
- 브라우저는 응답 헤더를 받아 캐시 메타데이터를 갱신한다
- 기존 캐시에 저장된 데이터(1MB)를 그대로 재사용한다
[캐시 미적용]
매 요청마다 1MB + 0.1KB = 1.1MB 전송
[Last-Modified 조건부 요청 — 데이터 변경 없음]
최초 요청: 1MB + 0.1KB = 1.1MB 전송
이후 만료 시: 0.1KB(헤더만) 전송 → 1MB 절감 (약 91% 절감)
[Last-Modified 조건부 요청 — 데이터 변경됨]
최초 요청: 1MB + 0.1KB = 1.1MB 전송
만료 + 변경 시: 1MB + 0.1KB = 1.1MB 전송 (어쩔 수 없음)
전체 캐시 흐름 — 재검증 포함
graph LR
C["브라우저"] -->|"GET /star.jpg"| S["서버"]
S -->|"200 + Last-Mod"| C
C -->|"If-Modified-Since"| S
S -->|"변경 없음 → 304"| CACHE["캐시 재사용"]
S -->|"변경됨 → 200"| NEW["새 이미지"]
서버 측 처리 로직
서버는 If-Modified-Since 헤더를 받으면 다음 로직으로 처리한다.
graph LR
A["요청 수신"] --> B{"If-Modified-Since"}
B -- "없음" --> C["일반 200 응답"]
B -- "있음" --> D{"리소스 수정일"}
D -- "수정됨" --> E["200 OK"]
D -- "수정 안됨\n(같거나 이전)" --> F["304 Not Modified"]
style F fill:#87CEEB
style E fill:#FFB6C1
style C fill:#90EE90
Spring에서의 Last-Modified 구현
WebRequest를 활용한 자동 처리
@GetMapping("/api/articles/{id}")
public ResponseEntity<Article> getArticle(
@PathVariable Long id,
WebRequest request) {
Article article = articleService.findById(id);
long lastModified = article.getUpdatedAt().toEpochMilli();
// checkNotModified: If-Modified-Since와 비교 후
// 변경 없으면 자동으로 304 반환
if (request.checkNotModified(lastModified)) {
return null; // Spring이 304 응답을 자동 생성
}
return ResponseEntity.ok()
.lastModified(lastModified)
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS))
.body(article);
}
HttpServletRequest 직접 사용
@GetMapping("/files/{filename}")
public ResponseEntity<byte[]> getFile(
@PathVariable String filename,
HttpServletRequest request) throws IOException {
File file = storageService.getFile(filename);
long lastModifiedMs = file.lastModified();
// If-Modified-Since 헤더 값 가져오기 (-1이면 헤더 없음)
long ifModifiedSince = request.getDateHeader("If-Modified-Since");
// 초 단위로 비교 (HTTP 날짜는 초 단위 정밀도)
if (ifModifiedSince != -1 &&
lastModifiedMs / 1000 <= ifModifiedSince / 1000) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
}
byte[] content = Files.readAllBytes(file.toPath());
return ResponseEntity.ok()
.header(HttpHeaders.LAST_MODIFIED, formatHttpDate(lastModifiedMs))
.header(HttpHeaders.CACHE_CONTROL, "max-age=3600")
.contentType(MediaType.IMAGE_PNG)
.body(content);
}
private String formatHttpDate(long millis) {
return DateTimeFormatter.RFC_1123_DATE_TIME
.format(Instant.ofEpochMilli(millis).atZone(ZoneId.of("GMT")));
}
정적 리소스 자동 설정
Spring Boot는 src/main/resources/static/ 하위 정적 파일에 Last-Modified를 자동 설정한다.
# application.yml
spring:
web:
resources:
cache:
period: 3600
cachecontrol:
max-age: 3600
must-revalidate: true
Last-Modified 날짜 형식
HTTP 날짜는 반드시 RFC 7231 형식을 사용해야 한다.
Last-Modified: Tue, 15 Jan 2024 10:30:00 GMT
# 형식 분해
Tue, = 요일 3글자
15 = 일 (2자리)
Jan = 월 (3글자 영문 약자)
2024 = 연도 (4자리)
10:30:00 = 시:분:초
GMT = 타임존 (반드시 GMT/UTC)
주의: 한국 시간(KST = UTC+9)이 아닌 반드시 GMT(UTC)로 표현해야 한다. 잘못된 시간대를 사용하면 캐시 재검증이 오동작한다.
If-Unmodified-Since — 안전한 업데이트
If-Modified-Since의 반대 방향으로 동작하는 헤더다. 변경되지 않았을 때만 요청을 처리한다.
PUT /documents/100 HTTP/1.1
If-Unmodified-Since: Mon, 01 Jan 2024 10:00:00 GMT
Content-Type: application/json
{"content": "수정된 내용..."}
graph LR
A["사용자 A"] -->|"GET /doc"| S["서버"]
B["사용자 B"] -->|"PUT 먼저 성공"| S
S -->|"수정일 변경"| S
A -->|"PUT If-Unmod-Since"| S
S -->|"412 충돌 감지"| A
이 패턴은 HTTP 레벨에서 낙관적 잠금(Optimistic Locking) 을 구현하는 방법이다.
네트워크 절감 효과
| 상황 | 전송 크기 | 비고 |
|---|---|---|
| 캐시 없음 (매 요청) | 1.1MB | 헤더 0.1MB + 바디 1MB |
| 캐시 유효 (만료 전) | 0KB | 서버 요청 없음 |
| 캐시 만료 + 데이터 미변경 | 0.1KB | 헤더만 (304) |
| 캐시 만료 + 데이터 변경됨 | 1.1MB | 어쩔 수 없는 전송 |
핵심 포인트 정리
| 헤더 | 방향 | 역할 |
|---|---|---|
Last-Modified |
응답 | 리소스 마지막 수정 시각 (RFC 7231 형식) |
If-Modified-Since |
요청 | 해당 날짜 이후 변경 여부 확인 |
If-Unmodified-Since |
요청 | 해당 날짜까지 변경 없을 때만 처리 |
- 304 Not Modified 응답에는 Body가 없다. 헤더만 전송해 네트워크를 절감한다
Last-Modified는 초 단위 정밀도만 가진다. 1초 이내 변경은 감지할 수 없다- 캐시가 만료됐다고 해서 반드시 새 데이터를 내려받는 것이 아니다. 304 → 캐시 재사용이 일반적이다
- 브라우저는 캐시와 함께
Last-Modified값을 저장해뒀다가, 만료 시If-Modified-Since에 담아 서버에 보낸다 - 서버는 데이터 변경 여부만 확인하고, 변경 없으면 Body 없이 304만 응답한다
댓글