구조화 출력(structured output)은 LLM이 자유 텍스트 대신 정해진 JSON 스키마를 따르도록 강제하는 기능이다. 파이프라인에서 모델 응답을 코드로 파싱해 써야 할 때, 형식이 매번 조금씩 달라지면 지옥이 열린다. 스키마 강제는 이 문제를 상당 부분 없애준다. 다만 "강제"라고 해서 100% 신뢰할 수 있는 건 아니라는 게 오늘의 핵심이다.
솔직히 처음엔 프롬프트에 "JSON으로만 답해"라고 적으면 되는 줄 알았다. 그런데 모델은 종종 ```json 코드펜스를 붙이거나, 앞에 "네, 알겠습니다"를 덧붙이거나, 마지막 필드에 쉼표를 흘리곤 했다. 파서가 터질 때마다 예외 처리를 덧대다 보니 코드가 누더기가 됐다. 제대로 된 방법이 필요했다.
세 단계로 신뢰성을 쌓는다
구조화 출력의 신뢰성은 한 방에 얻어지지 않는다. 나는 세 겹으로 방어한다.
- 스키마 강제: API 레벨에서 응답 형식을 제약한다.
- 검증: 받은 뒤 스키마로 다시 파싱·검증한다.
- 재시도: 검증 실패 시 오류를 알려주고 다시 시킨다.
1. 스키마 강제
가장 확실한 방법은 API가 제공하는 스키마 강제 기능을 쓰는 것이다. 프롬프트로 부탁하는 것보다 훨씬 안정적이다. 두 갈래가 있다. 응답 전체를 JSON 스키마로 제약하거나(structured outputs), 툴 호출의 인자를 엄격 검증(strict)하는 방식이다.
# 응답을 스키마로 강제하는 예 (Anthropic)
schema = {
"type": "object",
"properties": {
"sentiment": {"type": "string", "enum": ["pos", "neg", "neu"]},
"score": {"type": "number"},
},
"required": ["sentiment", "score"],
"additionalProperties": False,
}
resp = client.messages.create(
model="claude-opus-4-8", max_tokens=1024,
output_config={"format": {"type": "json_schema", "schema": schema}},
messages=[{"role": "user", "content": text}],
)
여기서 additionalProperties: false와 required는 거의 항상 넣는다. 안 넣으면 모델이 슬쩍 여분 필드를 추가하거나 필수 필드를 빠뜨릴 여지가 생긴다. enum으로 값의 후보를 못박는 것도 라벨링 작업에서 특히 효과가 크다.
2. 검증
스키마 강제를 걸었어도, 받은 뒤에 코드에서 한 번 더 검증하는 걸 생략하면 안 된다. 이유가 있다. 출력이 max_tokens에 걸려 잘리면 JSON이 미완성 상태로 온다. 또 안전상 거부(refusal)가 발생하면 스키마를 안 따를 수 있다. 이런 경우는 형식이 아니라 상황의 문제라 스키마 강제로 못 막는다.
from pydantic import BaseModel, ValidationError
class Result(BaseModel):
sentiment: str
score: float
def parse_safe(raw: str):
try:
return Result.model_validate_json(raw), None
except ValidationError as e:
return None, str(e) # 오류 메시지를 재시도에 활용
3. 재시도
검증이 실패하면 그냥 던지지 말고, 무엇이 틀렸는지를 담아 한 번 더 요청한다. "score 필드가 숫자가 아니라 문자열이었다" 같은 구체적 피드백을 주면 모델이 대개 두 번째 시도에서 고쳐온다. 재시도 횟수는 2~3회로 상한을 두고, 그래도 실패하면 폴백(기본값 반환, 사람 검수 큐로 보내기 등)으로 넘긴다.
| 실패 원인 | 스키마 강제로 막히나 | 대응 |
|---|---|---|
| 형식 어긋남 | 대체로 막힘 | 강제 + 검증 |
| 토큰 잘림 | 못 막음 | max_tokens 늘려 재시도 |
| 안전 거부 | 못 막음 | stop_reason 확인·폴백 |
흔한 함정
스키마가 복잡해지면 모델의 사고 여력이 형식 맞추기에 쏠려 내용 품질이 떨어지기도 한다. 필드를 최소한으로 유지하고, 정말 필요한 것만 required로 두는 게 낫다.
또 재귀 스키마, 숫자 범위 제약(min/max), 문자열 길이 제약 등은 스키마 강제가 지원하지 않는 경우가 많다. 이런 제약은 스키마에 넣기보다 받은 뒤 코드에서 검증하는 편이 안전하다. 그리고 구조화 출력은 인용(citations) 같은 일부 기능과 함께 못 쓰는 경우가 있으니, 조합할 땐 문서를 확인해야 한다.
자주 묻는 질문
프롬프트로 "JSON으로 답해"라고만 하면 안 되나요?
급하면 되긴 하지만 불안정하다. 코드펜스, 서두 인사말, 마지막 쉼표 같은 잔오류가 계속 새어나온다. API가 스키마 강제를 지원한다면 그걸 쓰는 게 압도적으로 안정적이다. 프롬프트 지시는 스키마 강제를 못 쓰는 환경에서의 차선책으로 두자.
스키마 강제를 걸면 검증은 생략해도 되나요?
안 된다. 형식은 강제로 대부분 맞지만, 토큰 잘림이나 안전 거부처럼 형식 문제가 아닌 실패가 남는다. 받은 뒤 한 번 더 파싱·검증하고, 실패 시 재시도/폴백 경로를 두는 이중 방어가 운영에서 훨씬 튼튼했다.
재시도는 몇 번이 적당한가요?
보통 2~3회면 충분하다. 그 이상 반복해도 같은 실패가 나는 경우가 많아 비용만 늘어난다. 대신 재시도할 때 "무엇이 왜 틀렸는지"를 구체적으로 되먹여주는 게 횟수보다 중요하다. 그래도 안 되면 사람 검수나 기본값으로 우회하는 게 낫다.
enum과 자유 문자열 중 뭐가 나은가요?
값의 후보가 정해져 있다면 무조건 enum이다. 오타·변형·예상 밖 값이 원천 차단되어 후처리가 크게 줄어든다. 자유 문자열은 후보를 미리 알 수 없을 때만 쓰고, 그 경우에도 받은 값을 정규화·화이트리스트 검증하는 단계를 두는 게 좋다.

댓글 0