왜 구조화 출력이 필요한가

LLM을 고객 응대, 문서 분류, 견적 초안, 업무 자동화에 연결하면 자연어 답변만으로는 다음 단계가 불안정해집니다. 같은 요청에도 필드 이름이 달라지거나, 숫자가 문장 속에 섞이거나, 허용하지 않은 상태값이 나오면 API와 데이터베이스, 승인 화면이 해석하지 못합니다. 이 문제는 프롬프트를 길게 쓰는 것보다 모델 출력과 애플리케이션 경계에 계약을 두는 방식으로 해결하는 편이 안정적입니다.

구조화 출력의 목표는 모델이 생성한 내용을 그대로 신뢰하라는 뜻이 아닙니다. 모델은 제한된 스키마 안에서 후보 데이터를 만들고, 애플리케이션은 파싱, 검증, 업무 규칙 확인을 통과한 결과만 저장하거나 실행합니다. 실패한 응답은 제한적으로 재시도하거나 사람 검토 큐로 보내야 합니다.

업무용 응답 스키마를 작게 정의하기

처음부터 모든 정보를 받으려 하면 누락과 모호성이 늘어납니다. 문의 분류 기능이라면 담당 부서, 긴급도, 요약, 확인이 필요한 항목처럼 실제 후속 처리에 필요한 필드만 고정합니다. 상태값은 자유 문자열 대신 enum으로 제한하고, 금액, 날짜, 식별자는 원문과 분리해 별도 검증합니다. 고객에게 보여 줄 문장과 내부 자동화에 사용할 데이터도 한 JSON에 섞지 않는 편이 좋습니다.

{ "department": "sales | support | billing | other", "priority": "low | normal | high", "summary": "200자 이내의 한국어 요약", "needs_human_review": true, "questions": ["확인이 필요한 질문"] }

스키마에는 최대 길이, 배열 최대 개수, 허용 문자 같은 제약도 넣습니다. 특히 외부 시스템에 전달할 URL, SQL 조각, 파일 경로, 명령은 모델의 출력값을 그대로 허용하지 않아야 합니다. 모델이 만든 텍스트는 데이터일 뿐 실행 지시가 아니며, 실행 후보는 서버가 가진 allowlist와 권한 정책으로 다시 판단해야 합니다.

생성, 파싱, 검증을 분리하는 서버 흐름

운영 코드에서는 모델 호출 직후 JSON.parse만 성공했다고 처리하지 않습니다. 먼저 스키마에 맞는 응답을 요청하고, JSON을 파싱한 뒤, 런타임 스키마 검증과 업무 규칙 확인을 거쳐 감사 로그를 남기고 저장합니다. 이 단계를 분리하면 모델 교체나 프롬프트 수정 때 어디에서 품질이 떨어졌는지 찾기 쉽습니다.

import { z } from "zod"; const Ticket = z.object({ department: z.enum(["sales","support","billing","other"]), priority: z.enum(["low","normal","high"]), summary: z.string().min(10).max(200), needs_human_review: z.boolean(), questions: z.array(z.string()).max(3) }); const raw = await callModel({ input, responseFormat: "json_schema" }); const ticket = Ticket.parse(JSON.parse(raw)); if (ticket.priority === "high" || ticket.needs_human_review) await enqueueForReview(ticket); else await saveTicket(ticket);

SDK마다 JSON Schema, tool calling, response format의 이름은 달라도 핵심은 같습니다. 가능하면 제공되는 strict schema 옵션을 사용하고, 지원하지 않는 모델은 프롬프트의 JSON 예시와 서버 검증을 함께 둡니다. 파싱 오류나 검증 오류가 났을 때는 원문 전체를 재전송하기보다 오류 종류를 짧게 알려 주고 한두 번만 수정 재시도합니다. 무제한 재시도는 비용과 지연을 키우며, 같은 입력이 계속 실패할 때 장애를 숨깁니다.

업무 규칙 검증과 안전한 후속 실행

스키마 검증은 형식만 확인합니다. 예를 들어 department가 sales여도 계약 번호가 없으면 담당자 배정이 불가능할 수 있고, priority가 high여도 야간 알림 권한이 없으면 즉시 전송하면 안 됩니다. 이 단계에서는 기존 고객 정보, 로그인 사용자 권한, 영업시간, 중복 요청 여부를 서버 데이터로 확인합니다. 모델이 제안한 액션은 actionType과 대상 ID를 분리하고, 대상 ID가 현재 사용자가 접근 가능한 범위에 있는지도 검증합니다.

프롬프트 인젝션 대응도 같은 원칙에서 출발합니다. 업로드 문서나 웹 페이지 안의 이전 지시를 무시하라는 문장은 신뢰할 수 없는 데이터입니다. 시스템 지시와 도구 권한은 애플리케이션이 유지하고, 문서 내용은 검색 결과로만 전달합니다. 도구 호출이 필요하다면 읽기와 쓰기 권한을 나누고, 결제, 삭제, 외부 발송 같은 영향이 큰 작업은 명시적 사용자 승인 또는 사람 검토를 요구합니다.

평가 지표와 운영 로그 만들기

정확도만 측정하면 구조화 출력의 문제를 놓치기 쉽습니다. 전체 요청 대비 JSON 파싱 성공률, 스키마 통과율, 재시도율, 사람 검토 전환율, 최종 업무 처리 성공률을 함께 기록합니다. 분류 업무라면 부서와 우선순위별 정답 데이터셋을 소량이라도 유지하고, 프롬프트나 모델 버전을 바꿀 때 동일 입력으로 회귀 평가합니다. 로그에는 입력의 민감정보를 마스킹한 식별자, 모델과 프롬프트 버전, 검증 실패 코드, 처리 시간만 남겨 원인을 추적할 수 있게 합니다.

운영 대시보드에서 특정 필드의 오류가 늘거나 사람 검토 비율이 급증하면 바로 배포를 되돌릴 기준을 정합니다. 새로운 모델은 전체 트래픽에 즉시 적용하지 말고 일부 요청에만 적용해 기존 결과와 비교합니다. 자동화 품질은 한 번의 좋은 데모보다, 실패를 안전하게 멈추고 고칠 수 있는 관측 체계에서 나옵니다.

적용 체크리스트

  • 후속 처리에 필요한 최소 필드와 enum을 JSON 스키마로 정의한다.
  • 모델 응답은 파싱 후 런타임 스키마와 업무 규칙을 모두 검증한다.
  • 재시도 횟수와 사람 검토 전환 조건을 명확히 둔다.
  • 외부 발송, 삭제, 결제 등 영향이 큰 실행은 별도 권한 확인을 거친다.
  • 파싱 성공률, 검증 실패 코드, 처리 성공률을 모델과 프롬프트 버전별로 관찰한다.