구조화 출력 API를 믿기 전에 해야 할 스키마 설계
LLM API가 JSON schema, structured output, tool calling을 지원한다고 해서 제품 코드가 자동으로 안전해지는 것은 아니다. 모델이 중괄호를 잘 닫는 문제와, 제품이 믿고 처리할 수 있는 객체를 받는 문제는 다르다. 실제 장애는 “JSON이 깨졌다”보다 “JSON은 맞는데 의미가 틀렸다”에서 더 자주 나온다.
예를 들어 모델이 고객 문의를 refund, billing, technical 중 하나로 분류해야 한다고 하자. structured output을 쓰면 enum 형식은 지킬 수 있다. 하지만 환불과 결제 분쟁이 섞인 문의에서 어떤 기준으로 하나를 고를지, 확신이 낮을 때 어떻게 표현할지, 근거 문장을 어떻게 연결할지는 스키마 설계의 문제다.
이 글은 구조화 출력을 제품에 넣기 전 스키마를 어떻게 설계해야 장애를 줄일 수 있는지 다룬다. 목표는 “모델이 JSON을 내게 하기”가 아니라, 다운스트림 코드가 과신하지 않는 계약을 만드는 것이다.
JSON 유효성과 업무 유효성은 다르다
많은 팀이 첫 성공을 너무 빨리 성공으로 판단한다. 모델이 { "category": "refund", "priority": "high" }를 반환하고 파서가 통과하면 끝났다고 본다. 하지만 업무 유효성은 별개의 층이다. 고객이 “배송이 늦어서 환불하고 싶다”고 했을 때 refund가 맞는지 shipping이 맞는지는 운영 정책에 따라 달라진다.
스키마는 타입만 표현하지 말고 결정 기준을 드러내야 한다. category 하나로 부족하면 primary_category, secondary_categories, confidence, requires_human_review, evidence_spans를 나눠야 한다. 그래야 자동 처리와 상담원 검토를 분리할 수 있다.
특히 enum은 강력하지만 위험하다. enum에 없는 상황을 억지로 기존 값에 넣게 만들기 때문이다. “해당 없음”을 표현할 수 없는 스키마는 모델에게 거짓 확신을 요구한다. unknown, other, insufficient_information을 무조건 넣으라는 뜻은 아니지만, 운영상 예외를 처리할 필드는 필요하다.
스키마는 화면이 아니라 후속 행동 기준으로 설계한다
구조화 출력은 대개 UI 표시용으로 시작한다. 요약 제목, 태그, 감정 점수, 우선순위 같은 필드를 만든다. 하지만 제품에서 진짜 중요한 것은 후속 행동이다. 이 객체가 티켓을 자동 배정하는가, 결제를 취소하는가, 관리자에게 알림을 보내는가, 검색 인덱스에 저장되는가에 따라 스키마가 달라져야 한다.
자동 배정에 쓰는 분류라면 assignee_team만으로 부족하다. routing_reason, blocking_missing_fields, safe_to_auto_route가 필요하다. 결제 취소처럼 되돌리기 어려운 행동이라면 모델 출력이 직접 실행 조건이 되면 안 된다. recommended_action과 action_preconditions를 분리하고, 서버 코드가 deterministic rule로 precondition을 검증해야 한다.
반대로 단순 UI 보조라면 너무 무거운 스키마가 방해가 된다. 사용자가 이메일 초안을 볼 때 tone, summary, suggested_reply 정도면 충분할 수 있다. 모든 구조화 출력에 confidence와 evidence를 붙이는 것도 비용이다. 중요한 것은 이 출력이 어디에 연결되는지 먼저 정하는 것이다.
필드 이름은 모델보다 사람과 코드가 오래 쓴다
스키마 필드명은 작은 결정처럼 보이지만 오래 남는다. score라는 필드는 나중에 무엇의 점수인지 알 수 없다. risk_score도 부족하다. 결제 사기 위험인지, 법무 리스크인지, 답변 신뢰도인지 불명확하다. 모델은 설명을 읽고 맞출 수 있지만, 로그와 대시보드를 보는 사람은 헷갈린다.
좋은 필드명은 단위와 의미를 포함한다. answer_confidence_0_to_1, customer_sentiment, requires_refund_policy_review, cited_policy_clause_ids처럼 downstream에서 바로 읽히는 이름이 낫다. 날짜는 due_date_iso, 금액은 amount_krw, token 범위는 evidence_char_spans처럼 형식을 드러낸다.
또한 nullable의 의미를 명확히 해야 한다. null이 “모델이 모름”인지, “해당 없음”인지, “추출 실패”인지 구분되지 않으면 운영자가 후처리를 못 한다. 필요하면 value와 status를 분리한다. 예를 들어 { "refund_amount": null, "refund_amount_status": "not_mentioned" }가 { "refund_amount": null }보다 낫다.
confidence는 숫자 하나로 끝내면 위험하다
LLM이 내는 confidence는 calibration이 잘 되어 있지 않을 수 있다. 모델이 0.92라고 써도 실제 정확도가 92%라는 뜻은 아니다. 그래도 confidence 필드가 쓸모없는 것은 아니다. 다만 숫자 하나를 자동화 기준으로 바로 쓰면 위험하다.
더 안전한 방식은 confidence를 행동과 연결해 bucket화하는 것이다. auto_action_allowed, human_review_recommended, insufficient_evidence처럼 후속 판단을 명시한다. 숫자 confidence가 필요하면 offline eval로 threshold를 보정해야 한다. 예를 들어 answer_confidence_0_to_1 >= 0.8일 때 실제 상담원 동의율이 95%인지 확인해야 한다.
근거 필드도 같이 있어야 한다. evidence_spans, source_document_ids, quoted_policy_clauses가 없으면 confidence를 검증할 방법이 없다. 특히 RAG 기반 답변에서는 “모델이 자신 있다”보다 “어떤 문서의 어떤 문장 때문에 그렇게 판단했는가”가 더 중요하다.
strict schema는 실패를 없애지 않고 실패 위치를 바꾼다
strict schema를 켜면 모델이 형식에서 벗어나는 경우는 줄어든다. 하지만 실패가 사라지는 것은 아니다. 모델이 필수 필드를 채우기 위해 그럴듯한 값을 만들어 넣을 수 있다. 또는 API가 schema validation 단계에서 실패해 재시도를 유발할 수 있다. 운영자는 이 실패 위치 변화를 알아야 한다.
필수 필드를 많이 둘수록 형식 성공률은 떨어질 수 있다. 반대로 필수 필드가 적으면 다운스트림 코드가 null 처리에 시달린다. 여기서 기준은 필드의 업무 중요도다. 결제 취소 action의 order_id는 필수여야 한다. 고객 감정 분석의 secondary_emotion은 optional이어도 된다.
재시도 정책도 스키마별로 달라야 한다. JSON 파싱 실패는 같은 prompt로 한 번 재시도해 볼 수 있다. enum 불일치는 tool description이나 schema 설명을 고쳐야 한다. evidence 누락은 모델 재시도보다 retrieval 입력을 점검해야 할 수 있다. 모든 실패를 “한 번 더 호출”로 처리하면 비용만 늘어난다.
버전 관리 없는 스키마는 조용한 장애를 만든다
구조화 출력 스키마는 API 계약이다. 프론트엔드, 백엔드, 데이터 웨어하우스, 고객용 export가 의존할 수 있다. 그런데 프롬프트 파일 안에서 schema를 수정하고 버전을 올리지 않으면 조용한 장애가 생긴다.
스키마에는 schema_name, schema_version을 포함하고 로그에도 남겨야 한다. 필드 추가는 대체로 안전하지만 필드 의미 변경은 breaking change다. priority: high가 “24시간 안에 처리”에서 “즉시 알림”으로 바뀌면 기존 대시보드의 의미가 바뀐다. enum 값 삭제도 위험하다. 과거 데이터와 비교가 깨진다.
마이그레이션 전략도 필요하다. 새 스키마를 바로 전체 트래픽에 적용하기보다 shadow mode로 병렬 생성해 기존 결과와 비교한다. 예를 들어 5% 샘플에서 v1과 v2를 동시에 만들고, category_changed, auto_action_changed, human_review_rate_delta를 본다. 이 차이를 보지 않고 배포하면 “모델은 그대로인데 자동 배정률이 변했다”는 문제가 생긴다.
예시: 문의 분류 스키마를 제품용으로 바꾸기
나쁜 시작점은 다음과 같다. { "category": "billing", "priority": "high", "summary": "..." }. 데모에는 충분하지만 자동 라우팅에는 약하다.
제품용으로 바꾸면 구조가 달라진다. primary_intent는 enum으로 두되 secondary_intents를 배열로 둔다. routing_team은 모델 추천값이지만, routing_preconditions_met를 별도로 둔다. evidence_message_ids와 evidence_quotes를 넣어 상담원이 근거를 확인할 수 있게 한다. missing_information에는 주문번호, 결제일, 계정 이메일처럼 후속 질문에 필요한 항목을 넣는다. safe_to_auto_route는 서버 정책이 다시 검증한다.
이 스키마는 길어 보이지만 장애를 줄인다. 모델이 “환불”이라고 판단해도 주문번호가 없으면 자동 처리하지 않는다. 문의가 결제와 기술 문제를 동시에 포함하면 secondary intent로 남는다. 상담원은 요약만 보는 것이 아니라 어떤 문장 때문에 라우팅됐는지 확인한다.
도입 전 확인할 것
구조화 출력 도입 전에는 세 가지를 먼저 정해야 한다. 첫째, 이 출력이 자동 행동을 유발하는지, 사람에게 표시만 되는지 구분한다. 둘째, 필드별 실패 의미를 정한다. null, unknown, not_applicable, extraction_failed를 같은 값으로 뭉개지 않는다. 셋째, 스키마 변경을 배포로 취급한다.
운영 지표는 schema_validation_fail_rate, field_null_rate, enum_distribution, human_override_rate, auto_action_reversal_rate를 본다. 특히 human override rate는 모델 품질보다 스키마 설계 문제를 드러낼 때가 많다. 상담원이 자주 고치는 필드는 enum이 부족하거나 evidence가 약하거나 업무 기준이 prompt에 제대로 들어가지 않은 것이다.
structured output은 좋은 도구다. 하지만 제품 안정성은 API 옵션 하나가 아니라 계약 설계에서 나온다. LLM에게 예쁜 JSON을 받는 데서 멈추지 말고, 그 JSON이 틀렸을 때 시스템이 어떻게 멈추고, 어떻게 검토되고, 어떻게 수정되는지까지 스키마에 반영해야 한다.
스키마 설명에는 negative example도 넣는 편이 좋다. 모델에게 “금액이 명시되지 않으면 0을 넣지 말고 refund_amount_status=not_mentioned로 둔다”처럼 금지 행동을 알려야 한다. 특히 날짜, 금액, 사람 이름, 계약 조항 번호처럼 downstream에서 자동 처리되는 필드는 “추정하지 말 것”을 필드 설명에 직접 넣는다. 자연어 prompt의 일반 지침보다 schema field description에 가까이 두는 것이 유지보수에 유리하다.
또 하나의 실무 장치는 parser 이후의 semantic validator다. JSON schema validation이 통과한 뒤에도 서버는 날짜가 과거인지, 금액 합계가 맞는지, evidence span이 실제 원문 범위 안에 있는지 확인해야 한다. 이 validator의 실패율은 모델 품질 지표이면서 스키마 품질 지표다. validator가 자주 실패하는 필드는 모델이 어려워하는 필드이거나, 필드 정의가 모호한 필드다. 이 신호를 보고 스키마를 줄이거나 둘로 나누는 결정을 해야 한다.