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

안전성과 멱등성 — 어떤 요청을 다시 보내도 되는가

~12 min · semantics, idempotency, safety, cacheability

Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"입문서에서는 흔히 'POST는 생성, PUT은 수정'이라고 가르쳐. 하지만 그건 자주 나타나는 결과일 뿐이야. 먼저 봐야 할 규칙은 멱등성이고, 그 규칙을 이해하면 메서드 선택도 자연스럽게 따라와."

모든 메서드를 가르는 세 가지 질문

RFC 9110은 HTTP 메서드의 의미를 설명하는 세 가지 핵심 속성을 정의해. 메서드마다 어떤 속성을 보장하는지가 다르고, 그 조합이 곧 해당 메서드의 계약이 된다. 이 계약은 서버와 클라이언트뿐 아니라 프록시, CDN, 재시도 로직을 포함한 HTTP 생태계 전체가 함께 의존하는 약속이야.

  • 안전성 — 클라이언트가 요청한 동작이 서버 상태의 변경을 의도하지 않는다는 뜻이야. 조회는 안전하지만 쓰기는 안전하지 않아. 중간 장치가 미리 가져오거나 자동으로 다시 시도할 수 있는 기반이 되지만, 실제 재시도 여부는 전송 오류와 구현 특성까지 함께 판단해야 해.
  • 멱등성 — 같은 요청을 N번 보내도 한 번 보낸 것과 같은 의도된 최종 상태를 남긴다는 뜻이야. 중요한 건 응답이 아니라 상태이야. 예를 들어 삭제 요청은 처음에는 204, 다음에는 404를 돌려줄 수 있지만 최종 상태는 똑같이 '리소스가 없음'이야.
  • 캐시 가능성 — 응답을 캐시에 저장해 이후 요청에 재사용할 수 있는지를 나타내. 기본적으로 캐시할 수 있는 메서드가 있는 반면, 명시적인 캐시 지시가 있어야 실용적으로 캐시할 수 있는 메서드도 있어.

메서드 속성표

이 표를 기준으로 보면 "이 설계가 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

두 가지가 특히 중요해. 첫째, 표준 메서드 가운데 상태를 바꾸면서 멱등성을 보장하는 대표적인 메서드는 PUT과 DELETE야. 같은 쓰기 요청을 재전송해야 할 때 이 차이가 결정적이지. 둘째, POST는 안전성도 멱등성도 보장하지 않는 범용 메서드야. 특정 리소스의 정해진 상태를 교체하기보다 서버에 처리를 맡기는 데 유연하지만, 바로 그 유연성 때문에 무심코 재시도하면 중복 부작용이 생길 수 있어.

핵심 불변 조건은 멱등성이고, 흔한 메서드 매핑은 그 결과야. PUT이 "이 사용자를 이 표현으로 교체한다"는 작업에 잘 맞는 이유는 입문서의 암기 규칙 때문이 아니야. 같은 사용자 페이로드를 여러 번 보내도 같은 최종 상태가 되기 때문이지. 반대로 같은 요청이 누적 효과를 내야 한다면 PUT의 계약과 맞지 않으므로 다른 메서드나 별도의 중복 방지 설계를 검토해야 해.

운영에서 중요한 이유

이 계약을 지켰을 때와 어겼을 때의 차이는 운영에서 세 가지 모습으로 드러나.

1. 전송 실패 뒤 재시도. 요청은 여러 라우터를 거쳐 서버에 도착했고 서버도 처리를 마쳤지만, 응답 패킷이 돌아오는 길에 사라질 수 있어. 클라이언트는 시간 초과만 보고 서버가 요청을 처리했는지 알 수 없지. 멱등한 메서드라면 같은 요청을 다시 보내도 의도된 최종 상태가 달라지지 않아. 반면 멱등하지 않은 요청을 재시도하면 결제가 두 번 이루어지거나 메시지가 두 번 발송되거나 Council 마무리 작업이 중복 실행될 수 있어.

2. CDN과 프록시의 동작. 캐시는 명시된 정책에 따라 저장한 GET 응답을 적극적으로 재사용해. POST 응답도 명시적인 캐시 지시가 있으면 캐시할 수 있지만, 실제 지원은 제한적인 편이야. 브라우저의 링크 미리 가져오기 역시 보통 GET처럼 안전한 메서드를 대상으로 한다. DELETE를 미리 실행해서는 안 되는 이유도 분명해. 중간 장치가 최적화할 수 있는 범위는 메서드가 약속한 의미론에 의해 정해져.

3. 낙관적 동시성 제어. PUT 같은 수정 요청에 If-Match: "<etag>"를 붙이면 "내가 읽은 뒤 리소스가 바뀌지 않았을 때만 이 요청을 적용하라"는 조건을 전달할 수 있어. 멱등성과 조건부 요청은 서로 다른 속성이지만, 함께 사용하면 재시도와 동시 수정 충돌을 더 예측 가능하게 다룰 수 있지. 자세한 캐시 검증과 조건부 요청은 이 트랙의 네 번째 레슨에서 다뤄.

Idempotency-Key 패턴 — POST에 중복 방지 더하기

POST 자체는 멱등성을 보장하지 않지만, 결제나 계정 생성처럼 안전한 재시도가 필요한 비즈니스 작업은 많아. 이때 클라이언트는 논리적 작업마다 고유한 Idempotency-Key 헤더를 보내고, 서버는 처음 처리한 키와 응답을 일정 기간 저장해. 같은 키가 다시 오면 작업을 반복하지 않고 저장해 둔 결과를 반환하는 방식이야. Stripe가 널리 알린 패턴으로, 중복 부작용을 막아야 하는 POST에서 사실상 표준처럼 쓰여.

cwkPippa의 실제 사례

cwkPippa의 POST /api/chat은 멱등하지 않아. 같은 메시지를 두 번 보내면 어시스턴트 응답도 두 개 생성되고 Claude API 토큰 비용도 두 번 발생해. 프런트엔드는 중복 입력을 막고 전송 시 UUID를 붙여 위험을 줄이지만, 전송 계층의 POST에는 아직 Idempotency-Key가 없어. 중복 전송이 실제 운영 문제로 드러나면 우선 보강할 지점이야. 반면 backend/routes/의 PUT과 DELETE는 같은 요청을 반복해도 의도된 최종 상태가 같도록 설계되어 있어. 이 비대칭은 각 작업의 의미가 다르기 때문에 생긴 의도적인 선택이야.

Code

같은 본문을 다섯 번 보내도 PUT은 사용자 하나, POST는 사용자 다섯 개를 남길 수 있어·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.
운영 재시도의 핵심: 메서드의 멱등성 확인·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의 중복 실행 막기·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 서버에 엔드포인트 두 개를 만든다. POST /messages는 호출할 때마다 목록에 append하는 멱등하지 않은 작업으로, PUT /messages/{id}는 같은 ID의 항목을 overwrite하는 멱등한 작업으로 구현해. 각각 curl로 다섯 번 연속 호출하고, 호출 묶음이 끝날 때마다 messages 컬렉션의 내용을 확인한다. 이어서 POST /messagesIdempotency-Key 헤더를 추가해 서버가 같은 키의 반복 요청을 dedup하도록 바꿔. 같은 키로 동일한 POST를 다섯 번 보내도 목록에는 메시지가 ONE 개만 남아야 해.
Hint
실습에서는 Idempotency-Key를 키로 쓰는 메모리 내 dict면 충분해. 운영 환경에서는 보통 Redis와 TTL을 사용해. 서버는 각 키의 첫 응답을 저장하고, 같은 키가 다시 들어오면 작업을 재실행하지 않고 저장된 응답을 그대로 반환해야 해. Stripe 패턴을 직접 구현해 보면 POST 재시도에 중복 방지 장치가 필요한 이유가 선명해져.

Progress

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

댓글 0

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

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