멱등성이 뭔가요?
컴퓨터 과학에서 멱등하다는 것은 첫 번째 수행을 한 뒤 여러 차례 적용해도 결과를 변경시키지 않는 작업 또는 기능의 속성을 뜻합니다. 즉, 멱등한 작업의 결과는 한 번 수행하든 여러 번 수행하든 같다는 뜻이되죠.
수학적 예로는 절대값 함수가 있어요. 같은 입력에 대해 몇 번을 적용해도 결과가 변하지 않죠.
이 아이디어를 웹 API에 가져오면, 중복 요청·재시도에도 상태가 뒤틀리지 않도록 만드는 설계 원칙이 됩니다.
HTTP 메서드와 멱등성
어떤 메서드가 멱등(Idempotent)한지부터 봅시다.
GET, PUT처럼 리소스를 조회하거나 대체하는 메서드는 멱등하고, DELETE 역시 여러 번 호출해도 삭제된 리소스에 대한 결과는 달라지지 않죠. 반면 POST, PATCH는 멱등한 메서드가 아니에요.
| 메서드 | 멱등성 |
| GET / HEAD / OPTIONS / TRACE | O |
| PUT / DELETE | O |
| POST / PATCH / CONNECT | X |
혹시 이 표를 보시면 멱등성과 안정성에 대해 궁금하시지 않으신가요? 두 개념은 비슷해 보일 수 있지만 포커스가 다릅니다.
안정성의 경우 서버 리소르를 변경하지 않는가?에 대한 포커스를 두고 있어요. 대표적인 예로 GET, HEAD, OPTIONS, TRACE 가 있고, 안전한 메서드는 보통 멱등이기도 합니다.
멱등성의 경우 같은 요청을 여러 번 보내도 결과(최종 상태)가 같은가?에 포커스를 두고 있어요. PUT, DELETE는 멱등하지만 상태를 바꾸기 때문에 안전하진 않죠.
몇 가지 REST API 예로 살펴보자면
- PUT /items/1 (같은 본문): 몇 번 보내도 최종 리소스는 같은 내용 → 멱등(O), 안전(X).
- DELETE /items/1: 첫 요청에 삭제되고, 이후엔 404가 올 수 있지만 최종 상태는 삭제됨으로 동일 → 멱등(O), 안전(X).
- POST /items: 호출할 때마다 새 리소스가 추가될 수 있음 → 멱등(X), 안전(X).
그렇다면 왜 API에서 멱등성이라는 게 중요할까요?
결제처럼 네트워크 오류·타임아웃이 있을 수 있는 환경에서 멱등성은 재시도 시나리오를 단순하게 만들고, 중복 요청(‘따닥’)에도 서버 상태(DB)가 일관되게 유지되도록 도와주죠.
클라이언트가 같은 ‘의미의’ 작업을 여러 번 요청해도 서버는 ‘한 번만 처리’하도록 만드는 것이 핵심이라 할 수 있습니다.
그래서 이 멱등성을 보장하려면 멱등키(Idempotency-Key)를 요청에 포함하면 됩니다.
같은 키를 가진 중복 요청은 실제로 처리하지 않고 첫 요청과 같은 응답을 재전달하거든요.
일반적인 패턴은 아래와 같아요.
- 클라이언트가 충분히 무작위적인 키(예: UUID v4) 를 생성해 요청 헤더에 보냄.
- 서버는 (API키, 메서드, 경로, 멱등키) 조합으로 요청 기록을 저장/조회.
- 이미 처리된 키면 저장된 응답을 그대로 반환.
- 키의 유효기간을 두어 일정 시간 후 재사용 가능하게 관리.
코드 예제
클라이언트가 같은 작업을 재전송해야 할 때
네트워크 타임아웃·일시 오류가 날 수 있어 같은 작업을 재전송해야 하는 클라이언트일 때의 예시입니다.
import uuid, time, requests
IDEMP_KEY = str(uuid.uuid4()) # 최초 1회 생성
URL = "https://api.example.com/payments/cancel"
PAYLOAD = {"orderId": "ORD-20250908-1", "amount": 10000}
HEADERS = {
"Authorization": "Bearer <token>",
"Idempotency-Key": IDEMP_KEY,
"Content-Type": "application/json",
}
def retry_safe_post(url, json, headers, max_attempts=5, base_sleep=0.5):
attempt = 0
while True:
attempt += 1
try:
resp = requests.post(url, json=json, headers=headers, timeout=5)
except (requests.Timeout, requests.ConnectionError):
if attempt >= max_attempts:
raise
time.sleep(base_sleep * (2 ** (attempt - 1))) # 지수 백오프
continue
# 서버가 분명히 말해주는 케이스들
if resp.status_code == 409: # 동일 키 처리 중
if attempt >= max_attempts:
resp.raise_for_status()
time.sleep(base_sleep * (2 ** (attempt - 1)))
continue
if resp.status_code in (400, 422): # 키 없음/형식 오류 or 페이로드 불일치
resp.raise_for_status()
# 2xx/기타 처리
resp.raise_for_status()
return resp
resp = retry_safe_post(URL, PAYLOAD, HEADERS)
print(resp.status_code, resp.json())
Idempotency-Key는 최초 시도 때 한 번 생성하고, 재시도 때 동일 키를 재사용하는 예제입니다.
서버가 409(처리 중)을 주면 백오프 후 재요청, 422(페이로드 불일치)면 즉시 중단하게 되죠.
이 때 주의해야 할 점은 재시도할 때 새 키를 만들면 멱등성이 깨집니다. 따라서 사용자가 같은 버튼을 여러 번 눌러도(‘따닥’) 동일 키가 전송되도록 UX/클라이언트 로직을 맞춰주시면 됩니다.
FastAPI + Redis로 400/409/422 구분
응답을 저장·재전달해 중복 처리를 막고, 표준적인 에러 코드(400/409/422)를 구분해 주고 싶을 때 사용하는 패턴입니다.
요청 스코프는 소유자(API 키), HTTP 메서드, 요청 경로, 그리고 Idempotency-Key의 조합으로 정의합니다. 최초 요청이 들어오면 서버는 상태를 processing으로 기록하고 짧은 TTL을 설정하여, 동일 키로 동시에 들어오는 다른 요청은 409 Conflict로 확실히 차단합니다.
이후 요청 처리가 정상적으로 완료되면 응답을 캐시에 저장하고 TTL을 연장하여, 같은 키로 재시도가 들어와도 저장된 응답을 반환할 수 있도록 합니다. 마지막으로, 요청 본문을 해시한 페이로드 지문을 함께 기록하여, 동일 키인데 본문이 달라질 경우 422 Unprocessable Entity를 판별할 수 있게 합니다.
from fastapi import FastAPI, Request, HTTPException
from pydantic import BaseModel
import redis, json, time, hashlib
app = FastAPI()
rds = redis.Redis(host="localhost", port=6379, decode_responses=True)
PROCESSING_TTL = 60 * 2 # processing 잠금은 짧게 (예: 2분)
DONE_TTL = 60 * 10 # 완료 응답 캐시는 길게 (예: 10분)
class CancelReq(BaseModel):
orderId: str
amount: int
def payload_fp(d: dict) -> str:
s = json.dumps(d, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(s.encode()).hexdigest()
def scope_key(owner: str, method: str, path: str, idem: str) -> str:
return f"idemp:{owner}:{method}:{path}:{idem}"
@app.post("/payments/cancel")
async def cancel_payment(req: Request, body: CancelReq):
idem = req.headers.get("Idempotency-Key")
if not idem or len(idem) < 10:
raise HTTPException(400, detail="Missing or invalid Idempotency-Key")
owner = (req.headers.get("Authorization") or "anon")[:40]
key = scope_key(owner, req.method, str(req.url.path), idem)
fp = payload_fp(body.dict())
# 1) 최초 진입 시도: 처리 중 잠금을 원자적으로 올림
# HSETNX로 'status' 필드 생성 + TTL 지정 (processing)
# → 다른 요청은 409 반환
created = rds.hsetnx(key, "status", "processing")
if created:
rds.hset(key, mapping={"payload": fp})
rds.expire(key, PROCESSING_TTL)
else:
saved = rds.hgetall(key)
if not saved:
# 키가 만료된 순간 레이스가 있을 수 있으니, 다시 최초처럼 처리
rds.hset(key, "status", "processing")
rds.hset(key, "payload", fp)
rds.expire(key, PROCESSING_TTL)
else:
if saved.get("status") == "processing":
raise HTTPException(409, detail="Request with this Idempotency-Key is still processing")
if saved.get("payload") and saved["payload"] != fp:
raise HTTPException(422, detail="Payload differs from original request for this Idempotency-Key")
# 완료된 응답 재전달
return json.loads(saved["response"])
try:
# === 실제 비즈니스 처리 ===
time.sleep(0.1) # 예시용 지연
response = {"message": "결제 취소 성공", "orderId": body.orderId, "amount": body.amount}
# 2) 완료 스냅샷 저장 + TTL 연장
rds.hset(key, mapping={"status": "done", "response": json.dumps(response)})
rds.expire(key, DONE_TTL)
return response
except Exception:
# 실패 시 잠금 해제하여 재시도 가능
rds.delete(key)
raise
여기서 주의하셔야 할 사항은 processing 상태가 영원히 남지 않도록 반드시 TTL을 부여해야 합니다.
그리고 멀티 프로세스/멀티 노드 환경이라면 위 흐름을 Lua 스크립트로 원자적 체크·설정으로 감싸 동시성 안전성을 높혀야 합니다. 그리고 스코프에 포함할 요소(소유자/API 키 등)는 업무 정의에 맞게 조정해야 하죠.
DB 유니크 제약으로 멱등 보장 (PostgreSQL)
Redis 같은 외부 캐시 없이 데이터베이스만으로 멱등성을 견고하게 보장하고 싶을 때 사용하는 예시입니다.
서버 재시작이나 스케일아웃 상황에서도 일관성이 유지되는 장점이 있죠.
요청 스코프 (owner_id, method, path, idempotency_key)에 UNIQUE 제약을 두어 최초 요청만 성공하도록 합니다. 이렇게 하면 첫 삽입만 성공하고 이후 같은 스코프·키로 들어오는 요청은 기존 레코드로 판단할 수 있습니다. 또한 레코드에 payload_fingerprint(요청 본문 지문)과 response_json(처리 결과 스냅샷)을 저장해, 본문이 달라지면 422로 판별하고, 같은 요청이면 저장된 응답을 재전달할 수 있습니다.
CREATE TABLE idempotent_requests (
id BIGSERIAL PRIMARY KEY,
owner_id TEXT NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
payload_fingerprint TEXT NOT NULL,
response_json JSONB,
status TEXT NOT NULL CHECK (status IN ('processing','done')),
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (owner_id, method, path, idempotency_key)
);
CREATE INDEX ON idempotent_requests (owner_id, method, path, idempotency_key);
# 트랜잭션 내 의사코드 (psycopg 계열)
try:
# 최초 요청만 성공
cur.execute("""
INSERT INTO idempotent_requests(owner_id, method, path, idempotency_key, payload_fingerprint, status)
VALUES (%s,%s,%s,%s,%s,'processing')
ON CONFLICT DO NOTHING
""", (owner, method, path, idem_key, payload_fp))
if cur.rowcount == 0:
row = select_by(owner, method, path, idem_key)
if row.status == 'processing':
raise HTTPException(409)
if row.payload_fingerprint != payload_fp:
raise HTTPException(422)
return row.response_json
# === 실제 처리 ===
result = {"message": "결제 취소 성공", "orderId": order_id, "amount": amount}
cur.execute("""
UPDATE idempotent_requests
SET status='done', response_json=%s
WHERE owner_id=%s AND method=%s AND path=%s AND idempotency_key=%s
""", (json.dumps(result), owner, method, path, idem_key))
return result
except:
# 실패 시 처리 중 레코드 정리(선택적)
cur.execute("""
DELETE FROM idempotent_requests
WHERE owner_id=%s AND method=%s AND path=%s AND idempotency_key=%s AND status='processing'
""", (owner, method, path, idem_key))
raise
ON CONFLICT DO NOTHING을 사용했다면 반드시 기존 레코드를 재조회해 상태에 따라 409(처리 중) / 422(페이로드 불일치) / 저장 응답 재전달을 정확히 분기해야 합니다. 또 멱등 기록을 오래 보관하면 테이블이 커지므로 TTL 정책(백그라운드 정리 작업·파티셔닝 등)을 마련해 주기적으로 정리하는 것이 좋습니다.
함께보면 좋을 자료들
- MDN - 멱등성
- IETF - The Idempotency HTTP Header Field
- HTTP Idempotent Methods
- Wikipedia - Idempotence(Computer Science)
'프로그래밍(Basic) > 이론' 카테고리의 다른 글
| [바미] - 자료구조 정리(JS) (0) | 2025.07.29 |
|---|---|
| [바미] - 정렬 알고리즘 정리 (JS) (0) | 2025.07.28 |
| [바미] 큐잉 이론(Queueing Theory)이란? (0) | 2024.12.09 |
| [바미] 자료구조 - 해시맵(HashMap) (0) | 2024.07.19 |
| [바미] 자료구조 - 트리(Tree) (0) | 2024.07.18 |