LLM에서 구조화된 JSON 출력을 안정적으로 받는 법

.cg-article{–accent:#4f46e5;–accent-soft:#eef2ff;–accent-line:#c7d2fe;–navy:#312e81;–warn:#d97706;–warn-soft:#fffbeb;line-height:1.75;color:#1f2937;font-size:17px;word-break:keep-all}
.cg-article h2{margin:2.2em 0 .7em;padding-left:.55em;border-left:5px solid var(–accent);font-size:1.42em;line-height:1.4;color:var(–navy)}
.cg-article h3{margin:1.6em 0 .5em;font-size:1.13em;color:var(–accent)}
.cg-article p{margin:.7em 0}
.cg-lead{background:var(–accent-soft);border:1px solid var(–accent-line);border-radius:12px;padding:18px 20px;margin:1.2em 0;font-size:1.02em}
.cg-lead strong{color:var(–accent)}
.cg-table{width:100%;border-collapse:collapse;margin:1.1em 0;font-size:.97em}
.cg-table th,.cg-table td{border:1px solid #e5e7eb;padding:11px 13px;text-align:left;vertical-align:top}
.cg-table th{background:var(–accent);color:#fff;font-weight:600}
.cg-table tr:nth-child(even) td{background:#f9fafb}
.cg-check{list-style:none;padding:0;margin:1.1em 0}
.cg-check li{position:relative;padding:9px 9px 9px 34px;margin:6px 0;background:var(–accent-soft);border-radius:8px}
.cg-check li::before{content:”✓”;position:absolute;left:12px;top:9px;color:var(–accent);font-weight:700}
.cg-warn{background:var(–warn-soft);border-left:5px solid var(–warn);border-radius:8px;padding:16px 18px;margin:1.3em 0}
.cg-warn strong{color:var(–warn)}
.cg-faq{margin:1.5em 0}
.cg-faq dt{font-weight:600;margin:1.1em 0 .3em;color:var(–accent)}
.cg-faq dd{margin:0 0 .6em}
.cg-article pre{background:#1e1b2e;color:#e9e7ff;border-radius:10px;padding:14px 16px;overflow-x:auto;font-size:.86em;line-height:1.55;margin:1.1em 0}
.cg-article pre code{background:none;color:inherit;padding:0}
.cg-article code{background:var(–accent-soft);color:var(–navy);padding:.1em .4em;border-radius:5px;font-size:.9em}
.cg-note{font-size:.9em;color:#6b7280;border-top:1px dashed #d1d5db;margin-top:2.4em;padding-top:1em}

LLM 출력을 코드에서 바로 쓰려면 자유 텍스트가 아니라 파싱 가능한 JSON이 필요합니다. 문제는 모델이 본질적으로 다음 토큰을 확률적으로 생성하기 때문에, 아무 제약 없이 “JSON으로 답해”라고만 하면 따옴표 누락, 후행 쉼표, 코드블록 감싸기, 설명 문장 끼워 넣기 같은 깨짐이 수시로 발생한다는 점입니다. 이를 막는 방법은 크게 두 갈래입니다. 하나는 모델이 스키마를 벗어난 토큰을 아예 만들지 못하게 제약된 디코딩(structured output)으로 형식을 강제하는 것이고, 다른 하나는 tool use / function calling으로 인자를 구조화해 받는 것입니다. 어느 방식을 쓰든, 받은 결과를 스키마로 검증하고 실패 시 재시도하는 방어 계층은 항상 함께 둬야 합니다.

왜 JSON이 깨지는가

LLM은 정해진 문법을 “이해”하고 출력하는 것이 아니라, 학습 분포에 따라 토큰을 하나씩 이어 붙입니다. 그래서 형식 제약이 없으면 다음과 같은 전형적 실패가 나옵니다. 모델이 친절을 부려 ```json ... ``` 코드펜스로 감싸거나, “다음은 결과입니다:” 같은 머리말을 붙이거나, 마지막 항목 뒤에 쉼표를 남기거나, 큰따옴표 대신 작은따옴표를 쓰거나, 문자열 안의 줄바꿈을 이스케이프하지 않는 식입니다. 길이 제한(max tokens)에 걸려 JSON이 중간에 잘리는 것도 흔합니다.

이런 실패는 무작위로 보이지만 대부분 구조적입니다. 즉 프롬프트로 “정확한 JSON만 출력”이라고 강하게 요청해도 확률적으로 일정 비율은 깨집니다. 프롬프트 지시는 깨짐 확률을 낮출 뿐 0으로 만들지 못합니다. 그래서 신뢰성이 필요한 서비스라면 형식 자체를 강제하는 메커니즘이 필요합니다.

흔한 깨짐 유형증상1차 대응
코드펜스 감싸기```json 으로 출력을 감쌈파싱 전 펜스 제거
머리말·꼬리말“다음은 결과입니다” 등 설명 문장 부착JSON 구간만 추출
후행 쉼표·따옴표 오류마지막 쉼표, 작은따옴표 사용관용 파서로 보정
출력 잘림길이 제한으로 중간에서 끊김max tokens 상향·분할
스키마 이탈임의 필드 추가·필드 누락·타입 불일치스키마 검증 후 교정

스키마를 강제하는 세 가지 방식

실무에서 쓰는 강제 방식은 크게 세 가지입니다. 각각 보장 수준과 적합한 상황이 다릅니다.

방식형식 보장적합한 상황
Structured Output(스키마 강제 디코딩)스키마 일치를 강하게 보장고정 스키마로 데이터를 추출·반환
Tool use / Function calling인자가 스키마로 구조화됨모델이 도구를 호출하거나 행동을 선택
JSON mode + 프롬프트유효 JSON 정도만 보장(키·타입은 별도 검증)가벼운 작업, 스키마 강제 미지원 모델

Structured Output — 디코딩 단계에서 형식 강제

가장 강한 방식은 모델의 토큰 생성 단계에서 JSON Schema를 만족하지 않는 토큰을 아예 선택하지 못하게 막는 제약된 디코딩입니다. 제공사에 따라 “Structured Outputs”, “response schema”, “guided decoding” 등으로 부릅니다. 스키마를 응답 형식으로 전달하면, 출력이 그 스키마에 들어맞는 것을 사실상 보장합니다.

# 응답 스키마를 강제하는 방식(개념 예시)
schema = {
  "type": "object",
  "properties": {
    "title":     { "type": "string" },
    "sentiment": { "type": "string", "enum": ["pos","neg","neutral"] },
    "score":     { "type": "number" }
  },
  "required": ["title","sentiment","score"],
  "additionalProperties": False
}
# response_format / response_schema 인자로 schema 전달
# → 출력이 schema에 맞는 JSON으로 디코딩됨

주의할 점은 지원하는 JSON Schema 하위 집합이 제공사마다 다르다는 것입니다. additionalProperties:false 요구, 모든 필드 required 강제, $ref·정규식·일부 키워드 미지원 같은 제약이 있을 수 있어, 스키마를 짜기 전에 해당 API가 받아들이는 스키마 범위를 공식 문서로 확인해야 합니다.

Tool use / Function calling — 인자를 구조화

도구 사용 방식은 모델에게 “이런 인자를 가진 함수가 있다”고 알려 주고, 모델이 그 함수를 호출하도록 유도해 인자(arguments)를 구조화된 형태로 받습니다. 데이터 추출 자체가 목적일 때도, 추출 결과를 담는 단일 도구를 정의해 그 인자로 받는 패턴을 자주 씁니다. 모델이 행동을 선택하고 외부 시스템을 호출하는 에이전트라면 이 방식이 자연스럽습니다.

# 추출용 단일 도구로 구조화 결과를 받는 패턴(개념 예시)
tools = [{
  "name": "save_extraction",
  "description": "추출 결과를 저장",
  "input_schema": {                 # 위 schema와 동일 구조
    "type": "object",
    "properties": { "title": {"type":"string"}, "score": {"type":"number"} },
    "required": ["title","score"]
  }
}]
# 모델이 save_extraction(arguments=...) 를 호출 → arguments를 파싱

받은 뒤에도 검증한다

형식 강제를 쓰더라도, 받은 JSON을 코드 측 스키마(예: Pydantic, zod)로 한 번 더 검증하는 계층을 두는 것이 안전합니다. 형식 강제는 “유효한 JSON과 스키마 구조”는 보장해도, 값의 의미적 타당성(범위, 상호 제약, 비즈니스 규칙)까지 보장하지는 않기 때문입니다. JSON mode만 쓰는 경우라면 검증은 선택이 아니라 필수입니다.

# 검증 → 실패 시 재시도 패턴(개념 예시)
from pydantic import BaseModel, ValidationError

class Result(BaseModel):
    title: str
    sentiment: str
    score: float

def call_with_retry(prompt, max_retry=2):
    for attempt in range(max_retry + 1):
        raw = call_llm(prompt)
        try:
            return Result.model_validate_json(raw)   # 구조+타입 검증
        except ValidationError as e:
            # 오류 메시지를 그대로 다음 프롬프트에 피드백
            prompt = repair_prompt(prompt, raw, str(e))
    raise RuntimeError("형식 검증 반복 실패")

재시도와 부분 파싱

검증에 실패했을 때 단순히 같은 요청을 반복하기보다, 무엇이 틀렸는지를 모델에 알려 주는 교정 루프가 효과적입니다. 검증기가 뱉은 오류 메시지(예: “score는 number여야 함”, “필드 누락”)를 다음 프롬프트에 그대로 넣어 “이 부분을 고쳐 다시 JSON만 출력하라”고 요청하면 성공률이 올라갑니다. 다만 재시도 횟수에는 상한을 둬야 무한 루프와 비용 폭증을 막을 수 있습니다.

출력이 길이 제한으로 잘렸다면 재시도만으로는 풀리지 않습니다. 이때는 최대 출력 토큰을 늘리거나, 응답을 더 작은 단위로 쪼개 여러 번에 나눠 받는 설계가 필요합니다. 또한 깨진 JSON을 버리기 전에 관용적 파서로 부분 복구를 시도하는 것도 실무 기법입니다. 후행 쉼표 제거, 코드펜스 벗기기, 닫히지 않은 괄호 보정 같은 전처리로 상당수 케이스를 살릴 수 있습니다.

재시도 비용을 줄이려면 처음부터 형식을 강제하는 경로를 1순위로 두고, 형식 강제를 쓸 수 없는 모델에서만 검증·교정 루프를 보강책으로 쓰는 것이 합리적입니다. 형식 강제가 적용된 경로에서는 형식 오류 자체가 드물어 재시도가 거의 발생하지 않으므로, 재시도 루프는 의미 검증(값 범위·상호 제약)에 집중하게 됩니다. 반대로 JSON mode나 순수 프롬프트 경로에서는 형식 오류와 의미 오류가 모두 들어오므로 전처리와 교정 루프의 비중이 커집니다. 어느 경로든, 재시도 횟수·소요 시간·실패 사유를 로깅해 두면 어떤 입력이 반복적으로 깨지는지 패턴을 찾아 프롬프트나 스키마를 개선할 수 있습니다.

코드펜스와 머리말을 먼저 벗기세요.

형식 강제를 쓰지 않는 경로에서 가장 흔한 파싱 실패는 모델이 JSON을 ```json ... ```로 감싸거나 앞에 설명 문장을 붙이는 것입니다. 파싱 직전에 코드펜스와 앞뒤 비-JSON 텍스트를 제거하는 전처리만 넣어도 실패율이 크게 떨어집니다.

스트리밍과의 관계

응답을 토큰 단위로 흘려보내는 스트리밍과 구조화 출력은 상충하는 면이 있습니다. JSON은 마지막 닫는 괄호가 와야 비로소 유효해지므로, 스트리밍 도중에는 항상 “불완전한 JSON”만 손에 들고 있게 됩니다. 따라서 스트리밍하면서 곧바로 표준 파서에 넘기면 거의 항상 실패합니다.

해결책은 두 가지입니다. 하나는 전체 스트림을 다 받은 뒤에 한 번 파싱하는 것으로, 가장 단순하고 안전합니다. 다른 하나는 부분 JSON(증분) 파서를 써서, 지금까지 도착한 토큰까지로 만들 수 있는 부분 객체를 점진적으로 구성해 UI에 흘려 보내는 것입니다. 사용자에게 진행 상황을 실시간으로 보여 줘야 한다면 후자를 쓰되, 최종 검증은 스트림이 끝난 뒤 완전한 JSON에 대해 다시 수행해야 합니다.

흔한 함정 체크리스트

  • 프롬프트로만 “JSON으로 답해”라고 하고 형식 강제·검증을 생략한다 — 일정 비율은 반드시 깨진다.
  • 스키마에 additionalProperties·required 설정을 빠뜨려 모델이 임의 필드를 추가하거나 누락한다.
  • max tokens가 작아 JSON이 중간에 잘리는데 이를 형식 오류로 오인한다.
  • 스트리밍 중간 조각을 표준 JSON 파서에 넘겨 매번 실패한다.
  • enum·날짜·숫자 범위 같은 의미 제약을 스키마에만 의존하고 값 검증을 건너뛴다.
  • 재시도 상한이 없어 형식이 계속 깨질 때 비용과 지연이 폭증한다.
  • 한글·이모지 등 비ASCII 문자열의 이스케이프·인코딩 처리를 가정만 하고 검증하지 않는다.

자주 묻는 질문

프롬프트로 “JSON만 출력”이라고 강하게 쓰면 충분하지 않나요?
충분하지 않습니다. 프롬프트 지시는 깨짐 확률을 낮출 뿐 보장하지 못합니다. 신뢰성이 필요하면 스키마 강제 디코딩이나 tool use로 형식을 강제하고, 받은 결과를 코드에서 다시 검증해야 합니다.
Structured Output과 Function calling 중 무엇을 써야 하나요?
순수하게 고정 스키마의 데이터를 추출·반환하는 것이 목적이면 Structured Output이 직관적입니다. 모델이 도구를 호출하거나 행동을 선택하는 에이전트 흐름이면 Function calling이 자연스럽습니다. 추출 목적이라도 단일 도구로 받는 패턴은 흔합니다.
스키마를 아무리 복잡하게 짜도 되나요?
제공사마다 지원하는 JSON Schema 범위가 다릅니다. $ref, 정규식, 중첩 깊이, additionalProperties 처리 등에 제약이 있을 수 있어, 복잡한 스키마는 미리 공식 문서로 지원 범위를 확인하고 가능하면 단순하게 설계하는 편이 안전합니다.
스트리밍하면서 JSON을 바로 파싱할 수 있나요?
표준 파서로는 안 됩니다. JSON은 끝까지 와야 유효하기 때문입니다. 전체를 받은 뒤 파싱하거나, 부분 JSON 파서로 증분 구성을 하되 최종 검증은 완성된 JSON에 대해 다시 수행해야 합니다.
검증 실패 시 재시도는 어떻게 설계하나요?
검증기의 오류 메시지를 다음 프롬프트에 넣어 교정을 요청하는 피드백 루프가 효과적입니다. 단 재시도 횟수에 상한을 두고, 길이 초과로 잘린 경우는 재시도 대신 출력 토큰을 늘리거나 작업을 분할해야 합니다.

이 글은 OpenAI·Anthropic·Google 등의 공개 API 문서에 설명된 구조화 출력, 함수 호출, JSON 모드의 일반 개념을 정리한 것입니다. 지원하는 스키마 범위와 형식 보장 수준, 옵션 이름은 모델·제공사·시점에 따라 다르므로, 실제 구현 전에는 사용하는 API의 공식 문서를 확인하시기 바랍니다.

위로 스크롤