LLM이 반환한 JSON 파싱이 자꾸 깨질 때 — 재시도 말고 구조를 바꾸는 3가지 방법

LLM이 반환한 JSON 파싱이 자꾸 깨질 때 — 재시도 말고 구조를 바꾸는 3가지 방법

LLM API 응답을 json.loads()에 넣었다가 Expecting ',' delimiter 에러를 본 적이 있다면, 문제는 프롬프트 문구가 아니라 출력 방식 자체일 가능성이 높습니다. LLM이 반환한 JSON 파싱이 자꾸 깨질 때 가장 먼저 시도하는 게 “다시 물어보기”인데, 이건 증상만 가릴 뿐 근본 원인은 그대로 남습니다. 이 글에서는 재시도 로직을 걷어내고 출력 구조 자체를 강제하는 세 가지 방법 — OpenAI Structured Outputs, 함수 호출(Tool Use), 로컬 모델의 GBNF 문법 제약 — 을 실제 코드와 함께 정리합니다.

재시도만 반복해도 JSON 파싱 에러가 사라지지 않는 이유는 무엇인가요?

LLM은 다음에 올 토큰을 확률적으로 예측하는 모델이지, JSON 문법을 규칙으로 검증하며 글자를 채우는 파서가 아닙니다. 학습 데이터에서 본 패턴을 흉내 내는 것이라, 배열이 길어지거나 객체가 여러 겹 중첩되면 닫는 괄호 개수를 놓치는 실수가 확률적으로 계속 발생합니다.

temperature가 0보다 크면 같은 프롬프트를 넣어도 매번 다른 텍스트가 나오므로, 어제는 통과했던 프롬프트가 오늘은 실패할 수 있습니다. 실패율이 5%라고 가정하면 재시도를 세 번 걸어도 완전히 0%가 되지 않고, 호출할 때마다 지연 시간과 토큰 비용만 누적됩니다.

게다가 “반드시 JSON으로만 답해”라는 지시문은 모델 입장에서 강제력이 없는 권고일 뿐입니다. 응답 앞뒤에 “알겠습니다, 다음은 결과입니다” 같은 설명 문장을 덧붙이거나, 마크다운 코드펜스로 감싸버리는 경우도 흔한데 이것도 같은 맥락의 문제입니다.

OpenAI Structured Outputs로 스키마를 강제하면 파싱 실패가 왜 줄어드나요?

2024년 8월 공개된 OpenAI Structured Outputs는 프롬프트로 “부탁”하는 방식이 아니라, API 서버 쪽 디코딩 단계에서 스키마에 맞지 않는 토큰 자체를 후보에서 제외하는 constrained decoding 방식을 씁니다. gpt-4o-2024-08-06 이후 모델과 gpt-4o-mini에서 지원하며, response_format"type": "json_schema""strict": true를 지정하면 됩니다.

from openai import OpenAI
from pydantic import BaseModel

client = OpenAI()

class Order(BaseModel):
    product_name: str
    quantity: int
    is_urgent: bool

response = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "주문 문장에서 정보를 추출하세요."},
        {"role": "user", "content": "노트북 파우치 2개 급하게 보내주세요"},
    ],
    response_format=Order,
)

order = response.choices[0].message.parsed
print(order)
# 출력: product_name='노트북 파우치' quantity=2 is_urgent=True

모니터 앞에서 코드 오류를 디버깅하는 개발자

주의할 점은 예전부터 있던 {"type": "json_object"} JSON 모드와는 다르다는 것입니다. JSON 모드는 “문법적으로 유효한 JSON”만 보장할 뿐, 필드 이름이나 타입이 스키마와 일치하는지는 보장하지 않습니다. strict: true를 켠 Structured Outputs만 스키마 준수 자체를 서버가 보증합니다. 자세한 지원 모델과 제약사항은 OpenAI Structured Outputs 공식 가이드에 정리되어 있습니다.

함수 호출(Tool Use)을 JSON 출력 채널로 쓰면 어떤 점이 달라지나요?

Anthropic Claude나 OpenAI 계열 모두 원래 함수 호출(tool calling)은 “모델이 도구를 실행하도록” 만든 기능이지만, 실제로는 자유 텍스트 응답 대신 정해진 input_schema에 맞는 JSON을 강제로 뽑아내는 우회 통로로도 널리 쓰입니다. tool_choice를 특정 도구로 고정하면 모델이 일반 문장을 낼 여지 자체가 사라집니다.

import anthropic

client = anthropic.Anthropic()

tools = [{
    "name": "extract_order",
    "description": "주문 정보를 구조화해서 반환한다",
    "input_schema": {
        "type": "object",
        "properties": {
            "product_name": {"type": "string"},
            "quantity": {"type": "integer"},
        },
        "required": ["product_name", "quantity"],
    },
}]

resp = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=200,
    tools=tools,
    tool_choice={"type": "tool", "name": "extract_order"},
    messages=[{"role": "user", "content": "머그컵 3개 주문할게요"}],
)

block = [c for c in resp.content if c.type == "tool_use"][0]
print(block.input)
# 출력: {'product_name': '머그컵', 'quantity': 3}

응답이 message.content의 일반 텍스트가 아니라 tool_use 콘텐츠 블록의 input 필드로 들어오기 때문에, 애초에 자유 텍스트를 파싱할 필요 자체가 없어집니다. 다만 도구 강제 호출을 지원하지 않는 구버전 모델도 있으니, 사용 중인 모델이 tool_choice 강제를 지원하는지는 Anthropic Tool Use 공식 문서에서 먼저 확인해야 합니다.

로컬 모델에서는 GBNF 문법으로 잘못된 토큰 자체를 막을 수 있나요?

API가 아니라 llama.cpp로 로컬 모델을 돌린다면 GBNF(Grammar-Backus-Naur Form) 문법 파일로 같은 효과를 낼 수 있습니다. --grammar 옵션에 문법 파일을 넘기면 샘플러가 매 토큰 생성 시점마다 문법에 맞지 않는 토큰의 확률을 아예 0으로 만들어버립니다.

# order.gbnf
root ::= "{" ws "\"product_name\":" ws string "," ws "\"quantity\":" ws number "}" ws
string ::= "\"" [^"]* "\""
number ::= [0-9]+
ws ::= [ \t\n]*

API 스키마 구조와 JSON 데이터 다이어그램

./llama-cli -m model.gguf --grammar-file order.gbnf \
  -p "머그컵 3개 주문 정보를 JSON으로:"
# 출력: {"product_name": "머그컵", "quantity": 3}

7B급 양자화 모델처럼 지시를 잘 안 따르는 작은 모델에서도 문법 위반 자체가 구조적으로 불가능해진다는 게 핵심입니다. 반면 중첩 객체나 선택적 필드가 많아지면 문법 파일이 급격히 복잡해지고, 문법 검증 오버헤드 때문에 토큰 생성 속도가 눈에 띄게 느려지는 트레이드오프가 있습니다. 문법 문법 자체는 llama.cpp GBNF 문서에서 확인할 수 있습니다.

세 가지 방법 중 지금 상황엔 뭘 골라야 하나요?

세 방법 모두 JSON 형식 자체를 정의하는 IETF RFC 8259 표준을 벗어나지 않는 선에서, “어느 단계에서 강제하느냐”만 다릅니다.

방법 강제 위치 지원 조건 주요 트레이드오프
Structured Outputs OpenAI 서버 디코딩 단계 gpt-4o-2024-08-06 이후 재귀 스키마·일부 JSON Schema 키워드 미지원
Tool Use 강제 API 서버, tool_choice 고정 최신 Claude/GPT 계열 원래 용도가 아니어서 우회적, 구버전 미지원
GBNF 문법 로컬 샘플러 토큰 마스킹 llama.cpp 등 로컬 실행 환경 문법 작성 난이도, 생성 속도 저하

스키마를 강제했는데도 필드 값이 이상하게 나오는 건 왜 그런가요? 세 방법 모두 문법(syntax)을 보장하지, 값의 사실 정확성까지 보장하지는 않습니다. quantity가 정수형으로는 들어오지만 원문에 없는 숫자를 지어내는 건 여전히 가능하다는 뜻입니다. 이 부분은 enum으로 값의 범위를 제한하거나, Pydantic validator로 후처리 검증을 별도로 추가해야 합니다.

스키마 정의가 먼저, 재시도는 최후의 안전망으로만 남기세요

LLM이 반환한 JSON 파싱이 자꾸 깨지는 문제는 프롬프트 문구를 더 정교하게 다듬는다고 해결되지 않습니다. 사용 중인 환경에 맞는 구조적 제약을 먼저 켜는 쪽이 훨씬 확실합니다.

지금 바로 해볼 수 있는 다음 단계는 이렇습니다. OpenAI GPT-4o 계열을 쓰고 있다면 strict: true Structured Outputs부터 켜보고, Claude 계열이라면 tool_choice를 특정 도구로 고정해보고, 로컬·온프레미스 환경이라면 간단한 GBNF 문법 파일 하나부터 작성해보는 것입니다. 재시도 로직은 이 구조적 제약을 다 적용한 뒤에도 남는 극소수 엣지 케이스를 위한 최후의 안전망 정도로만 남겨두면 충분합니다.

지도학습, 비지도학습, 강화학습: 3가지 머신러닝 방법

Leave a Comment