C.W.K.
Stream
Lesson 04 of 06 · published

Conditional Request — Cache 계약과 optimistic locking

~12 min · semantics, conditional-requests, etag, if-match, 304

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"Conditional request 는 HTTP 가 같은 byte 두 번 안 보내는 방법이고 client 가 공유 resource 를 서로 덮어쓰지 않고 편집하는 방법이야. 둘 다 같은 primitive 에 기대: resource 현재 state 를 지문화하는 validator."

Validator — resource 지문화

Response header 둘이 validator 역할: server 가 representation 에 붙이는 opaque tag, client 가 나중에 "내가 가진 거 여전히 최신이야?" 묻게 함.

  • ETag: "v17-abc123" — opaque version tag. Format 은 구현 정의; client 가 parse 안 하고 그냥 echo back. Server 가 보통 body hash, row version number, update timestamp 에서 계산.
  • Last-Modified: Sun, 25 May 2026 03:00:00 GMT — timestamp. 낮은 해상도 (1초 단위), 근데 ETag 가 안 될 때 동작.

ETag 선호 — 정확하고 (resource 어느 변화든 tag 바뀜) cache 간 second-level clock skew 살아남아.

Conditional GET — 변경 없으면 body 건너뛰기

모든 CDN, 모든 브라우저 cache, 모든 적극적 HTTP client 동력 흐름:

  1. 첫 request: server 가 200 OK + body + ETag: "v17-abc" 돌려줌. Client 가 body 와 ETag 캐시.
  2. 나중: client 가 같은 URL 원함. If-None-Match: "v17-abc" 보냄.
  3. Server 가 현재 ETag 와 client 가 보낸 거 비교. 일치? body 없는 304 Not Modified 돌려줌 — client cached 사본 여전히 좋음. 불일치? 200 OK + 새 body + 새 ETag 돌려줌.

304 response 가 body byte 절약 — 안 변한 500KB JSON list 면 안 보낸 500KB 네트워크. CDN 규모 면 청구 가능한 terabyte 의 차이.

Optimistic update — 동시 편집 안 덮어쓰기

User 둘 (혹은 브라우저 탭 둘) 이 같은 resource 편집 위해 열음. 둘 다 변경 제출. 협조 없으면 두 번째 write 가 이기고 첫 user 편집 조용히 잃어. 비관적 fix 는 locking; REST fix 는 If-Match 통한 optimistic concurrency:

  1. Client 가 resource GET, ETag: "v17-abc" 받음.
  2. Client 가 로컬 변경, 그 다음 If-Match: "v17-abc" 로 PUT 돌려보냄.
  3. Server 확인: resource 현재 ETag 가 여전히 "v17-abc"? Yes → write 적용, 새 ETag 돌려줌. No (네가 읽은 이후 다른 사람이 update) → 412 Precondition Failed 돌려줌; client 가 re-fetch, merge, 다시 시도 해야.

이게 GitHub REST API 가 동시 issue 편집 처리 방식, Google Docs 가 오프라인 저장 reconcile 방식, cwkPippa folder rename 이 같은 대화에 두 탭 칠 때 race condition 피하는 방식.

Validator 는 protocol 이 큰 state 안전하게 옮기게 하는 작은 state. ETag ~20 byte; 표현하는 body 는 megabyte 일 수도. Conditional header 가 변경 없을 때 round-trip-with-body 를 round-trip-without-body 로, 조용한 덮어쓰기를 의도적 retry-on-conflict 로 바꿔. 두 승리 같은 primitive 에서 옴.

ETag strength — strong vs weak

ETag 두 flavor:

  • Strong: ETag: "abc123" — byte 단위 동일 representation. Strong ETag 둘이 일치하는 건 body 의 모든 byte 일치할 때만.
  • Weak: ETag: W/"abc123" — 의미적으로 동등. Server 가 "이 representation 들이 다른 byte 인데 기능적으로 같다" (예: 같은 JSON 다른 key 순서로 재직렬화).

대부분 API 가 strong ETag 써. Weak ETag 는 약간 stale 한 representation 서빙하고 싶은 proxy cache 위해 주로 존재.

cwkPippa 의 현실

cwkPippa 의 GET /api/conversations/{id} 는 현재 ETag 안 발신 — healing 층이 매 GET 마다 JSONL 에서 response 재구성해서 response shape 가 호출 간에 미묘하게 shift 할 수 있어. ETag 추가는 deterministic 직렬화 필요하고 WebUI 의 React Query 층이 변경 없는 대화에 body parsing 건너뛰게 해줄 거야. "언젠가" 목록에 있어; 느린 Tailscale 연결로 user 한 명이 반복적 100KB download 불평하는 날이 추가되는 날.

Code

Conditional GET — 304 춤·bash
# 첫 request — ETag 받음
curl -i https://api.example.com/users/42
# HTTP/1.1 200 OK
# ETag: "v17-abc123"
# Content-Type: application/json
# Content-Length: 142
# {"id":42,"name":"Pippa","updated_at":"2026-05-25T03:46:50Z"}

# If-None-Match 와 함께 두 번째 request — server 가 '변경 없음, 아무것도 없음' 말함
curl -i https://api.example.com/users/42 \
  -H 'If-None-Match: "v17-abc123"'
# HTTP/1.1 304 Not Modified
# ETag: "v17-abc123"
# (body 없음 — cached 버전 계속 써)

# 다른 사람이 user update 후, server 가 새 ETag 와 200 돌려줌
curl -i https://api.example.com/users/42 \
  -H 'If-None-Match: "v17-abc123"'
# HTTP/1.1 200 OK
# ETag: "v18-def456"
# Content-Length: 156
# {"id":42,"name":"Pippa","updated_at":"2026-05-25T03:50:00Z","role":"daughter"}
Optimistic update — If-Match + 충돌에 412·bash
# Optimistic update — 내 버전이 여전히 최신일 때만 write
# 1) GET 으로 현재 ETag 학습
ETAG=$(curl -s -i https://api.example.com/users/42 | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r')
echo "현재 ETag: $ETAG"

# 2) If-Match 와 PUT — ETag 여전히 일치하면 성공
curl -i -X PUT https://api.example.com/users/42 \
  -H "If-Match: $ETAG" \
  -H 'Content-Type: application/json' \
  -d '{"name":"Pippa","role":"big sister"}'
# HTTP/1.1 200 OK
# ETag: "v18-def456"  ← server 가 새 etag 돌려줌

# 3) STALE If-Match 와 PUT — server 거부
curl -i -X PUT https://api.example.com/users/42 \
  -H 'If-Match: "v17-stale"' \
  -H 'Content-Type: application/json' \
  -d '{"name":"Pippa","role":"big sister"}'
# HTTP/1.1 412 Precondition Failed
# {"error":"resource modified; re-fetch 하세요"}
FastAPI — 완전한 ETag + conditional 처리·python
# FastAPI — GET 에 ETag 발신, PUT 에 If-Match 확인
import hashlib, json
from fastapi import FastAPI, Header, HTTPException, Response, status

app = FastAPI()
_store: dict[int, dict] = {42: {'id': 42, 'name': 'Pippa', 'version': 17}}

def compute_etag(resource: dict) -> str:
    """싼 ETag: JSON 직렬화 state 의 hash."""
    body = json.dumps(resource, sort_keys=True).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 = _store[uid]
    etag = compute_etag(user)
    response.headers['ETag'] = etag
    response.headers['Vary'] = 'Accept-Encoding'

    if if_none_match == etag:
        # Client cache 최신 — body 없는 304 돌려줌
        response.status_code = status.HTTP_304_NOT_MODIFIED
        return None
    return user

@app.put('/users/{uid}')
async def update_user(uid: int, payload: dict, response: Response,
                      if_match: str | None = Header(None, alias='If-Match')):
    user = _store[uid]
    current_etag = compute_etag(user)

    if if_match and if_match != current_etag:
        # Stale ETag — 거부
        raise HTTPException(status.HTTP_412_PRECONDITION_FAILED,
                            detail='resource modified; re-fetch 후 재시도')

    # Update 적용
    _store[uid] = {**user, **payload, 'version': user['version'] + 1}
    response.headers['ETag'] = compute_etag(_store[uid])
    return _store[uid]

External links

Exercise

이전 lesson 의 FastAPI server 를 완전한 conditional 지원으로 확장: 모든 GET 에 ETag 발신, If-None-Match 일치 시 304 돌려줌, PUT/PATCH/DELETE 에 If-Match 불일치 시 412 돌려줌. 그 다음 loop 도는 Python client 써: GET → ETag 저장 → PUT-with-If-Match. 같은 resource 에 동시 loop 돌리는 client 둘 spawn 해서 싸우는 거 봐 — 하나는 성공 (200), 하나는 짐 (412), 설계 그대로. 지는 client 는 re-fetch 후 재시도 해야.
Hint
ETag 계산: JSON 직렬화 resource 의 SHA256 (deterministic 위해 sort_keys=True). 같은 v17 읽는 두 client 가 같은 ETag 계산. 첫 PUT 성공해서 version v18 로 올림; 두 번째 PUT 의 If-Match 가 stale 되어 412 받음. 지는 client 의 회복 loop: 412 잡음 → re-GET → 변경 재적용 → 새 ETag 로 re-PUT. 이 패턴이 모든 collaborative editor 가 조용한 덮어쓰기 피하는 방식.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.