- Published on
LangChain 리트라이 중복청구 막는 idempotency_key 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LLM 호출을 “한 번만” 실행했다고 믿었는데, 결제 내역이나 사용량(토큰/과금)이 두 번 찍히는 경험은 생각보다 흔합니다. 특히 LangChain 체인이나 에이전트를 운영 환경에 붙이면 네트워크 타임아웃, 게이트웨이 502, 스트리밍 중 연결 끊김 같은 사건이 자주 발생하고, 이때 자동 리트라이가 “같은 일을 다시 실행”해 버리면서 중복청구가 발생합니다.
이 글은 LangChain 리트라이가 왜 중복청구로 이어지는지, 그리고 idempotency_key를 실전에서 어떻게 설계하고 어디에 저장하며 어떤 범위로 적용해야 하는지(요청 단위, 도구 호출 단위, 결제/DB 트랜잭션 단위)를 코드와 함께 정리합니다.
참고로 LangChain에서 도구 호출이 꼬이면서 같은 툴이 반복 실행되는 케이스도 종종 중복 비용으로 연결됩니다. 툴콜 무한루프를 먼저 끊어야 하는 상황이라면 이 글도 함께 보세요: LangChain에서 OpenAI 툴콜 무한루프 끊기
중복청구는 “리트라이”가 아니라 “재실행” 문제다
리트라이는 본질적으로 같은 입력을 다시 보내는 행위입니다. 문제는 호출이 “실패했다”는 판단이 애매하다는 데 있습니다.
- 서버는 실제로 LLM 요청을 처리했고, 결과를 만들었지만
- 클라이언트는 응답을 받기 전에 타임아웃이 나서 실패로 간주하고
- LangChain(또는 HTTP 클라이언트)이 재시도하면서
- 같은 프롬프트가 다시 실행되어 비용이 2배가 됩니다.
여기서 핵심은 “한 번만 실행되어야 하는 작업”에 대해 멱등성(idempotency) 을 강제하지 않으면, 재시도는 곧 중복 실행이라는 점입니다.
어떤 계층에서 중복이 생기나
중복청구는 다음 레이어 중 어디에서든 생깁니다.
- LLM API 레벨: 같은 요청이 여러 번 전송됨
- LangChain 런타임 레벨: 체인/에이전트가 동일 단계를 반복 실행
- 비즈니스 로직 레벨: 결제 생성, 주문 생성, 이메일 발송 같은 사이드이펙트가 중복 수행
따라서 idempotency_key는 “LLM 호출”에만 붙이는 것으로 끝나지 않고, 사이드이펙트 경계를 기준으로 설계해야 합니다.
idempotency_key 설계 원칙 5가지
1) 키의 스코프는 “사용자 요청 1건”이 기본
가장 안정적인 기본값은 request_id 같은 단일 키를 만들어, 그 요청에서 파생되는 모든 외부 호출에 전파하는 것입니다.
- 사용자 요청 1건 =
idempotency_key1개 - 체인 내부 단계가 여러 개여도 같은 키를 컨텍스트로 공유
단, “한 요청 안에서 서로 다른 사이드이펙트”를 분리해야 하면 파생 키를 만듭니다.
- 예:
request_id:charge,request_id:send_email,request_id:llm_call:step_2
2) 키는 UUIDv4 또는 ULID를 추천
- 충돌 가능성 낮음
- 로그/트레이싱에서 추적 쉬움
- ULID는 시간 정렬이 되어 운영 디버깅에 유리
3) 저장소는 “원자적 체크-앤-셋”이 가능한 곳
중복 방지의 본질은 다음 연산을 원자적으로 만드는 것입니다.
idempotency_key가 이미 처리되었나 확인- 아직이면 처리하고 결과를 저장
이를 위해 보통 다음 중 하나를 씁니다.
- Redis
SET key value NX EX ttl - DB unique constraint + upsert
4) TTL을 반드시 둔다
영구 저장은 비용과 운영 복잡도를 키웁니다. 보통은 다음을 권장합니다.
- 결제/주문 같은 강한 멱등이 필요: 24시간~7일
- LLM 호출 결과 캐시 성격: 5분~1시간
5) “결과 캐시”와 “실행 잠금”을 분리한다
중복 방지는 두 가지 문제가 섞여 있습니다.
- 동시에 두 요청이 들어와 같은 작업을 수행하는 문제(락)
- 이미 끝난 작업을 다시 수행하는 문제(결과 재사용)
실전에서는 다음 두 키를 분리하면 단단해집니다.
lock:{idempotency_key}: 처리 중임을 표시result:{idempotency_key}: 완료 결과 저장
LangChain에서 리트라이가 생기는 지점
LangChain 자체, 혹은 그 아래 HTTP 클라이언트/SDK에서 리트라이가 발생할 수 있습니다.
- 네트워크 오류, 429, 5xx에 대한 자동 재시도
- 스트리밍 중 연결 끊김 후 재호출
- 에이전트가 도구 호출 실패를 보고 다시 같은 도구를 호출
중요한 점은 “리트라이를 끄는 것”이 정답이 아니라는 겁니다. 리트라이는 필요합니다. 다만 리트라이해도 안전한 설계를 해야 합니다.
실전 아키텍처: LLM 호출 + 결제/DB 사이드이펙트 멱등화
여기서는 가장 흔한 시나리오를 가정합니다.
- 사용자가 요청을 보냄
- 서버가 LangChain으로 LLM을 호출해 결과를 만들고
- 결과를 DB에 저장하거나 결제/사용량 차감을 수행
이때 멱등성은 최소 2군데에 필요합니다.
- 비즈니스 사이드이펙트(결제/차감/주문 생성): 반드시 멱등
- LLM 호출: 비용 최적화를 위해 멱등(선택이지만 강력 추천)
이 구조는 분산 트랜잭션과 유사한 고민을 낳습니다. 보상 트랜잭션 관점의 디버깅 감각이 필요하면 이 글도 도움이 됩니다: MSA 사가 패턴 보상 트랜잭션 실패 디버깅
코드: FastAPI + Redis로 idempotency_key 구현
아래 예시는 다음을 만족합니다.
- 같은
idempotency_key로 들어온 요청은 결과를 재사용 - 동시에 같은 키로 들어온 요청은 하나만 실행하고 나머지는 대기/즉시 반환
- LLM 호출과 DB 저장 같은 사이드이펙트를 “요청 단위로” 1회만 수행
1) Redis 유틸
import json
import time
from typing import Any, Optional
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
def acquire_lock(key: str, ttl_sec: int = 60) -> bool:
lock_key = f"lock:{key}"
# NX: 없을 때만 set, EX: TTL
return bool(r.set(lock_key, "1", nx=True, ex=ttl_sec))
def release_lock(key: str) -> None:
r.delete(f"lock:{key}")
def get_result(key: str) -> Optional[dict[str, Any]]:
raw = r.get(f"result:{key}")
return json.loads(raw) if raw else None
def set_result(key: str, value: dict[str, Any], ttl_sec: int = 3600) -> None:
r.set(f"result:{key}", json.dumps(value), ex=ttl_sec)
2) LangChain 호출부에 키를 “컨텍스트로” 전파
LangChain의 콜백/런 설정을 이용해 request_id를 태그로 달아두면, 로그/추적과 함께 “이 요청이 같은 요청인지”를 구분하기 쉬워집니다.
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
def run_llm(prompt: str, request_id: str) -> str:
# tags/metadata는 LangChain 런타임 로깅과 트레이싱에 유용
resp = llm.invoke(
[HumanMessage(content=prompt)],
config={
"tags": ["api", "billing-sensitive"],
"metadata": {"idempotency_key": request_id},
},
)
return resp.content
여기서 주의할 점은 metadata를 달았다고 해서 외부 LLM API가 멱등해지는 것은 아니라는 것입니다. 이건 어디까지나 관측성(로그/트레이싱) 강화입니다. 실제 멱등성은 서버가 보장해야 합니다.
3) 요청 핸들러에서 멱등 처리
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
@app.post("/v1/answer")
def answer(
prompt: str,
idempotency_key: str = Header(default=""),
):
if not idempotency_key:
raise HTTPException(status_code=400, detail="Missing Idempotency-Key header")
cached = get_result(idempotency_key)
if cached:
return {"idempotent": True, **cached}
if not acquire_lock(idempotency_key, ttl_sec=90):
# 다른 워커가 처리 중일 가능성
# 정책 1) 409로 돌려서 클라이언트가 조금 뒤 재시도
raise HTTPException(status_code=409, detail="Request is being processed")
try:
content = run_llm(prompt, request_id=idempotency_key)
# 사이드이펙트 예시: DB 저장/사용량 차감/결제 생성 등
# 반드시 이 구간도 idempotency_key 기준으로 중복 실행이 막혀야 함
result = {
"answer": content,
"usage_recorded": True,
}
set_result(idempotency_key, result, ttl_sec=3600)
return {"idempotent": False, **result}
finally:
release_lock(idempotency_key)
이 방식의 장점은 단순함입니다. 단점은 다음과 같습니다.
- 처리 시간이 락 TTL을 넘기면 락이 풀려 중복 실행 가능
- 409 정책은 클라이언트 구현이 필요
이를 개선하려면 락 TTL 연장(heartbeat) 또는 작업 큐를 도입합니다.
결제/차감 같은 강한 사이드이펙트는 DB 유니크 키로 막아라
Redis는 빠르지만, 결제/주문 같은 핵심 데이터는 DB 제약으로 “최종 방어선”을 세우는 게 안전합니다.
예: charges 테이블에 idempotency_key를 유니크로 두고, 같은 키로는 한 번만 insert 되게 합니다.
CREATE TABLE charges (
id BIGSERIAL PRIMARY KEY,
idempotency_key TEXT NOT NULL,
user_id BIGINT NOT NULL,
amount_cents INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (idempotency_key)
);
애플리케이션에서는 다음처럼 처리합니다.
- insert 성공: 정상 과금
- unique 충돌: 기존 레코드 조회 후 그 결과 반환
import psycopg
def create_charge(conn: psycopg.Connection, user_id: int, amount_cents: int, key: str) -> dict:
with conn.cursor() as cur:
try:
cur.execute(
"""
INSERT INTO charges (idempotency_key, user_id, amount_cents)
VALUES (%s, %s, %s)
RETURNING id, created_at
""",
(key, user_id, amount_cents),
)
row = cur.fetchone()
conn.commit()
return {"charge_id": row[0], "created_at": str(row[1]), "deduped": False}
except psycopg.errors.UniqueViolation:
conn.rollback()
cur.execute(
"SELECT id, created_at, amount_cents FROM charges WHERE idempotency_key = %s",
(key,),
)
row = cur.fetchone()
return {"charge_id": row[0], "created_at": str(row[1]), "deduped": True}
이 패턴은 “어떤 경로로든 중복 과금은 불가능”하게 만듭니다. LLM 호출이 중복되더라도 최소한 과금/차감은 중복되지 않습니다.
LangChain 체인/에이전트에서 키를 어디까지 전파할까
실무에서 자주 하는 실수는 idempotency_key를 HTTP 요청 레벨에서만 쓰고, 체인 내부 단계(도구 호출, 서브체인 호출)에는 키를 잃어버리는 것입니다.
권장 전략은 다음과 같습니다.
- 최상위 요청에서
request_id생성 - LangChain
config.metadata로 전파 - 도구 함수(툴) 시그니처에도
request_id를 전달 - 툴 내부에서 외부 API 호출/DB 쓰기 시
request_id기반 멱등 처리
도구 호출에 파생 키 적용 예시
from langchain_core.tools import tool
def derive_key(request_id: str, suffix: str) -> str:
return f"{request_id}:{suffix}"
@tool
def charge_user(user_id: int, amount_cents: int, request_id: str) -> str:
key = derive_key(request_id, "charge")
# create_charge는 DB unique constraint로 멱등 보장
# 실제 구현에서는 conn 주입/풀 사용
# result = create_charge(conn, user_id, amount_cents, key)
return f"charged with key={key}"
포인트는 “요청 전체는 하나의 키로 묶되, 결제 같은 강한 사이드이펙트는 파생 키로 분리”입니다. 그래야 한 요청에서 결제를 두 번 시도하는 버그가 나도 같은 파생 키로 묶여 중복이 막힙니다.
운영에서 꼭 확인해야 할 체크리스트
1) 타임아웃과 리트라이 정책을 문서화
- 서버 타임아웃(예: ALB, Nginx, API Gateway)
- 애플리케이션 타임아웃(HTTP 클라이언트)
- LangChain/SDK 리트라이 정책(429/5xx)
타임아웃이 짧고 리트라이가 공격적이면 중복 실행 확률이 올라갑니다.
2) 스트리밍 응답은 “부분 성공”이 많다
스트리밍 중 끊기면 클라이언트는 실패로 보지만, 서버는 이미 토큰을 소비했을 수 있습니다. 이 경우 결과 캐시가 특히 중요합니다.
- 같은
idempotency_key면 이미 생성된 결과를 재전송 - 혹은 생성 중이라면 재연결 시 이어받기(더 복잡하지만 이상적)
3) 관측성: 키를 로그에 반드시 남겨라
idempotency_key- 사용자 ID
- 모델/버전
- 프롬프트 해시
- 최종 과금/차감 레코드 ID
이게 있어야 “중복청구” 신고가 들어왔을 때 재현 없이도 추적이 됩니다.
4) 캐시와 멱등을 혼동하지 말 것
- 캐시: 성능/비용 최적화(없어도 정합성 유지 가능)
- 멱등: 정합성/중복 실행 방지(없으면 돈이 새거나 데이터가 깨짐)
LLM 결과 캐시는 선택이지만, 결제/주문/차감 멱등은 필수입니다.
흔한 실패 사례와 처방
실패 1) 키를 프롬프트 해시로 만들었다
프롬프트가 같으면 같은 키가 되어 “다른 사용자 요청”까지 묶일 수 있습니다.
- 처방: 키는 요청 단위로 랜덤 생성
- 프롬프트 해시는 보조 필드로 저장(디버깅/분석용)
실패 2) Redis에만 의존했다
Redis 장애/플러시/클러스터 이슈로 멱등이 깨질 수 있습니다.
- 처방: 핵심 사이드이펙트는 DB unique constraint로 최종 방어
실패 3) 락 TTL이 짧아 중복 실행
LLM이 느려지거나 도구 호출이 늘면 90초가 금방 지나갑니다.
- 처방: TTL을 넉넉히 잡고, 가능하면 heartbeat로 연장
- 또는 작업 큐로 넘기고 상태 머신으로 관리
마무리
LangChain 리트라이는 “나쁜 기능”이 아니라, 불안정한 네트워크와 429/5xx가 일상인 환경에서 필수 안전장치입니다. 문제는 리트라이가 곧바로 중복 실행과 중복청구로 이어질 수 있다는 점이고, 이를 막는 가장 현실적인 방법이 idempotency_key입니다.
정리하면 다음 순서로 적용하면 시행착오가 줄어듭니다.
- 최상위 요청에
Idempotency-Key를 도입하고 결과 캐시를 붙인다 - 결제/차감/주문 생성 같은 강한 사이드이펙트는 DB 유니크 키로 멱등을 강제한다
- LangChain 체인/도구 호출까지
request_id를 전파하고, 파생 키로 단계별 멱등을 적용한다 - 로그/트레이싱에 키를 남겨 운영 디버깅 시간을 줄인다
이렇게 하면 “재시도는 하되, 돈은 한 번만 나가게” 만들 수 있습니다.