DevInsight

개발의 판단과 맥락을 기록하는 곳

AI
조회 2약 5분 읽기

LLM이 JSON만 뱉기로 약속했을 때 실제로 일어나는 일

OpenAI의 json_object 모드는 구문 유효성만 보장할 뿐 스키마를 강제하지 않는다. Zod/Pydantic 검증, json_repair로 복구하고 validation error를 피드백해 재시도하는 단계별 방어선과 서킷 브레이커, 멀티 프로바이더 폴백까지 실무 패턴을 파헤친다.

DevInsight 편집팀 발행

AI 보조 초안과 편집 검수를 거쳐 발행했습니다.

#LLM#JSON 모드#Structured Outputs#스키마 검증#Zod#Pydantic#json_repair#재시도 패턴#constrained decoding#OpenAI

response_format: { type: "json_object" }를 걸어놓고 "됐다, 이제 무조건 JSON으로 온다"라고 생각하는 순간, 운영 환경에서 가장 먼저 마주치는 건 json.JSONDecodeError도 아니고 pydantic.ValidationError도 아닌, 빈 화면을 40초째 응시하는 타임아웃이다. 프롬프트 어딘가에 "JSON"이라는 단어가 없으면 OpenAI API 자체가 에러를 던진다는 사실을 모르는 상태로 배포한 결과다. 문서를 한 줄 더 읽었으면 피할 수 있었던 장애다.

이 정도는 웃고 넘어갈 수 있다. 진짜 문제는 그다음부터다.

모델이 반환한 문자열이 json.loads를 통과했다고 안심하는 시점에, 코드는 response["category"]에서 KeyError를 맞는다. JSON 모드는 구문 유효성만 보장할 뿐 스키마를 강제하지 않기 때문이다. 모델은 프롬프트에 적어준 필드명 category 대신 type을 쓰거나, priority 필드를 통째로 빼먹거나, "high"여야 할 enum 값을 "urgent"로 즉흥 변주할 자유가 있다. 모두 유효한 JSON이다. 파서는 웃으며 통과시킨다.

여기에 더해 max_tokens 제한에 걸려 {"items": [{"id": 1}, {"id": 2}처럼 중괄호가 안 닫힌 채 끊기는 응답, 안전 필터가 발동해서 {"refusal": "I can't help with that"}를 정상 응답인 양 반환하는 상황, 그리고 "price": "1,499.00"처럼 타입은 같지만 의미가 완전히 다른 값을 실어 보내는 할루시네이션까지. JSON 모드가 잡아주는 건 이 중 극히 일부다.


OpenAI가 "100% 보장"이라고 말할 때의 실제 의미

2024년 8월, OpenAI는 Structured Outputs를 발표하며 constrained decoding이라는 무기를 들고 나왔다. 기존 JSON 모드와의 결정적 차이는 "모델이 스키마를 위반하는 토큰을 물리적으로 생성할 수 없게 만든다"는 점이다. 샘플링 레이어에서 문법을 강제하는 방식이라 사후 검증이 아니라 생성 자체를 제약한다.

response_format: { type: "json_schema", json_schema: { strict: true, schema: {...} } }로 설정하면 모델은 required에 명시된 필드를 반드시 포함하고, additionalProperties가 암시적으로 false로 설정되어 정의되지 않은 키를 만들어내지 않으며, enum에 명시된 값만 사용한다. 중첩 객체도 동일한 규칙을 따른다.

하지만 "100% 보장"이라는 표현에는 별표가 따라붙는다. max_tokens에 걸려 출력이 잘리면 문법적으로 유효한 JSON도, 스키마에 맞는 JSON도 아니다. 안전 정책에 의해 모델이 응답을 거부하면 message.parsedNone이고 message.refusal에 거부 사유가 들어온다. finish_reason"length""content_filter"인 케이스를 체크하지 않으면 이 두 가지 예외가 그대로 프로덕션 예외로 번진다.

Structured Outputs를 쓰더라도 Zod나 Pydantic 검증을 꺼야 할 이유는 되지 않는다. 스키마가 보장하는 건 형태지, 의미가 아니다. "end_date": "2024-01-01""start_date": "2024-12-31"보다 앞서는 논리적 오류는 JSON Schema만으로는 표현할 수 없는 제약이다. Pydantic의 model_validator나 Zod의 .refine()으로 한 번 더 걸러야 하는 지점이다.


실무에서 실제로 발생하는 여섯 가지 파손 패턴

패턴 하나. 침묵 절단. max_tokens가 모자라서 배열의 마지막 항목이 사라진다. JSON 파서는 정상 동작하고, 스키마 검증도 배열 길이 제약을 걸지 않았다면 통과한다. 유실된 데이터는 로그에도 안 남고, 며칠 뒤 집계 대시보드에서나 발견된다.

패턴 둘. 환각 키. 모델이 스키마에 없는 customer_id를 만들어내거나, client_id 대신 user_id를 쓴다. JSON 모드에서는 일상적인 일이고, Structured Outputs에서도 중첩 객체에 additionalProperties를 실수로 풀어두면 발생한다.

패턴 셋. 타입 위장. "price": "1,499.00"은 문자열이지만 JSON 파서는 아무 불만이 없다. json.loads 성공, model_validate 실패. 또는 반대로 "count": 42가 들어와야 할 자리에 "count": "42"가 오는 케이스. JSON 모드에서는 빈번하게 발생한다.

패턴 넷. 의미 표류. 스키마는 완벽하게 통과했는데 값 자체가 틀렸다. 잘못된 고객명, 존재하지 않는 국가 코드, 범위를 벗어난 자신감 점수. 이건 어떤 constrained decoding으로도 막을 수 없다. 모델이 사실을 몰랐거나, 프롬프트가 모호했거나, 학습 데이터에 없던 조합을 억지로 채워 넣은 결과다.

패턴 다섯. 거부 응답의 정상 위장. 안전 필터가 발동하면 Structured Outputs는 refusal 필드에 거부 메시지를 채우고 parsedNone으로 둔다. 그런데 이 필드를 확인하지 않고 message.content에서 바로 json.loads를 호출하는 코드는 {"refusal": "..."}을 평범한 응답으로 받아들인다. "refusal"이라는 키가 스키마에 정의되어 있지 않다면 여기서 검증이 실패하지만, 정의되어 있다면 그대로 통과한다.

패턴 여섯. 스키마 버전 불일치. 새 필드를 추가해서 배포했는데, 큐에 쌓여 있던 워커는 아직 이전 버전의 스키마로 돌고 있다. 새 필드가 required에 포함되어 있다면 검증이 실패하겠지만, optional로 추가했다면? 아무도 모르게 데이터가 소실된다. 배치 파이프라인이 두 시간 동안 조용히 망가지는 시나리오다.


json_repair가 구원할 수 있는 것과 없는 것

json_repair는 LLM이 만들어내는 전형적인 구문 오류를 복구하는 라이브러리다. 빠진 따옴표, trailing comma, 닫히지 않은 중괄호, JSON 앞뒤로 붙은 설명 텍스트, 마크다운 코드 펜스 같은 잡음을 제거하고 유효한 JSON으로 복원한다. BNF 기반의 경량 파서로 동작하며, 표준 json.loads가 실패할 때만 복구 로직을 태운다.

최신 버전(0.60.x)부터는 Pydantic v2 모델이나 JSON Schema를 넘겨서 스키마 기반 복구도 지원한다. 빠진 필드를 기본값으로 채우고, "1"을 정수 1로 안전하게 강제 변환하며, 스키마에 정의되지 않은 추가 프로퍼티를 제거한다. json.loads의 드롭인 대체품으로 설계되어 있어서 import json_repair; data = json_repair.loads(raw_string) 한 줄로 기존 코드를 보강할 수 있다.

하지만 복구할 수 없는 영역도 분명히 존재한다. 닫히지 않은 문자열 내부는 파서가 경계를 알 수 없어서 복구가 불가능하다. "name": "John처럼 따옴표가 하나만 있으면 어디까지가 값인지 판단할 방법이 없다. 또한 모델이 완전히 엉뚱한 구조를 출력한 경우(객체를 기대했는데 배열이 왔다거나), 복구 라이브러리는 그걸 바로잡을 수 없다. 이때는 재시도로 넘어가야 한다.


맹목적 재시도 대신 검증 실패를 모델에게 되먹이는 패턴

같은 프롬프트를 temperature=0으로 다시 보내는 건 시간과 토큰만 낭비하는 행위다. 결정적 응답은 결정적으로 같은 실수를 반복한다. 대신 검증 실패 내역을 대화 히스토리에 추가하고, 모델에게 구체적으로 무엇이 틀렸는지 알려주는 방식이 훨씬 복구율이 높다.

실무 데이터를 보면 첫 시도 성공률은 약 87.4%, 두 번째 시도가 9.1%, 세 번째가 2.8%, 그리고 나머지 0.7%는 진짜로 복구 불가능한 케이스로 수렴한다. 3회 이상의 시도는 한계 효용이 급격히 떨어지므로 시도 횟수 제한을 두는 편이 낫다.

이때 중요한 건 검증 에러 메시지를 어떻게 포맷하느냐다. pydantic.ValidationError.json()을 그대로 던지면 모델이 스택 트레이스 비슷한 텍스트를 해석하느라 토큰을 낭비한다. 대신 "priority 필드는 반드시 'low', 'medium', 'high' 중 하나여야 하는데 'urgent'가 입력되었습니다"처럼 평문으로 가공해서 전달하면 거의 한 번에 복구된다.

재시도 로직에는 실패 유형별 분기가 들어가야 한다. 전송 오류(5xx)는 지수 백오프로 재시도하고, rate limit은 Retry-After 헤더를 따르며, 거부 응답은 맹목적 재시도가 아니라 정책 수준의 애플리케이션 에러로 처리한다. 검증 실패는 한 번 재시도 후에도 실패하면 해당 샘플을 별도 큐에 격리해서 사람이 리뷰하게 만든다.


서킷 브레이커와 멀티 프로바이더 폴백

OpenAI가 5xx 에러를 연속으로 뱉기 시작했을 때, 같은 엔드포인트에 계속 요청을 쏘는 건 초당 비용을 태우는 것 이상으로 위험하다. 전체 파이프라인이 멈추기 전에 회로를 열고, 30초 간격으로 탐침 요청 하나만 보내서 복구 여부를 확인하는 서킷 브레이커 패턴이 필요하다.

동시에 Anthropic Claude의 tool use 기능으로 같은 작업을 처리할 수 있도록 폴백을 구성해둔다. Claude는 tool_use 블록에 구조화된 JSON을 채워서 반환하므로, Pydantic 모델의 model_json_schema()를 그대로 input_schema로 전달할 수 있다. 공급자마다 스키마 표현 방식이 미묘하게 다르기 때문에, 폴백 호출에도 동일한 Zod/Pydantic 검증을 통과시키는 방어선을 두는 게 안전하다.


조용한 모델 업그레이드가 만드는 관측 가능성 사각지대

대부분의 팀은 parse_compliance_rate 하나로 관측 가능성을 끝냈다고 생각한다. 그 지표는 모델이 gpt-4o-2024-08-06에서 gpt-4.1-mini로 업그레이드되면서 confidence 필드의 평균값이 0.85에서 0.62로 조용히 이동하는 순간을 전혀 감지하지 못한다. 스키마는 여전히 통과하고 있다. 단지 시스템이 전보다 덜 확신하는 상태일 뿐이다.

필드별 분포 추적이 필요한 지점이다. 수치형 필드에 대해 이동 평균과 표준편차를 기록하고, z-score가 2.5를 10분 이상 벗어나면 알람을 울리는 식이다. 스키마 버전을 모든 요청과 로그에 태깅하는 것도 기본 중의 기본이다. 나중에 "그때 왜 이 필드가 비어 있었지?"라고 물을 때 버전 태그 없이는 답을 찾을 수 없다.

Structured Outputs가 JSON 모드의 신뢰성 문제를 상당 부분 해결해준 건 사실이지만, 그건 첫 번째 계약일 뿐이다. 계약서에 서명했다고 해서 상대방이 계약 내용을 완벽히 이행하리라고 단정할 수는 없다. 검증, 복구, 재시도, 폴백, 관측을 한 겹씩 쌓아올린 방어선 위에서야 비로소 "LLM이 JSON을 뱉는다"는 문장이 신뢰할 수 있는 명제가 된다.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

AI 전체 보기

이전 글

Next.js 블로그를 운영한 지 3개월, 아직도 구글에 제대로 노출되지 않는다면

다음 글

Vercel Functions 크론 잡, 매번 딱 한 건만 처리하도록 설계한 이유

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독