본문 바로가기
C.W.K.
Stream
Lesson 04 of 06 · published

조건부 요청 — 캐시를 검증하고 덮어쓰기를 막는 법

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

Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"조건부 요청은 바뀌지 않은 본문을 다시 보내지 않는 방법이자, 여러 클라이언트가 공유 리소스를 서로 덮어쓰지 않고 수정하는 방법이야. 두 문제 모두 현재 표현을 식별하는 검증자에서 출발해."

검증자 — 표현 상태를 식별하는 표지

두 응답 헤더가 대표적인 검증자 역할을 해. 서버가 표현에 표지를 붙여 보내면, 클라이언트는 나중에 그 값을 조건부 요청에 담아 자신이 가진 표현이 여전히 최신인지 확인할 수 있어.

  • ETag: "v17-abc123" — 클라이언트가 내부 의미를 해석할 필요 없는 불투명한 표지야. 생성 방식은 서버가 정하며, 클라이언트는 값을 그대로 보관했다가 조건부 헤더에 돌려보내면 돼. 서버는 본문 해시, 행 버전, 수정 번호 같은 값으로 만들 수 있어.
  • Last-Modified: Sun, 25 May 2026 03:00:00 GMT — 표현이 마지막으로 수정된 시각이야. HTTP-date의 정밀도가 초 단위라 짧은 시간에 여러 번 바뀌는 리소스에는 한계가 있지만, ETag를 제공하기 어려울 때 유용해.

정확한 변경 감지가 중요하다면 보통 ETag가 더 적합해. 서버가 표현의 의미와 생성 방식에 맞게 표지를 바꿀 수 있고, 클라이언트와 서버의 시계 차이에도 의존하지 않기 때문이야.

조건부 GET — 바뀌지 않았다면 본문 생략

조건부 캐시 검증을 지원하는 브라우저, CDN, HTTP 캐시의 일반적인 흐름은 다음과 같아.

  1. 첫 요청에서 서버가 200 OK + 본문 + ETag: "v17-abc"를 반환해. 캐시는 본문과 ETag를 함께 저장해.
  2. 나중에 같은 URL을 다시 요청할 때 캐시는 If-None-Match: "v17-abc"를 보낼 수 있어.
  3. 서버가 현재 ETag와 요청 값을 비교해 일치하면 본문 없이 304 Not Modified를 반환하고, 캐시는 저장된 표현을 계속 사용해. 일치하지 않으면 200 OK + 새 본문 + 새 ETag를 반환해.

304는 변경되지 않은 본문 전체를 다시 전송하는 비용을 없애 줘. 500KB짜리 JSON 목록이 그대로라면 그만큼의 전송량을 아끼는 셈이고, CDN 규모에서는 테라바이트 단위의 차이로 커질 수 있어.

낙관적 수정 — 다른 편집을 조용히 덮어쓰지 않기

두 사용자나 두 브라우저 탭이 같은 리소스를 열고 각각 변경을 제출한다고 해 보자. 별도 장치가 없으면 마지막 저장이 앞선 변경을 덮어써 버릴 수 있어. 리소스를 미리 잠그는 비관적 방식 대신 If-Match를 사용하면 낙관적 동시성 제어를 구현할 수 있어.

  1. 클라이언트가 리소스를 GET하고 ETag: "v17-abc"를 받는다.
  2. 로컬에서 내용을 수정한 뒤 If-Match: "v17-abc"를 붙여 PUT을 보낸다.
  3. 서버는 현재 ETag가 여전히 "v17-abc"인지 확인해. 같으면 쓰기를 적용하고 새 ETag를 반환해. 다르면 클라이언트가 읽은 뒤 다른 변경이 있었다는 뜻이므로 412 Precondition Failed를 반환하고, 클라이언트는 최신 표현을 다시 받아 변경을 조정한 뒤 재시도해야 해.

이 패턴은 별도의 장기 잠금 없이도 문서, 설정, 프로필처럼 여러 클라이언트가 수정할 수 있는 리소스에서 '마지막 저장이 무조건 승리'하는 문제를 막아 줘.

검증자는 큰 표현을 안전하고 효율적으로 다루게 하는 작은 상태야. ETag 자체는 짧지만 가리키는 본문은 몇 MB일 수 있어. 조건부 헤더는 변경이 없을 때 본문이 있는 왕복을 본문 없는 왕복으로 바꾸고, 조용한 덮어쓰기를 명시적인 충돌 응답으로 바꿔 줘. 캐시 효율과 동시성 안전성이 같은 검증자에서 나오는 셈이야.

ETag 강도 — 강한 검증자와 약한 검증자

ETag에는 두 종류가 있어.

  • 강한 검증자: ETag: "abc123" — 표현이 바이트 단위로 동일함을 나타내. 강한 ETag가 일치한다면 비교 대상 표현의 모든 바이트가 같아야 해.
  • 약한 검증자: ETag: W/"abc123" — 바이트는 달라도 의미상 동등한 표현임을 나타낼 수 있어. 예를 들어 같은 JSON 데이터를 키 순서만 바꿔 직렬화한 경우가 해당할 수 있어.

바이트 단위 동일성이나 If-Match를 이용한 수정 전제 조건이 필요하다면 강한 ETag를 사용해야 해. 약한 ETag는 의미상 같은 표현의 캐시 검증에는 쓸 수 있지만 강한 비교가 필요한 조건에는 사용할 수 없어.

cwkPippa의 현재 상태

cwkPippa의 GET /api/conversations/{id}는 현재 ETag를 보내지 않아. 복구 계층이 요청마다 JSONL에서 응답을 재구성하므로, 같은 대화라도 직렬화된 표현이 호출 사이에 미묘하게 달라질 가능성이 있어. ETag를 도입하려면 먼저 결정론적 직렬화를 보장해야 하고, 이후 WebUI의 React 쿼리 계층이 변경되지 않은 대화 본문의 재전송과 재해석을 건너뛰도록 연결할 수 있어. 느린 연결에서 반복 전송 비용이 커질 때 우선 검토할 개선점이야.

Code

조건부 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"}
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와 조건부 요청 처리하기·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

앞에서 만든 FastAPI 서버에 완전한 조건부 요청 지원을 추가한다. 모든 GET 응답에 ETag를 보내고, If-None-Match가 일치하면 304를 반환해. PUT/PATCH/DELETE에서는 If-Match가 현재 ETag와 일치하지 않을 때 412를 반환하도록 만든다. 이어서 GET → ETag 저장 → PUT-with-If-Match 순서로 동작하는 Python 클라이언트 루프를 작성해. 같은 리소스를 동시에 수정하는 클라이언트 두 개를 실행하면 하나는 200으로 성공하고 다른 하나는 412로 실패해야 해. 실패한 클라이언트는 최신 표현을 다시 받은 뒤 변경을 재적용해 재시도한다.
Hint
ETag는 JSON 직렬화 결과의 SHA256으로 계산하되, 결정론적인 결과를 위해 sort_keys=True를 사용해. 같은 v17을 읽은 두 클라이언트는 같은 ETag를 갖는다. 먼저 도착한 PUT이 성공해 버전이 v18로 올라가면, 두 번째 PUT의 If-Match는 오래된 값이므로 412를 받아야 해. 복구 흐름은 412 처리 → re-GET → 변경 재적용 → 새 ETag로 re-PUT이야. 협업 편집에서 조용한 덮어쓰기를 피하는 핵심 패턴이야.

Progress

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

댓글 0

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

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