Idempotency-Key를 통한 API 복원력 보장
Wenhao Wang
Dev Intern · Leapcell

서론
사용자가 순간적인 네트워크 문제로 인해 중요한 거래를 완료하기 위해 "제출" 버튼을 여러 번 클릭하는 시나리오를 상상해 보세요. 아니면 결제 게이트웨이가 초기 요청을 보낸 후 적시에 응답을 받지 못해 해당 요청을 재시도해야 하는 경우도 있을 수 있습니다. 분산 시스템과 불안정한 네트워크 환경에서 이러한 이벤트는 가능성일 뿐만 아니라 빈번하게 발생하는 일입니다. 백엔드 개발자에게 중요한 질문은 다음과 같습니다. 이러한 재시도(때로는 우발적이고 때로는 의도적인)가 고객에게 이중으로 요금을 청구하거나 동일한 레코드를 여러 개 생성하는 것과 같이 의도치 않은 중복 작업을 초래하지 않도록 어떻게 보장할 수 있을까요?
이것이 바로 멱등성(idempotency) 개념이 매우 중요해지는 지점입니다. 특히 정의상 리소스를 생성하거나 업데이트하는 데 사용되는 POST API의 경우 멱등성을 보장하는 것은 상당한 과제입니다. 이 글에서는 강력하지만 종종 충분히 활용되지 않는 HTTP 헤더 메커니즘인 Idempotency-Key에 대해 탐구할 것입니다. 실용적인 구현 방법을 자세히 살펴보고, 이를 통해 POST 요청의 안전한 재시도 능력을 어떻게 뒷받침하여 API의 안정성과 사용자 편의성을 향상시키는지 보여줄 것입니다.
멱등성 및 관련 개념 이해
Idempotency-Key의 실용적인 측면을 자세히 살펴보기 전에, 토론의 기초를 이루는 몇 가지 기본 개념을 명확히 해 봅시다.
멱등성
API 맥락에서 연산은 여러 번 적용하더라도 한 번 적용하는 것과 동일한 결과를 생성하면 **멱등적(idempotent)**입니다. 중요한 것은 이것이 서버의 상태 변경을 의미한다는 것입니다. 예를 들어:
GET요청은 데이터를 검색하고 서버 상태를 변경하지 않기 때문에 본질적으로 멱등적입니다.- 특정 리소스 ID를 삭제하기 위한
DELETE요청은 멱등적입니다. 한 번 삭제하든 다섯 번 삭제하든 리소스는 삭제되거나(또는 이미 삭제됨) 동일한 최종 상태를 유지합니다. - 특정 상태로 리소스를 업데이트하기 위한
PUT요청도 멱등적입니다. 상태를 반복적으로 설정해도 동일한 최종 상태에 도달합니다. - 그러나 새 리소스를 생성하기 위한 일반적인
POST요청은 일반적으로 멱등적이지 않습니다. 동일한POST요청을 두 번 보내면 일반적으로 두 개의 별도 리소스가 생성됩니다.
우리의 목표는 이러한 멱등적이지 않은 POST 요청이 핵심 기능을 변경하지 않고도 클라이언트 관점에서 멱등적으로 작동하도록 만드는 것입니다.
재시도
재시도는 초기 실패 또는 시간 초과 후에 연산을 다시 실행하려는 시도입니다. HTTP에서 클라이언트는 특정 시간 내에 응답을 받지 못하거나, 네트워크 오류가 발생하거나, 서버가 특정 오류 코드(예: 5xx 오류)를 반환하는 경우 요청을 재시도할 수 있습니다. 멱등성이 없으면 POST 요청을 재시도하면 의도치 않은 부작용이 발생할 수 있습니다.
Idempotency-Key 헤더
Idempotency-Key는 개발자가 요청을 고유하게 식별하는 데 사용하는 클라이언트 제공 HTTP 헤더입니다. 서버가 Idempotency-Key가 포함된 요청을 받으면 이 키를 사용하여 동일한 요청이 이전에 처리되었는지 확인합니다. 처리된 경우 서버는 기본 작업을 다시 실행하지 않고 해당 요청의 원래 결과를 반환할 수 있습니다. 이것은 POST 요청을 효과적으로 클라이언트 관점에서 멱등적으로 만듭니다. 키 자체는 일반적으로 UUID이거나 암호학적으로 안전한 임의의 문자열로, 고유성을 보장합니다.
Idempotency-Key 구현
원칙, 구현 세부 정보 및 구체적인 코드 예제를 살펴보겠습니다.
핵심 원칙
서버는 다음을 수행하기 위한 메커니즘이 필요합니다:
- 키와 해당 요청/응답 저장: 새로운
Idempotency-Key가 들어오면 서버는 요청을 처리하고 키와 응답(또는 최소한 상태 코드 및 본문과 같은 응답에 대한 메타데이터)을 함께 저장합니다. - 기존 키 확인: 동일한
Idempotency-Key를 가진 후속 요청에 대해 서버는 저장소를 확인합니다. - 캐시된 응답 반환: 키가 발견되고 요청이 아직 보류 중이거나 이미 처리된 경우 서버는 비즈니스 로직을 다시 실행하지 않고 이전에 저장된 응답을 반환합니다.
- 동시 요청 처리: 동일한
Idempotency-Key에 대한 여러 동일한 요청이 거의 동시에 도착하는 시나리오를 처리하는 것이 중요합니다. 하나만 비즈니스 로직 실행을 진행하도록 하고 다른 요청은 대기하거나 보류 중인 결과를 검색하려면 잠금 메커니즘 또는 원자 연산이 필요합니다.
구현 단계
1. 클라이언트 측 Idempotency-Key 생성
클라이언트는 각 논리적 연산에 대해 고유한 Idempotency-Key를 생성할 책임이 있습니다. 이는 UUID v4 또는 유사한 강력한 임의 식별자여야 합니다.
import uuid import requests def create_order(order_data): idempotency_key = str(uuid.uuid4()) # 이 요청에 대한 고유 키 생성 headers = { 'Content-Type': 'application/json', 'Idempotency-Key': idempotency_key } try: response = requests.post( 'https://api.your-service.com/orders', json=order_data, headers=headers, timeout=5 # 타임아웃 설정 ) response.raise_for_status() # HTTP 오류에 대한 예외 발생 print(f"Order created successfully: {response.json()}") return response.json() except requests.exceptions.Timeout: print("Request timed out. Retrying with the same Idempotency-Key...") # 타임아웃 시, *동일한* idempotency 키로 재시도 response = requests.post( 'https://api.your-service.com/orders', json=order_data, headers=headers, timeout=5 ) response.raise_for_status() print(f"Order created successfully on retry: {response.json()}") return response.json() except requests.exceptions.RequestException as e: print(f"An error occurred: {e}") return None # 사용 예시 order_details = {"item": "Laptop", "quantity": 1, "price": 1200} create_order(order_details)
2. 서버 측 처리
서버 측(Flask 또는 FastAPI와 같은 Python 프레임워크를 예시로 사용)에서는 다음과 같습니다.
Idempotency 키와 해당 결과를 저장할 영구 저장소가 필요합니다. 데이터베이스(SQL 또는 NoSQL) 또는 Redis와 같은 분산 캐시를 사용할 수 있습니다. 간단히 하기 위해 여기서는 사전(dictionary)을 사용하지만, 프로덕션 환경에서는 올바른 데이터베이스를 사용해야 합니다.
IdempotencyRecord에 대한 간단한 모델을 정의해 보겠습니다.
# 실제 애플리케이션에서는 이것은 데이터베이스 모델이 될 것입니다 class IdempotencyRecord: def __init__(self, key: str, status: str, response_body: dict = None, http_status: int = None, created_at=None): self.key = key self.status = status # 'processing', 'completed', 'failed' self.response_body = response_body self.http_status = http_status self.created_at = created_at if created_at else datetime.utcnow() # 데이터베이스/캐시 시뮬레이션 idempotency_store = {} # {idempotency_key: IdempotencyRecord}
이제 이를 (간소화된) API 엔드포인트에 통합해 보겠습니다.
from flask import Flask, request, jsonify, make_response import uuid import time from datetime import datetime app = Flask(__name__) # IdempotencyStore 시뮬레이션 (실제 앱에서는 Redis/DB 사용) # {idempotency_key: {'status': 'processing/completed/failed', 'response': {data}}} idempotency_store = {} # 해당 키에 대한 동시 요청을 처리하기 위한 잠금 # 분산 시스템에서는 이것이 분산 잠금(예: Redlock)이 될 것입니다. key_locks = {} # 비동기 작업 시뮬레이션 헬퍼 def simulate_order_processing(order_data): time.sleep(2) # 작업 시뮬레이션 order_id = str(uuid.uuid4()) return {"order_id": order_id, "status": "processed", "data": order_data} @app.route('/orders', methods=['POST']) def create_order_api(): idempotency_key = request.headers.get('Idempotency-Key') if not idempotency_key: return jsonify({"message": "Idempotency-Key header is required"}), 400 # 이 키에 대한 잠금 획득 (실제 앱에서는 분산 잠금 사용) import threading if idempotency_key not in key_locks: key_locks[idempotency_key] = threading.Lock() with key_locks[idempotency_key]: # 이 시간 동안 동일한 키에 대한 단일 요청만 처리되도록 보장 # 1. 이 키가 이미 처리되었거나 처리 중인지 확인 if idempotency_key in idempotency_store: record = idempotency_store[idempotency_key] if record['status'] == 'completed': print(f"Returning cached response for key: {idempotency_key}") response = make_response(jsonify(record['response']), record['http_status']) response.headers['X-Idempotency-Processed'] = 'true' return response elif record['status'] == 'processing': # 다른 요청이 처리 중인 경우, 대기하거나 409 Conflict 반환 # 간단히 하기 위해 몇 초간 기다린 후 다시 확인하거나 즉시 충돌 반환 # 고급 설계에서는 "pending" 상태를 가지고 완료될 때까지 차단할 수 있습니다. print(f"Request for key {idempotency_key} is already processing, waiting...") # 여기서는 클라이언트에게 대기/재시도를 알리는 409를 반환할 것입니다. return jsonify({"message": "Request with this Idempotency-Key is currently being processed. Please retry later.", "status": "pending"}), 409 # 2. 키를 찾을 수 없거나 이전에 실패한 경우, 처리 진행 print(f"Processing new request for key: {idempotency_key}") order_data = request.json # 처리 중으로 표시 idempotency_store[idempotency_key] = { 'status': 'processing', 'request_payload': order_data # 디버깅을 위해 저장 (선택 사항) } try: # 3. 비즈니스 로직 실행 result = simulate_order_processing(order_data) http_status = 201 # 생성됨 # 4. 완료 상태 및 응답 저장 idempotency_store[idempotency_key].update({ 'status': 'completed', 'response': result, 'http_status': http_status }) response = make_response(jsonify(result), http_status) response.headers['X-Idempotency-Processed'] = 'true' # 성공적인 처리를 나타냄 return response except Exception as e: # 처리 중 발생할 수 있는 오류 처리 error_msg = f"Failed to process order: {str(e)}" http_status = 500 print(error_msg) idempotency_store[idempotency_key].update({ 'status': 'failed', 'response': {"error": error_msg}, 'http_status': http_status }) return jsonify({"error": error_msg}), http_status if __name__ == '__main__': app.run(debug=True, port=5000)
이 예제는 멱등성 원칙의 핵심 흐름을 보여줍니다(간단히 하기 위해 메모리 내 idempotency_store 및 key_locks 사용):
- 클라이언트는
Idempotency-Key전송: 클라이언트는 각 논리적 연산에 대해 고유한 UUID를 생성하고Idempotency-Key헤더에 포함합니다. - 서버는 캐시/데이터베이스 확인: 서버는
Idempotency-Key를 조회합니다.completed상태로 발견되면 캐시된 응답을 반환합니다.processing상태로 발견되면 클라이언트에게 재시도를 알리거나(구현에 따라) 대기합니다.
- 서버는 요청 처리: 키가 새롭거나 실패로 표시된 경우 서버는 요청을 처리합니다.
- 서버는 응답 캐시: 성공적으로 처리된 후 키, 상태 (
completed), 응답이 저장됩니다. - 오류 처리: 처리 중 오류가 발생하면 키를
failed로 표시하여 후속 재시도가 로직을 다시 실행하도록 허용할 수 있습니다(또는 오류에 따라 오류를 반환). 그러나 견고성을 위해 요청이 실패하면 일반적으로 해당 요청을 실패로 표시하고 원본 작업이 실제로 성공하지 않았다고 판단되면 클라이언트가 새 idempotency 키로 새 요청을 보내도록 하는 것이 가장 좋습니다. 일시적인 실패(예: 데이터베이스 연결 오류)의 경우 동일한 키로 다시 실행을 허용하는 것이 자주 합리적입니다.
적용 시나리오
Idempotency-Key는 특히 POST 요청이 재시도될 수 있는 시나리오에서 유용합니다.
- 결제 처리: 이중 청구를 방지합니다. 결제 요청이 시간 초과되면 동일한
Idempotency-Key로 재시도하면 결제가 한 번만 처리되도록 보장합니다. - 주문 생성: 사용자나 시스템이 실수로 동일한 주문을 여러 개 생성하지 않도록 합니다.
- 리소스 프로비저닝: 프로비저닝 요청이 여러 번 전송되더라도 클라우드 환경에서 리소스의 단일 인스턴스를 생성합니다.
- 이벤트 발행: 게시 API가 여러 번 호출되더라도 이벤트가 메시지 큐에 정확히 한 번 게시되도록 보장합니다.
키의 세분성
Idempotency-Key에 대한 적절한 세분성을 선택하는 것이 중요합니다. 키는 클라이언트가 수행하려는 논리적 연산을 고유하게 식별해야 합니다.
- 나쁨:
request.id(클라이언트 재시도 시마다 새 ID가 생성되는 경우), 사용자 ID 또는 현재 타임스탬프를 사용하는 것. 클라이언트가 재시도하는 경우 이러한 항목은 중복 트랜잭션을 방지하지 못합니다. - 좋음: 클라이언트가 특정 거래 시도에 대해 한 번 생성하는 고유 UUID. 예를 들어 사용자가 제품을 구매하려고 하면 해당 "구매 시도"에 하나의
Idempotency-Key가 할당됩니다. 새로고침하여 나중에 다시 시도하면 이는 새로운 "구매 시도"이므로 잠재적으로 새로운 키가 될 수 있습니다.
결론
Idempotency-Key HTTP 헤더는 특히 상태 변경 POST 작업을 포함하는 견고하고 안정적인 백엔드 API를 구축하는 데 필수적인 도구입니다. 클라이언트가 의도한 작업에 대한 고유 식별자를 제공하도록 함으로써 개발자는 재시도를 안전하게 처리하고, 의도치 않은 중복 부작용을 방지하며, 네트워크 불안정성이나 시스템 오류에 직면했을 때 사용자 경험을 대폭 향상시킬 수 있습니다. 이 키를 통한 멱등성 구현은 API가 스트레스 상황에서도 "올바른 일"을 수행하도록 보장하여 시스템을 더욱 복원력 있고 신뢰할 수 있게 만듭니다. 진정한 오류 허용 API를 구축하려면 Idempotency-Key를 채택하십시오.

