멱등성을 이용한 중복 작업 방지 API 구축
James Reed
Infrastructure Engineer · Leapcell

소개
백엔드 개발의 복잡한 세계에서 시스템의 신뢰성과 예측 가능성을 보장하는 것은 매우 중요합니다. 개발자가 흔히 접하는, 때로는 미묘한 문제는 중복 작업의 위험입니다. 네트워크 지연으로 인해 사용자가 '주문 제출' 버튼을 반복해서 클릭하거나 자동 재시도 메커니즘이 동일한 결제 요청을 여러 번 트리거하는 시나리오를 상상해 보십시오. 적절한 안전 장치가 없으면 이러한 발생은 잘못된 데이터, 재정적 불일치 및 좌절스러운 사용자 경험으로 이어질 수 있습니다. 바로 여기서 멱등성이라는 개념이 필수 불가결해집니다. 멱등성 API를 설계하고 구현하는 것은 단순한 모범 사례가 아니라, 분산 환경의 내재된 불확실성을 우아하게 처리할 수 있는 강력하고 내결함성 있는 시스템을 구축하는 데 필수적인 요구 사항입니다. 이 문서는 API 맥락에서 멱등성이 무엇을 의미하는지, 왜 그렇게 중요한지 살펴보고, 구체적인 코드 예제를 통해 이를 달성하기 위한 다양한 실용적인 전략을 조명합니다.
멱등성 API 디자인 이해
구현 세부 사항에 들어가기 전에 멱등성 API와 관련된 핵심 개념에 대한 명확한 이해를 확립해 봅시다.
멱등성이란 무엇인가? 수학 및 전산학에서 연산은 한 번 적용했을 때와 동일한 결과를 생성하면 멱등성을 갖습니다. API의 맥락에서 멱등성 API 엔드포인트는 동일한 매개변수로 여러 번 호출해도 한 번 호출한 것과 서버 상태에 동일한 영향을 보장합니다. 결정적으로, 이것이 항상 동일한 응답을 의미하는 것은 아닙니다(예: '리소스 생성됨' 메시지가 '리소스 이미 존재함' 메시지가 될 수 있음). 하지만 시스템에 대한 부작용은 일관되게 유지됩니다.
API에 왜 중요한가?
- 네트워크 불안정성: 네트워크 글리치, 타임아웃 및 재시도는 흔합니다. 멱등성이 없으면 재시도는 의도치 않은 중복 작업으로 이어질 수 있습니다.
- 사용자 행동: 사용자가 이중 클릭하거나, 제출 후 새로고침하거나, 로딩 시간 지연을 겪어 여러 번 제출하게 될 수 있습니다.
- 분산 시스템: 메시지 큐, 이벤트 스트림 및 마이크로 서비스는 종종 '최소 한 번' 전달 의미론을 포함하며, 이는 메시지(및 따라서 API 호출)가 한 번 이상 처리될 수 있음을 의미합니다.
- API 게이트웨이 및 프록시: 이러한 구성 요소는 API 호출을 자동으로 재시도할 수 있으므로 다운스트림 API는 멱등성을 가져야 합니다.
일반적인 HTTP 메서드 및 멱등성:
- GET: 본질적으로 멱등성입니다. 데이터를 여러 번 검색해도 데이터는 변경되지 않습니다.
- HEAD: 본질적으로 멱등성입니다. GET과 유사하지만 헤더만 반환합니다.
- OPTIONS: 본질적으로 멱등성입니다. 통신 옵션을 설명합니다.
- PUT: 일반적으로 멱등성입니다. 지정된 URI에 리소스를 교체하는 데 자주 사용됩니다. 동일한 URI에 동일한 데이터를 여러 번 PUT하면 리소스는 매번 동일한 값으로 교체됩니다.
- DELETE: 일반적으로 멱등성입니다. 리소스를 여러 번 삭제해도 리소스는 없는 상태가 되며, 이는 한 번 삭제한 것과 동일한 상태입니다. 첫 번째 호출은 '204 No Content'를 반환할 수 있으며, 후속 호출은 '404 Not Found'를 반환할 수 있지만, 리소스의 상태(삭제됨)는 일관됩니다.
- POST: 본질적으로 멱등성이 아닙니다. POST 요청은 일반적으로 서버에 데이터를 보내 새로운 리소스를 생성합니다. 동일한 POST 요청을 여러 번 보내면 일반적으로 여러 리소스가 생성됩니다. 멱등성 메커니즘이 가장 필요한 곳입니다.
- PATCH: 본질적으로 멱등성이 아닙니다. PATCH는 리소스에 부분적인 수정을 적용합니다. 동일한 패치를 여러 번 적용해도 패치 논리에 따라 다른 결과가 나올 수 있습니다(예: '10씩 증가').
멱등성 구현 전략
API를 멱등하게 만드는, 특히 POST와 같은 비멱등 작업의 경우, 각 작업에 고유 식별자를 할당하고 이 식별자를 사용하여 중복 처리를 감지하고 방지하는 것이 핵심 원칙입니다.
1. 멱등성 키 (클라이언트 생성)
이것은 가장 일반적이고 강력한 접근 방식입니다. 클라이언트는 각 요청에 대해 고유한 불투명 키(종종 UUID)를 생성하고 이를 특수 헤더(예: Idempotency-Key
또는 X-Idempotency-Key
)로 보냅니다.
원칙:
서버는 Idempotency-Key
를 수신합니다.
- 성공적인 작업에 대해 이 키를 이전에 본 적이 있는지 확인합니다.
- 예, 그렇다면 원래 작업을 다시 실행하지 않고 원래 작업의 저장된 결과를 반환합니다.
- 아니요, 그렇다면 요청을 처리하고, 작업 결과(또는 성공/실패 상태)와 함께 키를 저장한 다음, 결과를 반환합니다.
구현 세부 정보:
- 저장소:
Idempotency-Key
와 해당 응답/상태는 영구적이고 빠른 액세스가 가능한 저장소(예: Redis, 전용 데이터베이스 테이블)에 저장해야 합니다. - TTL (Time-To-Live): 멱등성 키는 무기한 저장소 확대를 방지하기 위해 만료 시간을 가져야 합니다. 일반적인 관행은 재시도 창을 포함할 수 있는 몇 분 또는 몇 시간 동안 유지하는 것입니다.
- 원자성: 경쟁 조건을 방지하려면 확인 및 저장 작업이 원자적이어야 합니다. 두 개의 동일한 요청이 동시에 서버에 도달하면, 해당 키에 대한 요청을 성공적으로 획득하거나 처리하는 것은 하나뿐입니다.
예제 (개념적 Python/Flask):
from flask import Flask, request, jsonify import uuid import time app = Flask(__name__) # 실제 앱에서는 Redis 또는 적절한 데이터베이스 테이블을 사용하십시오. idempotency_store = {} # {idempotency_key: {"status": "SUCCESS", "response_data": {...}, "timestamp": ...}} def process_payment(amount, currency, user_id): """결제 처리 로직을 시뮬레이션합니다.""" print(f"Processing payment for user {user_id}: {amount} {currency}") time.sleep(2) # 네트워크/처리 지연 시뮬레이션 if amount < 0: raise ValueError("Invalid amount") return {"message": "Payment successful", "transaction_id": str(uuid.uuid4()), "amount": amount, "currency": currency} @app.route('/payments', methods=['POST']) def create_payment(): idempotency_key = request.headers.get('Idempotency-Key') if not idempotency_key: return jsonify({"error": "Idempotency-Key header is required"}), 400 # 1. 저장소에서 키 확인 (그리고 여전히 유효한지) if idempotency_key in idempotency_store: stored_entry = idempotency_store[idempotency_key] # 단순화를 위해, 사용 가능한 경우 저장된 응답을 반환합니다. # 실제 시스템에서는 '처리 중' 상태를 확인하고 대기 # 또는 원본 요청이 실패한 경우 '충돌'을 반환할 수 있습니다. if stored_entry.get("status") == "SUCCESS": return jsonify(stored_entry["response_data"]), 200 # 원본 성공 응답 반환 # 2. 키를 찾을 수 없거나 만료되었으면, 처리 진행 try: data = request.json amount = data.get('amount') currency = data.get('currency') user_id = data.get('user_id') if not all([amount, currency, user_id]): return jsonify({"error": "Missing amount, currency, or user_id"}), 400 # 키에 잠금을 하여 동시 처리를 방지하는 시뮬레이션 # 실제 시스템에서는 분산 잠금(예: Redis SET NX)을 사용합니다. if idempotency_key in idempotency_store and idempotency_store[idempotency_key].get("status") == "PROCESSING": return jsonify({"error": "Request already being processed with this key"}), 409 # Conflict idempotency_store[idempotency_key] = {"status": "PROCESSING", "timestamp": time.time()} result = process_payment(amount, currency, user_id) # 3. 성공한 결과와 함께 키 저장 idempotency_store[idempotency_key] = { "status": "SUCCESS", "response_data": result, "timestamp": time.time() } return jsonify(result), 200 except ValueError as e: # 애플리케이션 수준 오류의 경우, 상태를 FAILED로 저장 idempotency_store[idempotency_key] = { "status": "FAILED", "error_message": str(e), "timestamp": time.time() } return jsonify({"error": str(e)}), 400 except Exception as e: # 일반 서버 오류, 상태를 FAILED로 저장 idempotency_store[idempotency_key] = { "status": "FAILED", "error_message": "Internal Server Error", "timestamp": time.time() } return jsonify({"error": "Internal Server Error"}), 500 if __name__ == '__main__': app.run(debug=True, port=5000)
클라이언트 예제 ( curl
사용):
# 첫 번째 요청 curl -X POST -H "Content-Type: application/json" \ -H "Idempotency-Key: f5a6b7c8-d9e0-f1a2-b3c4-d5e6f7a8b9c0" \ --data '{"amount": 100, "currency": "USD", "user_id": "user123"}' \ http://localhost:5000/payments # 동일한 Idempotency-Key로 두 번째 요청 (동일한 결과 반환) curl -X POST -H "Content-Type: application/json" \ -H "Idempotency-Key: f5a6b7c8-d9e0-f1a2-b3c4-d5e6f7a8b9c0" \ --data '{"amount": 100, "currency": "USD", "user_id": "user123"}' \ http://localhost:5000/payments # 다른 Idempotency-Key로 새 요청 curl -X POST -H "Content-Type: application/json" \ -H "Idempotency-Key: a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6" \ --data '{"amount": 50, "currency": "EUR", "user_id": "user456"}' \ http://localhost:5000/payments
2. 분산 잠금
공유 리소스를 수정하는 작업을 위해서는 분산 잠금(예: Redis, ZooKeeper 또는 SELECT ... FOR UPDATE
를 사용한 데이터베이스 사용)을 사용하여 주어진 Idempotency-Key
에 대해 한 번에 하나의 작업 인스턴스만 진행되도록 보장할 수 있습니다. 이는 동일한 키로 동시 요청을 처리할 때 특히 중요합니다.
3. 상태 전환 및 선행 조건
엔티티의 상태를 수정하는 작업의 경우, 변경 사항을 적용하기 전에 현재 상태를 확인하여 멱등성을 강제할 수 있습니다.
원칙: 연산이 특정 상태의 엔티티에서만 유효하고, 해당 연산이 다른 상태로 옮긴다면, 동일한 연산을 재시도하는 것(엔티티가 새 상태에 있음을 발견할 것임)은 정상적으로 거부되거나 무시될 수 있습니다.
예제:
- 주문 처리:
fulfill_order
API는 주문 상태가 'PENDING'인지 확인할 수 있습니다. 이미 'FULFILLED'이면 아무 작업도 수행하지 않고(성공 반환). - 차변/대변: 금융 거래를 처리할 때, 현재 잔액과 거래 상태를 확인합니다. 거래가 아직 적용되지 않은 경우에만 적용되도록 보장합니다.
4. 데이터베이스의 고유 제약 조건
새 레코드를 생성하는 작업을 포함하는 경우, 데이터베이스의 고유 제약 조건을 활용하십시오.
원칙: 고유 식별자(예: 주문 번호, 사용자 이메일)를 가진 레코드를 생성하려고 할 때, 해당 필드에 데이터베이스에 고유 제약 조건을 정의하면 됩니다.
예제:
POST /users
API: 이미 존재하는 이메일(및email
에 고유 제약 조건이 있음)로 사용자를 생성하려고 하면, 데이터베이스가 중복 삽입을 방지하고 API는 오류를 포착하여409 Conflict
또는200 OK
(사용자가 이미 존재함을 나타냄)를 반환할 수 있습니다.POST /orders
: 클라이언트 또는 이전 서비스에서 생성된 고유 식별자인order_id
를 사용하고 고유 제약 조건을 적용합니다.
주의: 고유 제약 조건은 중복을 방지하는 데 좋지만, 재시도된 요청에 대한 원본 응답을 본질적으로 제공하지는 않습니다. 완전한 멱등성 경험을 위해 이를 Idempotency-Key
전략과 결합하는 것이 이상적입니다.
애플리케이션 시나리오
멱등성 API는 여러 영역에서 매우 중요합니다.
- 결제 처리: 이중 청구를 방지합니다. 모든 결제 요청에는 멱등성 키가 포함되어야 합니다.
- 주문 관리: 주문이 정확히 한 번 생성되고 처리되도록 합니다.
- 이벤트 처리: 이벤트 기반 아키텍처에서 메시지 큐의 소비자는 종종 '최소 한 번' 방식으로 메시지를 처리합니다. 멱등성 처리는 메시지가 재배달되더라도 부작용이 한 번만 발생하도록 보장합니다.
- 리소스 생성: 중복 생성이 문제가 되는 엔티티(사용자, 제품, 문서)를 생성하는 API입니다.
- 상태 업데이트: 리소스 상태를 수정하는 모든 API(예:
PATCH /resource/{id}/status
,POST /resource/{id}/increment_counter
).
중요 고려 사항
- 오류 처리:
Idempotency-Key
충돌(예: 처리 중인 키를 처리하려는 요청)은 어떻게 처리해야 합니까?409 Conflict
는 종종 클라이언트에게 대기하고 원본 요청을 다시 시도하거나 새 키를 사용하도록 권장하는 데 적합합니다. - 저장소 및 TTL: 멱등성 저장소(Redis는 속도 및 TTL 기능으로 인기가 있음)를 신중하게 선택하십시오. 예상 재시도 창을 기반으로 적절한 TTL을 결정합니다. 너무 짧으면 합법적인 재시도가 다시 실행될 수 있고, 너무 길면 저장소가 무기한 늘어납니다.
- 멱등성 키 생성: 클라이언트는 충분히 고유하고 무작위적인 키(UUIDv4가 좋은 선택임)를 생성해야 합니다.
- 처리 중 서버 충돌: 서버가 처리 후 결과 저장 및 키 연결 전에 충돌하는 경우, 재시도가 여전히 중복 실행으로 이어질 수 있습니다. 원자적 트랜잭션 커밋(예: 데이터베이스 트랜잭션 내에서 키 저장 및 작업 처리)을 통해 이를 완화할 수 있습니다.
- 멱등성의 범위: '동일한 효과'를 구성하는 것이 무엇인지 명확히 하십시오. 외부 시스템을 포함합니까? API가 다른 비멱등 서비스에 대한 호출을 수행하는 경우, API의 멱등성은 자체 멱등성 메커니즘으로 해당 외부 호출을 래핑하거나 잠재적 중복을 처리해야 할 수 있습니다.
결론
멱등성 API를 설계하고 구현하는 것은 신뢰할 수 있고 내결함성 있는 백엔드 시스템을 구축하는 기초적인 실천입니다. 멱등 연산의 특성을 이해하고 Idempotency-Key
, 분산 잠금과 같은 전략을 사용하고 고유 제약 조건을 활용함으로써, 개발자는 중복 요청과 관련된 위험을 크게 완화할 수 있습니다. 이러한 사전 예방적 접근 방식은 일관된 시스템 상태를 보장하고, 이중 결제와 같은 의도치 않은 부작용을 방지하며, 궁극적으로 사용자 및 다운스트림 서비스 모두에게 더 안정적이고 예측 가능한 경험을 제공합니다. 네트워크 환경의 내재된 예측 불가능성에 견딜 수 있도록 API를 설계할 때 항상 멱등성을 핵심 요구 사항으로 고려하십시오.