주요 기사 요약
2026년 LLM 통합 개발자들은 Output 포맷 강제의 문제를 지속적으로 마주친다. OpenAI의 최신 연구에서는 LLM이 JSON 형식으로 코드를 반환할 때 Markdown으로 반환하는 것보다 성능이 떨어진다는 것을 발견했다. Google의 Gemini는 response_mime_type 파라미터로 JSON 출력을 강제할 수 있으며, lm-format-enforcer와 outline 같은 오픈소스 도구들은 Grammar 기반 제약을 통해 정확한 형식 준수를 보장한다. 특히 금융, 의료, 데이터 처리 등 구조화된 데이터가 필수인 분야에서 Output 포맷 강제는 선택이 아닌 필수가 되었다.
“여기 JSON이에요”로 시작하는 악몽
API 응답을 JSON으로 받으려고 했다. 요청은 간단했다. 사용자 정보를 JSON으로 돌려달라고. 프롬프트에도 명확하게 썼다: “다음 정보를 JSON으로 반환하세요.”
그런데 돌아온 응답은 이랬다.
“여기 요청하신 JSON입니다: json {"name": "John", "age": 30} ”
JSON이 아니라 마크다운으로 감싸진 JSON이었다. 파싱하려고 하니 실패했다. 우리 애플리케이션은 그냥 바로 JSON 문자열을 기대했거든.
그래서 정규식으로 마크다운을 제거하는 코드를 추가했다. 작동했다. 일시적으로는. 하지만 며칠 후, 같은 프롬프트를 같은 모델에 보냈는데 이번엔 마크다운이 없었다. 그대신 “Here is the JSON:”이라는 설명 텍스트가 앞에 붙어 있었다.
그 이후로는 매일 밤 1시간씩 LLM 출력을 정리하는 코드를 쓰고 있었다. 정규식을 추가하고, 예외 처리를 늘리고, 다양한 포맷 변형을 처리하는 로직을 짰다.
내가 문제를 제대로 푼 건 프롬프트를 바꿨을 때였다.
레이어 1: 프롬프트 엔지니어링 (무료이지만 약함)
가장 간단한 방법부터 시작했다. 프롬프트만 바꾸는 것.
처음 프롬프트: “사용자 정보를 JSON으로 돌려줘.”
이건 너무 약했다. 모델이 “JSON으로 돌려줘”를 해석하는 방식이 다양했다.
그래서 이렇게 바꿨다:
“사용자 정보를 JSON 형식으로 반환하세요. 마크다운 코드블록, 설명, 추가 텍스트는 절대 포함하지 마세요. 순수한 JSON만 반환합니다. 다음 스키마를 정확히 따릅니다: {“name”: string, “age”: number, “email”: string}”
효과는 있었다. 아까보다는 훨씬 자주 올바른 JSON을 받았다. 하지만 여전히 100%가 아니었다. 때때로 모델이 여전히 “Here is the JSON:”이라는 전치사를 붙였다.
프롬프트만으로는 한계가 있었다.
레이어 2: 정규식 파싱 (현실적이지만 고통스러움)
그래서 JSON을 받은 후 처리하는 코드를 짰다.
import json
import re
def clean_json_response(response: str) -> dict:
# 마크다운 코드블록 제거
response = re.sub(r'^```(?:json)?\s*\n', '', response)
response = re.sub(r'\n```\s*$', '', response)
# 일반적인 전치사 제거
response = re.sub(r'^Here is the JSON:\s*', '', response, flags=re.IGNORECASE)
response = re.sub(r'^Here.*?JSON:\s*', '', response, flags=re.IGNORECASE)
# 공백 정리
response = response.strip()
try:
return json.loads(response)
except json.JSONDecodeError as e:
print(f"JSON 파싱 실패: {e}")
return None
이건 작동했다. 대부분의 경우. 하지만 문제가 있었다.
첫째, 매번 새로운 변형이 나타났다. 어떤 날엔 json이 아니라 python으로 감싸졌다. 어떤 날엔 “Let me provide you with the JSON:”처럼 다른 표현이 나왔다.
둘째, 모델이 실수로 JSON을 손상시키는 경우도 있었다. 예를 들어 JSON 내 문자열에 이스케이프 문자가 제대로 처리되지 않은 경우.
이 방법은 “항상 고장날 준비가 되어 있는 해킹”이었다.
레이어 3: JSON Mode와 API 파라미터 (가장 강력)
깨달은 게, LLM 모델 자체가 JSON을 강제할 수 있는 기능을 가지고 있다는 거였다.
OpenAI의 경우, response_format 파라미터를 사용할 수 있다.
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4",
messages=[
{
"role": "user",
"content": "사용자 정보를 JSON으로 제공하세요."
}
],
response_format={"type": "json_object"} # 이 줄이 중요
)
data = json.loads(response.choices[0].message.content)
이건 마법처럼 작동했다. 이제 모델은 항상, 100% 유효한 JSON을 반환했다. 마크다운도, 설명도, 추가 텍스트도 없었다.
Google의 Gemini는 비슷한 방법으로 response_mime_type을 사용한다.
import google.generativeai as genai
model = genai.GenerativeModel(
'gemini-1.5-flash',
generation_config={
'response_mime_type': 'application/json'
}
)
response = model.generate_content("사용자 정보를 JSON으로")
이 방법의 장점은 모델이 JSON 스키마 검증을 자체적으로 수행한다는 것이다.
레이어 4: Schema 검증 (Pydantic)
하지만 API가 지원하지 않는 모델을 사용한다면? 혹은 로컬 모델을 사용한다면?
그 경우엔 Pydantic으로 스키마를 정의하고 검증했다.
from pydantic import BaseModel
from typing import Optional
class UserInfo(BaseModel):
name: str
age: int
email: Optional[str] = None
# 프롬프트에 스키마 포함
schema_str = UserInfo.model_json_schema()
prompt = f"""
사용자 정보를 다음 JSON 스키마로 반환하세요:
{schema_str}
응답은 유효한 JSON이어야 하며, 마크다운이나 추가 텍스트는 없어야 합니다.
"""
# LLM에서 받은 응답
response = get_llm_response(prompt)
# 파싱 후 검증
try:
user = UserInfo.model_validate_json(response)
print(f"검증 성공: {user}")
except ValidationError as e:
print(f"스키마 검증 실패: {e}")
이 방법은 프롬프트에 정확한 스키마를 제시하고, 응답을 받은 후 다시 검증한다. 모델이 실수해도 catch할 수 있다.
레이어 5: Grammar 기반 강제 (진정한 해결책)
위 방법들은 모두 “모델이 실수할 가능성”을 허용한다. 하지만 진정한 해결책은 모델이 실수할 수 없게 만드는 것이다.
이게 바로 Grammar 기반 강제(Constrained Decoding)다.
lm-format-enforcer나 outline 같은 라이브러리는 모델의 토큰 생성 과정에서 직접 개입해서, 다음 토큰이 JSON 문법을 만족하는 경우만 선택하도록 제약한다.
from lm_format_enforcer import JsonSchemaParser
from lm_format_enforcer.json_schema import build_json_schema_parser
json_schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
},
"required": ["name", "age"]
}
parser = build_json_schema_parser(json_schema)
# 모델이 생성하는 모든 토큰이 이 스키마를 만족하는 경우만 허용됨
response = model.generate(prompt, parser=parser)
이 방법은 100% 작동한다. 모델이 잘못된 JSON을 생성할 수 없다. 왜냐하면 다음 토큰을 선택할 때 유효한 경로만 남기기 때문이다.
각 방법의 장단점 정리
프롬프트 엔지니어링은 무료이고 구현이 쉽지만, 확률적이다. 대부분의 경우 작동하지만, 완전히 신뢰할 수 없다.
정규식 파싱은 기존 시스템에서 빠르게 적용할 수 있지만, 새로운 변형이 계속 나타난다. 유지보수 악몽이다.
JSON Mode(API 파라미터)는 매우 신뢰할 수 있고, API가 지원한다면 추천한다. 하지만 구형 모델이나 로컬 모델에선 사용할 수 없다.
Pydantic 검증은 최소한의 오버헤드로 스키마 검증을 수행하고, 실패한 경우를 처리할 수 있다. 프로덕션에 적합하다.
Grammar 기반 강제는 가장 강력하지만, 로컬 모델 사용 시에만 가능하고, 추론 속도가 약간 느려질 수 있다.
우리의 최종 선택
결국 우리는 여러 레이어를 조합했다.
먼저 프롬프트에 명확한 지시사항과 스키마를 포함했다. 그 다음, OpenAI API라면 response_format을 사용했다. API를 지원하지 않는 경우라면, 응답을 Pydantic으로 검증했다. 검증에 실패하면, 자동으로 재시도를 했다(최대 3회).
로컬 모델을 사용하는 경우엔 lm-format-enforcer를 사용했다. 이렇게 하니 JSON 파싱 에러는 거의 없었다.
실제 프로덕션 사례
우리의 고객 관계 관리(CRM) 시스템은 LLM을 사용해서 이메일을 분석하고 고객 정보를 추출한다. 처음엔 매일 수십 개의 파싱 에러가 발생했다.
위의 방법들을 적용한 후, 한 달에 1~2개 정도로 떨어졌다. 그 1~2개는 모델이 완전히 딴소리를 한 경우였다. (예를 들어, 이메일이 “아버지가 방에 들어가신다”는 문장이었는데, 고객 정보를 추출하려고 한 경우)
이건 더 이상 기술 문제가 아니라 프롬프트의 명확성 문제였다.
결론: 무조건 검증하라
LLM은 확률적이다. 아무리 좋은 프롬프트도, 아무리 강력한 강제도, 100%를 보장할 순 없다. 따라서 프로덕션에서는 항상 검증 단계가 필요하다.
우리는 이 사실을 받아들이고, 여러 방어 레이어를 구축했다. 그 결과, LLM의 Output을 신뢰할 수 있는 수준으로 만들 수 있었다.
최종 체크리스트: 명확한 프롬프트 → API 파라미터 사용(가능하면) → 스키마 검증 → 실패 시 재시도 → 로깅 및 모니터링.
이 모든 단계를 거치면, LLM은 더 이상 “제멋대로”가 아니라 신뢰할 수 있는 도구가 된다.