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

Idempotency & Safety — 진짜 invariant (민간 전승이 지는 곳)

~12 min · semantics, idempotency, safety, cacheability

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"모든 tutorial 이 'POST 가 create, PUT 이 update' 라고 가르쳐. 그건 결과야. 규칙은 idempotency — 규칙을 한 번 보면, verb 매핑이 스스로 정해져."

모든 method 를 정의하는 세 yes/no 질문

RFC 9110 은 세 semantic 속성을 정의해. 모든 method 가 각 속성을 가지거나 안 가져. 조합이 method 의 계약 — 전체 HTTP 생태계 (client, proxy, CDN, retry logic) 와의.

  • Safe — Request 가 server state 에 관찰 가능한 변화 안 만듦. 읽기는 safe; 쓰기는 아님. Safe request 는 자동 retry, prefetch, 추측 실행 가능.
  • Idempotent — 같은 request N번 보내도 한 번 보낸 것과 같은 state 남음. (주의: state, response 가 아님. DELETE 는 첫 번째 204, 두 번째 404 — 다른 response, 같은 최종 state.)
  • Cacheable — Response 가 intermediary 에 저장되고 재사용 허용. 기본으로 cacheable 한 method 도 있고; 명시 header 있어야만 한 것도 있음.

Method 매트릭스

한 번 외우면 "이게 RESTful 해?" 논쟁 대부분 사라져:

Method   | Safe | Idempotent | Cacheable (기본)
---------|------|------------|----------------
GET      | yes  | yes        | yes
HEAD     | yes  | yes        | yes
OPTIONS  | yes  | yes        | rarely (기술적 yes)
PUT      | no   | yes        | no
DELETE   | no   | yes        | no
POST     | no   | no         | response 가 그러라 해야만
PATCH    | no   | no (보통)  | no

두 가지 주목. 첫째: state 바꾸면서 idempotent 인 method 는 PUT 과 DELETE 뿐. 그게 "쓰기에 safe retry" 의 전체 모양. 둘째: POST 가 wildcard — safe 도 idempotent 도 아님 — 이 endpoint 가 뭐 하든 "그 뭐 해줘" 만능이라서. 그 열려있음이 POST 를 유용하게 만들고 순진한 POST client 를 위험하게 만들어.

Idempotency 가 진짜 invariant; verb 매핑은 결과야. PUT 이 "이 user 교체" 의 올바른 선택인 이유는 tutorial 이 그렇게 말해서가 아냐 — 그 operation 이 자연스럽게 idempotent 라서야 (같은 user payload 두 번 보내도 같은 최종 state). 네 "update" operation 이 idempotent 아니면, 실제론 POST 인데 PUT 인 척하는 거고, 그건 곧 물 버그야.

Production 이 신경 쓰는 이유

제대로 (혹은 잘못) 하면 발생하는 현실 결과 셋:

1. Transport retry. Request 가 라우터 10개 거쳐 server 도달. Server 처리. Response 패킷이 돌아오는 길에 떨어짐. Client timeout 됐는데 모름: server 가 봤어? Idempotent method 면 client 가 안전하게 retry 가능 — 최악의 경우 server 가 다른 최종 state 없이 다시 처리. Not-idempotent method 면 retry 위험: payment, message-send, council-finalize. 잘못된 retry 가 카드 두 번 청구.

2. CDN 과 proxy 행동. Cache 가 GET 의 cached response 를 적극적으로 서빙. POST 는 명시 opt-in 없으면 절대 안 캐시 (해도 대부분 안 함). 가끔 link 를 추측적으로 prefetch 하는데 GET 만 — DELETE 를 "미리" fetch 하면 재앙. Protocol 이 cache 가 안전히 이걸 하게 해주는 이유는 method 가 올바른 invariant 약속하니까.

3. Optimistic locking. PUT 이 idempotent 라서 If-Match: "<etag>" 가능 — "내가 읽은 이후 resource 가 update 안 됐을 때만 이 PUT 적용." Idempotency 없으면 이 춤이 일관성 잃어. Track 2 lesson 4 가 cache 계약 다뤄; foundation 은 여기.

Idempotency-Key 패턴 (POST 의 우회)

POST 는 idempotent 아님 — 근데 비즈니스 operation 은 자주 그래야 함. 널리 채택된 해결책: client 가 각 논리적 operation 마다 unique Idempotency-Key header 생성. Server 가 본 key 저장하고 같은 key 의 반복에 같은 response 돌려줌. Stripe 가 대중화했고; 지금은 안전하게 retry 하고 싶은 모든 POST (payment, 계정 생성, side effect 있는 거 뭐든) 의 표준.

cwkPippa 의 현실

cwkPippa 의 POST /api/chat 은 진짜로 idempotent 아냐 — 같은 메시지 두 번 보내면 assistant response 가 두 개 생성 (Claude API 토큰 두 번 비용). Frontend 가 debouncing 과 send-button-click 당 UUID 로 완화하지만, wire-level POST 엔 아직 Idempotency-Key 없음. 알려진 gap; production 에서 무는 날이 추가하는 날. 한편 backend/routes/ 의 모든 PUT/DELETE 는 retry 안전 — idempotent 설계라서. 비대칭은 의도적.

Code

PUT 5x = user 1개; POST 5x = user 5개. 같은 body, 다른 invariant.·bash
# Demonstrate: PUT 은 retry 안전; POST 는 아님.

# PUT — idempotent. 5번 돌려 봐; user 항상 동일 상태.
for i in {1..5}; do
  curl -X PUT https://api.example.com/users/42 \
    -H 'Content-Type: application/json' \
    -d '{"name":"Pippa","role":"daughter"}'
done
# 최종 state: user 42 가 정확히 그 한 user. 중복 없음, 추가 row 없음.

# POST — not idempotent. 5번 돌리면 user 5개 다른 거 나옴.
for i in {1..5}; do
  curl -X POST https://api.example.com/users \
    -H 'Content-Type: application/json' \
    -d '{"name":"Pippa"}'
done
# 최종 state: 별개 user row 5개, 각각 다른 생성 id.
Production retry: idempotency 인식이 load-bearing 결정·python
# Idempotency 존중하는 retry decorator
import time
import httpx

IDEMPOTENT_METHODS = {'GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'}

def safe_retry(request: httpx.Request, max_retries: int = 3):
    """Idempotent request retry; not-idempotent 은 절대 retry 안 함."""
    method = request.method.upper()
    if method not in IDEMPOTENT_METHODS:
        # POST/PATCH — idempotency key 없으면 단일 시도만
        with httpx.Client() as client:
            return client.send(request)

    last_exc = None
    for attempt in range(max_retries):
        try:
            with httpx.Client() as client:
                resp = client.send(request)
                if resp.status_code < 500:
                    return resp  # 성공 혹은 retry 불가한 client 에러
        except httpx.RequestError as e:
            last_exc = e  # transport 에러 — retry
        time.sleep(2 ** attempt)  # exponential backoff
    raise last_exc or Exception('retry 다 소진')
Idempotency-Key 패턴 — POST 를 retry 안전하게·python
# Idempotency-Key 패턴 — POST 를 안전하게 retry 가능하게 (Stripe convention)
from fastapi import FastAPI, Header, HTTPException, status
import uuid

app = FastAPI()
_seen_keys: dict[str, dict] = {}  # production 엔 Redis + TTL

@app.post('/payments')
async def create_payment(
    payload: dict,
    idempotency_key: str | None = Header(None, alias='Idempotency-Key'),
):
    if idempotency_key:
        # 본 key 의 반복? Cached response 돌려줌.
        if idempotency_key in _seen_keys:
            return _seen_keys[idempotency_key]

    # 처음 본 key — 실제 작업
    payment_id = str(uuid.uuid4())
    result = {'id': payment_id, 'amount': payload['amount'], 'status': 'charged'}

    if idempotency_key:
        _seen_keys[idempotency_key] = result
    return result

# Client 사용:
# curl -X POST https://api.example.com/payments \
#   -H 'Idempotency-Key: client-generated-uuid-7abc' \
#   -H 'Content-Type: application/json' \
#   -d '{"amount": 1000}'
# 같은 key 로 정확히 이 request 재시도 — 같은 payment_id, 중복 청구 없음.

External links

Exercise

작은 FastAPI (혹은 Express) server 만들어 endpoint 두 개: POST /messages (not idempotent — 매 호출이 list 에 append) 와 PUT /messages/{id} (idempotent — 매 호출이 overwrite). 각각을 curl 로 5번씩 연속 호출. 각 5번 호출 블록 후 messages 컬렉션 내용 나열. 그 다음 POST /messagesIdempotency-Key header 추가해서 server 가 같은 key 반복 request 를 dedup. 같은 key 로 동일 POST 5번 — list 에 메시지 ONE 개 보여야.
Hint
데모 위해 Idempotency-Key keyed in-memory dict 써 (production 은 Redis + TTL). Server 가 key 당 첫 response 저장; 중복 key 면 작업 재실행 없이 저장된 response 반환. 정확히 Stripe 패턴 — 한 번 만들고 나면 POST retry 를 다시는 같은 눈으로 안 봐.

Progress

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

댓글 0

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

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