HTTP 캐시와 조건부 요청
게시판 표현에 Cache-Control과 ETag를 적용해 200·304 흐름을 검증하고 개인 응답, Vary, 변경 후 무효화의 경계를 정합니다.
캐시는 같은 표현을 다시 계산하고 전송하는 비용을 줄입니다. 그러나 사용자별 게시글을 공유 프록시에 섞거나 변경 뒤 오래된 값을 계속 보여 주면 성능 이득보다 피해가 큽니다. 무엇을 저장할 수 있는지, 언제 최신인지, 오래됐을 때 어떤 검증기로 재검증할지 세 질문을 분리합니다.
Cache-Control 지시어
자주 혼동하는 두 지시어가 있습니다.
no-store: 응답을 캐시에 저장하지 말라는 뜻no-cache: 저장할 수 있지만 재사용 전 출처에 검증하라는 뜻
로그인·토큰·민감한 개인 정보에는 no-store를 사용합니다.
회원 자신의 게시글 목록은 브라우저 private 캐시만 허용하고 매번 검증기로 확인할 수 있습니다.
공개 통계는 public, max-age=60처럼 공유 캐시를 허용할 수 있습니다.
| 응답 | 예시 정책 | 이유 |
|---|---|---|
| 로그인 응답 | no-store | 인증 정보·세션 발급 |
| 개인 게시글 상세 | private, no-cache | 브라우저 저장 후 매번 재검증 |
| 공개 제목 통계 | public, max-age=60 | 여러 사용자가 같은 표현 공유 |
| 오류 상세 | 보통 no-store | 입력·권한 정보가 포함될 수 있음 |
max-age는 초 단위 최신성 수명입니다.
최신 상태인 동안 캐시는 출처에 묻지 않고 재사용할 수 있습니다.
데이터가 반드시 즉시 일치해야 하면 짧게 하거나 항상 재검증합니다.
ETag와 표현 버전
서버가 200 응답에 ETag: "post-42-v7"을 보내면 클라이언트는 다음 조회에 If-None-Match로 돌려보냅니다.
표현이 그대로면 304와 헤더만 반환하고 기존 본문을 재사용합니다.
GET /api/posts/42 HTTP/1.1
Host: board.example
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-cache
ETag: "post-42-v7"
{"id":42,"title":"HTTP cache","content":"조건부 요청을 정리합니다.","version":7}GET /api/posts/42 HTTP/1.1
Host: board.example
Accept: application/json
If-None-Match: "post-42-v7"
HTTP/1.1 304 Not Modified
Cache-Control: private, no-cache
ETag: "post-42-v7"304에는 새 표현 본문을 넣지 않습니다. 클라이언트는 자기 캐시 본문과 304 메타데이터를 결합합니다. 304 자체만 따로 저장해 본문처럼 보여 주는 것이 아닙니다.
게시판 조건부 응답
ETag는 반드시 따옴표가 포함된 엔티티 태그 문법이어야 합니다. 데이터베이스 행 버전과 표현 종류를 함께 반영합니다.
package board.web;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/posts")
class CachedPostController {
private final VersionedPostQuery query;
CachedPostController(VersionedPostQuery query) {
this.query = query;
}
@GetMapping("/{id}")
ResponseEntity<PostResponse> get(
@PathVariable long id,
@RequestHeader(
name = HttpHeaders.IF_NONE_MATCH,
required = false)
String ifNoneMatch) {
var post = query.required(id);
var response = PostResponse.from(post);
var etag = "\\"post-" + id
+ "-json-v" + post.version() + "\\"";
var cache = CacheControl
.noCache()
.cachePrivate();
if (etag.equals(ifNoneMatch)) {
return ResponseEntity.status(304)
.cacheControl(cache)
.eTag(etag)
.build();
}
return ResponseEntity.ok()
.cacheControl(cache)
.eTag(etag)
.body(response);
}
}예제는 한 태그 비교를 드러내기 위해 단순화했습니다.
실제 If-None-Match는 여러 태그와 와일드카드를 담을 수 있고 약한 비교 규칙이 있습니다.
Spring의 WebRequest.checkNotModified() 같은 조건부 요청 지원을 사용해 파서를 직접 재구현하지 않습니다.
JSON과 CSV 본문이 다르면 같은 데이터베이스 버전이어도 표현별 강한 ETag가 달라야 합니다.
본문 바이트가 정확히 같다는 보장이 없는 검증기는 W/"..." 약한 태그를 사용합니다.
200·304 응답 검증
package board.web;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.client.RestTestClient;
class ConditionalRequestTest {
@Test
void 같은_version은_304_변경된_version은_새_200이다() {
var query = new MutableVersionedPostQuery(
post(42L, "HTTP cache", 50, 7L));
var client = RestTestClient
.bindToController(
new CachedPostController(query))
.build();
client.get()
.uri("/api/posts/42")
.exchange()
.expectStatus().isOk()
.expectHeader()
.valueEquals(
"ETag", "\\"post-42-json-v7\\"")
.expectHeader()
.valueEquals(
"Cache-Control",
"no-cache, private")
.expectBody()
.jsonPath("$.version").isEqualTo(7);
client.get()
.uri("/api/posts/42")
.header("If-None-Match",
"\\"post-42-json-v7\\"")
.exchange()
.expectStatus().isNotModified()
.expectBody().isEmpty();
query.replace(post(
42L, "HTTP cache", "캐시 갱신을 정리합니다.", 8L));
client.get()
.uri("/api/posts/42")
.header("If-None-Match",
"\\"post-42-json-v7\\"")
.exchange()
.expectStatus().isOk()
.expectHeader()
.valueEquals(
"ETag", "\\"post-42-json-v8\\"")
.expectBody()
.jsonPath("$.content").isEqualTo("캐시 갱신을 정리합니다.")
.jsonPath("$.version").isEqualTo(8);
}
}ConditionalRequestTest
> 같은_version은_304_변경된_version은_새_200이다() PASSED
first = 200, ETag "post-42-json-v7"
revalidate = 304, empty body
after update = 200, ETag "post-42-json-v8"
BUILD SUCCESSFULLast-Modified 정밀도
Last-Modified와 If-Modified-Since도 검증기로 쓸 수 있습니다.
HTTP 날짜 정밀도와 데이터베이스 타임스탬프 정밀도가 다를 수 있고 같은 초 안의 여러 변경을 놓칠 수 있어 버전 ETag가 더 적합한 리소스도 있습니다.
둘을 함께 제공할 수 있지만 서버의 조건부 우선순위를 규격에 맞게 처리해야 합니다. 애플리케이션 코드에서 문자열 날짜 비교를 직접 만들지 않습니다. 파일·정적 자산처럼 수정 시각이 자연스러운 리소스에는 Last-Modified가 잘 맞습니다.
프록시 캐시 사용자 분리
리버스 프록시와 CDN은 출처 앞의 공유 캐시입니다.
공개 통계를 빠르게 제공할 수 있지만 Authorization·쿠키에 따라 달라지는 응답을 URI만으로 저장하면 다른 사용자에게 노출됩니다.
Vary: Accept, Accept-Encoding은 JSON·CSV·압축 표현을 분리합니다.
사용자 ID를 임의 헤더에 넣고 Vary로 해결하려 하면 캐시 키 카디널리티와 민감 정보 문제가 생깁니다.
개인 응답은 private로 공유 캐시를 막는 것이 기본입니다.
프록시가 오래된 응답을 잠시 제공하도록 stale-while-revalidate나 stale-if-error를 쓸 수 있습니다.
공개 집계처럼 약간 오래돼도 되는 데이터에만 최신성 예산을 정하고 적용합니다.
캐시 무효화 전략
게시글 42를 변경하면 다음 조회의 ETag가 v8로 바뀌어 이전 검증기에 200을 반환합니다.
그러나 max-age=10m으로 아직 최신인 캐시는 그동안 출처에 묻지 않습니다.
즉시 반영이 필요하면 짧은 최신성, 제거 API, 버전이 붙은 URI, 이벤트 기반 무효화 중 운영 구조에 맞는 방법이 필요합니다.
캐시 키에서 쿼리 순서와 기본값 파라미터를 어떻게 정규화하는지, 삭제한 리소스의 404도 부정 캐시되는지 확인합니다.
무효화 이벤트가 유실됐을 때 max-age가 최종 복구 경계가 되도록 무한 최신성을 피합니다.
연습 문제
공개 주간 게시글 통계 엔드포인트에 JSON과 CSV 표현별 ETag를 추가하세요.
같은 버전의 JSON 조건부 요청은 304, JSON 태그를 CSV 요청에 보내면 200이어야 합니다.
집계 갱신 뒤 두 태그가 모두 바뀌고 Vary: Accept, Accept-Encoding이 유지되는지 검증합니다.
해설 보기
태그 재료에 집계 버전과 표현 키를 포함합니다.
String etag(long version, String representation) {
return "\\"weekly-stats-"
+ representation
+ "-v" + version + "\\"";
}강한 ETag를 해시로 만들 경우 정규 직렬화 바이트를 기준으로 계산하고 비용을 측정합니다. DB 버전 기반 태그는 빠르지만 같은 버전에서 직렬화기 설정이 바뀌면 본문이 달라질 수 있으므로 배포 리비전이나 표현 스키마 버전을 재료에 포함하는 방안을 검토합니다.
테스트는 200 본문과 헤더, 304 빈 본문, 갱신 후 200을 한 흐름으로 실행합니다. 캐시 지시어가 개인 정보가 없는 공개 통계에만 적용됐는지도 별도 검증합니다.
다음 장에서는 HTTP 메시지가 내장 서버에 도착한 뒤 Servlet 컨테이너가 요청·응답 객체를 만들고 필터와 Servlet, DispatcherServlet을 호출하는 내부 경로로 내려갑니다.