max-age가 끝나면 캐시는 본문을 무조건 다시 받는 대신 If-None-Match: "<etag>"를 담은 조건부 GET을 보내.
현재 ETag가 같으면 서버는 304 Not Modified를 반환해. 본문 없이 “저장한 사본을 계속 써도 돼”라고 확인하는 거야.
캐시는 응답 메타데이터와 신선도를 갱신하고 저장된 본문을 재사용해.
절약량은 보내지 않은 본문 크기에 비례해. 활성 클라이언트 10,000개가 500KB JSON 카탈로그를 60초마다 재검증하고 내용이 그대로라면, 최대 약 5GB의 본문 전송을 분마다 피할 수 있어.
협력하는 세 계층
일반적인 요청은 여러 캐시를 지나고, 각 계층이 같은 프로토콜을 독립적으로 적용해.
브라우저 캐시 — 사용자별 개인 캐시야. Cache-Control에 따라 응답을 저장하고, 신선하면 네트워크 요청 없이 재사용하며, 오래되면 조건부 GET으로 확인해.
CDN 엣지 — 한 지역의 여러 사용자가 공유해. 공유 저장이 허용된 응답을 보관하고, 오래되면 원본 쪽에 조건부 요청을 보내.
역방향 프록시와 원본 — 애플리케이션에 가장 가까운 계층이야. Nginx나 Varnish의 마이크로캐시처럼 원본 앞에 캐시가 하나 더 있을 수도 있어.
브라우저는 CDN에, CDN은 보호 계층이나 원본에 같은 방식으로 재검증할 수 있어. 원본은 가끔 조건부 요청을 처리하지만, 사용자에게서 쏟아지는 전체 요청과 본문 전송은 위쪽 캐시가 흡수해. 계층이 쌓일수록 절약도 커져.
좋은 검증자 만들기
ETag는 표현의 버전을 비교하는 대표적인 검증자야. 만드는 방법은 크게 세 가지가 있어.
콘텐츠 해시 — 직렬화된 본문의 SHA-256 같은 해시를 써. 바이트가 바뀌면 ETag도 바뀌므로 정확하지만, 응답을 만들 때마다 본문을 해시하는 비용이 들어.
버전 필드 — 리소스의 updated_at이나 버전 카운터를 직접 반영해. 예를 들어 ETag: "v17-1716623210"처럼 만들 수 있어. 계산은 저렴하지만, 표현이 바뀔 때마다 버전도 반드시 바뀌어야 해.
복합 해시 — 리소스 버전과 로캘, 선택한 필드처럼 직렬화 결과에 영향을 주는 입력을 함께 묶어. 하나의 리소스가 콘텐츠 협상에 따라 여러 표현을 만들 때 유용해.
약한 ETag(W/"v17")는 두 표현이 바이트 단위로 같지는 않아도 재검증 목적에서는 동등하다는 뜻이야. 사소한 직렬화 차이를 같은 표현으로 보아도 될 때 쓸 수 있어. 바이트 동일성이 필요한 용도에는 강한 ETag를 사용해야 해.
304 응답 자세히 보기
304 응답에는 캐시된 항목을 올바르게 갱신하는 데 필요한 ETag, Cache-Control, Vary 같은 관련 헤더를 담아야 해. Last-Modified를 검증자로 사용했다면 그것도 일관되게 보내. 200 응답에서 정책이 달라졌다면 304에도 새 정책을 반영해야 하고, 본문은 보내지 않아야 해.
본문이 없다는 사실이 절약의 핵심이야. 304를 직접 받는 수동 클라이언트가 곧바로 response.json()을 호출하면 파싱할 본문이 없어 실패해. 브라우저의 HTTP 캐시는 내부 재검증 뒤 저장된 본문을 투명하게 제공할 수 있지만, httpx 같은 일반 라이브러리는 기본 캐시를 제공하지 않으므로 ETag와 저장된 본문을 애플리케이션에서 관리하거나 별도 캐시 계층을 써야 해.
조건부 GET은 이미 준비된 프로토콜을 제대로 연결하는 일. 브라우저 캐시, CDN, 역방향 프록시는 이 흐름을 구현하고 있어. 서버가 일관된 ETag와 정확한 Cache-Control을 보내면 표준 캐시에서는 절약이 자연스럽게 따라와. 다만 자체 캐시가 없는 API 클라이언트라면 저장과 재검증 로직이 별도로 필요해.
흔한 함정
1. 비결정적 직렬화. 같은 표현인데도 JSON의 키 순서, 시각 정밀도, 임의 값 때문에 바이트가 달라지면 해시 기반 ETag도 매번 바뀌어. 키 순서를 고정하고, 불필요한 변동 값을 제거하고, 같은 입력에서 같은 바이트가 나오도록 직렬화해야 304 경로가 열려.
2. Vary 빠뜨리기. 응답이 Accept-Encoding이나 Authorization에 따라 달라진다면 캐시 키도 그 차이를 알아야 해. Vary가 없으면 Brotli 표현이 지원하지 않는 클라이언트에 전달되거나, 한 사용자의 인증 응답이 다른 사용자에게 전달될 수 있어.
3. 인증 응답에 public을 무심코 붙이기. 모든 사용자에게 완전히 같은 표현이고 공유 저장이 안전하다는 확신이 있을 때만 허용해. 사용자마다 응답이 다르면 private과 ETag를 함께 써서 공유 캐시에 노출하지 않고 개인 캐시의 재검증 이득을 얻는 편이 안전해.
cwkPippa의 조건부 GET 현실
cwkPippa의 API 응답은 현재 ETag를 보내지 않아. 일부 GET은 JSONL에서 응답을 다시 구성하므로, 안정적인 직렬화와 올바른 버전 기준부터 정해야 해. 반면 해시가 붙은 Vite 정적 자산은 URL 자체가 버전 역할을 해 장기 캐시에 잘 맞아. 느린 Tailscale 연결에서 같은 대화 목록 본문이 반복 전송되는 비용이 실제 병목으로 확인된다면, 목록 버전으로 ETag를 만들어 조건부 GET을 지원하는 것이 비교적 작은 다음 단계가 될 수 있어. 측정된 병목이 없다면 먼저 복잡성을 늘릴 이유는 없어.
Code
curl로 304에서 생략된 본문 확인하기·bash
# curl 로 conditional GET 춤 봐
# 1. 첫 request — ETag 저장
ETAG=$(curl -s -i https://api.example.com/users/42 | grep -i '^etag:' | awk '{print $2}' | tr -d '\r')
echo "받은 ETag: $ETAG"
# 2. Conditional GET — server 가 304, body 없이 돌려줌
curl -i https://api.example.com/users/42 -H "If-None-Match: $ETAG"
# HTTP/1.1 304 Not Modified
# ETag: "v17-abc123"
# Cache-Control: public, max-age=60
# (body 없음)
# 3. 절약된 body byte 검증
echo "--- 둘 다 시간 재기 ---"
time curl -s https://api.example.com/users/42 > /dev/null # full
time curl -s https://api.example.com/users/42 -H "If-None-Match: $ETAG" > /dev/null # conditional
FastAPI: ETag와 Cache-Control을 보내고 일치하면 304 반환하기·python
# Server 쪽 — ETag 발신과 If-None-Match 처리
import hashlib, json
from fastapi import FastAPI, Header, Response, status
app = FastAPI()
_USER_DB = {42: {'id': 42, 'name': 'Pippa', 'version': 17}}
def compute_etag(resource: dict) -> str:
# Hash determinism 위한 byte-stable JSON
body = json.dumps(resource, sort_keys=True, separators=(',', ':')).encode()
return f'"{hashlib.sha256(body).hexdigest()[:12]}"'
@app.get('/users/{uid}')
async def read_user(uid: int, response: Response,
if_none_match: str | None = Header(None, alias='If-None-Match')):
user = _USER_DB[uid]
etag = compute_etag(user)
response.headers['ETag'] = etag
response.headers['Cache-Control'] = 'public, max-age=60'
response.headers['Vary'] = 'Accept-Encoding'
# Client ETag 일치 — BODY 없는 304 돌려줌
if if_none_match == etag:
response.status_code = status.HTTP_304_NOT_MODIFIED
return None
return user
클라이언트: 저장된 본문과 If-None-Match를 직접 관리하기·python
# Client 쪽 — 대부분 HTTP client 가 conditional GET 투명 처리
import httpx
# httpx (와 fetch 와 -H If-None-Match 가진 curl) 가 너 위해 춤 처리
# 근데 명확성 위해 수동도 가능
cache = {} # url -> (etag, body)
def get_with_cache(client: httpx.Client, url: str):
cached = cache.get(url)
headers = {'If-None-Match': cached[0]} if cached else {}
resp = client.get(url, headers=headers)
if resp.status_code == 304:
return cached[1] # cached body 씀
if resp.status_code == 200 and 'ETag' in resp.headers:
body = resp.json()
cache[url] = (resp.headers['ETag'], body)
return body
resp.raise_for_status()
return resp.json()
# 두 번 호출 — 두 번째 호출이 304 + cached body
with httpx.Client() as c:
user1 = get_with_cache(c, 'https://api.example.com/users/42')
user2 = get_with_cache(c, 'https://api.example.com/users/42') # 304 경로
assert user1 == user2
JSON 리소스 하나를 제공하는 FastAPI 엔드포인트에 ETag와 조건부 GET 처리를 추가해. 첫 응답에서 받은 ETag를 후속 If-None-Match에 넣어 curl로 100번 연속 요청하고, --write-out '%{size_download}\n'으로 내려받은 본문 바이트의 합을 측정해. 전체 본문을 100번 받은 경우와 한 번의 전체 응답 뒤 99번의 304를 받은 경우를 비교해. 보너스로 논리적으로 같은 객체를 서로 다른 키 삽입 순서로 만들고, sort_keys=True를 뺐을 때 해시 기반 ETag가 달라지는지 확인해.
Hint
ETag는 sha256(json.dumps(리소스, sort_keys=True))처럼 계산할 수 있어. Bash 반복문에서는 각 응답의 ETag를 다음 요청의 If-None-Match로 전달하고, curl의 size_download 값을 누적해 본문 전송량을 비교해. 304 응답에도 헤더 바이트는 남으므로 전체 전송량이 정확히 99% 줄어드는 것은 아니지만, 본문 바이트는 거의 모두 사라져야 해. Python 3.7 이상에서는 dict가 삽입 순서를 보존하므로, 보너스 실험에서는 키를 다른 순서로 넣은 두 객체를 의도적으로 만들어야 차이를 볼 수 있어.
Progress
Progress is local-only — sign in to sync across devices.