본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 07. 10:08

GPT의 JSON 출력 문제 해결하기: 실제로 작동하는 패턴

요약

GPT-4 기반 애플리케이션에서 발생하는 불안정한 JSON 출력 문제를 해결하는 방법을 다룹니다. 프롬프트 엔지니어링이나 사후 처리 방식의 한계를 지적하며, 모델이 생성 과정에서부터 유효한 형식을 따르도록 강제하는 제약된 디코딩(Constrained Decoding)의 중요성을 설명합니다.

핵심 포인트

  • 프롬프트 엔지니어링만으로는 완벽한 JSON 구조를 보장하기 어려움
  • 정규 표현식이나 커스텀 파서를 이용한 후처리는 유지보수가 매우 힘듦
  • 생성 후 수정이 아닌 생성 과정에서의 제약(Constrained Decoding)이 핵심
  • 스키마를 활용해 모델의 출력을 구조화된 데이터로 강제해야 함

저는 GPT-4 기반의 앱이 왜 계속 잘못된 형식의 JSON을 반환하는지 디버깅하는 데 3일을 보냈습니다. 그것은 프롬프트(Prompt)의 문제가 아니었습니다. 퓨샷 예시(Few-shot examples), 시스템 메시지(System messages)를 시도해 보았고, 심지어 모델에게 '제발 유효한 JSON을 주세요'라고 애원하기까지 했습니다. 하지만 프로덕션(Production) 환경에서는 여전히 문제가 발생했습니다.

이것은 제가 예외 케이스(Edge cases)를 하나하나 해결하는 소모적인 과정 없이, 어떻게 마침내 LLM(Large Language Models)으로부터 신뢰할 수 있는 구조화된 출력(Structured output)을 얻어냈는지에 대한 이야기입니다.

문제: JSON 룰렛

저는 회의록을 추출하여 실행 항목(Action items), 날짜, 담당자와 같은 구조화된 데이터로 변환하는 작은 내부 도구를 만들고 있었습니다. 프롬프트는 매우 명확했습니다:

action, due_date (ISO 8601), assignee 필드를 가진 객체들의 JSON 배열을 반환하세요.

저는 OpenAI API 호출 시 response_format: { "type": "json_object" }를 사용했습니다. 로컬 테스트에서는 모든 것이 잘 작동했습니다. 그러다 첫 번째 실제 사용자가 "다음 주 목요일"과 같은 날짜가 포함된 전사본(Transcript)을 업로드하자 모델은 다음과 같이 반환했습니다:

{"action": "Review Q3 budget", "due_date": "next Thursday", "assignee": "Alice"}

실제 날짜조차 아니었습니다. 그리고 때때로 추가 필드가 나타나거나, 중첩된 객체(Nested objects)가 생기거나, 전체 배열이 추가적인 객체로 감싸져서 나오기도 했습니다. 그야말로 혼돈 그 자체였습니다.

효과가 없었던 시도들

1. 프롬프트 엔지니어링 (Prompt engineering)

저는 시스템 프롬프트(System prompt)를 계속해서 다시 작성했습니다. 다음과 같은 내용을 추가했습니다:

  • "유효한 JSON만 출력하세요. 설명은 생략하세요."
  • "비어 있는 날짜에는 엔 대시(En dashes)를 사용하세요."
  • 완벽하게 형식이 맞춰진 5개의 레코드를 포함한 퓨샷 예시(Few-shot example).

도움이 되었을까요? 아주 약간은 되었습니다. 하지만 단 하나의 예외적인 전사본만으로도 여전히 다른 출력 형식이 유발될 수 있었습니다. 영어가 아닌 텍스트에 대한 모델의 동작은 훨씬 더 심각했습니다.

2. 정규 표현식(Regex)을 이용한 후처리

저는 첫 번째 [와 마지막 ] 사이의 모든 것을 캡처하는 Python 코드를 작성했습니다. 그런 다음 try-except 문 안에서 json.loads()를 시도했습니다. 실패할 경우, 원시 출력(Raw output)을 로그에 남기고 수동으로 수정했습니다. 이것은 끔찍한 아이디어였습니다. 저는 기본적으로 고장 난 파이프를 임시방편으로 때우고 있었던 셈입니다.

3. 커스텀 출력 파서 (Custom output parsers)

저는 적절한 JSON 구분자(delimiters)가 없더라도 키-값 쌍(key-value pairs)을 찾아내는 파서를 직접 만들었습니다. 80%의 사례에서는 작동했지만, 예외 사례(edge cases)는 기하급수적으로 늘어났습니다. 그리고 파서를 변경할 때마다 전체 회귀 테스트(regression test) 사이클을 거쳐야 했습니다.

실제로 작동했던 방법: 스키마를 활용한 제약된 디코딩 (Constrained Decoding with a Schema)

돌파구는 생성된 이후의 출력을 수정하려는 시도를 멈추고, 대신 모델이 생성 과정 중에 유효한 JSON을 생성하도록 강제했을 때 찾아왔습니다. 이 기술을 제약된 디코딩 (Constrained decoding, 또는 structured generation)이라고 부릅니다.

핵심 아이디어는 다음과 같습니다. 모델이 어떤 토큰(token)이든 선택할 수 있도록 허용하는 대신, JSON 스키마(schema)를 기반으로 허용되는 다음 토큰을 제한하는 것입니다. 모델은 해당 스키마에 따라 유효한 JSON을 결과로 낼 수 있는 토큰만을 생성할 수 있습니다.

저는 Outlines라는 Python 라이브러리(오픈 소스이며, OpenAI 및 로컬 모델과 함께 작동함)를 사용했습니다. 패턴은 다음과 같습니다:

import outlines
from outlines.generate import json as json_generator
from pydantic import BaseModel
...

출력은 항상 MeetingNotes와 일치하는 유효한 JSON입니다. 더 이상 파싱 지옥(parsing hell)은 없습니다.

내부 작동 원리

제약된 디코딩은 JSON 스키마를 유한 상태 머신(finite state machine)으로 컴파일하여 작동합니다. 각 생성 단계에서 라이브러리는 스키마를 깨뜨릴 수 있는 토큰들을 마스킹(masking) 처리합니다. 예를 들어, 필드 이름 뒤에 콜론(:)을 생성한 후에는 스키마 타입에 따라 (문자열의 경우) 따옴표(") 또는 (숫자의 경우) 숫자만이 허용되는 다음 토큰이 됩니다. 이는 생성 후 검증(post-generation validation) 방식보다 수만 광년 앞서 있는 기술입니다.

트레이드오프(Trade-offs) 및 한계점

  • 속도 (Speed): 제약 조건이 있는 디코딩 (Constrained decoding)은 마스크 계산 (mask computation)으로 인해 토큰당 약간의 오버헤드를 추가합니다. 제 테스트 결과, 생성 시간에 약 5~10% 정도가 추가되었으나 대부분의 사용 사례에서는 무시할 만한 수준입니다.
  • 모델 지원 (Model support): 모든 제공업체가 Outlines와 같은 라이브러리에서 요구하는 로짓 바이어스 (logit biases)를 노출하는 것은 아닙니다. OpenAI의 API는 GPT-4/GPT-4o에 대해서는 로짓 바이어스를 지원하지만, GPT-3.5-turbo는 지원하지 않습니다. Anthropic의 API는 로짓 바이어스를 전혀 노출하지 않으므로, 다른 방식 (재시도를 포함한 출력 검증 등)이 필요합니다.
  • 스키마 복잡도 (Schema complexity): anyOf 또는 재귀적 정의가 포함된 깊게 중첩된 스키마는 상태 머신 (state machine)을 거대하게 만들 수 있습니다. 신뢰성을 위해 JSON 스키마를 평탄하게 (flat) 유지하는 것을 권장합니다.
  • 비완성 엔드포인트 (Non-completion endpoints): 채팅 완성 (chat completion) API를 사용하는 경우, 응답 형식을 json_object가 아닌 text로 설정하고 생성 파이프라인의 일부로 스키마를 전달해야 합니다. 일부 라이브러리는 이를 자동으로 처리합니다.

고려했던 대안들 (Alternatives I Considered)

  • 프롬프트 기반 스키마 임베딩 (Prompt-based schema embedding): 일부 사람들은 시스템 프롬프트에 JSON 스키마를 직접 포함시킵니다. 이 방식은 어느 정도 작동하지만 완벽하지는 않습니다. 모델이 여전히 필드를 환각 (hallucinate)할 수 있기 때문입니다. 저는 위험도가 낮은 내부 도구에만 이 방식을 사용할 것입니다.
  • 출력 검증 + 재시도 (Output validation + retry): JSON이 유효하지 않은 경우, 에러 메시지와 함께 프롬프트를 다시 보냅니다. 이 방식은 작동하지만 에러율에 비례하여 지연 시간 (latency)과 비용이 증가합니다.
  • OpenAI의 json_object 모드 사용: 이 모드는 모델이 단일 JSON 객체를 출력하도록 강제하지만, 특정 스키마를 보장하지는 않습니다. 여전히 무작위 필드 이름이 생성될 수 있습니다. 구조화된 추출 (structured extraction) 용도로는 가치가 없습니다.

배운 점 (Lessons Learned)

  1. 프롬프트 엔지니어링 (Prompt engineering)에는 한계가 있습니다. 아무리 영리하게 문구를 작성하더라도 확률적 모델 (stochastic model)을 결정론적 (deterministic)으로 강제할 수는 없습니다. 보장된 구조가 필요하다면 구조적 제약 (structural constraint)이 필요합니다.
  2. 생성 후가 아니라 생성 시점에 검증하십시오. 사후 파싱 (Post-hoc parsing)은 패배가 예정된 싸움입니다. 하나의 엣지 케이스 (edge case)를 해결할 때마다 두 개의 새로운 문제가 발생합니다.
  3. "구조화된 출력 (structured output)" API 기능들을 신뢰하지 마십시오. 대부분의 기능은 모델이 JSON을 "선호"하도록 플래그를 설정할 뿐, 이를 강제하지는 않습니다. 여전히 Outlines나 Guidance와 같은 라이브러리를 상단에 추가로 사용해야 합니다.
  4. 실제 환경의 입력값으로 테스트하십시오. 제가 만든 합성 테스트 데이터 (synthetic test data)는 완벽해 보였지만, 비원어민의 실제 전사 데이터 (transcripts)는 모든 것을 망가뜨렸습니다. 날짜 누락, 동일한 이름을 가진 여러 명의 인물 등과 같은 엣지 케이스를 항상 포함하십시오.

다음에 다시 한다면 다르게 할 점

저는 첫날부터 제약된 디코딩 (constrained decoding)으로 시작했을 것입니다. 제가 사용한 라이브러리 (Outlines)는 하나의 옵션일 뿐이며, Microsoft의 Guidance나 lm-format-enforcer도 있습니다. 만약 로컬 모델을 사용한다면, logits_processor와 함께 .generate() 메서드를 사용할 것입니다. 로짓 바이어스 (logit bias)를 노출하지 않는 클라우드 API의 경우, 스키마를 인식하는 에러 메시지를 포함한 재시도 메커니즘 (retry mechanism)과 함께 요청을 배치 (batch) 처리할 것입니다.

또한, 더 일찍 스키마 설계 (schema design)에 더 많은 시간을 투자했어야 했습니다. 모든 필드가 반드시 존재할 것을 기대하는 스키마보다, 선택적 필드 (optional fields)와 null 값을 허용하는 스키마가 훨씬 더 견고합니다.

저는 여전히 창의적인 작업에는 프롬프트 엔지니어링을 사용하지만, 출력이 데이터베이스나 자동화 파이프라인 (automation pipeline)으로 들어가는 모든 애플리케이션의 경우, 제약된 디코딩이 유일하게 합리적인 선택입니다.

저의 최종 설정은 다음과 같습니다: 스키마를 위한 Pydantic 모델, 생성을 위한 Outlines, 그리고 간단한 FastAPI 엔드포인트입니다. 이를 통해 JSON 에러를 25%에서 0.1% 미만으로 줄였습니다. 그리고 남은 에러들은 모델 때문이 아니라 거의 항상 네트워크 타임아웃 (network timeout) 때문입니다.

LLM으로부터 구조화된 출력을 얻기 위한 여러분의 설정은 무엇인가요? 다른 분들은 이 문제를 어떻게 처리하는지 정말 듣고 싶습니다. 특히 로짓 바이어스를 지원하지 않는 API를 사용하고 계신다면 더욱 그렇습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0