Published on

OpenAI API JSON 깨짐, LangChain 강제출력으로 해결

Authors

서버에서 LLM을 붙이다 보면 가장 흔하게 부딪히는 장애 중 하나가 JSON 깨짐입니다. 프론트나 백엔드 파이프라인은 JSON.parse 한 번 실패하면 전체 요청을 실패로 처리하는 경우가 많고, 재시도 로직이 없으면 사용자 경험도 바로 무너집니다.

이 글에서는 OpenAI API 응답이 왜 깨지는지(구조적 원인), 그리고 LangChain으로 스키마 기반 강제 출력을 걸어 항상 파싱 가능한 JSON을 받는 방법을 정리합니다. 마지막에는 운영 환경에서 필요한 검증, 리트라이, 폴백 전략까지 함께 다룹니다.

왜 OpenAI 응답 JSON이 깨질까

LLM은 기본적으로 “텍스트 생성기”입니다. 우리가 "JSON으로만 답해"라고 프롬프트에 써도, 모델은 다음과 같은 이유로 JSON을 깨뜨릴 수 있습니다.

1) 설명 텍스트가 JSON 앞뒤에 붙는다

  • Here is the JSON: 같은 접두어
  • ````json` 코드펜스
  • 요약/주의사항 같은 꼬리말

이런 경우는 사람 눈에는 친절하지만, 파서는 실패합니다.

2) 스트리밍/토큰 제한으로 JSON이 중간에 잘린다

  • max_tokens 부족
  • 네트워크 끊김
  • 스트리밍 중간 취소

결과적으로 닫는 중괄호 }가 없는 상태로 끝나거나, 배열이 닫히지 않습니다.

3) 따옴표/이스케이프 문제

  • 문자열 안에 줄바꿈이 들어가는데 \n으로 이스케이프되지 않음
  • 따옴표가 매칭되지 않음
  • 백슬래시가 깨짐

4) “거의 JSON”을 만든다

  • 키에 따옴표가 없음: {name: "foo"}
  • True/False/None 같은 파이썬 스타일
  • 트레일링 콤마: { "a": 1, }

5) 프롬프트 인젝션/컨텍스트 오염

대화 히스토리나 사용자 입력이 "규칙 무시하고 설명해" 식으로 섞이면, 출력 포맷이 쉽게 무너집니다.

해결 방향: “프롬프트”가 아니라 “구조화 출력”을 써야 한다

운영에서 JSON 파싱 안정성을 확보하려면 다음 원칙이 중요합니다.

  • 스키마를 코드로 정의하고
  • 모델에게 스키마에 맞춘 출력만 허용하고
  • 결과를 파서가 검증하며
  • 실패 시 자동 재시도/수정 루프를 둔다

LangChain은 이 흐름을 비교적 일관된 형태로 제공하고, OpenAI 계열 모델에서는 structured output 또는 JSON schema 기반으로 강제하는 패턴을 쉽게 만들 수 있습니다.

아래 예시는 파이썬 기준입니다.

재현: 프롬프트만으로 JSON을 요구하면 깨진다

from openai import OpenAI
import json

client = OpenAI()

resp = client.responses.create(
    model="gpt-4.1-mini",
    input="다음 정보를 JSON으로만 출력해줘: 이름=홍길동, 나이=30"
)
text = resp.output_text
print(text)

# 종종 이런 형태가 섞입니다:
# Here is the JSON:
# {"name": "홍길동", "age": 30}

data = json.loads(text)  # 여기서 실패 가능

실제 운영에서는 사용자가 입력을 길게 주거나, 이전 대화가 누적되거나, 모델이 친절 모드로 설명을 붙이는 순간 바로 깨집니다.

LangChain으로 “강제 JSON 출력” 만들기 (Pydantic 스키마)

핵심은 스키마를 Pydantic으로 정의하고, LangChain이 제공하는 구조화 출력 체인을 사용해 모델이 그 스키마를 따르도록 만드는 것입니다.

1) 스키마 정의

from pydantic import BaseModel, Field
from typing import List, Literal

class PersonProfile(BaseModel):
    name: str = Field(description="사람 이름")
    age: int = Field(description="나이")
    tags: List[str] = Field(default_factory=list, description="특징 태그")
    risk: Literal["low", "medium", "high"] = Field(description="리스크 등급")

2) LangChain 모델 + structured output

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
    model="gpt-4.1-mini",
    temperature=0,
)

structured_llm = llm.with_structured_output(PersonProfile)

result = structured_llm.invoke(
    "이름=홍길동, 나이=30, 특징은 성실함/지각없음. 리스크는 낮음으로 분류해"
)

print(type(result))
print(result)
# result는 이미 PersonProfile 인스턴스
# result.model_dump()로 안전한 dict 획득

이 방식의 장점은 다음과 같습니다.

  • 출력이 문자열이 아니라 객체로 들어온다
  • 파싱/검증이 LangChain+Pydantic 레이어에서 수행된다
  • 누락 필드/타입 불일치가 있으면 예외로 잡히며, 재시도 전략을 붙이기 쉽다

“깨졌을 때 자동 복구”까지 포함한 운영 패턴

강제 출력이 있어도 100% 완벽하다고 가정하면 위험합니다. 모델/네트워크/토큰 제한 이슈로 실패할 수 있으니, 아래처럼 검증 + 재시도 + 폴백을 넣는 구성이 안전합니다.

1) 재시도 래퍼 (간단 버전)

import time
from pydantic import ValidationError

def invoke_with_retry(chain, prompt: str, retries: int = 2, backoff: float = 0.5):
    last_err = None
    for i in range(retries + 1):
        try:
            return chain.invoke(prompt)
        except (ValidationError, ValueError) as e:
            last_err = e
            time.sleep(backoff * (2 ** i))
    raise last_err

profile = invoke_with_retry(structured_llm, "이름=홍길동, 나이=30, 리스크=low")
print(profile.model_dump())

2) “수정 프롬프트”를 별도 루프로 두기

실전에서는 실패 시 곧바로 같은 프롬프트를 재시도하는 것보다, 에러 메시지와 함께 JSON만 다시 출력하라고 명령하는 수정 루프가 더 잘 먹힙니다.

  • 1차: 정상 생성 시도
  • 실패: ValidationError 내용을 요약해서 "스키마에 맞게 JSON만 다시" 요청

이때도 본문에 < > 같은 문자가 들어가면 MDX에서 문제 될 수 있으니, 로그/에러를 그대로 노출하기보다는 인라인 코드로 감싸거나, 서버 로그로만 남기는 편이 안전합니다.

출력 강제 시에도 자주 깨지는 포인트와 대응

1) 필드 누락

  • tags 같은 선택 필드는 default_factory로 기본값을 주면 안정적입니다.
  • 필수 필드는 정말 필수만 두세요. 운영에서는 "없으면 null"보다 "없으면 빈 배열"이 더 다루기 쉽습니다.

2) enum 값 드리프트

risklow/medium/high로 제한했는데 모델이 "낮음"을 내는 경우가 있습니다.

  • 입력 언어와 enum을 맞추거나
  • 프롬프트에 "risk는 low|medium|high 중 하나"를 반복해서 명시하세요.

3) 토큰 부족

구조화 출력은 종종 추가 토큰이 필요합니다.

  • max_tokens 또는 응답 길이를 넉넉히
  • 출력 필드를 최소화
  • 긴 본문은 별도 필드로 분리하거나 요약을 요구

4) 스트리밍 사용 시

스트리밍은 UX에는 좋지만, JSON 파싱에는 불리합니다.

  • 스트리밍이 필요하면 완료 후 한 번에 파싱하도록 버퍼링
  • 혹은 이벤트 단위로 JSON Lines 같은 포맷을 고려

LangChain 강제출력 설계 팁: “스키마를 API 계약으로 본다”

LLM 출력은 곧 API 계약입니다. 따라서 다음을 권장합니다.

  • 스키마는 버저닝한다: v1, v2로 나누고 점진적으로 마이그레이션
  • 필드는 최소화한다: 필요한 것만 받는다
  • 서버에서 최종 검증한다: Pydantic 검증 실패는 곧 400 또는 재시도 대상
  • 관측 가능성을 확보한다: 실패율, 재시도 횟수, 토큰 사용량을 메트릭으로 남긴다

이런 “계약 기반” 사고는 타입 안정성 측면에서도 도움이 됩니다. TypeScript 쪽에서 타입 추론/계약을 다듬는 관점은 TS 5.6 satisfies로 타입추론 깨짐 해결 7가지 글과도 결이 비슷합니다.

(보너스) Self-Consistency와 조합해 안정성 더 끌어올리기

JSON이 깨지는 문제와 별개로, 결과 품질이 흔들리는 경우가 있습니다. 이때는 동일 스키마로 여러 번 생성한 뒤 다수결로 선택하는 식의 접근이 유효합니다.

  • 동일 입력에 대해 N회 생성
  • 모두 스키마 검증을 통과한 결과만 후보로 채택
  • 핵심 필드(예: risk)를 다수결로 결정

이 패턴은 CoT 없이 성능 올리는 Self-Consistency 구현법에서 다룬 아이디어를 구조화 출력에 적용한 형태입니다.

결론: “JSON으로만 답해”는 운영 해법이 아니다

정리하면,

  • 프롬프트만으로 JSON을 강제하는 방식은 운영에서 깨지기 쉽고
  • LangChain의 with_structured_output 같은 구조화 출력 기능으로 스키마 기반 강제를 걸면 파싱 안정성이 크게 올라갑니다.
  • 그래도 실패는 발생하므로 검증, 재시도, 수정 루프, 폴백을 함께 설계해야 합니다.

LLM을 제품에 넣는 순간부터는 “모델이 잘 해주겠지”가 아니라, “깨져도 시스템이 복구한다”가 기본 전제가 되어야 합니다. 구조화 출력은 그 출발점으로 가장 비용 대비 효과가 큰 안전장치입니다.