CodeGym /행동 /ChatGPT Apps /내결함성: 단계 롤백, 재시도, 오류 제어

내결함성: 단계 롤백, 재시도, 오류 제어

ChatGPT Apps
레벨 11 , 레슨 3
사용 가능

1. 왜 ChatGPT App에서 오류는 비정상이 아니라 일상인가

이전 강의에서는 작업을 단계로 나누어 ChatGPT App에서 다단계 워크플로우를 구성하는 방법을 이야기했습니다. 이제 여기에 현실을 더해 봅시다: 오류, 타임아웃, 그리고 사용자의 중단입니다.

클래식 웹에서는 흔히 “해피 패스” 중심으로 로직을 짜고, 오류는 드문 비상사태—빨간 500 페이지 등으로 취급합니다. ChatGPT App에서는 다릅니다. 여러분은 LLM, 외부 API, MCP, 위젯과 함께 동작하는 분산 시스템에서 일하고 있고, 언제든 사용자가 탭을 닫을 수도 있습니다. 오류와 중단은 일상입니다.

상황을 어렵게 만드는 특징이 몇 가지 있습니다:

  • 첫째, LLM은 비결정적입니다. 같은 프롬프트라도 약간 다른 결정을 내릴 수 있습니다. 다른 도구를 호출하거나, 파라미터를 바꾸거나, 아예 “다시 물어보는” 쪽이 낫다고 판단할 수도 있습니다.
  • 둘째, 네트워크 및 인프라 한계입니다. ChatGPT의 tool‑call에는 타임아웃(보통 수십 초)이 있고, 여러분의 Next.js/Vercel 백엔드에도 타임아웃이 있습니다. 외부 API가 느리면 중간에서 끊길 수 있습니다.
  • 셋째, UX 요소입니다. 사용자가 잠깐 자리를 비우거나, 챗을 닫고 하루 뒤 돌아옵니다. 그동안 데이터베이스에서 트랜잭션을 계속 열어둘 수는 없습니다.

여기서 이 강의의 핵심 논제:

내결함 워크플로우란 = 어떤 단계든 실패할 수 있다고 가정하고, 그때 무엇을 할지 명시적으로 정의해 둔 시나리오입니다.

오류는 사용자에게 메시지를 보여줄 이유일 뿐 아니라, 모델에 대한 신호이기도 합니다. 모델은 전략을 바꾸거나, 롤백을 제안하거나, 다른 도구를 시도하거나, 시나리오를 안전하게 종료할 수 있습니다.

2. 워크플로우의 오류 지형: 어떤 종류가 있는가

장애를 제대로 처리하려면 먼저 구분할 수 있어야 합니다. ChatGPT Apps 기반 LLM 애플리케이션에는 전형적으로 다음과 같은 오류 클래스가 나타납니다.

기술적 오류. 분산 시스템의 고전입니다: 네트워크 타임아웃, 내부 또는 외부 API의 5xx, MCP 서버 다운, 도구 핸들러 코드의 버그 등. 예를 들어 GiftGenius에서 MCP‑tool search_products가 카탈로그에 질의했는데, 응답이 503 Service Unavailable로 돌아오는 경우입니다. 이는 자동 재시도(retry) 후보입니다.

논리(모델) 오류. 모델의 거부(정책 위반 판단), 환각, 도구 응답의 깨진 JSON 등이 포함됩니다. 모델이 tool‑call에 대해 잘못된 인자를 생성했고, 여러분의 JSON 검증이 이를 통과시키지 못한 경우입니다. 이는 대체로 입력 데이터 오류이지, 인프라 문제가 아닙니다.

비즈니스 오류. 의미론적 문제입니다. 재고 소진, 사용자의 예산이 선택된 필터에 비해 너무 작은 경우, 프로모션 코드 무효, 예약 만료 등. GiftGenius에선 “500개의 후보 중 제약을 만족하는 항목이 하나도 없는” 상황입니다. 여기서는 재시도가 거의 도움이 되지 않습니다. 파라미터를 바꾸거나, 제약이 비현실적임을 사용자에게 설명해야 합니다.

UX 중단. 사용자가 직접 시나리오를 끊습니다. ChatGPT를 닫거나, 위젯에서 “뒤로”를 누르거나, 작업을 취소하거나, 이전 단계의 답변을 바꾸는 경우입니다. 이것 역시 오류가 아니라 정상적인 흐름으로 취급해야 합니다. 이런 상황에서 상태를 복구하고 롤백하는 능력이 중요합니다. 이는 뒤에서 더 이야기합니다.

논리와 기술 오류의 경계에 있는 특별한 문제는 에이전트의 무한 루프입니다. 모델이 오류를 받고 “음, 한 번 더 해보자”고 생각하여 또 시도하고, 또 오류가 나고… 컨텍스트나 예산이 끝날 때까지 반복합니다. 이런 행동으로부터 스스로를 보호하는 것이 오류 설계의 중요한 부분입니다.

3. 기본 전략: retry, fail‑fast, rollback, 사용자 개입

모든 오류는 분기점으로 볼 수 있습니다. 해당 단계를 다시 시도할지, 롤백할지, 사용자를 개입시킬지를 선택합니다. 이 전략들은 조합될 수 있다는 점이 중요합니다.

기술적·일시적 장애(네트워크 흔들림, API의 503)에는 제한된 횟수의 backoff 기반 재시도가 합리적입니다. 논리·비즈니스 오류(“검증기가 예산을 통과시키지 않음”, “상품 소진”)에는 반복이 무의미합니다. fail‑fast하고 사용자에게 입력이나 파라미터 수정을 요청해야 합니다.

외부 세계에 이미 변화를 일으킨 작업(주문 생성, 예약 등)에는 rollback이 필요합니다. UI/컨텍스트에서 “한 단계 뒤로” 같은 논리 롤백이거나, 실제 보상 작업(주문 취소, 환불)일 수 있습니다.

마지막으로, 애초에 사용자 참여가 필요한 경우도 있습니다. 예를 들어 결제 시스템이 “카드를 발급 은행이 거부함”을 이유로 거절했다면, 자동으로 해결할 수 없습니다. 모델은 무슨 일이 있었는지 명확히 설명하고, 다른 카드를 시도하거나 금액을 낮추거나 구매를 포기하는 등의 선택지를 제시해야 합니다.

신뢰성 있는 워크플로우를 위해 각 단계마다 어떤 유형의 오류가 가능한지와, 각 오류에 대해 무엇을 할 것인지—자동 재시도, 롤백, 사용자 요청, 로그 후 분기 종료—를 명시해 두면 큰 도움이 됩니다.

4. 재시도(retry)와 backoff: 언제, 어떻게 할 것인가

개발자가 가장 자연스럽게 떠올리는 반응부터 시작해 봅시다. “그냥 한 번 더 해보자.” 아이디어는 맞지만, 디테일이 중요합니다.

어떤 오류를 재시도할 수 있는가

실무 통합에서 자주 쓰는 좋은 휴리스틱은 다음과 같습니다. 네트워크 오류와 5xx는 지연을 두고 재시도할 수 있지만, 4xx는 대개 불가합니다.

즉, 503, 504를 받았거나 외부 API 응답을 기다리지 못했다면, 약간의 지연 후 재시도가 타당합니다. 반면 서버가 400 Bad Request422 Unprocessable Entity를 반환했다면, 문제는 데이터에 있을 가능성이 높고, 같은 파라미터로 반복해도 달라질 게 없습니다.

TypeScript로 작성한 간단한 callWithRetry

MCP 또는 백엔드 레이어에서 도구로 사용할 수 있는 작은 유틸리티를 작성해 봅시다:

type RetryOptions = {
  maxRetries: number;
  baseDelayMs: number;
};

async function callWithRetry<T>(
  fn: () => Promise<T>,
  { maxRetries, baseDelayMs }: RetryOptions
): Promise<T> {
  let attempt = 0;

  // 무한 루프는 필요하지 않습니다
  while (true) {
    try {
      return await fn();
    } catch (err: any) {
      attempt++;
      const status = err?.status ?? err?.response?.status;

      // 4xx 는 재시도하지 않습니다
      const isClientError = typeof status === "number" && status >= 400 && status < 500;
      if (attempt > maxRetries || isClientError) {
        throw err;
      }

      const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), 10_000);
      // 동시 재시도 폭주를 피하기 위한 짧은 대기(jitter 포함)
      const jitter = Math.random() * 200;

      await new Promise((r) => setTimeout(r, delay + jitter));
    }
  }
}

이 함수는 다음을 수행합니다:

  • fn 호출을 제한된 횟수만큼 반복합니다.
  • 지수형 backoff와 작은 무작위 지터(jitter)를 사용해 동시 재시도 “떼 러시”를 피합니다.
  • 4xx에서는 재시도를 중단합니다.

예를 들어 상품 카탈로그나 내부 추천 API를 호출하는 MCP 도구 내부에서 사용하기 좋습니다.

재시도를 어디에서 할 것인가

흔한 실수는, 통제할 수 없는 레이어까지 포함해 모든 요청을 닥치는 대로 재시도하는 것입니다. ChatGPT 생태계에서는 재시도를 넣을 수 있는 지점이 몇 군데 있습니다:

  • 자체 백엔드/MCP 내부(callWithRetry에서 했던 방식);
  • 백그라운드 워커/큐 내부(앞선 모듈에서 job 큐와 DLQ를 더 자세히 다룹니다);
  • 경우에 따라 위젯 자체—부작용이 없는 “목록 갱신” 같은 가벼운 요청에 한해.

로직 중복을 피하는 게 중요합니다. 이미 job 워커가 backoff로 3회 재시도한다면, 위젯에서 추가로 5회 더 얹을 이유가 없습니다. 그리고 절대 while(true) { try ... } 같은 코드를 두지 마세요. 스스로에게 DDoS를 거는 지름길입니다.

5. 단계의 idemopotency: 중복 방지

재시도는 두 번째 문제를 만듭니다. 같은 동작을 두 번 수행하지 않도록 하는 방법입니다. LLM 세계에서는 특히 심각합니다. 모델이 실수로 같은 도구를 여러 번 호출할 수 있고, ChatGPT가 타임아웃 후 tool‑call을 재전송할 수 있으며, 사용자가 “Regenerate”를 누른 뒤 UI나 에이전트가 나름의 방식으로 호출을 또 추가할 수도 있습니다.

Idempotency의 아이디어는 단순합니다. 같은 입력 데이터로 단계를 반복 실행해도 추가적인 부작용이 생기지 않으면 그 단계는 idemopotent로 봅니다. product feed 요청—OK, 추천 재계산—OK. 하지만 같은 데이터로 돈을 다시 빼거나 두 번째 주문을 만드는 일—절대 OK가 아닙니다.

ChatGPT App에서의 idempotency key

고전 패턴은 이렇습니다. 부작용이 있는 각 논리적 단계마다 idempotency_key(보통 UUID)를 생성하고, 이를 모델을 통해 MCP 도구로 전달합니다. 도구는 “키 → 결과” 매핑을 저장해 둡니다. 같은 키로 도구가 다시 호출되면, 동작을 반복하지 않고 이미 저장된 결과만 반환합니다.

우리 GiftGenius에는 create_order 단계가 있습니다. 사용자가 “결제” 버튼을 눌렀고, 모델이 도구를 호출해 결제가 진행되었지만, 어딘가에서 응답이 유실되었다고 해봅시다. 모델이나 플랫폼이 호출을 재시도하기로 하면, idemopotency가 없다면 중복 주문이나 이중 결제가 발생합니다.

TypeScript로 작성한 간단한 idemopotent 도구 예시

idempotency 키를 사용하는 MCP 도구 create_order의 매우 단순한 핸들러를 만들어 봅시다. 간단히 in‑memory Map을 쓰지만, 실제로는 DB나 캐시를 사용합니다.

type CreateOrderInput = {
  userId: string;
  items: Array<{ sku: string; qty: number }>;
  idempotencyKey: string;
};

type CreateOrderResult = { orderId: string; status: "created" };

const idempotencyStore = new Map<
  string,
  { paramsHash: string; result: CreateOrderResult }
>();

export async function createOrderTool(input: CreateOrderInput): Promise<CreateOrderResult> {
  const { idempotencyKey, ...rest } = input;
  const paramsHash = JSON.stringify(rest);

  const existing = idempotencyStore.get(idempotencyKey);
  if (existing) {
    // 키가 이미 존재하면, 파라미터가 동일한지 확인합니다
    if (existing.paramsHash !== paramsHash) {
      throw new Error("Idempotency key reuse with different params");
    }
    return existing.result;
  }

  // 여기에서 실제 주문 생성과 결제를 수행합니다
  const result: CreateOrderResult = {
    orderId: "order_" + Math.random().toString(36).slice(2),
    status: "created",
  };

  idempotencyStore.set(idempotencyKey, { paramsHash, result });
  return result;
}

여기서 우리는 다음을 수행합니다:

  • 입력 데이터에 idempotencyKey를 요구합니다.
  • 키와 함께 파라미터 해시(간단히 JSON.stringify)를 저장합니다.
  • 같은 키인데 다른 데이터로 재호출되면 오류로 간주합니다.
  • 같은 키와 같은 데이터로 재호출되면 이전 결과를 그대로 반환합니다.

실제 프로젝트에서는 다음을 권장합니다:

  • 키를 TTL이 있는 DB에 저장해 테이블이 무한 증가하지 않도록 합니다.
  • idempotency_key를 로깅하고 MCP 메시지의 _meta에 포함시켜 Inspector와 대시보드에서 추적하기 쉽게 합니다.

6. 단계 롤백과 Saga 패턴

Idempotency는 중복을 막아주지만 또 다른 문제는 해결하지 못합니다. 시나리오 중간의 한 단계가 실패하면 어떻게 할 것인가입니다.

e‑commerce에서의 고전적 문제입니다. 이미 주문을 만들고 창고에 재고를 예약했는데, 결제 단계에서 문제가 생겼습니다. 그냥 “잊어버릴” 수는 없습니다. 이전 상태를 어떤 식으로든 되돌려야 합니다.

논리적 롤백 vs 기술적 롤백

ChatGPT 워크플로우에는 두 가지 수준의 롤백이 있습니다.

논리적 롤백은 시나리오의 이전 단계로 돌아가 컨텍스트를 조정하는 것입니다. 예를 들어 “결제” 단계에서 오류가 발생했고, “결제 수단 선택” 또는 심지어 “선물 선택” 단계로 돌아가기로 한 경우입니다. 이때 중요한 점:

  • 백엔드의 WorkflowContext(현재 단계, 선택된 파라미터)를 갱신합니다.
  • tool‑call/ToolOutput 등을 통해 모델에 단계 변경을 알려, 모델이 이전 분기를 “잊고” 이후 행동을 조정하게 합니다.
  • 위젯 UI를 갱신해 단계와 버튼이 새로운 상태에 맞게 합니다.

기술적 롤백은 비즈니스 레벨입니다. 생성된 엔터티를 취소하고 외부 효과를 보상합니다. 예: 주문 취소, 재고 예약 해제, 환불 시작. 이것이 Saga 패턴입니다. “위험한” 각 단계에 대해 보상 작업을 미리 정해 둡니다.

GiftGenius를 위한 forward/compensate 개요

간단한 GiftGenius 체크아웃에 대해 다음과 같은 순서를 그릴 수 있습니다:

flowchart TD
  A[단계 1: create_order] --> B[단계 2: reserve_items]
  B --> C[단계 3: charge_card]

  C -->|성공| D[상태: completed]

  C -->|오류| E[보상: cancel_reservation]
  E --> F[보상: cancel_order]
  F --> G[상태: failed + 사용자에게 메시지]

외부 세계를 변경하는 각 작업(주문 생성, 예약, 결제)에는 보상 작업(주문 취소, 예약 해제, 환불)이 1:1로 대응됩니다. 항상 완벽히 대칭적이거나 가능한 것은 아니지만, 기본 원칙은 그렇습니다.

코드로 보는 간단한 보상 예시

이 단계를 수행하는 작은 코드 조각을 봅시다:

async function completeCheckout(ctx: { userId: string }) {
  const order = await createOrderInDb(ctx.userId);

  try {
    await reserveItems(order.id);
    await chargeCard(order.id);
    return { orderId: order.id, status: "paid" as const };
  } catch (err) {
    // 보상(컴펜세이션) 작업
    await safeCancelReservation(order.id);
    await safeCancelOrder(order.id);
    throw err;
  }
}

여기서:

  • createOrderInDb, reserveItems, chargeCard는 forward 단계입니다.
  • safeCancelReservationsafeCancelOrder는 보상 단계이며, 이들 자체도 idemopotent여야 합니다(이미 취소된 것을 다시 취소하려 해도 문제 없어야 합니다).

실패 시 오류를 숨기지 않고 그대로 던진다는 점에 주목하세요. 모델(툴 결과를 통해)은 이해 가능한 오류 메시지를 받아 사용자에게 설명하고, 다음 단계를 제안해야 합니다.

7. 단계 롤백과 상태 동기화: 상태 불일치를 어떻게 방지할까

과소평가하기 쉬운 특별한 “오류”가 있습니다. 바로 UI, 백엔드, 모델 사이의 상태 불일치입니다.

전형적인 시나리오:

  1. 사용자가 1 → 2 → 3단계를 진행합니다.
  2. 3단계에서 문제가 생겨, 사용자가 위젯에서 “뒤로”를 누릅니다.
  3. 위젯은 로컬 상태를 2단계로 되돌립니다.
  4. 하지만 모델은 우리가 3단계였고 결제를 시도했다는 걸 “기억”합니다. 다음 메시지에서 계속 결제를 이야기하고, 사용자는 선물 선택 화면을 보고 있습니다.

이런 일이 없도록 명시적인 단계 롤백 이벤트를 도입하는 게 유용합니다. 위젯이 MCP/모델에—도구 호출이든 ToolOutput이든—이 이벤트를 보냅니다.

예를 들어 현재 단계와 상태를 기록하는 간단한 도구 user_navigated_to_step을 만들 수 있습니다:

type NavigateInput = {
  workflowId: string;
  stepId: string;
};

export async function userNavigatedToStep(input: NavigateInput) {
  await workflowRepo.setCurrentStep(input.workflowId, input.stepId);
  return {
    message: `User moved to step ${input.stepId}`,
  };
}

위젯은 “뒤로”를 눌렀을 때 이 도구를 호출합니다. 모델은 도구 호출 히스토리에서 그 결과를 보고, 이제 새로운 단계에 맞춰 대화를 이어가야 함을 이해합니다.

UI 쪽 코드는 대략 다음과 같습니다:

async function handleBackClick() {
  const { workflowId, prevStepId } = widgetState;

  await window.openai.tools.call("user_navigated_to_step", {
    workflowId,
    stepId: prevStepId,
  });

  setWidgetState((s) => ({ ...s, currentStepId: prevStepId }));
}

중요한 점: 현재 단계의 단일 진실의 원천은 백엔드/에이전트입니다. 모델은 도구를 통해 이를 봅니다. 그러면 세션 복원 시에도 컨텍스트를 올바르게 동기화할 수 있습니다.

8. 오류 UX: 사용자와 모델이 각각 무엇을 보아야 하는가

재시도, 롤백, idemopotency, 상태 동기화를 마련했으니, 이제 사용자와 모델 모두에게 자연스러워 보이게 만드는 일이 남았습니다.

재시도와 롤백이 완벽해도, 오류 UX가 “옛날 Java 서블릿”처럼 빨간 글자, 스택 트레이스, “Unexpected error”라면 소용이 없습니다.

ChatGPT App에서 오류 메시지의 대상은 두 부류입니다:

  • 사용자: 무슨 일이 일어났는지, 다음에 무엇을 할 수 있는지 이해해야 합니다.
  • 모델: 재시도, 파라미터 변경, 대안 제시, 시나리오 종료 등 결정을 내릴 수 있을 만큼 구조화된 정보를 받아야 합니다.

권장 사항:

  • MCP/도구 레벨에서 코드, 타입, retryable 플래그, 짧은 기술 메시지를 담은 구조화된 오류를 반환합니다.
  • 모델에는 스택 트레이스 한가득 대신, 이 구조(예: result.structuredContent)를 전달합니다.
  • UI에는 사용자 친화적이고 짧은 메시지를 보여줍니다.

도구가 반환하는 오류 구조의 간단한 예:

type ToolError = {
  code: string;          // e.g. "PAYMENT_TIMEOUT"
  message: string;       // 간단한 기술 설명
  retryable: boolean;    // 다시 시도할 수 있는지 여부
};

throw {
  isError: true,
  error: <ToolError>{
    code: "PAYMENT_TIMEOUT",
    message: "Payment provider did not respond in time",
    retryable: true,
  },
};

모델은 retryable: true를 보고 다른 도구를 시도하거나 사용자에게 재시도를 제안할 수 있습니다.

위젯 쪽에서는 이 코드를 사용자 친화적인 텍스트로 매핑하기만 하면 됩니다:

function ErrorBanner({ code }: { code: string }) {
  const text =
    code === "PAYMENT_TIMEOUT"
      ? "결제 서비스의 응답이 지연되었습니다. 1분 후에 다시 시도해 주세요."
      : "문제가 발생했습니다. 다시 시도해 주세요.";

  return <div className="error-banner">{text}</div>;
}

그리고 한 가지 더 중요한 점: 사용자에게 예외 스택, 토큰, 시크릿을 보여주지 마세요. 보기에도 안 좋고, 보안상 위험합니다. 기술 정보는 내부에 로깅하고, 사용자에게는 짧고 안전한 메시지를 제공합니다.

Insight

ChatGPT 같은 LLM 시스템에서는 잘못된 도구 호출이 예외가 아니라 일상입니다. 모델은 타입을 바꾸거나, 필드를 빠뜨리거나, 잘못된 값을 넣거나, 구조를 깨뜨리는 등 검증을 통과하지 못하는 인자를 정기적으로 생성합니다. 이는 전통적 엔지니어링 관점의 “버그”라기보다 확률적 모델의 본질이며, 여기에 맞게 오류 인터페이스 전반을 설계해야 합니다.

핵심 아이디어: 오류 메시지는 “망가졌다”는 신호가 아니라, 다음 시도를 고치기 위한 지시문입니다. 그 주된 청자는 모델 자체입니다. 메시지가 구조화되어 있고 정확한 지시를 담고 있다면, 모델은 파라미터를 자동으로 조정해 올바르게 다시 호출할 수 있습니다. 이것이 바로 Tool‑Reflection 기법의 기반입니다. 올바른 피드백은 사람의 개입 없이도 에이전트의 다음 행동을 개선합니다.

오류 형식은 다음 요구사항을 따를 것을 권합니다:

  • 메시지는 검증 실패 필드를 구체적으로 지목해야 합니다. “Invalid parameters” 같은 포괄적 표현은 피하세요.
  • 모델이 적합한 값을 고를 수 있도록 기대 형식 또는 허용 값을 명시하세요.
  • 메시지는 짧고, 형식적이며, 구조화되어야 합니다. error_type, field, expected, allowed_values 같은 필드는 모델에 크게 도움이 됩니다.
  • 가능하다면 최소한의 올바른 입력 예를 제공하세요. 모델의 복원 정확도를 자주 높여 줍니다.

이상적인 모델용 오류 피드백은 두 가지 사실을 담습니다. 무엇이 잘못되었는지, 그리고 어떻게 고쳐야 하는지.

9. 워크플로우 오류의 로깅과 지표

오류 UX가 아무리 깔끔해도, 실제로 무엇이 망가지는지 이해하려면 사용자 메시지만으로는 부족합니다. 구조화된 로그와 단계별 지표가 필요합니다.

각 워크플로우 단계 로깅 시 최소한으로 유용한 항목:

  • user_id 또는 최소한 session_id
  • workflow_idstep_id
  • 단계 상태(success, failed, retry, rolled_back)
  • error_code(발생 시)
  • 외부 호출과 연관된 경우 idempotency_keycorrelation_id

MCP와 Agents에는 _meta 필드가 있습니다. 여기에 idempotency_keycorrelation_id를 넣으면 로그와 Inspector 모두에서 확인하기 쉽습니다.

Node.js/TypeScript에서의 가장 단순한 로깅 예시(console을 쓰거나 winston/pino를 사용할 수 있습니다):

function logStepFailure(params: {
  userId?: string;
  workflowId: string;
  stepId: string;
  errorCode: string;
  idempotencyKey?: string;
}) {
  console.error(
    JSON.stringify({
      level: "error",
      event: "workflow_step_failed",
      ...params,
      timestamp: new Date().toISOString(),
    })
  );
}

이런 로그는 파싱과 대시보드 구성이 쉽고, 다음을 집계할 수 있습니다:

  • 단계 간 전환율
  • 가장 빈번한 오류 유형
  • 재시도로 끝난 단계와 최종 실패로 끝난 단계의 비율

모든 오류가 프로덕션 알림이 될 필요는 없습니다. MCP 다운, 체계적인 타임아웃, 특정 단계에서의 대량 실패 같은 치명적 이슈만 모니터링하세요. “매우 좁은 필터로는 적합한 선물이 없음” 같은 것은 비즈니스 이벤트일 뿐, 인시던트가 아닙니다.

10. GiftGenius 확장: 견고한 체크아웃 단계

이제 지금까지의 내용을 모읍니다. 재시도, idemopotency, Saga, 상태 동기화, 오류 UX, 로깅을 우리 학습용 앱 GiftGenius의 체크아웃 단계에 적용해 봅시다.

이미 준비된 것

현재까지 우리는 다음을 갖추었습니다:

  • 다단계 워크플로우: 정보 수집 → 아이디어 추천 → 선물 선택 → 체크아웃
  • 툴 게이팅 구성: 체크아웃 단계에서만 commerce 도구 집합(create_order, get_payment_methods 등)을 허용
  • 선택된 선물, 예산, userId, 현재 단계를 저장하는 WorkflowContext

이번 강의에서 추가할 것

체크아웃 단계에 다음을 도입합니다:

  1. idempotency_key — 도구 create_order에 적용
  2. 결제 제공자 일시 오류 시 retry
  3. 보상 — 부분 성공 시의 처리
  4. 올바른 오류 UX — 위젯에서의 표현

“결제” 버튼 클릭 시 위젯에서 idempotency 키 생성:

import { v4 as uuid } from "uuid";

async function handlePayClick() {
  const idempotencyKey = uuid();
  setWidgetState((s) => ({ ...s, idempotencyKey }));

  await window.openai.tools.call("create_order", {
    userId: widgetState.userId,
    items: [/* ... */],
    idempotencyKey,
  });
}

도구 create_order 측에는 앞서 작성한 idemopotent 핸들러가 있습니다. 키와 결과를 저장하고, 재호출 시 새 주문을 만들지 않습니다.

결제 API와의 상호작용 코드는 callWithRetry로 감싸 네트워크 이상 시 몇 차례 재시도하도록 합니다. 그리고 오류에 retryable: true 플래그를 넣어, 모델이 재시도를 제안할 수 있음을 이해하게 합니다.

주문 생성과 결제가 성공한 뒤 어떤 것이 깨지더라도(예: 외부 webhook이 제때 오지 않음), correlation_idworkflow_id와 함께 로깅하고, 이어서:

  • 백그라운드 재시도를 시도합니다(추후 모듈에서 큐와 이벤트를 다룹니다).
  • 혹은 단계를 명시적으로 failed로 표시하고 보상 작업을 수행한 다음, 사용자에게 상황을 설명합니다.

11. 내결함 워크플로우 설계 시 자주 하는 실수

실수 №1: “될 때까지 전부 재시도”.
모든 단계를 끝날 때까지 자동 재시도하는 것은 지옥을 자초하는 방법입니다. 네트워크 및 5xx 오류는 backoff와 시도 횟수 제한을 두고 재시도할 수 있습니다. 하지만 4xx, 비즈니스 오류, 모델 논리 실패는 데이터로 고치거나 사용자에게 설명해야 합니다. 그렇지 않으면 불안정한 동작, 이상한 청구, 로그 오염을 보게 됩니다.

실수 №2: 돈과 주문이 걸린 곳에 idemopotency가 없음.
create_ordercharge_card 같은 도구가 idemopotent하지 않다면, 타임아웃, Regenerate, 에이전트 버그 등으로 인한 재호출이 중복을 야기할 수 있습니다. LLM 시나리오에서는 재호출이 클래식 REST 프런트엔드보다 훨씬 자주 발생하므로, idempotency_key는 “있으면 좋은” 것이 아니라 결제 및 기타 핵심 단계에서 필수입니다.

실수 №3: 보상 작업(Saga) 부재.
주문을 만들고 재고를 예약했는데, 결제에서 실패했다고 “문제가 발생했습니다”만 보여준다면, 시스템에는 반쯤 만들어진 주문, 예약, 재무적 꼬리표가 남습니다. 외부 세계를 바꾸는 각 단계에 대해 다음 단계 실패 시 무엇을 할지—취소, 환불, “expired” 표시 등—미리 설계하세요.

실수 №4: 에이전트의 무한 재시도 루프 방치.
시도 횟수(maxRetries 헬퍼나 에이전트 로직의 max_iterations 등) 제한이 없고, 재시도가 무의미한 곳에서 retryable: false를 표시하지 않으면, 모델은 “한 번 더…”에 갇힐 수 있습니다. 토큰, 시간, 정신력을 태웁니다.

실수 №5: 롤백 시 UI와 모델 사이의 상태 불일치.
개발자는 종종 UI에만 “뒤로” 버튼을 구현하고, 백엔드와 모델의 단계 동기화를 잊습니다. 결국 사용자는 2단계를 보지만, 모델은 3단계에 머물며 엉뚱한 제안을 합니다. 해결책은 user_navigated_to_step 같은 명시적 이벤트와, 전환 시마다 WorkflowContext를 갱신하는 것입니다.

실수 №6: 사용자에게는 기술적 메시지, 개발자에게는 로그 부재.
사용자에게는 “Error: ECONNRESET at TcpSocket.onEnd…”가 보이고, 여러분에게는 어떤 workflow_id의 어떤 단계가 깨졌는지 정보가 없습니다. 올바른 접근은 다음과 같습니다. 사용자에게는 짧고 이해 가능한 텍스트와 다음 행동 제안, 개발자에게는 workflow_id, step_id, error_code, idempotency_key, correlation_id가 들어간 구조화 로그.

실수 №7: 알림 전략 부재.
“아주 좁은 필터에 맞는 선물이 없음”까지 전부 알림으로 보내거나, 반대로 MCP 다운 같은 진짜 문제도 알리지 않는 경우가 있습니다. 치명적 시스템 장애(서비스 다운, 대량 타임아웃, webhook 손실)와 예상 가능한 비즈니스 이벤트를 구분하세요. 전자는 모니터링과 온콜로, 후자는 분석 지표로만 집계합니다.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION