구조화된 출력 - LLM에서 안정적으로 JSON 받기
LLM을 챗봇이 아니라 시스템의 한 부품으로 쓰기 시작하면, 곧 이 벽에 부딪힙니다. "답변을 자연어로 받으면 코드에서 어떻게 파싱하지?" LLM의 출력을 다음 단계 코드가 소비하려면, 자유서술이 아니라 예측 가능한 구조(주로 JSON) 로 받아야 합니다.
이 글에서는 자유서술의 문제부터, JSON 모드·스키마 강제, 함수 호출과의 관계, 검증과 실패 대응까지 실무 패턴을 정리합니다.
1. 자유서술의 문제
"결과를 JSON으로 줘"라고 프롬프트에 쓰는 것만으로는 부족합니다. 모델은 종종 이렇게 답합니다.
네, 알겠습니다! 요청하신 결과는 다음과 같습니다:
```json
{ "sentiment": "positive", "score": 0.9 }
도움이 되었길 바랍니다!
코드 입장에서 이건 재앙입니다.
- **앞뒤 설명 문장**이 붙어 `JSON.parse`가 바로 깨집니다.
- 마크다운 **코드펜스(```)** 를 걷어내야 합니다.
- 어떤 날은 필드명이 `sentiment`, 어떤 날은 `emotion`으로 **흔들립니다.**
- 후행 쉼표, 홑따옴표, 주석 등 **JSON이 아닌 것**을 섞기도 합니다.
정규식으로 JSON을 긁어내는 방어 코드는 금세 지옥이 됩니다. 근본적으로는 **모델이 구조를 벗어나지 못하게 강제**해야 합니다.
---
## 2. JSON 모드와 스키마 강제
요즘 대부분의 LLM API는 출력 형식을 **강제하는 기능**을 제공합니다. 크게 두 단계가 있습니다.
### 2-1. JSON 모드
"반드시 유효한 JSON만 출력"하도록 강제합니다. 설명 문장이나 코드펜스가 붙지 않습니다.
```ts
const res = await llm.chat({
messages,
response_format: { type: "json_object" }, // 유효한 JSON만 나옴
});
다만 이것만으로는 "어떤 모양의" JSON인지는 보장하지 못합니다. 필드가 빠지거나 타입이 다를 수 있습니다.
2-2. 스키마 강제 (Structured Output)
한 걸음 더 나아가, JSON 스키마를 함께 넘겨 모델이 그 구조를 정확히 따르도록 합니다. 필드 이름, 타입, 필수 여부까지 지켜집니다.
const schema = {
type: "object",
properties: {
sentiment: { type: "string", enum: ["positive", "negative", "neutral"] },
score: { type: "number" },
},
required: ["sentiment", "score"],
additionalProperties: false,
};
const res = await llm.chat({
messages,
response_format: { type: "json_schema", json_schema: { schema, strict: } },
});