AI JSON 출력 안정화 – Structured Output 완전 정복
AI 출력을 코드로 처리하려면 형식이 보장되어야 한다. JSON을 안정적으로 받기 위한 5단계 전략을 완전히 다룬다.
들어가며: "JSON으로 줘"가 왜 안 통하는가
코드에서 AI를 호출해 데이터를 추출하는 상황을 상상해 보겠습니다. 고객 리뷰 텍스트에서 감정(긍정/부정), 언급된 제품명, 핵심 불만 사항을 추출해 데이터베이스에 저장해야 합니다. 자연스럽게 프롬프트 끝에 이렇게 씁니다.
결과를 JSON 형식으로 출력해줘.
그러면 AI는 이렇게 답합니다.
물론이죠! 아래에 요청하신 분석 결과를 JSON 형식으로 제공해 드리겠습니다:
\`\`\`json
{
"sentiment": "negative",
"product": "노트북 배터리",
"complaint": "3시간도 못 가는 배터리 수명"
}
\`\`\`
이 분석이 도움이 되셨으면 좋겠습니다. 추가로 필요하신 것이 있으면 말씀해 주세요!
JSON 앞에 한 줄, JSON 뒤에 두 줄. 코드블록 마커(```)도 있습니다. JSON.parse(response)는 즉시 예외를 던집니다.
이것이 Structured Output 문제의 본질입니다. AI에게 형식을 지시하는 것과 그 형식이 코드로 파싱 가능하게 보장되는 것은 완전히 다른 문제입니다. 이 두 가지를 혼동하는 한 AI 출력을 안정적으로 처리하는 시스템을 만들 수 없습니다.
이 글은 그 간극을 메우는 방법을 다섯 가지 계층으로 나눠 완전히 설명합니다. 프롬프트 한 줄 수정부터 API 레벨 강제, 오류 복구 파이프라인까지. 이 편을 읽고 나면 어떤 상황에서 어떤 방법을 써야 하는지 명확하게 판단할 수 있어야 합니다.
1. 왜 LLM은 JSON을 못 지키는가
1.1. LLM의 출력은 확률적 텍스트 생성이다
8편에서 다뤘듯, LLM은 다음 토큰을 확률 분포에서 샘플링하며 텍스트를 생성합니다. 이 관점에서 JSON은 LLM에게 매우 특수한 형식입니다.
JSON은 자연어가 아닙니다. 모델이 학습한 데이터의 대부분은 자연어 텍스트입니다. JSON, YAML, CSV 같은 구조화된 형식도 학습 데이터에 포함되어 있지만, 자연어에 비하면 훨씬 적은 비중입니다. 이 때문에 "JSON으로 출력해"라는 지시를 받으면 모델은 JSON을 생성하려 하면서도, 동시에 자연어로 응답하려는 강력한 경향을 억누르지 못합니다. 학습 데이터에서 "물론이죠!", "아래에", "이 분석이 도움이 됐으면" 같은 표현이 질문-응답 쌍에 압도적으로 많이 등장했기 때문입니다.
이것은 모델의 결함이 아닙니다. 모델은 학습 데이터의 패턴을 충실하게 반영하는 것입니다. 문제는 우리가 그 패턴에서 벗어난 특수한 출력 형식을 요구하고 있다는 데 있습니다.
1.2. 구조를 깨는 세 가지 실패 패턴
실전에서 JSON 출력이 실패하는 방식은 대부분 세 가지 패턴 중 하나입니다.
패턴 1 — 서문(Preamble)
JSON 앞에 자연어 설명이 붙습니다. "물론이죠!", "아래에 결과를 제공합니다" 같은 문장이 전형적입니다. 코드로 파싱하면 문자열의 첫 번째 {를 찾아서 처리하는 방법으로 우회할 수 있지만, 이 자체가 불안정한 해킹입니다.
패턴 2 — 후기(Postamble)
JSON 뒤에 설명이 추가됩니다. "이 분석은...", "참고로...", "추가 정보가 필요하시면..." 같은 문장입니다. 서문보다 처리하기 더 까다롭습니다. 마지막 }를 찾아서 잘라내는 방법도 있지만, 중첩된 JSON 구조에서는 잘못된 위치를 잘라낼 수 있습니다.
패턴 3 — 형식 불량(Malformed JSON) 구조는 JSON처럼 보이지만 문법적으로 유효하지 않습니다. 문자열 값에 따옴표 누락, 키에 따옴표 없음, 마지막 항목 뒤의 trailing comma, 단일 따옴표 사용, 주석 삽입 등이 자주 발생합니다.
// 자주 발생하는 Malformed JSON 예시
{
sentiment: "negative", // 키에 따옴표 없음
'product': "노트북 배터리", // 단일 따옴표 사용
"complaint": "배터리 수명", // trailing comma
}
이 세 패턴은 단순히 "더 명확하게 지시하면" 해결되는 문제가 아닙니다. 모델의 확률적 특성 때문에 지시가 아무리 명확해도 낮은 확률로 이 패턴들이 발생합니다. 99%의 안정성을 원한다면 프롬프트 지시만으로는 부족합니다. 여러 계층의 방어가 필요합니다.
1.3. 신뢰도 스택이란
Structured Output 문제는 단일 기법으로 해결되지 않습니다. 대신 여러 계층을 쌓아 올린 **신뢰도 스택(Reliability Stack)**으로 접근해야 합니다.
Level 5 — 오류 복구 파이프라인 ← 가장 강력
Level 4 — API 레벨 강제 (Tool Use)
Level 3 — 스키마 명시 + Few-shot
Level 2 — 출력 분리 구조
Level 1 — 명령형 지시 ← 가장 약함
각 레벨은 독립적으로도 사용할 수 있지만, 높은 신뢰도가 필요한 시스템에서는 여러 레벨을 조합합니다. 어떤 레벨을 선택할지는 태스크의 중요도, 오류 허용 범위, 사용하는 모델의 능력에 따라 달라집니다.
2. Level 1 — 명령형 지시: 기본기를 제대로
단순한 지시만으로도 상당히 많은 것을 개선할 수 있습니다. 단, "JSON으로 줘"가 아니라 훨씬 더 구체적이어야 합니다.
2.1. 위치가 중요하다
프롬프트 내에서 형식 지시의 위치는 생각보다 큰 영향을 미칩니다.
LLM은 프롬프트를 순서대로 처리하며 앞부분에 더 많은 가중치를 두는 경향이 있습니다. 형식 지시를 프롬프트 끝에만 두면 모델이 이미 자연어 응답을 준비하기 시작한 뒤 형식을 적용하려 합니다. 앞과 뒤 두 군데 모두 명시하면 형식 준수율이 눈에 띄게 높아집니다.
[나쁜 예 — 형식 지시가 끝에만 있음]
다음 텍스트에서 감정, 제품명, 불만 사항을 추출해줘.
텍스트: {{text}}
JSON 형식으로 출력해줘.
[좋은 예 — 앞뒤 모두 명시]
다음 텍스트를 분석해 결과를 JSON으로만 출력해줘. 설명, 서문, 후기 없이 순수 JSON만.
텍스트: {{text}}
반드시 아래 형식 그대로 출력할 것. 다른 텍스트 절대 포함 금지:
{"sentiment": "...", "product": "...", "complaint": "..."}
2.2. 금지 명시가 허용 명시보다 효과적이다
"JSON으로 출력해줘"보다 "설명 없이 JSON만 출력해줘"가 더 효과적입니다. 그리고 "JSON만"보다 "서문, 후기, 코드블록 마커 없이 순수 JSON만"이 더 효과적입니다.
모델에게 하면 안 되는 것을 구체적으로 열거하면, 모델이 자연스럽게 선택하려는 패턴을 명시적으로 차단하는 효과가 있습니다.
[출력 형식 지시 강화 예시]
출력 규칙 (엄격히 준수):
✗ 절대 하지 말 것: "물론이죠", "아래에", "이 분석은", "추가로" 등의 서문/후기
✗ 절대 하지 말 것: ```json 코드블록 마커
✗ 절대 하지 말 것: // 주석
✓ 해야 할 것: { 로 시작해서 } 로 끝나는 순수 JSON만 출력
3. Level 2 — 출력 분리 구조: 사고와 형식을 분리한다
프롬프트 지시만으로 형식 불량이 발생하는 근본 원인 중 하나는 모델이 생각하면서 동시에 형식을 지키려는 부담을 안고 있다는 것입니다. 추론 과정과 최종 출력을 같은 공간에 쓰다 보면 형식이 흐트러집니다.
3.1. 생각은 자유롭게, 출력은 고정되게
이 문제를 해결하는 핵심 패턴은 추론과 출력을 명시적으로 분리하는 것입니다. 모델에게 먼저 자유롭게 분석하고, 그 분석이 완전히 끝난 뒤에 JSON을 출력하도록 지시합니다.
[추론-출력 분리 패턴]
다음 고객 리뷰를 분석해줘.
[분석 과정]
먼저 아래 항목을 자유롭게 분석해줘 (형식 없이):
- 전반적인 감정 톤
- 언급된 제품이나 기능
- 핵심 불만이나 칭찬
[최종 출력]
위 분석을 바탕으로 아래 JSON 스키마 그대로만 출력해줘.
설명 없이 JSON만:
{
"sentiment": "positive" | "negative" | "neutral",
"product": "문자열 또는 null",
"main_point": "핵심 내용 한 문장"
}
리뷰: {{review_text}}
모델이 [분석 과정] 섹션에서 자유롭게 사고를 펼친 뒤 [최종 출력] 섹션에서 JSON을 생성하면, 형식 오류 발생률이 크게 낮아집니다. 이미 분석이 완료된 상태이므로 모델이 "설명을 추가해야 한다"는 충동을 덜 느낍니다.
3.2. XML 태그로 영역을 명확히 구분한다
Anthropic의 공식 프롬프팅 가이드에서도 권장하는 방식입니다. XML 태그를 사용하면 모델이 어느 영역에 무엇을 써야 하는지 더 명확하게 인식합니다.
[XML 태그 분리 패턴]
<task>
다음 텍스트에서 정보를 추출해줘.
</task>
<text>
{{input_text}}
</text>
<thinking>
여기서 분석 과정을 작성해줘. 형식 무관.
</thinking>
<output>
여기에 JSON만 작성해줘. 다른 텍스트 없이.
{"key": "value"}
</output>
이 패턴은 모델에게 각 태그가 어떤 목적의 공간인지 명시적으로 알려줍니다. <output> 태그 안에서는 JSON 형식 유지에만 집중하면 된다는 신호입니다.
4. Level 3 — 스키마 명시와 Few-shot: 예시가 지시보다 강력하다
4.1. JSON Schema로 기대값을 정의한다
"key는 문자열이어야 해"라고 말하는 것보다, 정확한 JSON Schema를 프롬프트에 포함시키는 것이 훨씬 효과적입니다. JSON Schema는 각 필드의 타입, 필수 여부, 허용 값을 명시적으로 정의합니다.
[JSON Schema 포함 프롬프트]
다음 텍스트를 분석해 아래 스키마를 정확히 따르는 JSON을 출력해줘.
스키마:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["sentiment", "confidence", "entities"],
"properties": {
"sentiment": {
"type": "string",
"enum": ["positive", "negative", "neutral"]
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"entities": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "type"],
"properties": {
"name": {"type": "string"},
"type": {"type": "string", "enum": ["product", "person", "place"]}
}
}
}
}
}
텍스트: {{input_text}}
순수 JSON만 출력. 스키마 외의 필드 추가 금지.
모델은 JSON Schema 문법을 학습 데이터에서 학습했기 때문에 스키마 정의를 기반으로 출력을 구성하는 능력이 있습니다. 특히 enum으로 허용 값을 제한하는 것은 분류 태스크에서 매우 효과적입니다.
4.2. Few-shot 예시가 지시를 압도한다
5편에서 다뤘지만 Structured Output 맥락에서 특히 강조할 필요가 있습니다. 아무리 상세한 형식 지시도 실제 입출력 예시 하나를 이기지 못합니다.
[Few-shot Structured Output 패턴]
텍스트에서 정보를 추출해 JSON으로 출력해줘. 예시를 참고할 것.
---
입력: "갤럭시 S24 배터리가 너무 빨리 닳아요. 하루도 못 버팁니다."
출력: {"sentiment":"negative","product":"갤럭시 S24","issue":"배터리 수명"}
---
입력: "아이폰 카메라 정말 최고예요. 특히 야간 촬영이 놀랍습니다."
출력: {"sentiment":"positive","product":"아이폰","issue":null}
---
입력: "배송이 빠른 건 좋은데 포장이 좀 허술했어요."
출력: {"sentiment":"neutral","product":null,"issue":"포장 품질"}
---
이제 다음을 처리해줘:
입력: {{new_text}}
출력:
마지막 출력: 뒤에 아무것도 없으면 모델은 자연스럽게 그 패턴을 이어서 JSON을 씁니다. 앞의 예시들이 모두 서문 없이 바로 JSON으로 시작했기 때문입니다. 이 패턴은 프롬프트 엔지니어링에서 **프리픽스 강제(Prefix Forcing)**라고 부릅니다. 모델이 생성해야 할 텍스트의 시작 부분을 예시로 채워 놓음으로써 뒤따르는 내용의 형식을 유도합니다.
5. Level 4 — API 레벨 강제: 프롬프트 밖에서 보장한다
프롬프트 레벨의 기법들은 아무리 정교해도 낮은 확률로 실패합니다. 99%의 신뢰도가 필요한 시스템에서 이 1%의 실패는 허용되지 않을 수 있습니다. 이를 위해 모델 제공자들은 API 레벨에서 구조화된 출력을 강제하는 기능을 제공합니다.
5.1. OpenAI Structured Outputs
OpenAI는 두 가지 방식을 제공합니다.
JSON Mode (response_format: { type: "json_object" }): 출력이 유효한 JSON임을 보장합니다. 하지만 어떤 구조의 JSON인지는 보장하지 않습니다. {"answer": "42"} 같은 완전히 다른 구조가 나올 수도 있습니다. 프롬프트에 기대하는 스키마를 함께 명시해야 합니다.
Structured Outputs (gpt-4o-2024-08-06 이상 지원): JSON Schema를 API 파라미터로 전달하면 모델이 해당 스키마를 100% 따르는 JSON만 생성합니다. 유효하지 않은 필드, 잘못된 타입, 누락된 필수 필드가 발생할 수 없습니다. 내부적으로 모델의 디코딩 단계에서 스키마를 기반으로 토큰 선택을 제한합니다.
from openai import OpenAI
from pydantic import BaseModel
client = OpenAI()
class ReviewAnalysis(BaseModel):
sentiment: str # "positive" | "negative" | "neutral"
product: str | None
main_issue: str | None
response = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "user", "content": f"다음 리뷰를 분석해줘: {review_text}"}
],
response_format=ReviewAnalysis,
)
result = response.choices[0].message.parsed
# result는 ReviewAnalysis 타입이 보장된 객체
print(result.sentiment) # "negative"
Pydantic 모델 정의가 곧 스키마 정의입니다. 응답을 파싱하는 코드 없이 타입이 보장된 객체를 바로 받을 수 있습니다.
5.2. Anthropic Tool Use로 JSON 강제
Anthropic Claude는 별도의 Structured Outputs API 대신, Tool Use(Function Calling) 기능을 활용해 동일한 효과를 냅니다. 핵심 아이디어는 AI가 "도구를 호출하는 척"하게 만드는 것입니다. 도구 호출의 파라미터는 JSON Schema로 정의되기 때문에, 모델이 도구 파라미터를 채우는 과정에서 자연스럽게 구조화된 JSON이 생성됩니다.
import anthropic
client = anthropic.Anthropic()
tools = [
{
"name": "analyze_review",
"description": "고객 리뷰를 분석해 감정, 제품, 불만 사항을 추출한다",
"input_schema": {
"type": "object",
"properties": {
"sentiment": {
"type": "string",
"enum": ["positive", "negative", "neutral"],
"description": "전반적인 감정 톤"
},
"product": {
"type": ["string", "null"],
"description": "언급된 제품명. 없으면 null"
},
"main_issue": {
"type": ["string", "null"],
"description": "핵심 불만 또는 칭찬 사항. 없으면 null"
}
},
"required": ["sentiment", "product", "main_issue"]
}
}
]
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
tools=tools,
tool_choice={"type": "tool", "name": "analyze_review"}, # 반드시 이 도구 사용
messages=[
{"role": "user", "content": f"다음 리뷰를 분석해줘: {review_text}"}
]
)
# tool_use 블록에서 input 추출
tool_use = next(b for b in response.content if b.type == "tool_use")
result = tool_use.input # 스키마가 보장된 딕셔너리
tool_choice: {"type": "tool", "name": "analyze_review"}를 지정하면 모델이 반드시 이 도구를 사용해야 합니다. 도구 파라미터가 스키마를 따르지 않으면 API가 오류를 반환합니다.
5.3. instructor 라이브러리: 통합 추상화
instructor는 Jason Liu가 만든 오픈소스 라이브러리로, OpenAI, Anthropic, Gemini 등 여러 LLM 클라이언트를 Pydantic 기반의 Structured Output으로 통일된 인터페이스에서 사용할 수 있게 해줍니다. 내부적으로는 각 제공자의 최적 방식(OpenAI는 Structured Outputs, Anthropic은 Tool Use)을 자동으로 선택합니다.
import instructor
from anthropic import Anthropic
from pydantic import BaseModel
from typing import Literal
client = instructor.from_anthropic(Anthropic())
class ReviewAnalysis(BaseModel):
sentiment: Literal["positive", "negative", "neutral"]
product: str | None
main_issue: str | None
result = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
response_model=ReviewAnalysis,
messages=[
{"role": "user", "content": f"다음 리뷰를 분석해줘: {review_text}"}
]
)
# result는 ReviewAnalysis 타입 객체. 파싱 코드 불필요.
print(result.sentiment) # "negative"
print(result.product) # "갤럭시 S24"
instructor의 핵심 가치는 두 가지입니다. 첫째, Pydantic 모델 정의 하나로 스키마, 타입 검증, 파싱을 모두 처리합니다. 둘째, 파싱 실패 시 자동으로 재시도(retry)하며 오류 메시지를 모델에게 피드백해 스스로 수정하게 만드는 기능이 내장되어 있습니다.
6. Level 5 — 오류 복구 파이프라인: 실패를 설계한다
API 레벨 강제를 사용하지 못하는 상황이 있습니다. 오래된 모델, 서드파티 API 래퍼, 비용 제약 등으로 인해 프롬프트 레벨에만 의존해야 하는 경우입니다. 또는 API 레벨 강제를 사용하더라도 예상치 못한 예외 상황을 처리해야 할 수 있습니다.
이런 상황을 위한 최후의 방어선이 오류 복구 파이프라인입니다.
6.1. 파싱 → 검증 → 복구 루프
기본 원리는 단순합니다. 파싱에 실패하면 포기하지 않고, 오류 내용을 모델에게 알려서 스스로 고치게 합니다.
import json
import re
def extract_json(text: str) -> dict | None:
"""텍스트에서 JSON 추출 시도. 서문/후기 제거 후 파싱."""
# 1차: 그대로 파싱 시도
try:
return json.loads(text.strip())
except json.JSONDecodeError:
pass
# 2차: 첫 { 부터 마지막 } 까지 추출
match = re.search(r'\{.*\}', text, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
# 3차: 코드블록 마커 제거 후 재시도
cleaned = re.sub(r'```(?:json)?\n?', '', text).strip()
try:
return json.loads(cleaned)
except json.JSONDecodeError:
return None
def get_structured_output(prompt: str, schema: dict, max_retries: int = 3) -> dict:
"""구조화된 출력을 얻기 위한 재시도 루프."""
last_error = None
for attempt in range(max_retries):
if attempt == 0:
current_prompt = prompt
else:
# 이전 오류를 피드백으로 제공
current_prompt = f"""{prompt}
[이전 시도 오류]
네가 생성한 응답을 파싱하려 했더니 오류가 발생했습니다:
{last_error}
반드시 순수 JSON만 출력해주세요. 서문, 후기, 코드블록 마커 없이."""
response_text = call_llm(current_prompt) # 실제 LLM 호출
result = extract_json(response_text)
if result is not None:
# 스키마 검증 (선택적)
if validate_against_schema(result, schema):
return result
else:
last_error = f"스키마 불일치: {get_schema_errors(result, schema)}"
else:
last_error = f"JSON 파싱 실패. 원본 응답: {response_text[:200]}"
raise ValueError(f"최대 재시도 횟수 초과. 마지막 오류: {last_error}")
이 패턴에서 핵심은 오류 메시지를 다음 프롬프트의 컨텍스트로 포함한다는 것입니다. 모델은 자신이 무엇을 잘못했는지 알고 수정합니다. 경험적으로 첫 번째 재시도에서 거의 대부분의 오류가 수정됩니다.
6.2. 용도별 전략 선택 가이드
어떤 레벨의 전략을 선택할지는 상황에 따라 다릅니다.
| 상황 | 권장 전략 |
|---|---|
| 간단한 분류, 오류 허용 가능 | Level 1~2 (명령형 + 분리 구조) |
| 중요한 데이터 추출, 가끔 실패 허용 | Level 3 (스키마 + Few-shot) |
| 프로덕션 서비스, 실패 비용 높음 | Level 4 (API 강제) |
| API 강제 불가 + 높은 신뢰도 필요 | Level 5 (복구 파이프라인) |
| 최고 수준의 신뢰도 | Level 4 + Level 5 조합 |
결론: AI 출력을 신뢰하려면 신뢰를 설계해야 한다
Structured Output은 "AI에게 JSON 달라고 하는 법"이 아닙니다. AI 출력을 코드가 처리할 수 있는 수준으로 신뢰도를 높이는 시스템 설계입니다.
이 둘의 차이는 작아 보이지만, 실제 시스템에서는 결정적입니다. 단순히 "JSON으로 줘"라고 지시하는 것은 AI를 도구로 사용하는 것입니다. 신뢰도 스택을 설계하는 것은 AI를 안정적인 시스템 컴포넌트로 통합하는 것입니다.
이 관점의 전환이 중요한 이유는 AI 활용의 영역이 확장될수록 단발성 프롬프트보다 파이프라인, 에이전트, 자동화 시스템이 더 중요해지기 때문입니다. 그 모든 시스템은 AI 출력을 프로그래밍적으로 처리해야 하고, 그 처리는 형식이 보장될 때만 안정적으로 작동합니다.
실전 적용 순서
Structured Output을 처음 도입한다면 이 순서를 따르세요.
- 먼저 Level 2(출력 분리 구조)로 시작합니다. 프롬프트 구조만 바꿔도 형식 준수율이 크게 높아집니다.
- 안정성이 더 필요하다면 Level 3(JSON Schema + Few-shot)을 추가합니다.
- 프로덕션 시스템이라면 Level 4(API 강제)로 전환합니다. OpenAI를 쓴다면 Structured Outputs, Anthropic이라면 Tool Use + instructor.
- 여전히 실패가 허용되지 않는다면 Level 5(복구 파이프라인)로 최후 방어선을 추가합니다.
10편을 향하여: Context Engineering
9편까지 우리는 프롬프트의 내용(무엇을 쓸 것인가)과 형식(어떻게 받을 것인가)을 다뤘습니다.
하지만 모든 AI 시스템에는 또 다른 결정적 제약이 있습니다. 바로 **컨텍스트 윈도우(Context Window)**입니다. 모델이 한 번에 처리할 수 있는 텍스트의 양은 유한합니다. 긴 문서, 긴 대화, 복잡한 시스템 프롬프트를 동시에 다뤄야 할 때 컨텍스트 윈도우는 빠르게 소진됩니다.
이어지는 **[10편: Context Engineering – 컨텍스트 윈도우를 설계하는 법]**에서는 한정된 컨텍스트를 최대한 효율적으로 사용하는 기술을 다룹니다. 무엇을 컨텍스트에 넣고, 무엇을 버리고, 어떤 순서로 배치해야 하는지. 컨텍스트 윈도우를 설계의 대상으로 바라보는 시각이 10편의 핵심입니다.
