Published on

LangChain OpenAI Structured Output 파싱 실패 해결

Authors

서버에서 LLM 응답을 곧바로 객체로 파싱해 비즈니스 로직에 넣고 싶을 때 LangChain의 Structured Output은 거의 필수 도구입니다. 그런데 운영 환경에 올리면 OutputParserException, JSON.parse 에러, 스키마 불일치 같은 파싱 실패가 생각보다 자주 터집니다. 특히 스트리밍, 툴 호출 혼용, 모델 변경, 프롬프트에 예외 케이스가 섞이는 순간 실패율이 급격히 올라갑니다.

이 글에서는 “왜 깨지는지”를 유형별로 분해하고, LangChain + OpenAI 조합에서 재현 가능한 해결책(스키마 설계, 프롬프트 패턴, 재시도/복구, 로깅)을 코드 중심으로 정리합니다.

파싱 실패가 발생하는 대표 증상

운영에서 흔히 보는 에러는 대략 아래 범주로 묶입니다.

  • JSON이 아닌 텍스트가 섞임: 설명 문장, 마크다운 코드펜스, 불필요한 접두사
  • JSON은 맞는데 스키마가 다름: 필드 누락, 타입 불일치, enum 외 값, 날짜 포맷
  • 부분 응답/잘림: 스트리밍 도중 중간 덩어리를 파싱하거나 토큰 제한으로 JSON이 미완성
  • 도구 호출과 텍스트가 혼재: 툴 결과를 사람이 읽는 텍스트로 다시 감싸며 구조가 변형
  • 모델/버전 변경: 같은 프롬프트라도 출력 안정성이 다른 모델에서 깨짐

Structured Output은 “모델이 반드시 JSON을 내놓는다”가 아니라 “JSON을 내도록 강하게 유도하고, 파서가 검증한다”에 가깝습니다. 즉, 실패는 설계/운영의 정상적인 일부라고 보고 방어적으로 접근해야 합니다.

원인 1: 프롬프트가 JSON 외 텍스트를 허용하는 경우

가장 흔한 케이스입니다. 예를 들어 시스템/유저 메시지에 “설명도 같이 해줘”가 들어가면 모델은 구조화 출력과 자연어를 섞어버립니다.

해결: 출력 채널을 분리하고, 자연어 설명을 스키마 내부로 넣기

설명이 필요하면 reason 같은 필드로 스키마에 포함시키고, 그 외 텍스트는 금지합니다.

import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";

const schema = z.object({
  title: z.string(),
  priority: z.enum(["low", "medium", "high"]),
  reason: z.string().optional(),
});

type Output = z.infer<typeof schema>;

const llm = new ChatOpenAI({
  model: "gpt-4o-mini",
  temperature: 0,
});

const structured = llm.withStructuredOutput(schema);

const res: Output = await structured.invoke([
  {
    role: "system",
    content:
      "You must output only valid JSON that matches the schema. Do not include markdown code fences or extra text.",
  },
  {
    role: "user",
    content: "다음 이슈를 분류해줘: 결제 API 간헐적 504",
  },
]);

console.log(res);

핵심은 “설명은 reason 필드로만” 같은 규칙을 스키마로 강제하는 것입니다.

원인 2: 마크다운 코드펜스가 섞여 파서가 깨짐

모델이 ```json 같은 코드펜스를 붙이면 JSON.parse 가 바로 실패합니다. LangChain 파서가 어느 정도 정리해주기도 하지만, 조합/버전/모드에 따라 그대로 들어오는 경우가 있습니다.

해결: 사전 정규화 레이어를 두고, 파서 앞에서 청소하기

Structured Output을 쓰더라도, “마지막 안전망”으로 문자열 정규화 함수를 두면 장애를 줄일 수 있습니다.

function stripCodeFences(text: string) &#123;
  // ```json ... ``` 또는 ``` ... ``` 제거
  return text
    .replace(/```json\s*/g, "")
    .replace(/```\s*/g, "")
    .trim();
&#125;

function safeJsonParse&lt;T&gt;(text: string): T &#123;
  const cleaned = stripCodeFences(text);
  return JSON.parse(cleaned) as T;
&#125;

다만 이 방법은 “스키마 검증”까지 해결하지는 못합니다. 정규화는 1차 방어선, 스키마 검증은 2차 방어선으로 두세요.

원인 3: 스키마가 과도하게 엄격하거나, 반대로 모호한 경우

  • 너무 엄격: z.enum 이나 z.number().int() 같은 제약이 실제 데이터 분포와 안 맞으면 실패율이 상승
  • 너무 모호: z.any() 나 느슨한 문자열만 있으면 모델이 제멋대로 출력해도 통과해 버려 후속 로직에서 터짐

해결: “모델 친화적 스키마”로 설계하고, 후처리로 엄격화

예를 들어 날짜는 처음부터 YYYY-MM-DD 를 강제하기보다 문자열로 받고 후처리에서 파싱/검증하는 편이 더 안정적입니다.

const schema = z.object(&#123;
  dueDate: z.string().optional(), // 일단 문자열
  estimateHours: z.number().optional(),
&#125;);

function normalize(output: z.infer&lt;typeof schema&gt;) &#123;
  const due = output.dueDate ? new Date(output.dueDate) : null;
  if (due && Number.isNaN(due.getTime())) &#123;
    return &#123; ...output, dueDate: undefined &#125;;
  &#125;
  return output;
&#125;

운영에서는 “파싱 실패로 전체 요청 실패”보다 “일부 필드 무효 처리 후 진행”이 더 나은 경우가 많습니다.

원인 4: 스트리밍과 Structured Output을 섞을 때의 부분 JSON 문제

스트리밍은 토큰이 도착하는 대로 UI에 뿌리기 좋지만, 구조화 출력은 “완결된 JSON”이 필요합니다. 스트리밍 도중 중간 버퍼를 파싱하면 100% 실패합니다.

해결: 구조화 출력은 비스트리밍으로, 텍스트 생성만 스트리밍으로 분리

  • 사용자에게 보여줄 설명: 스트리밍
  • 서버에서 사용할 구조화 데이터: 비스트리밍 호출

또는 단일 호출로 끝내야 한다면, 스트리밍 중에는 누적만 하고 “완료 이벤트”에서만 파싱하세요.

네트워크 레벨에서 끊김/리셋이 섞이면 부분 JSON이 더 자주 발생합니다. 이때는 타임아웃/재시도 설계가 중요합니다. 관련해서는 Node.js fetch ECONNRESET·ETIMEDOUT 해결법도 함께 참고하면 좋습니다.

원인 5: 도구 호출 결과를 다시 자연어로 감싸는 2단 변환

RAG나 에이전트 흐름에서 “툴 호출로 얻은 결과”를 다시 모델이 요약하면서 JSON 구조가 망가지는 패턴이 많습니다.

해결: “툴 결과는 그대로 구조화”하고, 요약은 별도 필드로

예를 들어 검색 결과를 items 배열로 고정하고, 요약은 summary 로 분리합니다.

const schema = z.object(&#123;
  items: z
    .array(
      z.object(&#123;
        id: z.string(),
        score: z.number(),
        snippet: z.string(),
      &#125;)
    )
    .min(1),
  summary: z.string(),
&#125;);

RAG에서 결과 품질이 흔들리면 모델이 애매한 출력을 내며 파싱 실패가 늘기도 합니다. 검색 결과를 리랭킹으로 안정화하면 구조화 출력도 덜 흔들립니다. 관련 주제는 RAG 리랭커 도입 - nDCG·MRR로 성능 2배에서 더 깊게 다룹니다.

실전 패턴 1: “재시도 가능한” 파싱 복구 루프 만들기

운영에서 가장 효과적인 방법은 “한 번 실패하면 끝”이 아니라, 실패 원인을 모델에 다시 주고 “오직 JSON만” 재출력하게 만드는 것입니다.

아래는 개념 코드입니다.

&#105;mport &#123; ChatOpenAI &#125; from "@langchain/openai";
&#105;mport &#123; z &#125; from "zod";

const schema = z.object(&#123;
  category: z.enum(["bug", "feature", "question"]),
  confidence: z.number().min(0).max(1),
&#125;);

const llm = new ChatOpenAI(&#123; model: "gpt-4o-mini", temperature: 0 &#125;);

async function invokeWithRepair(input: string, maxAttempts = 2) &#123;
  const structured = llm.withStructuredOutput(schema);

  try &#123;
    return await structured.invoke(input);
  &#125; catch (e: any) &#123;
    let lastErr = e;

    for (let i = 0; i &lt; maxAttempts; i++) &#123;
      const repairPrompt = [
        &#123;
          role: "system",
          content:
            "You are a JSON repair assistant. Return only valid JSON that matches the schema. No extra text.",
        &#125;,
        &#123;
          role: "user",
          content:
            "The previous output failed schema validation or JSON parsing. " +
            "Re-output the result strictly as JSON for this input: " +
            JSON.stringify(input),
        &#125;,
      ] as const;

      try &#123;
        return await structured.invoke(repairPrompt);
      &#125; catch (err: any) &#123;
        lastErr = err;
      &#125;
    &#125;

    throw lastErr;
  &#125;
&#125;

const out = await invokeWithRepair("로그인 버튼 클릭 시 500 에러");
console.log(out);

포인트는 두 가지입니다.

  • “수리 전용” 시스템 프롬프트로 역할을 바꾼다
  • 입력을 다시 제공하되, 실패한 출력 텍스트를 그대로 재주입할지 여부는 보안/개인정보 정책에 맞춘다

개인정보가 섞일 수 있으면 실패한 원문 출력 전체를 재주입하지 말고, 에러 요약(예: 누락 필드명)만 제공하는 방식이 안전합니다.

실전 패턴 2: 로그를 “원문 + 정규화본 + 검증 에러”로 남기기

파싱 실패는 재현이 어려워서, 로그 설계가 곧 해결 속도입니다.

  • 모델 원문 출력(PII 마스킹 필요)
  • 코드펜스 제거 등 정규화 후 텍스트
  • Zod 에러의 path, expected, received
  • 요청 파라미터(모델, temperature, maxTokens, 프롬프트 버전)

이렇게 남겨야 “특정 프롬프트 버전에서만 실패한다” 같은 패턴을 잡을 수 있습니다.

실전 패턴 3: 모델/설정 안정화 체크리스트

Structured Output은 모델의 “규칙 준수 성향”에 크게 좌우됩니다. 아래 체크리스트를 권장합니다.

  • temperature: 0 으로 고정
  • 프롬프트에 “JSON만 출력”을 명시하고, 금지 사항(마크다운/설명문/서문)을 함께 명시
  • 스키마 필드명은 짧고 명확하게(모호한 약어 지양)
  • enum은 너무 빡빡하게 잡지 말고, 불가피하면 unknown 같은 탈출구를 허용
  • 토큰 제한으로 JSON이 잘리지 않도록 maxTokens 와 출력 크기를 관리

서버리스/컨테이너 환경에서는 콜드스타트나 동시성에 의해 타임아웃이 늘고, 그 결과 부분 응답/재시도 증가로 파싱 실패가 관측되기도 합니다. 인프라 측면 이슈가 의심되면 GCP Cloud Run 503·Cold Start 원인과 튜닝처럼 “응답이 왜 불안정해졌는지”부터 같이 점검하는 게 좋습니다.

디버깅: 실패 유형을 빠르게 분류하는 방법

파싱 실패가 나면 아래 순서로 보면 원인이 빨리 좁혀집니다.

  1. 원문에 JSON 외 텍스트가 섞였나
  2. 코드펜스/주석/후행 콤마 등 “사소한 문법” 문제인가
  3. JSON은 맞는데 스키마 mismatch 인가
  4. 특정 입력(예: 긴 문서, 특수문자, 다국어)에서만 재현되나
  5. 스트리밍/타임아웃/재시도 등 전송 레이어 문제가 있나

3번(스키마 mismatch)인 경우, Zod 에러의 path 를 그대로 프롬프트에 넣어 “이 필드를 이 타입으로 맞춰라”라고 지시하면 복구 성공률이 크게 올라갑니다.

결론: Structured Output은 “검증 + 복구”까지가 한 세트

LangChain의 Structured Output은 생산성을 크게 올려주지만, 운영에서 안정적으로 쓰려면 아래 3가지를 기본 구성으로 가져가야 합니다.

  • 모델 친화적인 스키마 설계(엄격함의 위치를 조절)
  • JSON 외 텍스트를 차단하는 프롬프트 패턴
  • 파싱 실패를 전제로 한 재시도/복구 루프와 관측 가능한 로깅

이 구성을 갖추면 “가끔 깨지는 JSON” 때문에 전체 파이프라인이 멈추는 일을 크게 줄일 수 있고, 장애가 나도 빠르게 원인 분류와 재현이 가능합니다.