HTTP 캐시와 조건부 요청
선택된 표현의 강한 ETag와 Last-Modified를 계산하고 200·304 재검증, 저장 범위, stale 예산, 무효화 경계를 MockMvc 계약으로 검증합니다.
HTTP 캐시는 응답 메시지를 저장했다가 이후 요청에 재사용합니다. 성능을 얻으려면 세 질문을 분리해야 합니다.
- 이 응답을 어떤 캐시가 저장해도 되는가?
- 저장된 응답은 언제까지 fresh하며, stale이 된 뒤 어떤 조건으로 재사용하는가?
- 출처에 다시 물을 때 어떤 검증기가 지금 선택된 표현을 식별하는가?
Cache-Control은 저장과 재사용 규칙을 말하고, ETag와 Last-Modified는 표현이 바뀌었는지 판단할 근거를 제공합니다. 어느 하나가 다른 하나를 대신하지 않습니다.
첫 200 응답을 저장한 캐시는 다음 요청에 If-None-Match를 보냅니다. 태그가 맞으면 출처는 적용 가능한 메타데이터를 담은 304를 보내고 캐시는 그 메타데이터를 저장된 응답에 합친 뒤 기존 본문을 재사용합니다. 선택된 표현이 바뀌었다면 새 본문과 새 태그를 가진 200을 저장합니다.
INITIAL 200 · STRONG ETAG · IF-NONE-MATCH LIST · 304 MERGE
조건부 재검증은 같은 표현이면 304, 바뀌면 새 200을 반환한다
cache는 첫 200의 body와 선택 표현 전용 validator를 함께 저장합니다. 재검증 때 If-None-Match 목록을 weak 비교해 같으면 304 메타데이터를 병합하고 저장 body를 재사용하며, 달라졌으면 새 200 body와 tag로 교체합니다.
01 · INITIAL 200
선택된 JSON 표현의 body와 strong ETag를 함께 저장한다
- client의 첫
GET /posts/42를 cache가 origin server로 전달합니다. 200은 JSON body와 표현별 strongETag: "post-42-json-v7"를 돌려줍니다.- private cache가 body와
Cache-Control·Vary메타데이터를 같은 항목에 저장합니다.
02 · CONDITIONAL REQUEST
If-None-Match 목록은 현재 선택 표현의 tag와 weak 비교한다
- 조건 값은
*또는 entity-tag 목록일 수 있습니다. If-None-Match: W/"post-42-json-v7", "other"중 하나만 weak 비교로 맞아도 match입니다.If-None-Match가 있으면 함께 온If-Modified-Since는 무시합니다.
03 · MATCH → 304
304 메타데이터를 병합하고 저장한 body를 재사용한다
- GET의 선택 표현과 tag가 맞으면 origin server는
304 Not Modified를 반환합니다. 304는 content나 trailer 없이 적용되는ETag·Cache-Control·Vary를 포함합니다.- cache가 저장 응답의 header fields를 갱신한 뒤 기존 body를 client에 재사용합니다.
04 · CHANGED → 200
표현이 바뀌면 새 body와 새 strong ETag로 항목을 교체한다
- 현재 JSON 표현이 v8로 바뀌면 기존 v7 tag는 match하지 않습니다.
- origin server가 새 body와
ETag: "post-42-json-v8"를 담은200을 반환합니다. - cache가 이전 항목을 새 body·tag·메타데이터로 교체하고 client에 전달합니다.
SELECTED REPRESENTATION · STRONG VALIDATOR
strong ETag는 실제로 선택된 representation bytes에 묶인다
- JSON과 CSV처럼 표현이 다르거나 gzip과 identity처럼 content coding 뒤 bytes가 다르면 서로 다른 strong tag가 필요합니다.
- 서로 다른 표현 bytes에 같은 validator를 공유해야 한다면 그 validator는
W/접두사가 있는 weak tag여야 합니다. Vary는 요청 header가 선택 표현을 바꾸는 축을 cache key에 반영하도록 알립니다.
TEST BOUNDARY · ORIGIN CONTRACT
MockMvc는 origin 응답 계약을 검증하지만 실제 cache 동작까지 증명하지 않는다
- 검증 대상은 status,
ETag·Cache-Control·Varyheader와304의 빈 body입니다. - 실제 browser cache나 CDN의 저장·병합·재사용 동작은 별도의 통합 검증이 필요합니다.
Vary분기와 gzip content coding도 proxy 또는 browser를 포함한 환경에서 확인합니다.
304는 저장 body를 새로 보내는 응답이 아니라, 선택 표현이 그대로임을 알리고 적용 가능한 메타데이터를 갱신하게 하는 응답입니다. tag가 달라지면 200의 새 body와 validator가 cache 항목 전체를 교체합니다.
검증기는 선택된 표현을 식별한다
RFC 9110 §8.8의 강한 검증기는 GET 200에서 관찰 가능한 표현 데이터가 바뀔 때마다 바뀌어야 합니다. 이 문서의 실행 예제는 이미 직렬화된 최종 byte[]에 SHA-256을 적용하고 결과를 따옴표로 감싸 강한 ETag를 만듭니다.
JSON과 CSV는 같은 게시글에서 만들어져도 바이트가 다르므로 태그도 다릅니다. 실제 서비스에서 직렬화기 설정이나 표현 스키마가 같은 데이터 버전 안에서 바뀔 수 있다면 최종 바이트를 해시하거나 배포·스키마 리비전을 태그 재료에 포함합니다. 압축 계층이 gzip 같은 content coding을 실제로 선택한다면 인코딩된 표현은 별도 강한 태그를 가져야 합니다. 이 예제에는 압축 계층이 없으므로 Accept-Encoding과 gzip을 검증했다고 주장하지 않으며, 실제 선택 필드인 Accept만 Vary에 둡니다.
Last-Modified는 HTTP 날짜의 초 정밀도로 다룹니다. 같은 초 안에서 두 번 바뀔 수 있는 리소스에는 단독 강한 검증기로 충분하지 않을 수 있습니다. 예제 저장소는 밀리초 값을 받은 즉시 초 경계로 내리고, 응답과 비교에 같은 값을 사용합니다.
조건부 요청의 우선순위
RFC 9110 §13.1.2에 따라 If-None-Match는 약한 비교를 사용하며, 값 하나뿐 아니라 쉼표 목록과 *도 처리합니다. 따라서 애플리케이션의 단일 문자열 동등 비교로 처리하면 안 됩니다.
RFC 9110 §13.1.3은 If-None-Match가 있으면 If-Modified-Since를 무시하도록 정합니다. 태그가 맞지 않고 미래 날짜가 함께 와도 GET은 304가 아니라 현재 표현의 200이어야 합니다. 아래 예제는 Spring Framework 6.2의 ETag.parse로 *라는 존재 조건을 식별하고, WebRequest.checkNotModified(etag, lastModifiedMillis)에 태그 목록의 약한 비교와 날짜 우선순위를 맡깁니다. 애플리케이션이 validator field 문법을 문자열 분할로 다시 만들지 않습니다.
RFC 9110 §15.4.5의 304는 헤더 구역에서 끝나며 content와 trailer를 가질 수 없습니다. 같은 요청의 200에도 보냈을 ETag, Vary, Cache-Control 같은 적용 가능한 메타데이터는 유지합니다. 304를 독립된 빈 표현으로 저장하는 것이 아니라, 캐시가 저장된 응답의 메타데이터를 갱신하고 기존 본문을 재사용합니다.
실행 가능한 origin 계약
다음 Java 17/Spring Framework 6.2.11 파일 하나에 정확히 두 GET handler, 저장소, 표현 선택, 강한 ETag 생성, 그리고 열 개의 테스트를 함께 둡니다. 두 handler는 같은 URI를 소유하되 produces가 JSON과 CSV를 구분합니다. 존재 확인은 조건부 요청 평가보다 먼저 하므로 If-None-Match: *가 와도 없는 게시글은 304가 아니라 명시적인 404입니다.
package board.web;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import jakarta.servlet.http.HttpServletResponse;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.http.ETag;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
class HttpCacheContractTest {
private static final String CACHE_POLICY =
"private, no-cache";
private static final String CSV_MEDIA_TYPE =
"text/csv;charset=UTF-8";
private static final MediaType CSV =
MediaType.parseMediaType("text/csv");
private static final long INITIAL_RAW_LAST_MODIFIED =
Instant.parse("2030-01-15T00:00:00.987Z")
.toEpochMilli();
private static final long INITIAL_LAST_MODIFIED =
toHttpSecond(INITIAL_RAW_LAST_MODIFIED);
private static final long UPDATED_RAW_LAST_MODIFIED =
Instant.parse("2030-01-15T00:01:00.654Z")
.toEpochMilli();
private static final String INITIAL_HTTP_DATE =
"Tue, 15 Jan 2030 00:00:00 GMT";
private static final String FUTURE_HTTP_DATE =
"Tue, 15 Jan 2030 00:10:00 GMT";
private static final byte[] JSON_V1 = (
"{\"id\":42,\"title\":\"HTTP cache\","
+ "\"content\":\"조건부 요청을 정리합니다.\","
+ "\"version\":7}\n")
.getBytes(UTF_8);
private static final byte[] CSV_V1 = (
"id,title,content,version\n"
+ "42,HTTP cache,조건부 요청을 정리합니다.,7\n")
.getBytes(UTF_8);
private static final byte[] JSON_V2 = (
"{\"id\":42,\"title\":\"HTTP cache\","
+ "\"content\":\"캐시 갱신을 정리합니다.\","
+ "\"version\":8}\n")
.getBytes(UTF_8);
private static final byte[] CSV_V2 = (
"id,title,content,version\n"
+ "42,HTTP cache,캐시 갱신을 정리합니다.,8\n")
.getBytes(UTF_8);
@Test
void 첫_JSON_조회는_정확한_표현과_검증기를_200으로_보낸다()
throws Exception {
var fixture = fixture();
var result = fixture.mvc().perform(
get("/api/posts/42")
.accept(APPLICATION_JSON))
.andReturn();
var response = result.getResponse();
assertAll(
() -> assertEquals(
200, response.getStatus()),
() -> assertEquals(
APPLICATION_JSON_VALUE,
response.getContentType()),
() -> assertArrayEquals(
JSON_V1,
response.getContentAsByteArray()),
() -> assertEquals(
strongEtag(JSON_V1),
response.getHeader(
HttpHeaders.ETAG)),
() -> assertEquals(
CACHE_POLICY,
response.getHeader(
HttpHeaders.CACHE_CONTROL)),
() -> assertEquals(
HttpHeaders.ACCEPT,
response.getHeader(
HttpHeaders.VARY)),
() -> assertEquals(
INITIAL_LAST_MODIFIED,
response.getDateHeader(
HttpHeaders.LAST_MODIFIED)));
}
@Test
void 정확한_ETag는_메타데이터를_유지한_빈_304를_만든다()
throws Exception {
var fixture = fixture();
var etag = strongEtag(JSON_V1);
var result = fixture.mvc().perform(
get("/api/posts/42")
.accept(APPLICATION_JSON)
.header(
HttpHeaders.IF_NONE_MATCH,
etag))
.andReturn();
var response = result.getResponse();
assertAll(
() -> assertEquals(
304, response.getStatus()),
() -> assertEquals(
0,
response.getContentAsByteArray()
.length),
() -> assertEquals(
etag,
response.getHeader(
HttpHeaders.ETAG)),
() -> assertEquals(
CACHE_POLICY,
response.getHeader(
HttpHeaders.CACHE_CONTROL)),
() -> assertEquals(
HttpHeaders.ACCEPT,
response.getHeader(
HttpHeaders.VARY)));
}
@Test
void 약한_If_None_Match도_약한_비교로_304가_된다()
throws Exception {
var fixture = fixture();
var result = fixture.mvc().perform(
get("/api/posts/42")
.accept(APPLICATION_JSON)
.header(
HttpHeaders.IF_NONE_MATCH,
"W/" + strongEtag(JSON_V1)))
.andReturn();
assertAll(
() -> assertEquals(
304,
result.getResponse().getStatus()),
() -> assertEquals(
0,
result.getResponse()
.getContentAsByteArray()
.length));
}
@Test
void 쉼표_목록의_하나가_맞으면_304가_된다()
throws Exception {
var fixture = fixture();
var list = "\"unrelated\", "
+ strongEtag(JSON_V1);
var result = fixture.mvc().perform(
get("/api/posts/42")
.accept(APPLICATION_JSON)
.header(
HttpHeaders.IF_NONE_MATCH,
list))
.andReturn();
assertEquals(
304, result.getResponse().getStatus());
}
@Test
void 와일드카드는_존재하는_표현에_304가_된다()
throws Exception {
var fixture = fixture();
var result = fixture.mvc().perform(
get("/api/posts/42")
.accept(APPLICATION_JSON)
.header(
HttpHeaders.IF_NONE_MATCH,
"*"))
.andReturn();
assertEquals(
304, result.getResponse().getStatus());
}
@Test
void 맞지_않는_ETag가_있으면_미래_IMS보다_우선해_200이다()
throws Exception {
var fixture = fixture();
var result = fixture.mvc().perform(
get("/api/posts/42")
.accept(APPLICATION_JSON)
.header(
HttpHeaders.IF_NONE_MATCH,
"\"not-current\"")
.header(
HttpHeaders.IF_MODIFIED_SINCE,
FUTURE_HTTP_DATE))
.andReturn();
assertAll(
() -> assertEquals(
200, result.getResponse().getStatus()),
() -> assertArrayEquals(
JSON_V1,
result.getResponse()
.getContentAsByteArray()));
}
@Test
void IMS만_보내면_초_정밀도로_304가_된다()
throws Exception {
var fixture = fixture();
var result = fixture.mvc().perform(
get("/api/posts/42")
.accept(APPLICATION_JSON)
.header(
HttpHeaders.IF_MODIFIED_SINCE,
INITIAL_HTTP_DATE))
.andReturn();
assertAll(
() -> assertEquals(
304, result.getResponse().getStatus()),
() -> assertEquals(
INITIAL_LAST_MODIFIED,
fixture.store()
.find(42L)
.lastModifiedMillis()),
() -> assertEquals(
INITIAL_LAST_MODIFIED,
result.getResponse().getDateHeader(
HttpHeaders.LAST_MODIFIED)),
() -> assertEquals(
0,
result.getResponse()
.getContentAsByteArray()
.length));
}
@Test
void 변경_뒤_옛_ETag는_새_본문과_태그의_200을_받는다()
throws Exception {
var fixture = fixture();
var oldEtag = strongEtag(JSON_V1);
fixture.store().replace(
42L,
JSON_V2,
CSV_V2,
UPDATED_RAW_LAST_MODIFIED);
var result = fixture.mvc().perform(
get("/api/posts/42")
.accept(APPLICATION_JSON)
.header(
HttpHeaders.IF_NONE_MATCH,
oldEtag))
.andReturn();
var response = result.getResponse();
assertAll(
() -> assertEquals(
200, response.getStatus()),
() -> assertArrayEquals(
JSON_V2,
response.getContentAsByteArray()),
() -> assertEquals(
strongEtag(JSON_V2),
response.getHeader(
HttpHeaders.ETAG)),
() -> assertNotEquals(
oldEtag,
response.getHeader(
HttpHeaders.ETAG)));
}
@Test
void JSON_ETag로_CSV를_조회하면_다른_표현의_200이다()
throws Exception {
var fixture = fixture();
var jsonEtag = strongEtag(JSON_V1);
var result = fixture.mvc().perform(
get("/api/posts/42")
.accept(CSV)
.header(
HttpHeaders.IF_NONE_MATCH,
jsonEtag))
.andReturn();
var response = result.getResponse();
assertAll(
() -> assertEquals(
200, response.getStatus()),
() -> assertEquals(
CSV_MEDIA_TYPE,
response.getContentType()),
() -> assertArrayEquals(
CSV_V1,
response.getContentAsByteArray()),
() -> assertEquals(
strongEtag(CSV_V1),
response.getHeader(
HttpHeaders.ETAG)),
() -> assertNotEquals(
jsonEtag,
response.getHeader(
HttpHeaders.ETAG)));
}
@Test
void 없는_리소스는_조건부_헤더보다_먼저_404_no_store다()
throws Exception {
var fixture = fixture();
var result = fixture.mvc().perform(
get("/api/posts/404")
.accept(APPLICATION_JSON)
.header(
HttpHeaders.IF_NONE_MATCH,
"*")
.header(
HttpHeaders.IF_MODIFIED_SINCE,
FUTURE_HTTP_DATE))
.andReturn();
var response = result.getResponse();
assertAll(
() -> assertEquals(
404, response.getStatus()),
() -> assertEquals(
"no-store",
response.getHeader(
HttpHeaders.CACHE_CONTROL)),
() -> assertNull(
response.getHeader(
HttpHeaders.ETAG)),
() -> assertEquals(
0,
response.getContentAsByteArray()
.length));
}
private static Fixture fixture() {
var store = new PostStore();
var controller =
new HttpCacheController(store);
var mvc = MockMvcBuilders
.standaloneSetup(controller)
.build();
return new Fixture(store, mvc);
}
private static boolean hasWildcardIfNoneMatch(
WebRequest request) {
var fieldValues = request.getHeaderValues(
HttpHeaders.IF_NONE_MATCH);
if (fieldValues == null) {
return false;
}
for (var fieldValue : fieldValues) {
if (ETag.parse(fieldValue).stream()
.anyMatch(ETag::isWildcard)) {
return true;
}
}
return false;
}
private static long toHttpSecond(long millis) {
return Math.floorDiv(millis, 1_000L)
* 1_000L;
}
private static String strongEtag(
byte[] representation) {
try {
var digest = MessageDigest
.getInstance("SHA-256")
.digest(representation);
return "\"" + HexFormat.of()
.formatHex(digest) + "\"";
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(
"SHA-256 must be available",
exception);
}
}
@RestController
@RequestMapping("/api/posts")
static final class HttpCacheController {
private final PostStore store;
private HttpCacheController(
PostStore store) {
this.store = store;
}
@GetMapping(
value = "/{id}",
produces = APPLICATION_JSON_VALUE)
byte[] json(
@PathVariable("id") long id,
WebRequest request,
HttpServletResponse response) {
return respond(
id,
Representation.JSON,
request,
response);
}
@GetMapping(
value = "/{id}",
produces = "text/csv")
byte[] csv(
@PathVariable("id") long id,
WebRequest request,
HttpServletResponse response) {
return respond(
id,
Representation.CSV,
request,
response);
}
private byte[] respond(
long id,
Representation representation,
WebRequest request,
HttpServletResponse response) {
var post = store.find(id);
if (post == null) {
response.setStatus(
HttpServletResponse.SC_NOT_FOUND);
response.setHeader(
HttpHeaders.CACHE_CONTROL,
"no-store");
return null;
}
var body = representation.select(post);
var etag = strongEtag(body);
var lastModifiedMillis =
post.lastModifiedMillis();
response.setHeader(
HttpHeaders.ETAG, etag);
response.setHeader(
HttpHeaders.CACHE_CONTROL,
CACHE_POLICY);
response.setHeader(
HttpHeaders.VARY,
HttpHeaders.ACCEPT);
response.setDateHeader(
HttpHeaders.LAST_MODIFIED,
lastModifiedMillis);
if (hasWildcardIfNoneMatch(request)) {
response.setStatus(
HttpServletResponse.SC_NOT_MODIFIED);
return null;
}
if (request.checkNotModified(
etag, lastModifiedMillis)) {
return null;
}
response.setContentType(
representation.contentType());
response.setContentLength(body.length);
return body;
}
}
enum Representation {
JSON(APPLICATION_JSON_VALUE),
CSV(CSV_MEDIA_TYPE);
private final String contentType;
Representation(String contentType) {
this.contentType = contentType;
}
private String contentType() {
return contentType;
}
private byte[] select(StoredPost post) {
return switch (this) {
case JSON -> post.json().clone();
case CSV -> post.csv().clone();
};
}
}
static final class PostStore {
private final Map<Long, StoredPost> posts =
new HashMap<>();
private PostStore() {
replace(
42L,
JSON_V1,
CSV_V1,
INITIAL_RAW_LAST_MODIFIED);
}
private StoredPost find(long id) {
return posts.get(id);
}
private void replace(
long id,
byte[] json,
byte[] csv,
long lastModifiedMillis) {
posts.put(
id,
new StoredPost(
json.clone(),
csv.clone(),
toHttpSecond(
lastModifiedMillis)));
}
}
record StoredPost(
byte[] json,
byte[] csv,
long lastModifiedMillis) {}
record Fixture(
PostStore store,
MockMvc mvc) {}
}handler는 존재하는 표현의 ETag, Cache-Control, Vary, Last-Modified를 먼저 설정합니다. Spring의 ETag.parse로 If-None-Match: *라는 존재 조건을 식별한 뒤, 나머지 태그 목록·약한 비교·날짜 우선순위는 checkNotModified에 맡깁니다. 조건이 맞으면 304와 null로 본문 직렬화를 멈추고, 맞지 않을 때만 선택된 최종 바이트와 Content-Type을 200으로 보냅니다. 애플리케이션이 validator 문법을 문자열 분할로 다시 구현하지 않는 Spring Framework 6.2 HTTP caching 문서의 controller 경계를 따릅니다.
이 MockMvc 테스트가 증명하는 범위는 origin handler의 상태, 헤더, 정확한 본문뿐입니다. 실제 캐시가 계산하는 Age, 304 메타데이터 병합, CDN 저장·제거, 압축 필터, stale 제공은 증명하지 않습니다. 그 동작은 해당 프록시·CDN·서버 구성을 포함한 별도 통합 테스트에서 확인합니다.
저장 위치와 재사용 조건
RFC 9111 §5.2.2의 응답 지시어는 각각 다른 질문에 답합니다.
no-store는 private·shared 캐시 모두에 저장하지 말라고 지시합니다.no-cache는 저장을 허용하지만 다른 요청에 사용하기 전에 성공적으로 재검증하도록 합니다.private는 shared 캐시의 저장을 막고 private 캐시의 저장을 허용합니다.public은 다른 규칙 때문에 저장할 수 없을 응답도 명시적으로 cacheable하게 만들 수 있습니다.max-age=600은 응답 나이가 600초를 넘으면 stale이라는 뜻입니다. 값은10m같은 기간 문자열이 아니라delta-seconds입니다.
private는 저장 범위이지 기밀성 보장이 아닙니다. 전송 구간 보호, 인증·인가, 응답 내용 최소화는 별도 보안 통제입니다. RFC 9111 §3.5의 특별한 shared-cache 제한은 Authorization 요청 필드가 있는 응답을 대상으로 합니다. 이 규칙을 Cookie에 자동으로 일반화하지 않습니다. 쿠키에 따라 달라지는 개인 응답은 그 의미에 맞춰 private 또는 no-store를 명시합니다.
Vary는 실제로 표현 선택에 참여한 요청 필드만 이름으로 둡니다. 예제의 두 handler는 Accept로 JSON과 CSV를 선택하므로 Vary: Accept가 필요합니다. 압축이 설치되지 않은 MockMvc fixture에 Vary: Accept-Encoding을 넣어 gzip을 지원하는 것처럼 보이게 하지 않습니다.
fresh 이후의 stale 예산
max-age는 fresh 수명만 정합니다. RFC 9111 §4.2.4는 연결 단절, 요청의 max-stale, 명시적 확장 지시어, 외부 계약 같은 조건에서 stale 응답이 제공될 수 있음을 구분합니다. 따라서 max-age=600 하나를 “최대 10분 뒤 반드시 원본으로 복구된다”는 엄격한 경계로 설명하면 안 됩니다.
stale 재사용이 잘못된 동작을 만들면 must-revalidate로 stale 이후 성공적인 출처 검증을 요구합니다. 가용성을 위해 stale을 허용한다면 RFC 5861의 stale-while-revalidate와 stale-if-error가 각각 백그라운드 재검증과 오류 시 제공을 허용하는 별도 예외임을 밝히고 초 단위 예산을 정합니다. 어떤 예외를 선택하든 fresh 수명과 stale 허용 수명을 더한 최악의 노출 시간을 운영 요구와 맞춥니다.
변경과 무효화
게시글이 바뀌면 최종 JSON·CSV 바이트가 바뀌고 다음 조건부 요청은 새 태그와 본문의 200을 받습니다. 그러나 fresh 응답은 아직 출처에 오지 않을 수 있습니다.
RFC 9111 §4.4의 기본 무효화는 캐시가 unsafe 요청의 non-error 응답을 직접 보았을 때 그 요청의 target URI에 대해 수행됩니다. 이는 요청이 지나간 캐시의 URI 로컬 동작이지 모든 CDN 노드를 전역 제거한다는 뜻이 아닙니다. 즉시 전역 반영이 필요하면 CDN purge API, surrogate key, 버전 URI, 이벤트 전달과 재시도 같은 운영 경로를 별도로 설계합니다.
RFC 9110 §15.5.5에 따라 404 같은 negative response도 저장될 수 있고 휴리스틱 캐시 대상이 될 수 있습니다. 존재 여부를 오래 숨기면 안 되는 API는 예제처럼 명시적으로 Cache-Control: no-store를 보냅니다. 반대로 의도적인 negative caching을 쓴다면 TTL, 생성 직후 허용 지연, purge 경로를 함께 정합니다.
저장 가능성, fresh 여부, 재검증 가능성, stale 예외, 변경 후 무효화를 순서대로 판단하면 정책은 다음 흐름으로 정리됩니다.
RESPONSE CONTEXT · FRESHNESS · STALE BUDGET · INVALIDATION
응답 의미를 먼저 분류하고 stale과 운영 경계를 따로 잠근다
저장 위치, freshness, stale 허용은 서로 다른 선택입니다. 민감 응답과 개인 표현을 공개 재사용에서 분리한 뒤, 공유 가능한 표현에만 명시적인 초 단위 수명과 유한한 실패 예산을 둡니다.
01 · RESPONSE CONTEXT
응답 의미로 저장 위치와 공유 가능성을 먼저 고른다
- credential·민감 응답은
no-store이며, 이 지시어 하나가 privacy 전체를 보장하지는 않습니다. - 개인·비공개 표현은
private와 validator 재검증을 함께 쓰며,private는 저장 위치이지 비밀성 표지가 아닙니다. - 공유 가능한 공개 표현만
public, max-age=600으로 명시하고,Cookie만으로Authorization과 같은 자동 공유 cache 금지를 가정하지 않습니다.
02 · STALE BUDGET
freshness 수명과 stale 재사용 허용을 분리한다
max-age=600은 delta-seconds freshness이며 그 자체가 모든 stale 재사용의 hard stop은 아닙니다.- 엄격한 표현은 재검증과
must-revalidate로 stale 재사용을 막습니다. - 그 밖의 stale 재사용은 연결 단절 또는 명시적 허용 범위에 한정하며, 허용할 때만
stale-while-revalidate=30·stale-if-error=120처럼 유한한 fallback을 둡니다.
03 · OPERATIONAL BOUNDARY
변경 무효화와 404 부정 cache는 별도 정책이다
- unsafe method의 표준 무효화는 요청 target URI를 그 요청이 통과한 cache에서 처리하며, 전역 CDN 제거는 별도 purge가 필요합니다.
- 관련된 다른 URI는 규칙에 따라 함께 무효화될 수 있지만 자동 전역 purge를 뜻하지 않습니다.
404는 heuristic cache가 가능하므로 명시적인 negative-cache 정책과 유한한 TTL을 둡니다.
이 흐름은 HTTP cache 정책의 선택 경계를 보여 줍니다. MockMvc는 애플리케이션이 내보내는 헤더와 조건부 응답은 검증할 수 있지만, 실제 browser·공유 cache·CDN 저장과 purge 동작까지 증명하지는 않습니다.
연습 문제
개인 게시글 예제를 공개 주간 통계 GET /api/stats/weekly로 확장하세요.
- JSON과 CSV의 최종 바이트를 먼저 만들고 각각의 강한 SHA-256 ETag를 계산합니다.
- 실제 선택 필드가
Accept뿐이면Vary: Accept만 보냅니다. - 공개 데이터가 10분 동안 fresh해도 된다면
public, max-age=600, must-revalidate를 사용합니다. - 같은 JSON 태그는 JSON 요청에서 304, JSON 태그는 CSV 요청에서 서로 다른 본문과 태그의 200인지 검증합니다.
- 집계 변경 후 예전 두 태그가 새 표현의 200을 받는지 검증하고, CDN 즉시 반영이 필요하다면 HTTP 기본 무효화와 별도 purge 경로를 구분해 기록합니다.
해설 보기
정답의 핵심은 데이터베이스 행 버전이 아니라 실제 선택된 표현을 검증하는 것입니다. JSON과 CSV 바이트를 독립적으로 해시하면 같은 집계 버전에서도 서로 다른 강한 태그가 만들어집니다. 직렬화 전에 버전 문자열만 조합한다면 직렬화기나 스키마가 바뀌는 배포를 태그 재료에 포함해야 합니다.
JSON 태그가 있는 CSV 요청은 If-None-Match가 현재 CSV 태그와 맞지 않으므로 200입니다. 같은 요청에 미래 If-Modified-Since가 있어도 If-None-Match가 우선합니다. 반대로 태그 없이 초 정밀도의 If-Modified-Since만 보내면 변경 시각이 더 최신이 아닐 때 304가 됩니다.
must-revalidate는 stale 뒤 검증 없는 재사용을 막지만 CDN 전체를 제거하지는 않습니다. 즉시 반영 요구가 있다면 purge 성공·실패·재시도와 최대 stale 노출 예산을 별도 운영 계약으로 검증합니다.
문서 경계
- ch4-5는 메서드의 cacheability와 unsafe 요청이 만드는 기본 무효화 의미를 소유합니다.
- ch4-6은 일반 상태 코드와 응답 결과를 소유하고, 304의 검증·메타데이터 세부는 이 문서에 둡니다.
- ch4-7은
Accept,produces, 표현 선택,Vary의 선택 의미를 소유합니다. - ch4-8은 쿠키 저장·전송과 서버 세션 수명을 소유합니다.
- ch4-9는 저장 범위, freshness, 검증기, 304, 재검증, 표현별 재사용, 운영 무효화를 소유합니다.
- ch6-7은
If-Match, 412, lost update 방지, 원자적 변경을 소유합니다. - 다음 ch5-1은 프록시, Servlet 컨테이너,
DispatcherServlet의 실행 경로를 소유하며 캐시 정책을 다시 정의하지 않습니다.
주요 규격 근거는 RFC 9110 §8.8, §13.1.2, §13.1.3, §15.4.5, RFC 9111 §3.5, §4.2.4, §4.4, §5.2.2, RFC 5861, Spring Framework 6.2 HTTP caching입니다.