이 글은 누구를 위한 것인가
- API 응답을 고정 필드로 쓰고 싶은 백엔드·플랫폼 엔지니어
- 에이전트 툴 인자가 틀리면 치명적인 자동화를 만드는 팀
json.loads만으로는 부족하다고 느낀 ML·애플리케이션 경계 담당자
들어가며
대형 언어모델은 자연어에 강하지만, 형식에 대한 약한 계약을 만족시키지 못하면 시스템 전체가 멈춘다. "JSON으로 답해줘"는 사람에게는 명확해 보여도, 모델에게는 힌트에 가깝다. 프로덕션에서는 스키마 검증이 계약이고, 모델 출력은 가설이다.
이 글에서는 공급자별 response_format 옵션 나열이 아니라, 검증 실패 시 무엇을 할지까지 포함한 레이어드 설계를 다룬다.
1. 계약: 스키마를 먼저 고정한다
최소한 다음을 JSON Schema(또는 동등한 제약)로 적는다.
- 필수 필드·타입·열거형
- 문자열
maxLength, 숫자minimum/maximum - 중첩 객체와 배열의 허용 크기
비즈니스 규칙(예: "시작일 ≤ 종료일")은 스키마만으로 부족할 수 있어 두 번째 검증 단계로 둔다.
스키마는 프롬프트 안에 긴 예시를 붙이는 것보다, 코드에서 단일 소스로 유지하는 편이 변경 관리에 유리하다.
2. 파이프라인 레이어
권장 흐름:
모델 raw 텍스트
→ (선택) 코드 블록·마크다운 탈거
→ JSON 파싱
→ 스키마 검증 (예: ajv, pydantic)
→ 비즈니스 규칙 검증
→ 애플리케이션 로직
한 단계라도 실패하면 동일한 입력으로 재시도할지, 축약 스키마로 폴백할지, 사람에게 넘길지를 정책으로 둔다.
3. 재시도 전략
첫 응답이 깨졌을 때:
- 짧은 수정 프롬프트: "아래 JSON이 스키마를 어겼다. 오류: … 수정만 출력하라."
- temperature 낮춤, max tokens 조정
- 동일 실패가 반복되면 모델·엔드포인트 스위치 (
multi-provider-llm-routing-resilient-ai-architecture와 연계)
무한 루프를 막기 위해 최대 재시도 횟수와 총 지연 상한을 코드에 박는다.
4. 폴백: "완전한 JSON"이 아닐 때
일부 유스케이스는 구조화된 전체가 아니라 부분 필드만 있어도 진행할 수 있다.
- 부분 성공 모델:
Partial<T>를 받아 나머지는 기본값·null·수동 입력 - 다운그레이드: 복잡한 스키마 대신 태그·단일 등급 같은 축약 출력으로 재요청
폴백은 관측 가능해야 한다. "몇 %가 폴백인가"가 품질 지표다 (llm-eval-pipeline-regression-guard와 맞물림).
5. 툴 호출 인자
함수/툴 호출 API를 쓸 때도 인자는 여전히 모델이 생성한다. 서버 측에서:
- 스키마 검증 후에만 실제 Side effect 실행
- 위험한 툴은 승인 큐 또는 2단계 확인 (
llm-agent-tools-guardrails-and-approvals)
클라이언트만 믿지 말고 서버에서 동일 검증을 반복한다.
6. 보안·주입
구조화 출력이라도 프롬프트 인젝션은 들어온다. 외부 텍스트를 스키마 검증 전에 그대로 시스템 프롬프트에 삽입하면, 검증 통과 후에도 다른 레이어에서 문제가 난다. 입력은 역할 분리·이스케이프·길이 제한을 적용한다 (llm-prompt-injection-defense 참고).
7. 운영 체크리스트
- 스키마 버전을 로그에 남긴다 (
schema_v2) - 검증 실패 샘플을 비PII 해시로 저장해 회귀 테스트에 넣는다
- 파싱 라이브러리 strict 모드 사용 여부를 팀 합의로 고정한다
8. 스키마 예시와 “왜 깨지는가”
아래는 지원 티켓을 분류하는 응답을 상상한 JSON Schema 일부다. 실제로는 additionalProperties: false를 쓸지, 열림을 허용할지 팀 합의가 필요하다.
{
"type": "object",
"required": ["intent", "priority", "summary"],
"properties": {
"intent": { "type": "string", "enum": ["billing", "bug", "account"] },
"priority": { "type": "string", "enum": ["P1", "P2", "P3"] },
"summary": { "type": "string", "maxLength": 280 },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
},
"additionalProperties": false
}
모델이 흔히 범하는 오류는 다음과 같다.
intent에 enum에 없는 문자열 — 문의 내용이 애매할 때 모델이 새 라벨을 발명한다.summary에 줄바꿈·따옴표를 이스케이프하지 않아 파싱이 실패한다.- 숫자 대신 문자열
"0.9"— 스키마가number면 검증 실패. 타입을 완화할지, 파이프라인 전처리로 캐스팅할지 정한다.
이 때문에 검증 오류 메시지를 그대로 재프롬프트에 넣는 2차 호출이 효과적인 경우가 많다. 다만 오류 문자열이 길어질수록 토큰 비용이 늘므로, “필드별 한 줄 피드백”으로 요약해 보내는 편이 낫다.
9. 스트리밍·부분 출력과의 충돌
응답을 스트리밍으로 받을 때 완전한 JSON이 도착하기 전에 UI에 쓰면, 절반짜리 객체를 렌더링하려다 터진다. 선택지는 둘 다 명확해야 한다.
- 스트리밍은 텍스트로만 보여 주고, 구조화된 페이로드는 버퍼링 후 파싱한다.
- SSE나 WebSocket으로 서버가 파싱·검증까지 마친 뒤 클라이언트에 내려준다.
“모델 스트림 → 클라이언트가 JSON repair” 패턴은 데모에는 편하지만, 프로덕션 신뢰성은 서버 측 검증이 더 낫다.
10. 공급자 기능과 코드 계약의 정렬
일부 API는 구조화 출력을 모델에 강하게 걸 수 있다. 이 경우에도 서버에서 한 번 더 검증하는 이유는 다음과 같다.
- 공급자 레이어와 애플리케이션 레이어의 배포·롤백 주기가 다르다.
- 멀티 클라우드·온프레미스 모델을 섞을 때 최소 공통 스키마만 내부 계약으로 유지한다.
- 구조화 모드가 특정 모델에서만 지원될 때 폴백 경로가 필요하다.
팀은 response_format 유무와 무관하게 pydantic·TypeScript Zod·ajv 중 하나를 “내부 진실”로 고정하고, 외부 API 변경 시 스키마 마이그레이션을 코드 리뷰 아이템으로 둔다.
11. 관측 가능성: 검증 실패는 품질의 전조
다음 메트릭을 대시보드에 올리면, 프롬프트 변경 전후를 논의할 때 “감”이 아니라 숫자로 말할 수 있다.
- JSON parse 실패율 / 스키마 검증 실패율 (분리)
- 재시도 성공률 (첫 호출은 실패했으나 두 번째는 통과)
- 폴백 비율 (간단 스키마로 다시 요청했는지)
- 지연 tail (재시도로 인한 p99 악화)
트레이스 ID를 LLM 호출과 묶어 두면, 특정 고객 입력에서만 터지는 장난감 같은 JSON을 재현 데이터로 축적할 수 있다. 개인정보는 해시·마스킹 후 회귀 테스트 세트에만 넣는다.
12. 비용·지연: 재시도의 경제성
재시도는 품질을 살리지만 비용과 지연을 배로 가져온다. 정책 예시:
- P0 사용자 영향 경로(결제, 계정 삭제): 재시도 1회까지, 그다음은 사람
- 배치·백그라운드 분류: 재시도 3회 + 지수 백오프
temperature를 0에 가깝게 고정하면 형식 안정성은 좋아지지만, 창의적 요약 품질이 필요한 유스케이스와 같은 파이프라인을 쓰면 안 된다. 작업 유형별로 모델·온도·스키마를 분리하는 것이 장기적으로 저렴하다.
13. 코드 경계에서의 검증 위치
엣지(API Gateway)에서만 검증하면 내부 마이크로서비스가 스키마를 모르는 채 전달할 수 있고, 도메인 서비스에서만 검증하면 동일 입입력이 여러 번 파싱된다. 실무적으로는 다음이 균형이 잘 잡힌다.
- 공개 API 경계: 엄격한 스키마 + 레이트 리밋
- 내부 큐 메시지: 프로듀서·컨슈머가 동일 스키마 패키지를 공유
- LLM 특화 레이어: 파싱·수정 재프롬프트·메트릭만 담당하는 얇은 서비스
이렇게 두면 LLM 교체·업그레이드 시 비즈니스 로직 파일을 건드리는 범위가 줄어든다.
14. 계약 테스트와 골든 파일
스키마를 코드로 관리하면 계약 테스트가 쉬워진다. 저장소에 모델 출력 샘플(골든 파일)을 두고, 스키마를 바꿀 때 “기존 샘플이 여전히 통과하는지”를 CI에서 돌린다. 프롬프트만 바꿨는데 형식이 흔들리는 회귀를 잡는 데 유효하다.
단, 골든 파일에 PII를 넣지 말고, 합성 데이터나 마스킹을 사용한다. 샘플 수가 늘어나면 카테고리별로 폴더를 나눠 유지보수 비용을 관리한다.
맺음말
구조화 출력의 목표는 "예쁜 JSON"이 아니라 다운스트림이 신뢰할 수 있는 계약이다. 스키마·검증·재시도·폴백을 코드 경계에 명시하면, 모델이 가끔 틀려도 서비스는 멈추지 않는다.