본문 바로가기

HTTP 캐시와 조건부 요청

선택된 표현의 강한 ETag와 Last-Modified를 계산하고 200·304 재검증, 저장 범위, stale 예산, 무효화 경계를 MockMvc 계약으로 검증합니다.

HTTP 캐시는 응답 메시지를 저장해 두었다가 이후의 요청에 다시 씁니다. 캐시로 성능을 얻으려면 세 질문을 나누어 생각해야 합니다. 이 응답을 어떤 캐시가 저장해도 되는지, 저장한 응답이 언제까지 신선(fresh)하며 오래된(stale) 뒤에는 어떤 조건에서 다시 쓰는지, 원 서버에 다시 물을 때 어떤 검증기(validator)로 지금 선택된 표현을 알아보는지입니다. Cache-Control은 저장과 재사용의 규칙을, ETag와 Last-Modified는 표현이 바뀌었는지 판단할 단서를 주며, 어느 하나가 다른 하나를 대신하지 않습니다. 테스트는 앞 글들과 같은 Java 17·Spring 6.2.11 별도 단위에서 실행합니다.

첫 200 응답을 저장한 캐시는 다음 요청에 If-None-Match를 보냅니다. 태그가 맞으면 원 서버는 해당 메타데이터만 담은 304를 보내고, 캐시는 그 메타데이터를 저장한 응답에 합친 뒤 기존 본문을 다시 씁니다. 선택된 표현이 바뀌었다면 새 본문과 새 태그를 가진 200을 저장합니다.

조건부 재검증은 같은 표현이면 304, 바뀌면 새 200을 반환한다

첫 200 저장 뒤 재검증에서 tag가 일치하면 304와 본문 재사용으로, 불일치하면 새 200으로 항목을 교체하여 캐시 값이 바뀐다.

클라이언트의 첫 GET에서 private HTTP cache가 선택된 JSON 표현의 본문, 표현별 strong ETag, Cache-Control, Vary를 저장합니다. 재검증 요청의 If-None-Match 목록은 현재 선택 표현의 태그와 weak 비교하며, If-None-Match가 있으면 If-Modified-Since는 무시합니다. 태그가 맞으면 origin server가 콘텐츠와 trailer 없이 304와 ETag, Cache-Control, Vary를 보내고 cache는 저장 메타데이터를 갱신한 뒤 기존 본문을 재사용합니다. 표현이 바뀌면 새 본문과 새 strong ETag를 담은 200으로 cache 항목을 교체합니다.

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로 교체합니다.

If-None-Match 재검증의 두 응답 분기첫 GET 응답의 본문과 표현별 ETag를 캐시가 저장합니다. 다음 재검증에서 If-None-Match를 약한 비교하고 If-Modified-Since는 무시합니다. tag 일치는 content와 trailer 없는 304를 받아 메타데이터를 병합하고 저장 본문을 재사용합니다. 표현 변경은 새 본문과 tag를 가진 200으로 항목을 교체합니다. 클라이언트GET /api/posts/42개인 HTTP 캐시본문 + 응답 메타데이터출처 서버선택된 JSON 표현GET /api/posts/42 · JSON캐시 miss · 출처 조회200 · JSON + ETagCache-Control · Vary본문·메타데이터 저장 후 전달다음 조회 · 재검증 필요If-None-Match 목록현재 tag와 약한 비교 · IMS 무시tag 일치304 · ETag + CC + Vary메타데이터 병합 · 본문 재사용표현 변경200 · 새 JSON + 새 ETag항목 교체 · 새 본문 전달
선택 JSON의 첫200과 저장리소스가 존재하고 선택 표현이 JSON인 기존 시퀀스의 첫 네 전달입니다. client의 GET을 개인 캐시가 miss로 출처에 보냅니다. 200은 본문·강한 ETag·Cache-Control·Vary를 돌려주고, 캐시는 같은 항목에 저장한 뒤 본문을 전달합니다. CC는 Cache-Control을 뜻합니다. 실제 캐시 저장은 MockMvc의 origin 응답 검증과 별도로 확인해야 합니다.첫 GET · JSON 표현조회miss200·tag본문본문·ETag·CC·Vary 저장client개인캐시출처서버
태그 재검증의304·200 대안다음 조회에서 캐시가 If-None-Match 목록을 보내 현재 선택 JSON 태그와 약하게 비교하고 IMS는 무시합니다. 일치한 304는 본문과 trailer 없이 ETag·CC·Vary를 보내며, 캐시가 메타데이터를 병합해 기존 본문을 재사용합니다. 태그가 다르면 새 본문과 새 태그의 200으로 항목을 교체합니다. ALT의 두 결과 중 조건에 맞는 하나를 실행합니다. 리소스 없음의 404는 원 fixture의 별도 존재 확인 분기입니다.조건부 재검증 · 같은 표현재조회태그 목록[일치]304병합재사용[다름]200새 본문If-None-Match 우선client개인캐시출처서버

01 · INITIAL 200

선택된 JSON 표현의 body와 strong ETag를 함께 저장한다

  1. client의 첫 GET /api/posts/42를 cache가 origin server로 전달합니다.
  2. 200은 JSON body와 최종 JSON 바이트의 SHA-256으로 만든 표현별 strong ETag를 돌려줍니다.
  3. private cache가 body와 Cache-Control·Vary 메타데이터를 같은 항목에 저장합니다.

02 · CONDITIONAL REQUEST

If-None-Match 목록은 현재 선택 표현의 tag와 weak 비교한다

  • 조건 값은 * 또는 entity-tag 목록일 수 있습니다.
  • 목록의 W/ 접두사 유무와 관계없이 현재 tag와 하나라도 weak 비교로 맞으면 match입니다.
  • If-None-Match가 있으면 함께 온 If-Modified-Since는 무시합니다.

03 · MATCH → 304

304 메타데이터를 병합하고 저장한 body를 재사용한다

  1. GET의 선택 표현과 tag가 맞으면 origin server는 304 Not Modified를 반환합니다.
  2. 304는 content나 trailer 없이 적용되는 ETag·Cache-Control·Vary를 포함합니다.
  3. cache가 저장 응답의 header fields를 갱신한 뒤 기존 body를 client에 재사용합니다.

04 · CHANGED → 200

표현이 바뀌면 새 body와 새 strong ETag로 항목을 교체한다

  1. 현재 JSON 본문 바이트가 바뀌면 이전 본문의 tag는 match하지 않습니다.
  2. origin server가 새 body와 그 바이트로 계산한 새 ETag를 담은 200을 반환합니다.
  3. 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·Vary header와 304의 빈 body입니다.
  • 실제 browser cache나 CDN의 저장·병합·재사용 동작은 별도의 통합 검증이 필요합니다.
  • 이 fixture의 JSON·CSV 선택과 Vary: Accept는 MockMvc에서 검증합니다. 실제 cache 재사용과 gzip content coding은 해당 serving layer를 포함해 따로 확인합니다.

304는 저장 body를 새로 보내는 응답이 아니라, 선택 표현이 그대로임을 알리고 적용 가능한 메타데이터를 갱신하게 하는 응답입니다. tag가 달라지면 200의 새 body와 validator가 cache 항목 전체를 교체합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 첫 GET 응답의 본문과 표현별 ETag를 캐시가 저장합니다. 다음 재검증에서 If-None-Match를 약한 비교하고 If-Modified-Since는 무시합니다. tag 일치는 content와 trailer 없는 304를 받아 메타데이터를 병합하고 저장 본문을 재사용합니다. 표현 변경은 새 본문과 tag를 가진 200으로 항목을 교체합니다.
  • 리소스가 존재하고 선택 표현이 JSON인 기존 시퀀스의 첫 네 전달입니다. client의 GET을 개인 캐시가 miss로 출처에 보냅니다. 200은 본문·강한 ETag·Cache-Control·Vary를 돌려주고, 캐시는 같은 항목에 저장한 뒤 본문을 전달합니다. CC는 Cache-Control을 뜻합니다. 실제 캐시 저장은 MockMvc의 origin 응답 검증과 별도로 확인해야 합니다.
  • 다음 조회에서 캐시가 If-None-Match 목록을 보내 현재 선택 JSON 태그와 약하게 비교하고 IMS는 무시합니다. 일치한 304는 본문과 trailer 없이 ETag·CC·Vary를 보내며, 캐시가 메타데이터를 병합해 기존 본문을 재사용합니다. 태그가 다르면 새 본문과 새 태그의 200으로 항목을 교체합니다. ALT의 두 결과 중 조건에 맞는 하나를 실행합니다. 리소스 없음의 404는 원 fixture의 별도 존재 확인 분기입니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
35선택 JSON의 첫 200과 저장
48태그 재검증의 304·200 대안
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
첫 표현 저장

첫 GET의 200 본문과 표현별 strong ETag·Cache-Control·Vary를 캐시에 함께 저장합니다.

저장 항목: JSON 본문·strong ETag·응답 메타데이터 · 두 대안: 다음 재검증에서 조건에 맞는 하나만 선택첫 저장 본문과 validator가 다음 재검증의 공통 입력이 됩니다.
태그 조건 비교

If-None-Match 목록을 현재 선택 표현의 태그와 weak 비교하며 두 결과를 함께 비교합니다.

비교 입력: If-None-Match 목록, 현재 선택 표현 tag · 일치 조건: 목록 중 하나가 weak 비교로 일치 · 변경 조건: 현재 tag와 일치하지 않음 · If-Modified-Since: If-None-Match가 있으면 무시같은 비교에서 일치 또는 불일치 중 한 응답 경로만 선택합니다.
조건별 응답

태그 일치는 304 메타데이터를, 불일치는 새 본문과 새 tag의 200을 받습니다.

일치 응답: 304·ETag·Cache-Control·Vary · 일치 content·trailer: 없음 · 불일치 응답: 200·새 JSON 본문·새 strong ETag선택 표현이 같으면 본문을 재전송하지 않고, 바뀌면 새 표현을 보냅니다.
조건별 캐시 적용

304 경로는 메타데이터를 병합하고 기존 본문을 쓰며 200 경로는 저장 항목을 교체합니다.

일치 적용: 메타데이터 병합·기존 본문 재사용 · 불일치 적용: 새 본문·tag·메타데이터로 항목 교체각 경로가 선택한 응답만 적용하며 304 뒤에 200을 연속 실행하는 흐름이 아닙니다.

조건부 재검증은 같은 표현이면 304, 바뀌면 새 200을 반환한다

첫 200 저장 뒤 재검증에서 tag가 일치하면 304와 본문 재사용으로, 불일치하면 새 200으로 항목을 교체하여 캐시 값이 바뀐다.

클라이언트의 첫 GET에서 private HTTP cache가 선택된 JSON 표현의 본문, 표현별 strong ETag, Cache-Control, Vary를 저장합니다. 재검증 요청의 If-None-Match 목록은 현재 선택 표현의 태그와 weak 비교하며, If-None-Match가 있으면 If-Modified-Since는 무시합니다. 태그가 맞으면 origin server가 콘텐츠와 trailer 없이 304와 ETag, Cache-Control, Vary를 보내고 cache는 저장 메타데이터를 갱신한 뒤 기존 본문을 재사용합니다. 표현이 바뀌면 새 본문과 새 strong ETag를 담은 200으로 cache 항목을 교체합니다.

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로 교체합니다.

If-None-Match 재검증의 두 응답 분기첫 GET 응답의 본문과 표현별 ETag를 캐시가 저장합니다. 다음 재검증에서 If-None-Match를 약한 비교하고 If-Modified-Since는 무시합니다. tag 일치는 content와 trailer 없는 304를 받아 메타데이터를 병합하고 저장 본문을 재사용합니다. 표현 변경은 새 본문과 tag를 가진 200으로 항목을 교체합니다. 클라이언트GET /api/posts/42개인 HTTP 캐시본문 + 응답 메타데이터출처 서버선택된 JSON 표현GET /api/posts/42 · JSON캐시 miss · 출처 조회200 · JSON + ETagCache-Control · Vary본문·메타데이터 저장 후 전달다음 조회 · 재검증 필요If-None-Match 목록현재 tag와 약한 비교 · IMS 무시tag 일치304 · ETag + CC + Vary메타데이터 병합 · 본문 재사용표현 변경200 · 새 JSON + 새 ETag항목 교체 · 새 본문 전달
선택 JSON의 첫200과 저장리소스가 존재하고 선택 표현이 JSON인 기존 시퀀스의 첫 네 전달입니다. client의 GET을 개인 캐시가 miss로 출처에 보냅니다. 200은 본문·강한 ETag·Cache-Control·Vary를 돌려주고, 캐시는 같은 항목에 저장한 뒤 본문을 전달합니다. CC는 Cache-Control을 뜻합니다. 실제 캐시 저장은 MockMvc의 origin 응답 검증과 별도로 확인해야 합니다.첫 GET · JSON 표현조회miss200·tag본문본문·ETag·CC·Vary 저장client개인캐시출처서버
태그 재검증의304·200 대안다음 조회에서 캐시가 If-None-Match 목록을 보내 현재 선택 JSON 태그와 약하게 비교하고 IMS는 무시합니다. 일치한 304는 본문과 trailer 없이 ETag·CC·Vary를 보내며, 캐시가 메타데이터를 병합해 기존 본문을 재사용합니다. 태그가 다르면 새 본문과 새 태그의 200으로 항목을 교체합니다. ALT의 두 결과 중 조건에 맞는 하나를 실행합니다. 리소스 없음의 404는 원 fixture의 별도 존재 확인 분기입니다.조건부 재검증 · 같은 표현재조회태그 목록[일치]304병합재사용[다름]200새 본문If-None-Match 우선client개인캐시출처서버

01 · INITIAL 200

선택된 JSON 표현의 body와 strong ETag를 함께 저장한다

  1. client의 첫 GET /api/posts/42를 cache가 origin server로 전달합니다.
  2. 200은 JSON body와 최종 JSON 바이트의 SHA-256으로 만든 표현별 strong ETag를 돌려줍니다.
  3. private cache가 body와 Cache-Control·Vary 메타데이터를 같은 항목에 저장합니다.

02 · CONDITIONAL REQUEST

If-None-Match 목록은 현재 선택 표현의 tag와 weak 비교한다

  • 조건 값은 * 또는 entity-tag 목록일 수 있습니다.
  • 목록의 W/ 접두사 유무와 관계없이 현재 tag와 하나라도 weak 비교로 맞으면 match입니다.
  • If-None-Match가 있으면 함께 온 If-Modified-Since는 무시합니다.

03 · MATCH → 304

304 메타데이터를 병합하고 저장한 body를 재사용한다

  1. GET의 선택 표현과 tag가 맞으면 origin server는 304 Not Modified를 반환합니다.
  2. 304는 content나 trailer 없이 적용되는 ETag·Cache-Control·Vary를 포함합니다.
  3. cache가 저장 응답의 header fields를 갱신한 뒤 기존 body를 client에 재사용합니다.

04 · CHANGED → 200

표현이 바뀌면 새 body와 새 strong ETag로 항목을 교체한다

  1. 현재 JSON 본문 바이트가 바뀌면 이전 본문의 tag는 match하지 않습니다.
  2. origin server가 새 body와 그 바이트로 계산한 새 ETag를 담은 200을 반환합니다.
  3. 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·Vary header와 304의 빈 body입니다.
  • 실제 browser cache나 CDN의 저장·병합·재사용 동작은 별도의 통합 검증이 필요합니다.
  • 이 fixture의 JSON·CSV 선택과 Vary: Accept는 MockMvc에서 검증합니다. 실제 cache 재사용과 gzip content coding은 해당 serving layer를 포함해 따로 확인합니다.

304는 저장 body를 새로 보내는 응답이 아니라, 선택 표현이 그대로임을 알리고 적용 가능한 메타데이터를 갱신하게 하는 응답입니다. tag가 달라지면 200의 새 body와 validator가 cache 항목 전체를 교체합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 첫 GET 응답의 본문과 표현별 ETag를 캐시가 저장합니다. 다음 재검증에서 If-None-Match를 약한 비교하고 If-Modified-Since는 무시합니다. tag 일치는 content와 trailer 없는 304를 받아 메타데이터를 병합하고 저장 본문을 재사용합니다. 표현 변경은 새 본문과 tag를 가진 200으로 항목을 교체합니다.
  • 리소스가 존재하고 선택 표현이 JSON인 기존 시퀀스의 첫 네 전달입니다. client의 GET을 개인 캐시가 miss로 출처에 보냅니다. 200은 본문·강한 ETag·Cache-Control·Vary를 돌려주고, 캐시는 같은 항목에 저장한 뒤 본문을 전달합니다. CC는 Cache-Control을 뜻합니다. 실제 캐시 저장은 MockMvc의 origin 응답 검증과 별도로 확인해야 합니다.
  • 다음 조회에서 캐시가 If-None-Match 목록을 보내 현재 선택 JSON 태그와 약하게 비교하고 IMS는 무시합니다. 일치한 304는 본문과 trailer 없이 ETag·CC·Vary를 보내며, 캐시가 메타데이터를 병합해 기존 본문을 재사용합니다. 태그가 다르면 새 본문과 새 태그의 200으로 항목을 교체합니다. ALT의 두 결과 중 조건에 맞는 하나를 실행합니다. 리소스 없음의 404는 원 fixture의 별도 존재 확인 분기입니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
35선택 JSON의 첫 200과 저장
48태그 재검증의 304·200 대안
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
첫 표현 저장

첫 GET의 200 본문과 표현별 strong ETag·Cache-Control·Vary를 캐시에 함께 저장합니다.

저장 항목: JSON 본문·strong ETag·응답 메타데이터 · 두 대안: 다음 재검증에서 조건에 맞는 하나만 선택첫 저장 본문과 validator가 다음 재검증의 공통 입력이 됩니다.
태그 조건 비교

If-None-Match 목록을 현재 선택 표현의 태그와 weak 비교하며 두 결과를 함께 비교합니다.

비교 입력: If-None-Match 목록, 현재 선택 표현 tag · 일치 조건: 목록 중 하나가 weak 비교로 일치 · 변경 조건: 현재 tag와 일치하지 않음 · If-Modified-Since: If-None-Match가 있으면 무시같은 비교에서 일치 또는 불일치 중 한 응답 경로만 선택합니다.
조건별 응답

태그 일치는 304 메타데이터를, 불일치는 새 본문과 새 tag의 200을 받습니다.

일치 응답: 304·ETag·Cache-Control·Vary · 일치 content·trailer: 없음 · 불일치 응답: 200·새 JSON 본문·새 strong ETag선택 표현이 같으면 본문을 재전송하지 않고, 바뀌면 새 표현을 보냅니다.
조건별 캐시 적용

304 경로는 메타데이터를 병합하고 기존 본문을 쓰며 200 경로는 저장 항목을 교체합니다.

일치 적용: 메타데이터 병합·기존 본문 재사용 · 불일치 적용: 새 본문·tag·메타데이터로 항목 교체각 경로가 선택한 응답만 적용하며 304 뒤에 200을 연속 실행하는 흐름이 아닙니다.

그림은 이 흐름을 차례로 보여 줍니다. 첫 GET에서 개인 캐시가 원 서버의 200을 받아 본문과 강한 ETag, Cache-Control, Vary를 저장하고, 다음 재검증에서 If-None-Match 목록을 지금 선택된 표현의 태그와 약한 비교로 견줍니다. 태그가 맞으면 본문 없는 304로 메타데이터를 갱신하고 저장한 본문을 다시 쓰며, 표현이 바뀌었으면 새 본문과 태그의 200으로 항목을 바꿉니다. 이 캐시 동작은 아래의 MockMvc 테스트가 확인하는 원 서버의 응답과는 별도로 확인해야 합니다.


검증기는 선택된 표현을 식별한다

RFC 9110 §8.8의 강한 검증기는 GET 200에서 관찰할 수 있는 표현 데이터가 바뀔 때마다 바뀌어야 합니다. 이 글의 예제는 이미 직렬화를 마친 최종 byte[]에 SHA-256을 적용하고, 그 결과를 따옴표로 감싸 강한 ETag를 만듭니다. JSON과 CSV는 같은 게시글에서 만들어도 바이트가 다르므로 태그도 다릅니다. 실제 서비스에서 같은 데이터 버전 안에서 직렬화 설정이나 표현 형식이 바뀔 수 있다면, 최종 바이트를 해시하거나 배포와 스키마의 리비전을 태그 재료에 넣습니다. 압축 계층이 gzip 같은 콘텐츠 코딩을 실제로 고른다면 인코딩된 표현에도 별도의 강한 태그가 있어야 합니다. 이 예제에는 압축 계층이 없으므로 Accept-Encoding과 gzip을 확인했다고 주장하지 않으며, 실제 선택에 쓰는 Accept만 Vary에 둡니다.

Last-Modified는 HTTP 날짜의 초 단위 정밀도로 다룹니다. 같은 초 안에 두 번 바뀔 수 있는 리소스에는 날짜 하나만으로 충분한 강한 검증기가 되지 못할 수 있습니다. 예제 저장소는 밀리초 값을 받자마자 초 단위로 내리고, 응답과 비교에 같은 값을 씁니다.


조건부 요청의 우선순위

RFC 9110 §13.1.2에 따라 If-None-Match는 약한 비교를 쓰며, 값 하나뿐 아니라 쉼표로 이은 목록과 *도 처리해야 합니다. 그러니 애플리케이션이 문자열 하나를 같은지 비교하는 것으로 처리하면 안 됩니다. §13.1.3은 If-None-Match가 있으면 If-Modified-Since를 무시하도록 정합니다. 그래서 태그가 맞지 않으면 미래 날짜가 함께 와도 GET은 304가 아니라 현재 표현의 200이어야 합니다. 아래 예제는 Spring Framework 6.2의 ETag.parse로 *라는 존재 조건을 알아보고, 태그 목록의 약한 비교와 날짜의 우선순위는 WebRequest.checkNotModified(etag, lastModifiedMillis)에 맡깁니다. 애플리케이션이 검증기 필드의 문법을 문자열 분할로 다시 만들지 않습니다.

§15.4.5의 304는 헤더 영역에서 끝나며 콘텐츠와 트레일러를 가질 수 없습니다. 대신 같은 요청에 200으로 응답했을 때 보냈을 ETag, Vary, Cache-Control 같은 메타데이터는 그대로 보냅니다. 304는 별도의 빈 표현으로 저장되는 것이 아니라, 캐시가 저장해 둔 응답의 메타데이터를 고치고 기존 본문을 다시 쓰게 합니다.

예를 들어 게시글 42의 JSON 응답을 저장한 뒤, 같은 JSON 태그를 들고 CSV를 요청한다고 해 봅시다. 먼저 Accept로 CSV 표현을 고르고, 그 최종 CSV 바이트로 현재 태그를 계산합니다. JSON 태그와 지금의 CSV 태그는 다르므로 If-None-Match가 맞지 않고, 새 CSV 본문과 태그를 가진 200을 돌려줍니다. 이 요청에 미래의 If-Modified-Since를 함께 넣어도 If-None-Match가 있으므로 날짜 조건은 적용하지 않습니다. 순서는 "아무 검증기나 하나 맞으면 304"가 아니라, 표현을 고르고, 태그 조건을 먼저 보고, 태그가 없을 때만 날짜 조건을 보는 것입니다. 아래 테스트의 JSON_ETag로_CSV를_조회하면_다른_표현의_200이다와 맞지_않는_ETag가_있으면_미래_IMS보다_우선해_200이다를 이 순서로 읽어 보세요.


실행할 수 있는 원 서버 테스트

다음 Java 17·Spring Framework 6.2.11 파일 하나에 GET 핸들러 두 개와 저장소, 표현 선택, 강한 ETag 생성, 테스트 열 개를 함께 둡니다. 두 핸들러는 같은 URI를 맡되 produces로 JSON과 CSV를 나눕니다. 리소스가 있는지를 조건부 요청을 평가하기 전에 확인하므로, If-None-Match: *가 와도 없는 게시글은 304가 아니라 명시적인 404입니다.

src/test/java/board/web/HttpCacheContractTest.java
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) {}
}

핸들러는 있는 표현의 ETag, Cache-Control, Vary, Last-Modified를 먼저 설정합니다. 그다음 ETag.parse로 If-None-Match: *라는 존재 조건을 알아보고, 나머지 태그 목록과 약한 비교, 날짜의 우선순위는 checkNotModified에 맡깁니다. 조건이 맞으면 304와 null로 본문 직렬화를 멈추고, 맞지 않을 때만 고른 최종 바이트와 Content-Type을 200으로 보냅니다. 이것은 애플리케이션이 검증기 문법을 직접 구현하지 않게 하는 Spring Framework 6.2의 HTTP 캐시 문서의 컨트롤러 방식을 따른 것입니다.

이 MockMvc 테스트가 확인하는 것은 원 서버 핸들러의 상태와 헤더, 정확한 본문뿐입니다. 실제 캐시가 계산하는 Age, 304 메타데이터의 병합, CDN의 저장과 제거, 압축 필터, 오래된 응답의 제공은 확인하지 않으므로, 그런 동작은 해당 프록시와 CDN, 서버 구성을 포함한 별도의 통합 테스트에서 확인합니다.

더 깊이: 본문으로 계산한 ETag가 아끼는 것과 아끼지 못하는 것

최종 바이트에서 태그를 계산하려면 그 바이트가 먼저 있어야 합니다. 이 예제의 저장소는 이미 직렬화한 JSON과 CSV를 들고 있어서 요청마다 드는 비용이 해시 계산 하나지만, 보통의 서비스라면 304로 답할 요청에서도 조회와 직렬화를 모두 끝낸 뒤에야 태그를 비교할 수 있습니다. 그래서 304가 아끼는 것은 전송 바이트와 클라이언트의 처리이고, 서버의 작업은 거의 그대로입니다.

Spring의 ShallowEtagHeaderFilter는 같은 방식을 필터 하나로 모든 응답에 적용합니다. 응답 본문을 버퍼에 모아 기본 구현으로 MD5 해시를 만들고, If-None-Match와 같으면 본문 대신 304를 보냅니다. API 문서와 레퍼런스 문서 모두 뷰는 여전히 렌더링되므로 대역폭만 아낄 뿐 서버의 CPU 시간은 아끼지 못한다고 밝힙니다. 또 이 필터는 커밋되지 않은 2xx 응답 가운데 GET 요청이고 Cache-Control에 no-store가 없을 때만 태그를 만들며, If-Match나 If-Unmodified-Since 같은 조건과 상태를 바꾸는 메서드는 다루지 않습니다. 스트리밍처럼 버퍼에 모으면 안 되는 응답은 disableContentCaching으로 빼야 합니다.

서버의 일까지 줄이려면 표현을 만들기 전에 값싸게 얻을 수 있는 값으로 검증기를 정하고, checkNotModified를 조회와 직렬화보다 먼저 호출해야 합니다. 예를 들어 게시글의 버전 번호로 태그를 만들면 304일 때 본문을 만들지 않고 끝낼 수 있습니다. 다만 강한 태그는 선택된 표현의 바이트가 바뀔 때마다 바뀌어야 하므로, 버전 번호에 표현 형식과 직렬화 정책의 리비전까지 넣어야 앞 절의 규칙을 지킬 수 있습니다.


저장 위치와 재사용 조건

RFC 9111 §5.2.2의 응답 지시는 각각 다른 질문에 답합니다.

지시뜻
no-store개인 캐시와 공유 캐시 모두 저장하지 않음
no-cache저장은 허용하지만, 다른 요청에 쓰기 전에 성공적으로 재검증해야 함
private공유 캐시의 저장은 막고 개인 캐시의 저장은 허용
public다른 규칙 때문에 저장할 수 없을 응답도 캐시할 수 있다고 명시
max-age=600응답의 나이가 600초에 이르면 오래된 것으로 봄. 값은 10m 같은 문자열이 아니라 초 단위 숫자

private는 저장 범위를 정할 뿐 기밀성을 보장하지 않습니다. 전송 구간의 보호나 인증과 권한, 응답 내용의 최소화는 별도의 보안 통제입니다. RFC 9111 §3.5의 공유 캐시 제한은 Authorization 요청 필드가 있는 응답을 대상으로 하며, 이 규칙을 Cookie에 저절로 넓혀 적용하지 않습니다. 쿠키에 따라 달라지는 개인 응답에는 그 뜻에 맞춰 private나 no-store를 명시합니다. Vary에는 실제로 표현 선택에 쓰인 요청 필드만 넣습니다. 예제의 두 핸들러는 Accept로 JSON과 CSV를 고르므로 Vary: Accept가 필요하며, 압축이 없는 MockMvc 픽스처에 Vary: Accept-Encoding을 넣어 gzip을 지원하는 것처럼 보이게 하지 않습니다.


fresh 이후의 stale 예산

max-age는 신선한 기간만 정합니다. RFC 9111 §4.2.4는 연결이 끊겼을 때나 요청의 max-stale, 명시적인 확장 지시, 바깥의 약속 같은 조건에서 오래된 응답이 제공될 수 있다고 구분합니다. 그러니 max-age=600 하나를 "늦어도 10분 뒤에는 반드시 원본으로 돌아온다"는 엄격한 한계로 설명하면 안 됩니다.

오래된 응답을 다시 쓰면 잘못된 동작이 생기는 경우라면 must-revalidate로 오래된 뒤에는 원 서버에서 성공적으로 재검증하도록 요구합니다. 가용성을 위해 오래된 응답을 허용한다면, RFC 5861의 stale-while-revalidate와 stale-if-error가 각각 뒤에서 재검증하는 동안의 제공과 오류가 났을 때의 제공을 허용하는 별도의 예외라는 점을 밝히고, 초 단위의 예산을 정합니다. 어떤 예외를 고르든, 신선한 기간과 오래된 응답을 허용하는 기간을 더한 최악의 노출 시간을 운영 요구와 맞춥니다.


변경과 무효화

게시글이 바뀌면 최종 JSON과 CSV 바이트가 바뀌고, 다음 조건부 요청은 새 태그와 본문의 200을 받습니다. 하지만 아직 신선한 응답이 있다면 요청이 원 서버까지 오지 않을 수도 있습니다. RFC 9111 §4.4의 기본 무효화는, 캐시가 안전하지 않은 요청의 오류 아닌 응답을 직접 보았을 때 그 요청의 대상 URI에 대해 일어납니다. 이것은 요청이 지나간 캐시 한 곳에서 그 URI에만 일어나는 동작이며, 모든 CDN 노드에서 한꺼번에 지운다는 뜻이 아닙니다. 모든 곳에 바로 반영해야 한다면 CDN의 퍼지(purge) API나 대리 키(surrogate key), 버전을 넣은 URI, 이벤트 전달과 재시도 같은 운영 경로를 따로 설계합니다.

RFC 9110 §15.5.5에 따라 404 같은 부정 응답도 저장될 수 있고, 휴리스틱 캐시의 대상이 될 수도 있습니다. 리소스가 없다는 사실을 오래 캐시하면 안 되는 API는 예제처럼 Cache-Control: no-store를 명시합니다. 반대로 부정 응답을 일부러 캐시한다면 유효 기간과 생성 직후 허용할 지연, 퍼지 경로를 함께 정합니다.

응답을 민감·credential, 개인·비공개, 공유 가능한 공개 context로 분류해 no-store, private 재검증, public max-age 정책을 고르고, stale 재사용은 엄격한 재검증이나 유한한 SWR·SIE로 명시하며, unsafe method 무효화와 404 부정 cache는 별도 운영 경계로 다룹니다.

RESPONSE CONTEXT · FRESHNESS · STALE BUDGET · INVALIDATION

응답 의미를 먼저 분류하고 stale과 운영 경계를 따로 잠근다

저장 위치, freshness, stale 허용은 서로 다른 선택입니다. 민감 응답과 개인 표현을 공개 재사용에서 분리합니다. 표현마다 freshness 수명과 재검증 조건을 고르고, stale을 허용할 때는 유한한 예산을 둡니다.

응답 context에서 저장 정책 선택이 문서가 민감·개인·공유 가능한 공개로 확인한 세 context의 예제 분기입니다. 민감은 no-store, 개인은 private와 no-cache, 공개만 public·max-age=600을 선택합니다. 공개 분류에는 별도의 확인 근거가 필요합니다. 저장 위치 선택과 함께 전송 보호 등 보안 통제와 뒤의 stale 정책·운영 경계도 따로 판단합니다.민감아님개인공개응답 context세 범주로 분류한 예제credential·민감?공개 재사용과 분리개인·비공개?그 밖은 공개 확인민감 · no-store캐시 저장 금지개인 · privateno-cache매번 재검증공유 가능한 공개public·max-age=600
must-revalidate가 있는 공개 응답의 기한대상 URI와 Vary 선택 축이 맞고 다른 금지 조건이 없는 public·max-age=600·must-revalidate 예제입니다. 599초는 fresh이고 정확히 600초는 stale입니다. stale은 출처 재검증에 성공해야 하며, 실패하면 그 본문을 그대로 재사용할 수 없습니다. 200·304 갱신은 앞 그림의 계약을 따릅니다. SWR·SIE를 허용한 다른 정책과 전역 CDN purge는 원 해설의 별도 운영 경계입니다.freshstale성공실패대상·Vary가 일치한 응답max-age=600must-revalidateage < 600인가?정확한 기한 비교fresh 응답 재사용이 조건에서 검증 없이출처 재검증성공 확인이 필요검증 성공?200 또는304조건별 캐시 갱신새 표현·메타데이터stale 재사용 금지엄격한 정책의 실패
응답 context에서 저장 정책 선택이 문서가 민감·개인·공유 가능한 공개로 확인한 세 context의 예제 분기입니다. 민감은 no-store, 개인은 private와 no-cache, 공개만 public·max-age=600을 선택합니다. 공개 분류에는 별도의 확인 근거가 필요합니다. 저장 위치 선택과 함께 전송 보호 등 보안 통제와 뒤의 stale 정책·운영 경계도 따로 판단합니다.아님공개민감개인응답 context세 범주로 분류한 예제민감한 응답?credential 포함개인·비공개?그 밖은 공개 확인공유 가능한 공개public·max-age=600민감 · no-store캐시 저장 금지개인 · privateno-cache매번 재검증
must-revalidate가 있는 공개 응답의 기한대상 URI와 Vary 선택 축이 맞고 다른 금지 조건이 없는 public·max-age=600·must-revalidate 예제입니다. 599초는 fresh이고 정확히 600초는 stale입니다. stale은 출처 재검증에 성공해야 하며, 실패하면 그 본문을 그대로 재사용할 수 없습니다. 200·304 갱신은 앞 그림의 계약을 따릅니다. SWR·SIE를 허용한 다른 정책과 전역 CDN purge는 원 해설의 별도 운영 경계입니다.stalefresh성공실패대상·Vary 일치max-age=600must-revalidateage < 600?경계는 정확히 비교출처 재검증성공 확인이 필요검증 성공?200 또는 304조건별 캐시 갱신새 표현·메타데이터fresh 응답 재사용이 조건에서 검증 없이stale 재사용 금지엄격한 정책의 실패

01 · RESPONSE CONTEXT

응답 의미로 저장 위치와 공유 가능성을 먼저 고른다

  • credential·민감 응답은 no-store이며, 이 지시어 하나가 privacy 전체를 보장하지는 않습니다.
  • 이 예제의 개인·비공개 표현은 private, no-cache로 저장 범위와 매번 재검증을 함께 정하며, 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 요청의 non-error 응답에 대한 표준 무효화는 요청 target URI를 그 요청이 통과한 cache에서 처리하며, 전역 CDN 제거는 별도 purge가 필요합니다.
  • 개인 응답에도 freshness와 stale 정책이 필요합니다. no-cache의 성공적 재검증과 공개 max-age의 fresh 재사용을 구분합니다. 관련 URI의 무효화는 규격 조건에 따르며 전역 purge가 아닙니다.
  • 404는 heuristic cache가 가능하므로 명시적인 negative-cache 정책과 유한한 TTL을 둡니다.

이 흐름은 HTTP cache 정책의 선택 경계를 보여 줍니다. MockMvc는 애플리케이션이 내보내는 헤더와 조건부 응답은 검증할 수 있지만, 실제 browser·공유 cache·CDN 저장과 purge 동작까지 증명하지는 않습니다.

그림은 지금까지의 판단을 순서대로 정리합니다. 먼저 저장할 수 있는지, 다음으로 아직 신선한지, 재검증할 수 있는지, 오래된 응답을 허용하는 예외가 있는지를 보고, 마지막으로 변경 뒤에 어떻게 무효화할지를 정합니다.


이 글이 다루는 범위

메서드의 캐시 가능성과 안전하지 않은 요청이 만드는 기본 무효화는 HTTP 메서드 속성에서, 일반적인 상태 코드는 URI·상태 코드·PRG에서, Accept와 produces, Vary의 선택 의미는 콘텐츠 협상에서, 쿠키와 서버 세션은 쿠키와 상태 관리에서 다루었습니다. 이 글은 저장 범위와 신선도, 검증기, 304와 재검증, 표현별 재사용, 운영 무효화를 다룹니다. 동시에 고치는 사람을 막는 If-Match와 412, 원자적 변경은 6장의 게시판 CRUD 웹에서 다룹니다. 이 글의 주요 기준은 RFC 9110, RFC 9111, RFC 5861, Spring Framework 6.2의 HTTP 캐시 문서입니다.


정리

  • Cache-Control은 누가 얼마나 오래 저장하고 다시 쓸 수 있는지를, ETag와 Last-Modified는 표현이 바뀌었는지를 알려 주는 별개의 장치입니다.
  • 강한 ETag는 선택된 표현의 최종 바이트가 바뀔 때마다 바뀌어야 하므로, 같은 게시글이라도 JSON과 CSV는 태그가 다릅니다.
  • If-None-Match가 있으면 If-Modified-Since는 무시되며, 태그가 맞을 때만 본문 없는 304로 저장된 본문을 다시 쓰게 합니다.
  • max-age는 신선한 기간일 뿐이며, 오래된 응답을 제공할지는 must-revalidate나 stale-* 같은 지시로 따로 정합니다.
  • 안전하지 않은 요청의 기본 무효화는 요청이 지나간 캐시의 그 URI에만 적용되므로, 모든 CDN에 바로 반영하려면 별도의 퍼지 경로가 필요합니다.

연습 문제

1. 정확한 만료 시점

공개 응답의 정책이 public, max-age=600, must-revalidate이고, 저장한 응답이 이 요청에 맞는다고 가정합니다. 다른 요청 지시나 재사용 금지 조건은 없고, 캐시가 계산한 현재 나이(current_age)가 각각 599초와 600초입니다. 어느 경우에 검증 없이 쓸 수 있을까요? 600초에 원 서버로 검증을 시도했지만 연결할 수 없다면, 오래된 본문을 성공 응답으로 돌려줘도 될까요?

해설 보기

RFC 9111의 신선도 계산은 신선한 기간이 현재 나이보다 클 때만 신선하다고 봅니다. 599초는 600보다 작으므로 다시 쓸 수 있고, 정확히 600초는 이미 오래된 응답입니다. must-revalidate가 있으므로 원 서버 검증에 실패했을 때 오래된 본문을 그대로 성공 응답으로 쓰지 않습니다. 실제 캐시가 오류 응답을 어떻게 처리하고 Age를 어떻게 계산하는지는 응답을 제공하는 계층에서 따로 확인합니다.

2. 공개 주간 통계로 넓히기

게시글 예제를 공개 주간 통계 GET /api/stats/weekly로 넓히세요. JSON과 CSV의 최종 바이트를 먼저 만들어 각각 강한 SHA-256 ETag를 계산하고, 실제 선택 필드가 Accept뿐이면 Vary: Accept만 보냅니다. 공개 데이터가 10분 동안 신선해도 된다면 public, max-age=600, must-revalidate를 씁니다. 같은 JSON 태그로 JSON을 요청하면 304이고 같은 JSON 태그로 CSV를 요청하면 다른 본문과 태그의 200인지, 집계가 바뀐 뒤에는 예전 두 태그가 새 표현의 200을 받는지 확인하세요.

해설 보기

핵심은 데이터베이스 행의 버전이 아니라 실제로 선택된 표현을 검증하는 것입니다. JSON과 CSV 바이트를 따로 해시하면 같은 집계 버전에서도 서로 다른 강한 태그가 만들어집니다. 직렬화 전에 버전 문자열만 조합한다면, 직렬화 설정이나 형식이 바뀌는 배포를 태그 재료에 넣어야 합니다.

JSON 태그를 든 CSV 요청은 If-None-Match가 지금의 CSV 태그와 맞지 않으므로 200이며, 같은 요청에 미래의 If-Modified-Since가 있어도 If-None-Match가 우선합니다. 반대로 태그 없이 초 단위의 If-Modified-Since만 보내면, 변경 시각이 그보다 최신이 아닐 때 304가 됩니다. must-revalidate는 오래된 뒤 검증 없이 다시 쓰는 것을 막지만 CDN 전체에서 지우지는 않으므로, 바로 반영해야 한다면 퍼지의 성공과 실패, 재시도, 최대 노출 시간을 따로 확인합니다.

3. 같은 초 안의 변경

JSON이 12:00:00.100에 버전 1로, 12:00:00.900에 버전 2로 바뀌었고, 두 시각은 같은 날 같은 시간대이며 최종 JSON 바이트도 다릅니다. 예제의 toHttpSecond로 정규화한 Last-Modified는 같을까요? 버전 1을 가진 클라이언트가 같은 초의 If-Modified-Since만 보내는 경우와, 버전 1의 If-None-Match도 함께 보내는 경우를 구분하세요.

해설 보기

두 변경 시각은 모두 12:00:00으로 내려가므로, 날짜 비교만으로는 같은 초 안의 변경을 구분하지 못해 If-Modified-Since만 있는 요청에 304가 날 수 있습니다. 버전 1의 태그를 함께 보냈다면 지금의 버전 2 바이트로 만든 태그가 다르므로 태그 조건이 먼저 적용되어 새 본문의 200이 됩니다. 날짜를 더 정밀한 문자열로 보내는 것은 HTTP 날짜가 초 단위라는 한계를 푸는 방법이 아닙니다. 다음 파일은 같은 초가 되는 두 시각과 서로 다른 표현 태그를 JDK로 확인하며, 비교 대상은 JSON의 의미상 같음이나 데이터베이스 행 번호가 아니라 최종 UTF-8 바이트입니다.

SameSecondValidatorLab.java
import static java.nio.charset.StandardCharsets.UTF_8;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;

public class SameSecondValidatorLab {
    public static void main(String[] args) {
        byte[] first = "{\"version\":1}".getBytes(UTF_8);
        byte[] second = "{\"version\":2}".getBytes(UTF_8);
        long firstSecond = toHttpSecond(1_100L);
        long secondSecond = toHttpSecond(1_900L);
        boolean sameDate = firstSecond == secondSecond;
        boolean sameTag = strongEtag(first).equals(strongEtag(second));
        if (!sameDate || sameTag) throw new AssertionError("unexpected validators");
        System.out.println("sameLastModified=" + sameDate);
        System.out.println("sameStrongEtag=" + sameTag);
    }

    private static long toHttpSecond(long millis) {
        return Math.floorDiv(millis, 1_000L) * 1_000L;
    }

    private static String strongEtag(byte[] representation) {
        try {
            byte[] digest = MessageDigest.getInstance("SHA-256")
                    .digest(representation);
            return "\"" + HexFormat.of().formatHex(digest) + "\"";
        } catch (NoSuchAlgorithmException exception) {
            throw new IllegalStateException("SHA-256 must be available", exception);
        }
    }
}

javac -encoding UTF-8 SameSecondValidatorLab.java와 java SameSecondValidatorLab으로 실행합니다. 밀리초 입력은 설명을 위해 같은 초 안의 작은 값으로 바꾸었고, 이 실행은 정규화와 태그 생성만 확인할 뿐 Spring의 조건부 요청 평가를 실행하지 않습니다. 운영 정책은 날짜 검증기의 한계를 받아들일지, 더 정확한 태그 검증을 요구할지 정해야 합니다.

4. 받은 시각부터 max-age를 새로 세도 될까

다시 쓸 수 있는 공개 응답에 max-age=60, must-revalidate가 있습니다. Date는 12:00:00, 캐시가 원 서버로 요청을 보낸 시각은 12:00:02, 응답을 받은 시각은 12:00:04이며, 응답의 Age는 3초입니다. 모든 시계는 정확히 맞고 다른 조건은 없습니다. 보수적인 max(apparent_age, corrected_age_value) 계산으로 12:00:58과 12:00:59의 신선도를 판단하세요.

해설 보기
계산 항목값
겉보기 나이: 받은 시각 − Date4초
응답 지연: 받은 시각 − 요청 보낸 시각2초
보정한 나이: Age + 응답 지연5초
처음 나이: 앞의 두 나이 중 큰 값5초
12:00:58의 머문 시간과 현재 나이54초, 59초
12:00:59의 머문 시간과 현재 나이55초, 60초

12:00:58은 신선하고, 12:00:59는 정확히 수명에 이르러 오래된 응답입니다. 받은 뒤 55초만 지났다고 신선하다고 보면, 앞단 캐시에 머문 시간과 전달 시간을 무시하게 됩니다. must-revalidate가 있으므로 두 번째 시점에는 성공적인 재검증이 필요합니다. RFC 9111 §4.2.3의 Age 계산을 다음의 고정 입력 산술로 확인할 수 있습니다.

CacheAgeArithmeticLab.java
public class CacheAgeArithmeticLab {
    public static void main(String[] args) {
        long date = 0, requestTime = 2, responseTime = 4, ageValue = 3;
        long apparentAge = Math.max(0L, responseTime - date);
        long responseDelay = responseTime - requestTime;
        long correctedAgeValue = ageValue + responseDelay;
        long initialAge = Math.max(apparentAge, correctedAgeValue);
        long ageAt58 = initialAge + (58L - responseTime);
        long ageAt59 = initialAge + (59L - responseTime);
        if (initialAge != 5L || ageAt58 != 59L || ageAt59 != 60L) {
            throw new AssertionError("age arithmetic mismatch");
        }
        System.out.println("initialAge=" + initialAge);
        System.out.println("freshAt58=" + (ageAt58 < 60L));
        System.out.println("freshAt59=" + (ageAt59 < 60L));
    }
}

javac CacheAgeArithmeticLab.java와 java CacheAgeArithmeticLab으로 실행합니다. 이 코드는 12:00:00을 0으로 둔 초 단위의 고정 계산이며, HTTP 날짜 파서나 캐시 엔진, 시계 오차 정책을 구현한 것이 아닙니다. CDN의 실제 Age 계산은 그 계층에서 확인합니다.

5. 304 뒤에 쓸 본문

개인 캐시에 같은 URI의 JSON J1과 CSV C1을 별도의 항목으로 저장했고, 각각의 최종 바이트로 만든 강한 태그는 "json-1"과 "csv-1"입니다. Vary: Accept가 요청의 선택 축을 구분합니다. CSV를 재검증한 응답이 본문 없이 304, ETag: "csv-1", Vary: Accept, Cache-Control: private, max-age=120을 보냈습니다. 캐시가 알맞은 저장 항목을 알아볼 수 있다면 어떤 본문을 쓰고 어떤 메타데이터를 고칠까요? 이미 CSV 본문을 지우고 태그만 남겨 둔 클라이언트라면 같은 304를 CSV 문서로 해석할 수 있을까요?

해설 보기

요청의 선택에 맞고 강한 태그가 같은 CSV C1 항목을 고칩니다. 응답에 담긴 캐시 정책과 Vary, ETag를 반영하되 C1의 본문은 그대로 둡니다. JSON J1의 본문으로 바꾸거나 304의 빈 바이트로 C1을 덮어쓰지 않으며, 저장 항목을 알아보고 필드를 고치는 규칙은 RFC 9111 §4.3.4를 따릅니다. 이후 다시 쓸 때도 고친 정책으로 신선도를 판단합니다.

태그만 있고 본문이 없는 클라이언트는 304에서 CSV 내용을 되살릴 수 없으므로, 조건을 뺀 조회로 본문이 있는 응답을 받아 저장해야 합니다. 테스트도 304의 빈 본문만 확인하는 원 서버 테스트와, 저장된 C1의 바이트를 지키면서 메타데이터를 고치는 실제 캐시 테스트로 나눕니다. Vary가 다른 요청에 J1이 잘못 쓰이지 않는 반례도 함께 확인합니다.

이것으로 4장을 마칩니다. 이제 브라우저의 요청이 서버에 닿기까지의 연결과 URI, 메서드, 상태 코드, 표현 협상, 쿠키, 캐시가 각각 무엇을 맡는지 설명할 수 있습니다. 다음 장의 웹 서버·WAS·서블릿 컨테이너부터는 다시 게시판 프로젝트로 돌아가, 요청이 프록시와 서블릿 컨테이너, DispatcherServlet을 거쳐 컨트롤러에 닿는 실행 경로를 살펴봅니다.