- Published on
LangChain OpenAI Structured Output 파싱 실패 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 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) {
// ```json ... ``` 또는 ``` ... ``` 제거
return text
.replace(/```json\s*/g, "")
.replace(/```\s*/g, "")
.trim();
}
function safeJsonParse<T>(text: string): T {
const cleaned = stripCodeFences(text);
return JSON.parse(cleaned) as T;
}
다만 이 방법은 “스키마 검증”까지 해결하지는 못합니다. 정규화는 1차 방어선, 스키마 검증은 2차 방어선으로 두세요.
원인 3: 스키마가 과도하게 엄격하거나, 반대로 모호한 경우
- 너무 엄격:
z.enum이나z.number().int()같은 제약이 실제 데이터 분포와 안 맞으면 실패율이 상승 - 너무 모호:
z.any()나 느슨한 문자열만 있으면 모델이 제멋대로 출력해도 통과해 버려 후속 로직에서 터짐
해결: “모델 친화적 스키마”로 설계하고, 후처리로 엄격화
예를 들어 날짜는 처음부터 YYYY-MM-DD 를 강제하기보다 문자열로 받고 후처리에서 파싱/검증하는 편이 더 안정적입니다.
const schema = z.object({
dueDate: z.string().optional(), // 일단 문자열
estimateHours: z.number().optional(),
});
function normalize(output: z.infer<typeof schema>) {
const due = output.dueDate ? new Date(output.dueDate) : null;
if (due && Number.isNaN(due.getTime())) {
return { ...output, dueDate: undefined };
}
return output;
}
운영에서는 “파싱 실패로 전체 요청 실패”보다 “일부 필드 무효 처리 후 진행”이 더 나은 경우가 많습니다.
원인 4: 스트리밍과 Structured Output을 섞을 때의 부분 JSON 문제
스트리밍은 토큰이 도착하는 대로 UI에 뿌리기 좋지만, 구조화 출력은 “완결된 JSON”이 필요합니다. 스트리밍 도중 중간 버퍼를 파싱하면 100% 실패합니다.
해결: 구조화 출력은 비스트리밍으로, 텍스트 생성만 스트리밍으로 분리
- 사용자에게 보여줄 설명: 스트리밍
- 서버에서 사용할 구조화 데이터: 비스트리밍 호출
또는 단일 호출로 끝내야 한다면, 스트리밍 중에는 누적만 하고 “완료 이벤트”에서만 파싱하세요.
네트워크 레벨에서 끊김/리셋이 섞이면 부분 JSON이 더 자주 발생합니다. 이때는 타임아웃/재시도 설계가 중요합니다. 관련해서는 Node.js fetch ECONNRESET·ETIMEDOUT 해결법도 함께 참고하면 좋습니다.
원인 5: 도구 호출 결과를 다시 자연어로 감싸는 2단 변환
RAG나 에이전트 흐름에서 “툴 호출로 얻은 결과”를 다시 모델이 요약하면서 JSON 구조가 망가지는 패턴이 많습니다.
해결: “툴 결과는 그대로 구조화”하고, 요약은 별도 필드로
예를 들어 검색 결과를 items 배열로 고정하고, 요약은 summary 로 분리합니다.
const schema = z.object({
items: z
.array(
z.object({
id: z.string(),
score: z.number(),
snippet: z.string(),
})
)
.min(1),
summary: z.string(),
});
RAG에서 결과 품질이 흔들리면 모델이 애매한 출력을 내며 파싱 실패가 늘기도 합니다. 검색 결과를 리랭킹으로 안정화하면 구조화 출력도 덜 흔들립니다. 관련 주제는 RAG 리랭커 도입 - nDCG·MRR로 성능 2배에서 더 깊게 다룹니다.
실전 패턴 1: “재시도 가능한” 파싱 복구 루프 만들기
운영에서 가장 효과적인 방법은 “한 번 실패하면 끝”이 아니라, 실패 원인을 모델에 다시 주고 “오직 JSON만” 재출력하게 만드는 것입니다.
아래는 개념 코드입니다.
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";
const schema = z.object({
category: z.enum(["bug", "feature", "question"]),
confidence: z.number().min(0).max(1),
});
const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
async function invokeWithRepair(input: string, maxAttempts = 2) {
const structured = llm.withStructuredOutput(schema);
try {
return await structured.invoke(input);
} catch (e: any) {
let lastErr = e;
for (let i = 0; i < maxAttempts; i++) {
const repairPrompt = [
{
role: "system",
content:
"You are a JSON repair assistant. Return only valid JSON that matches the schema. No extra text.",
},
{
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),
},
] as const;
try {
return await structured.invoke(repairPrompt);
} catch (err: any) {
lastErr = err;
}
}
throw lastErr;
}
}
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 원인과 튜닝처럼 “응답이 왜 불안정해졌는지”부터 같이 점검하는 게 좋습니다.
디버깅: 실패 유형을 빠르게 분류하는 방법
파싱 실패가 나면 아래 순서로 보면 원인이 빨리 좁혀집니다.
- 원문에 JSON 외 텍스트가 섞였나
- 코드펜스/주석/후행 콤마 등 “사소한 문법” 문제인가
- JSON은 맞는데 스키마 mismatch 인가
- 특정 입력(예: 긴 문서, 특수문자, 다국어)에서만 재현되나
- 스트리밍/타임아웃/재시도 등 전송 레이어 문제가 있나
3번(스키마 mismatch)인 경우, Zod 에러의 path 를 그대로 프롬프트에 넣어 “이 필드를 이 타입으로 맞춰라”라고 지시하면 복구 성공률이 크게 올라갑니다.
결론: Structured Output은 “검증 + 복구”까지가 한 세트
LangChain의 Structured Output은 생산성을 크게 올려주지만, 운영에서 안정적으로 쓰려면 아래 3가지를 기본 구성으로 가져가야 합니다.
- 모델 친화적인 스키마 설계(엄격함의 위치를 조절)
- JSON 외 텍스트를 차단하는 프롬프트 패턴
- 파싱 실패를 전제로 한 재시도/복구 루프와 관측 가능한 로깅
이 구성을 갖추면 “가끔 깨지는 JSON” 때문에 전체 파이프라인이 멈추는 일을 크게 줄일 수 있고, 장애가 나도 빠르게 원인 분류와 재현이 가능합니다.