Published on

LangChain 리트라이 중복청구 막는 idempotency_key 실전

Authors

서버에서 LLM 호출을 “한 번만” 실행했다고 믿었는데, 결제 내역이나 사용량(토큰/과금)이 두 번 찍히는 경험은 생각보다 흔합니다. 특히 LangChain 체인이나 에이전트를 운영 환경에 붙이면 네트워크 타임아웃, 게이트웨이 502, 스트리밍 중 연결 끊김 같은 사건이 자주 발생하고, 이때 자동 리트라이가 “같은 일을 다시 실행”해 버리면서 중복청구가 발생합니다.

이 글은 LangChain 리트라이가 왜 중복청구로 이어지는지, 그리고 idempotency_key를 실전에서 어떻게 설계하고 어디에 저장하며 어떤 범위로 적용해야 하는지(요청 단위, 도구 호출 단위, 결제/DB 트랜잭션 단위)를 코드와 함께 정리합니다.

참고로 LangChain에서 도구 호출이 꼬이면서 같은 툴이 반복 실행되는 케이스도 종종 중복 비용으로 연결됩니다. 툴콜 무한루프를 먼저 끊어야 하는 상황이라면 이 글도 함께 보세요: LangChain에서 OpenAI 툴콜 무한루프 끊기

중복청구는 “리트라이”가 아니라 “재실행” 문제다

리트라이는 본질적으로 같은 입력을 다시 보내는 행위입니다. 문제는 호출이 “실패했다”는 판단이 애매하다는 데 있습니다.

  • 서버는 실제로 LLM 요청을 처리했고, 결과를 만들었지만
  • 클라이언트는 응답을 받기 전에 타임아웃이 나서 실패로 간주하고
  • LangChain(또는 HTTP 클라이언트)이 재시도하면서
  • 같은 프롬프트가 다시 실행되어 비용이 2배가 됩니다.

여기서 핵심은 “한 번만 실행되어야 하는 작업”에 대해 멱등성(idempotency) 을 강제하지 않으면, 재시도는 곧 중복 실행이라는 점입니다.

어떤 계층에서 중복이 생기나

중복청구는 다음 레이어 중 어디에서든 생깁니다.

  1. LLM API 레벨: 같은 요청이 여러 번 전송됨
  2. LangChain 런타임 레벨: 체인/에이전트가 동일 단계를 반복 실행
  3. 비즈니스 로직 레벨: 결제 생성, 주문 생성, 이메일 발송 같은 사이드이펙트가 중복 수행

따라서 idempotency_key는 “LLM 호출”에만 붙이는 것으로 끝나지 않고, 사이드이펙트 경계를 기준으로 설계해야 합니다.

idempotency_key 설계 원칙 5가지

1) 키의 스코프는 “사용자 요청 1건”이 기본

가장 안정적인 기본값은 request_id 같은 단일 키를 만들어, 그 요청에서 파생되는 모든 외부 호출에 전파하는 것입니다.

  • 사용자 요청 1건 = idempotency_key 1개
  • 체인 내부 단계가 여러 개여도 같은 키를 컨텍스트로 공유

단, “한 요청 안에서 서로 다른 사이드이펙트”를 분리해야 하면 파생 키를 만듭니다.

  • 예: 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군데에 필요합니다.

  1. 비즈니스 사이드이펙트(결제/차감/주문 생성): 반드시 멱등
  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입니다.

정리하면 다음 순서로 적용하면 시행착오가 줄어듭니다.

  1. 최상위 요청에 Idempotency-Key를 도입하고 결과 캐시를 붙인다
  2. 결제/차감/주문 생성 같은 강한 사이드이펙트는 DB 유니크 키로 멱등을 강제한다
  3. LangChain 체인/도구 호출까지 request_id를 전파하고, 파생 키로 단계별 멱등을 적용한다
  4. 로그/트레이싱에 키를 남겨 운영 디버깅 시간을 줄인다

이렇게 하면 “재시도는 하되, 돈은 한 번만 나가게” 만들 수 있습니다.