JSON을 안정적으로 출력하는 법 — 4층 출력 방어선
← 전체 목차 · 이전: JSON 출력과 Function Calling의 본질적 차이 · 다음: MCP란 무엇인가
4층 출력 방어선: 프롬프트 Few-shot으로 기반 다지기 → 인터페이스 네이티브 능력(JSON Mode / Function Calling) → 밑바탕의 제약 디코딩(Token 단위 감시 + 문법 트리 제약) → 코드 검증과 에러 되먹임 재시도.
1층: 프롬프트로 기반 다지기
- 방식: 구호만 외치지 말 것("JSON으로 출력해 주세요"). 완벽한 입력/출력 쌍 예시를 한두 개 직접 줄 것
- few-shot 예:
# 작업: 이름과 나이 추출
- 원리: LLM의 모방 능력이 매우 강해서 구체적 샘플이 있으면 크게 벗어나지 않는다
- 한계: 이것만으로는 턱없이 부족하다 — Few-shot은 확률을 높일 뿐 어떤 보장도 제공하지 않는다
2층: 모델 네이티브 능력
{"response_format": {"type": "json_object"}}
3층: 밑바탕의 비법 — 제약 디코딩
- 전제: 반드시 모델을 로컬에서 직접 돌려야 한다(디코딩 과정에 개입할 수 있어야 함)
- 메커니즘: 모델이 글자를 뱉을 때(토큰 단위 생성) 코드로 지켜보다가, 문법에 맞지 않는 내용을 출력하려 하면 그 글자의 생성 확률을 즉시 0으로 만든다
- 예시:
{ "name" : John — 이미 합법적으로 생성된 key는 유지하고 비합법 후속은 차단
4층: 폴백 사고 — 검증과 재시도
- 철칙: 앞에서 아무리 잘했어도 반드시 폴백이 있어야 한다
- 방식: 결과를 받으면 즉시 코드로 검사·검증. 괄호 누락, 타입 오류 등은 에러 정보를 그대로 되먹여 모델이 스스로 고쳐 다시 출력하게 한다
프로덕션 디테일: 4층을 실제로 99.9%까지 끌어올리는 법
4층 방어선을 갖췄다고 해도 "해석 성공률 99.9% 이상"을 프로덕션에서 약속하려면 다음 다섯 가지 공학적 디테일이 더 필요하다.
- 하나의 출구로 모으는 "포맷 검증 게이트웨이"를 둘 것: 모든 모델 출력을 하나의 관문에 통과시키고, 합격한 것만 하류 업무로 흘려보낸다. 포맷 문제를 게이트웨이 안에서 전부 닫아 두면, 불확실성이 시스템 경계 밖으로 새어 나가지 않는다.
- 재시도를 전량 재추론이 아니라 등급별로 나눌 것:
공학측 문법 수복(정규식/괄호 보정 등, 모델 호출 0회) → 단일 라운드 경량 자가 수정(에러난 조각 + 포맷 규칙만 되먹임) → 그래도 실패하면 강등의 순서로 처리한다. 정정 대상은 항상 오류 필드와 오류 유형까지 정확히 짚은 조각이어야 한다 — 전체 컨텍스트를 통째로 되먹이면 정정 비용의 이점이 사라진다.
- 포맷 검증과 업무 실행을 분리할 것: 도구 호출이 이미 성공적으로 실행된 요청은 출력 포맷이 틀렸다고 해서 도구를 다시 호출하면 안 된다. 결과 텍스트만 다시 포맷팅하고, 실행 자체는 재현하지 않는다 — 그러지 않으면 포맷 재시도가 부작용을 중복 발생시키는 사고로 이어진다.
- 모델이 네이티브 구조화 출력을 지원하지 않고 모델 교체도 불가능할 때는 "포맷 적응층"을 따로 둘 것: 경량의 별도 소형 모델이 포맷 정리와 필드 추출만 전담하게 하고, 주 모델은 업무 콘텐츠 생성만 맡는다. 콘텐츠 능력과 포맷 능력을 분리하면 "모델을 못 바꾼다"는 제약에도 대응할 수 있다.
- 네 가지 지표를 상시 모니터링할 것: 포맷 해석 성공률, 자가 수정 트리거율, 강등 요청 비율, 요청당 평균 재시도 횟수. 이 지표들이 조용히 나빠지는 것이야말로 "99.9%를 약속했는데 실제로는 운에 맡기고 있었다"는 상태의 초기 신호다.
- 자가 수정도 실패하면 업무 등급에 따라 강등할 것: 핵심 시나리오는 사전 정의된 템플릿 결과를 반환하며 "사람 복기 대기"로 표시하고, 비핵심 시나리오는 자연어 결과를 반환하며 "비구조화"로 표시한다. 공통 원칙은 서비스를 중단시키지 않고 사후에 포맷을 보정하는 것이다.
핵심 통찰
- "확률적 이어쓰기"가 모든 포맷 불안정 문제의 제1원인이다 — LLM에는 "구조를 출력한다"는 개념이 없고 다음 토큰을 샘플링할 뿐이다. 이 점을 이해하면 프롬프트에 쓴 어떤 요구도 확률을 높일 뿐 보장을 제공하지 못하는 이유를 알 수 있다.
- 모델의 "예의"가 포맷 킬러다 — RLHF로 훈련된 대화 습관(접두 인사말, 접미 설명, 마크다운 코드 펜스)이야말로 구조화 출력의 최대 오염원이다. "정렬 목표"와 "엔지니어링 목표"가 충돌하는 전형적 사례다.
- 방어선은 "보장 강도" 순으로 정렬해야지 "구현 난이도" 순으로 정렬하면 안 된다 — 프롬프트는 확률급, 인터페이스 파라미터는 프로세스급, 제약 디코딩은 문법급, 코드 검증은 결과급이다. 강도가 점증하며 뒤로 갈수록 결정론에 가까워진다.
- 제약 디코딩은 유일하게 문법급 보장을 제공하는 수단이지만 배포 전제가 있다 — 샘플링 과정에 개입할 수 있어야 하므로 모델 가중치/추론 스택을 직접 보유해야 한다. 즉 클라우드 API를 쓸 때 3층 "밑바탕 차단"은 손댈 수 없다.
- 폴백 층의 가치는 "더 강해서"가 아니라 "무조건적이어서"다 — 앞의 세 층은 모델 버전, 파라미터 지원도, 배포 형태에 따라 무력화될 수 있고, "코드 검증 + 에러 되먹임 재시도"만이 어떤 형태에서도 쓸 수 있다. 그래서 유일하게 생략할 수 없는 층이다.
- 에러 되먹임(error feedback loop)은 LLM을 자가 치유 가능한 컴포넌트로 만든다 — 파서의 에러 원문을 모델에게 되던지는 것이, 사람이 규칙을 짜서 JSON을 고치는 것보다 범용적이다. 에러 정보 자체가 고품질의 수정 힌트이기 때문이다.
- 다층 방어는 이식 가능한 엔지니어링 패러다임이다 — "소프트 유도 → 하드 제약 → 밑바탕 차단 → 사후 폴백"이라는 계층 사고는 JSON뿐 아니라 도구 호출 파라미터, 콘텐츠 안전, 길이 통제 등 "모델을 말 듣게 하는" 모든 상황에 똑같이 적용된다.
엔지니어링 실전 Tips
- Few-shot 예시는 "그대로 복사 가능"해야 한다: 예시 출력은 최종적으로 기대하는 맨 JSON이어야 하며, 예시에 어떤 설명 문구도 넣지 말 것. 그렇지 않으면 모델이 그대로 따라 배운다.
- 순수 JSON Mode보다 Function Calling을 우선할 것: JSON Mode는 "합법적인 JSON임"만 보장하지만, Function Calling은 필드명과 필드 타입(
name: string / age: number)까지 제약하므로 후속 검증이 한 겹 줄어든다.
- 제약 디코딩은 자체 추론 스택에서만 가능하다: 클라우드 API 호출 시 이 층은 아예 부재하므로, 아키텍처 리뷰 때 배포 형태를 미리 확인하고 손에 넣을 수 없는 능력 위에 신뢰성 방안을 세우지 말 것.
- 검증 층은 두 종류의 에러를 구분해야 한다: 구조 오류(괄호 누락)는 바로 재시도해도 되지만, 의미 오류(값을 잘못 추출)는 재시도가 무효이므로 사람이나 규칙 폴백으로 가야 한다.
- 되먹임 시 원본 에러 텍스트를 함께 돌려보낼 것. 모델은
Expecting ',' delimiter: line 1 column 24 같은 정보에 대한 수정 성공률이 막연한 "포맷이 틀렸다"보다 확연히 높다.
- 재시도에는 상한과 다운그레이드가 있어야 한다: 무한 재시도는 비용과 지연을 폭발시킨다. N회를 넘으면 계속 토큰을 태우지 말고 구조화된 실패 객체를 반환할 것.
- 2층/3층을 넣었다고 4층을 지우지 말 것: 폴백 층은 모델 형태와 무관한 유일한 보험이다.
- 포맷 정의는 하나의 근원(schema)에서만 나오게 관리할 것: 도구 파라미터가 바뀔 때마다 Prompt 속 예시를 손으로 고치면 반드시 어긋난다. Schema를 유일한 사실 소스로 삼아 제약 규칙과 Prompt 조각을 자동 생성하면, 파라미터 반복 갱신이 더 이상 유지보수 비용이 되지 않는다.