운영 환경에서 LLM이 생성한 JSON을 신뢰하는 방법
요약
LLM이 생성한 JSON 데이터를 운영 환경에서 안전하게 사용하기 위한 검증 전략을 다룹니다. 모델의 출력 형식을 스키마로 제한하는 것을 넘어, Zod와 같은 라이브러리를 통해 데이터 타입과 유효성을 코드 수준에서 강제해야 함을 강조합니다.
핵심 포인트
- 프롬프트는 요청일 뿐이며, 스키마가 진정한 계약(contract)이다.
- 구조화 모드(Structured Mode)는 문법적 오류는 막아주지만 값의 논리적 오류는 막지 못한다.
- Zod를 활용해 데이터 타입을 정의하고 명시적인 null 처리를 권장한다.
- API의 검증에만 의존하지 말고 애플리케이션 레벨의 2차 검증이 필수적이다.
데모에서는 항상 작동합니다. 모델에게 '다음 필드를 가진 JSON으로 반환해 줘'라고 프롬프트를 보내면, 깨끗한 JSON이 반환되고, 여러분은 그것을 파싱하고, 모두가 박수를 보냅니다. 그러다가 실제 트래픽과 실제 사용자 입력 뒤에 배치하면, 일주일 후에 어떤 다운스트림 시스템에서 문제가 발생합니다. 왜냐하면 숫자가 될 것이라고 가정한 필드가 문자열 "two"로 반환되었고, 그 과정에서 아무것도 유효성 검사를 해주지 않았기 때문입니다.
이 게시물은 '모델이 보통 올바른 형태를 반환한다'와 '내 시스템의 나머지 부분이 그 형태에 의존할 수 있다' 사이의 계층에 관한 것입니다. 이 계층은 화려하지 않으며, 대부분의 작업량이 여기에 집중됩니다. 예제에서는 고객의 자유 형식 메시지를 구조화된 인텐트로 변환하는 가상의 통신사 어시스턴트를 사용하지만, 패턴은 청구서 추출, 티켓 분류 또는 지원 요청 라우팅 등 어떤 경우에도 동일합니다.
실제로 문제가 발생하는 지점들
LLM이 구조화된 데이터를 생성하도록 의존할 때, 실패는 여러분이 계획한 종류가 아닙니다. 제가 시간적으로 비용을 치른 순서대로 나열하면 다음과 같습니다:
- 형식 오류(Malformed output). 모델이 JSON을 코드 펜스 안에 감싸거나, 앞에 친절한 문장을 추가하거나, 부하 상태에서 객체 중간에 끊기는 경우입니다. 구조화된 출력 모드 덕분에 이제는 드물지만 여전히 발생합니다.
- 유효한 JSON이지만 잘못된 타입(Valid JSON, wrong types). 카운트가
3대신 `
제가 초기에 저질렀던 실수는 프롬프트(prompt) 내에 출력 형태(output shape)를 서술형으로 기술하고, 그것을 계약(contract)으로 취급한 것이었습니다. 프롬프트는 요청(request)일 뿐입니다. 스키마(schema)가 계약이며, 이를 강제할 수 있는 코드 내에 존재해야 합니다.
저는 Zod를 사용하여 형태를 한 번 정의하고, 그로부터 다른 모든 것을 유도합니다.
import { z } from "zod";
export const CustomerIntent = z.object({
...
여기에는 의도적인 두 가지 장치가 있습니다. 존재하지 않을 수도 있는 모든 필드는 선택 사항(optional)이 아닌 .nullable()로 설정하여, 모델이 필드를 조용히 누락하는 대신 명시적인 선택을 하도록 만들었습니다. 그리고 confidence는 모델이 채워 넣는 필드이지만, 곧 보게 되시겠지만 저는 모델이 스스로 무엇인가를 결정하도록 절대 내버려 두지 않습니다.
구조화 모드(structured mode)를 사용하되, 그럼에도 불구하고 신뢰하지 마세요
Anthropic과 OpenAI의 SDK 모두 도구 호출(tool calling) 또는 응답 형식(response-format) 파라미터를 통해 출력을 스키마로 제한할 수 있게 해줍니다. 이를 사용하세요. 이를 통해 전송 형식(wire format)을 사용자의 문제가 아닌 모델의 문제로 만듦으로써, 첫 번째 실패 모드인 잘못된 형식의 출력(malformed-output) 사례를 대부분 제거할 수 있습니다.
하지만 이것이 실패 모드 2번부터 4번까지를 제거해주지는 않습니다. 제공업체(provider)는 당신이 전달한 JSON 스키마와 일치하는 구문적으로 유효한(syntactically valid) 객체를 받는다는 것을 보장합니다. 하지만 그 값들이 무엇을 의미하는지는 보장하지 않습니다. 따라서 제한된 호출(constrained call)이 1단계라면, 아래의 검증(validation)은 2단계입니다. "API가 이미 검증하니까"라는 이유로 2단계를 건너뛰는 것이 바로 환각(hallucinated)을 일으킨 라우터가 당신의 결제 시스템에 침투하게 되는 경로입니다.
import Anthropic from "@anthropic-ai/sdk";
import { zodToJsonSchema } from "zod-to-json-schema";
...
반환 타입이 의도적으로 unknown이라는 점에 주목하세요. 이 함수를 통과하기 전까지는 그 어떤 코드도 형태(shape)를 가정할 수 없습니다.
경계(boundary)에서 검증하세요
이것이 이 글의 핵심입니다. 모델의 출력은 정확히 단 한 곳을 통해 당신의 시스템으로 들어오며, 그 지점에서 검증하거나 거부합니다. 하위(downstream) 코드에서 모델 출력을 두 번 파싱하는 일은 없어야 합니다.
type ExtractResult =
| { ok: true; intent: CustomerIntent }
| { ok: false; error: string };
...
이제 호출자는 판별된 유니온 (discriminated union)을 받게 됩니다. 확인되지 않은 CustomerIntent를 보유하게 되는 경로는 존재하지 않으며, 타입 시스템 (type system)은 실패 분기 (failure branch)를 반드시 처리하도록 강제합니다. 태그가 지정된 결과 (tagged result)를 반환하는 검증된 경계 (validated boundary)라는 이 하나의 패턴은, 제가 지금까지 했던 그 어떤 프롬프트 수정 (prompt tweak)보다 더 많은 운영 환경 사고를 방지해 줍니다.
실제로 도움이 되는 재시도 (retry)
검증이 실패했을 때, 순진한 대응은 동일한 입력값으로 모델을 다시 호출하며 더 나은 운을 바라는 것입니다. 때로는 이것이 통합니다. 하지만 모델에게 무엇이 잘못되었는지 알려준다면 훨씬 더 자주 통합니다.
async function extractIntent(
message: string,
attempts = 2,
...
검증 오류 (validation error)를 다시 입력으로 넣어주는 것은 두 번째 시도를 단순한 재굴림 (re-roll)이 아닌 교정 (correction)으로 바꿔줍니다. 실제로 복구 가능한 실패의 대부분은 두 번째 시도에서 복구되며, 두 번 안에 복구되지 않는다면 세 번째 시도도 도움이 되는 경우가 드뭅니다. 제한을 두십시오. 유료 API에 대해 제한 없는 재시도 루프 (unbounded retry loop)를 돌리는 것은 나쁜 입력을 나쁜 청구서로 바꾸는 방법일 뿐입니다.
폴백 (fallback) 시점을 결정하기
여기가 바로 confidence (신뢰도)가 제 역할을 하는 지점이자, 제가 모델을 신뢰하기를 멈추는 지점입니다. 모델이 스스로 보고한 신뢰도는 제 라우팅 로직 (routing logic)의 입력값일 뿐, 결코 최종 결정권이 아닙니다. 신뢰도가 low (낮음)라면 사람에게 라우팅합니다. 취소와 같이 실제 결과가 따르는 작업에 대해 신뢰도가 high (높음)라고 하더라도 사람에게 라우팅합니다. 틀렸을 때의 비용이 비대칭적이기 때문입니다.
function route(intent: CustomerIntent): "auto" | "human" {
if (intent.confidence === "low") return "human";
if (intent.action === "cancel") return "human";
...
제가 따르는 규칙은 모델은 고객이 무엇을 말했는지를 결정하고, 제 코드는 그것에 대해 무엇을 할지를 결정한다는 것입니다. 신뢰도 점수 (confidence scores), 임계값 (thresholds), 그리고 폴백 (fallbacks)은 모델에게 위임된 판단이 아니라, 제가 작성한 일반적인 소프트웨어입니다. 이 경계선이 바로 이 모든 과정을 관리자 없이 안전하게 실행할 수 있게 만드는 핵심입니다.
도구 호출 (tool calling)은 모자를 쓴 동일한 문제일 뿐입니다
위의 모든 내용은 데이터 추출 (data extraction)의 관점에서 설명되었지만, 에이전트 방식의 도구 호출 (agentic tool calling) 또한 동일한 문제입니다. 모델이 특정 인자 (arguments)와 함께 process_order를 호출하기로 결정할 때, 해당 인자들은 모델이 생성한 구조화된 출력 (structured output)이며, 도구가 실제로 실행되기 전에 동일한 스키마 (schema), 동일한 검증 경계 (validation boundary), 그리고 동일한 "사람에게 전달하는 고위험 경로 (high stakes route to a human)" 규칙을 적용받아야 합니다. 흔히 하는 실수는 도구 호출을 실행을 위한 명령 (instruction)으로 취급하는 것이지, 검증을 위한 제안 (proposal)으로 취급하지 않는 것입니다. 인자를 검증하고, 위험도를 확인한 다음, 실행하십시오. 모델이 건네주는 것은 무엇이든 실행해 버리는 도구 핸들러 (tool handler)는 데모일 뿐, 프로덕션 통합 (production integration)이 아닙니다.
내가 기록하는 것 (what I log)
프로덕션 환경에서 이러한 파이프라인 중 하나가 오작동할 때, 당신은 정확히 어떤 일이 일어났는지 재구성하고 싶을 것입니다. 모델의 출력은 결정론적 (deterministic)이지 않으므로 단순히 다시 실행하는 것만으로는 불가능합니다. 모든 추출 과정에 대해 나는 입력 메시지, 검증 전의 모델 원시 출력 (raw model output), 검증 결과, 시도 횟수, 그리고 최종 경로를 기록합니다. 무언가 잘못되었을 때, 이 기록은 모델이 잘못된 출력을 생성했는지, 아니면 모델은 올바른 출력을 생성했으나 내 코드가 이를 잘못 처리했는지를 알려줍니다. 이 둘은 해결 방법이 다른 별개의 버그이며, 원시 출력 로그 (raw-output log) 없이는 이를 구분할 수 없습니다.
요약 (the short version)
LLM이 단 한 번 올바른 형태를 반환하게 만드는 것은 프롬프트 (prompt)의 영역입니다. 하지만 당신이 신뢰할 수 있는 시스템을 만드는 것은 코드 내의 스키마 (schema), 제약된 호출 (constrained call), 태그가 지정된 결과를 반환하는 엄격한 검증 경계 (hard validation boundary), 상한선이 있는 오류 인지 재시도 (error-aware retry), 그리고 모델이 아닌 당신이 소유하는 라우팅 로직 (routing logic)을 구축하는 것입니다. 모델은 주장 (claim)을 생성합니다. 그 주장을 믿을지 말지는 당신의 코드가 결정합니다. 이 분리를 명확하게 유지한다면 나머지는 관리 가능한 수준이 될 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기