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. "확률적 이어쓰기"가 모든 포맷 불안정 문제의 제1원인이다 — LLM에는 "구조를 출력한다"는 개념이 없고 다음 토큰을 샘플링할 뿐이다. 이 점을 이해하면 프롬프트에 쓴 어떤 요구도 확률을 높일 뿐 보장을 제공하지 못하는 이유를 알 수 있다.
  2. 모델의 "예의"가 포맷 킬러다 — RLHF로 훈련된 대화 습관(접두 인사말, 접미 설명, 마크다운 코드 펜스)이야말로 구조화 출력의 최대 오염원이다. "정렬 목표"와 "엔지니어링 목표"가 충돌하는 전형적 사례다.
  3. 방어선은 "보장 강도" 순으로 정렬해야지 "구현 난이도" 순으로 정렬하면 안 된다 — 프롬프트는 확률급, 인터페이스 파라미터는 프로세스급, 제약 디코딩은 문법급, 코드 검증은 결과급이다. 강도가 점증하며 뒤로 갈수록 결정론에 가까워진다.
  4. 제약 디코딩은 유일하게 문법급 보장을 제공하는 수단이지만 배포 전제가 있다 — 샘플링 과정에 개입할 수 있어야 하므로 모델 가중치/추론 스택을 직접 보유해야 한다. 즉 클라우드 API를 쓸 때 3층 "밑바탕 차단"은 손댈 수 없다.
  5. 폴백 층의 가치는 "더 강해서"가 아니라 "무조건적이어서"다 — 앞의 세 층은 모델 버전, 파라미터 지원도, 배포 형태에 따라 무력화될 수 있고, "코드 검증 + 에러 되먹임 재시도"만이 어떤 형태에서도 쓸 수 있다. 그래서 유일하게 생략할 수 없는 층이다.
  6. 에러 되먹임(error feedback loop)은 LLM을 자가 치유 가능한 컴포넌트로 만든다 — 파서의 에러 원문을 모델에게 되던지는 것이, 사람이 규칙을 짜서 JSON을 고치는 것보다 범용적이다. 에러 정보 자체가 고품질의 수정 힌트이기 때문이다.
  7. 다층 방어는 이식 가능한 엔지니어링 패러다임이다 — "소프트 유도 → 하드 제약 → 밑바탕 차단 → 사후 폴백"이라는 계층 사고는 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 조각을 자동 생성하면, 파라미터 반복 갱신이 더 이상 유지보수 비용이 되지 않는다.