본문 바로가기
AI2026년 9월 1일20분 읽기

구조화 출력·함수 호출로 LLM 신뢰성 높이기 — JSON 스키마 강제와 재시도 실전

YS
김영삼
조회 10
구조화 출력·함수 호출로 LLM 신뢰성 높이기 — JSON 스키마 강제와 재시도 실전

LLM을 프로덕션에 태우면 결국 문제는 "말"이 아니라 "형식"이다. 구조화 출력(JSON 스키마 강제)과 함수 호출을 쓰면, 모델이 뱉는 값이 항상 파싱 가능한 정해진 스키마를 따르도록 강제할 수 있다.

프롬프트에 "JSON으로만 답해줘"라고 애원하는 방식은 언젠가 반드시 깨진다. 이 글은 JSON 모드와 스키마 강제(strict)의 차이, tool/function calling, 파싱 실패 시 재시도, 그리고 스키마로는 못 잡는 의미 검증까지 실전 코드로 정리한다.

몇 년 전 내가 처음 LLM을 파이프라인에 넣었을 때 가장 많이 데인 게 이거다. 데모에서는 완벽하게 JSON을 뱉던 모델이, 트래픽이 몰리면 갑자기 Sure! Here is the JSON you requested:를 앞에 붙이거나, 마지막에 마크다운 코드펜스 ```를 남기거나, 숫자여야 할 필드에 "약 3,200원" 같은 문자열을 넣는다. 파서가 죽고, 새벽에 알림이 울린다. 구조화 출력은 이 문제를 프롬프트가 아니라 디코딩 단계에서 해결한다.

항목 핵심 수치 / 사실
유효 JSON 보장(JSON 모드)문법은 보장, 스키마(필드·타입)는 미보장
스키마 강제(strict / constrained decoding)스키마 준수 사실상 100% (문법상 불가능한 토큰을 봉쇄)
권장 재시도 횟수2~3회 (지수 백오프 + 에러 메시지 되먹임)
스키마 캐시 (OpenAI Structured Outputs)첫 요청은 스키마 컴파일 지연, 이후 24시간 캐시
스키마 강제로도 못 잡는 것값의 의미(합계 > 0, 날짜 범위, 존재하는 SKU인지)

구조화 출력이 뭐고, 왜 신뢰성이 오르나?

구조화 출력은 모델의 응답을 미리 정한 JSON 스키마에 맞도록 디코딩 단계에서 제약하는 기능이다. 신뢰성이 오르는 이유는 간단하다. 프롬프트로 부탁하는 방식은 모델이 "협조하기를" 기대하지만, 스키마 강제는 협조 여부와 무관하게 스키마에 맞지 않는 토큰이 애초에 생성될 수 없게 만들기 때문이다.

구현 원리는 제약 디코딩(constrained decoding)이다. 각 토큰을 뽑을 때, 스키마가 허용하는 다음 토큰들만 남기고 나머지 확률을 0으로 눌러버린다. 문자열을 여는 " 다음에는 문자열을 닫기 전까지 객체를 여는 {가 나올 수 없다. total 필드가 숫자로 정의됐다면 "약 같은 토큰은 후보에서 제거된다. 그래서 결과물은 파싱 실패가 원천적으로 사라진다.

개인적으로는 이걸 알기 전과 후로 LLM 파이프라인 안정성이 완전히 갈렸다. 예전엔 정규식으로 코드펜스를 벗겨내고 try/except로 감싸는 방어 코드가 함수 절반이었는데, 지금은 그 코드가 거의 다 사라졌다.

JSON 모드 vs 스키마 강제 — 헷갈리면 여기서 갈린다

둘은 이름이 비슷해서 처음엔 나도 같은 거라고 생각했다. 아니다. JSON 모드는 "출력이 유효한 JSON 문법이어야 한다"만 보장한다. 필드가 빠지든, 타입이 틀리든, 엉뚱한 키가 추가되든 상관 안 한다. 스키마 강제(strict)는 거기에 더해 "이 스키마의 필드·타입·필수값을 정확히 지킨다"까지 보장한다.

방식 유효 JSON 스키마 준수 언제 쓰나
프롬프트로 "JSON만 답해"불안정불안정프로토타이핑, 스키마 강제 미지원 모델
JSON 모드보장미보장구조가 느슨하거나 가변적일 때
스키마 강제(strict)보장보장DB 적재, API 응답 등 고정 스키마
함수 호출(tool calling)보장보장(strict 시)모델이 외부 액션·도구를 호출할 때

실무 규칙은 이렇다. 저장하거나 다음 시스템에 넘길 데이터라면 무조건 스키마 강제. 사람이 눈으로 보고 넘길 결과라면 JSON 모드로도 충분하다.

참고 스키마 강제를 켜려면 대개 모든 객체에 additionalProperties: false를 붙이고, 모든 프로퍼티를 required에 넣어야 한다. "선택 필드"는 required에 넣되 타입을 ["string", "null"]처럼 null 허용으로 표현하는 게 정석이다. 이걸 몰라서 "왜 optional이 안 먹지" 하고 한참 헤맸다.

함수 호출은 결국 "스키마가 붙은 출력"이다

tool/function calling은 겉보기엔 "모델이 도구를 부른다"는 기능이지만, 신뢰성 관점에서 보면 입력 스키마가 정의된 구조화 출력과 같다. 도구 정의의 input_schema(또는 parameters)가 곧 응답 스키마다. 모델은 그 스키마에 맞는 인자 객체를 만들어 낸다.

그래서 "구조화된 데이터 추출"만 필요한 경우에도 함수 호출을 쓰는 패턴이 흔하다. 도구 하나를 정의하고, tool_choice로 그 도구를 반드시 호출하게 강제한 뒤, 실제로 실행하지는 않고 인자만 받아 쓰는 것이다. 나는 지금도 추출 작업엔 종종 이 방식을 쓴다. 여러 추출 대상을 각각 다른 도구로 나눠두면 라우팅까지 겸할 수 있어서 편하다.

실전 코드 — Pydantic으로 스키마와 검증을 한 번에

핵심 전략은 스키마를 코드로 한 곳에 정의하는 것이다. Python이면 Pydantic, TypeScript면 Zod. 이 모델 클래스가 (1) LLM에 보낼 JSON 스키마의 출처이자 (2) 응답을 파싱·검증하는 도구가 된다. 두 개를 따로 관리하면 언젠가 어긋난다.

먼저 스키마 정의. 인보이스(청구서) 텍스트에서 필드를 뽑는다고 하자.

from pydantic import BaseModel, Field

class Invoice(BaseModel):
    vendor: str = Field(description="청구 업체명")
    total: float = Field(description="총액(통화기호 없이 숫자만)")
    currency: str = Field(description="ISO 4217 통화코드, 예: KRW")
    line_items: list[str] = Field(description="품목명 리스트")

OpenAI라면 parse 헬퍼가 Pydantic 모델을 그대로 받아서 스키마 강제와 파싱을 동시에 처리한다.

from openai import OpenAI

client = OpenAI()

completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",   # Structured Outputs 지원 모델
    messages=[
        {"role": "system", "content": "인보이스 텍스트에서 필드를 추출한다."},
        {"role": "user", "content": invoice_text},
    ],
    response_format=Invoice,      # 스키마 강제 + 자동 파싱
)

invoice = completion.choices[0].message.parsed   # 이미 Invoice 인스턴스
# 모델이 거부(refusal)했는지 반드시 확인
if completion.choices[0].message.refusal:
    raise ValueError(completion.choices[0].message.refusal)

Claude(Anthropic)라면 messages.parseoutput_config.format으로 JSON 스키마를 넘긴다. 모델은 claude-opus-4-8처럼 구조화 출력을 지원하는 걸 써야 한다.

from anthropic import Anthropic

client = Anthropic()

resp = client.messages.parse(
    model="claude-opus-4-8",
    max_tokens=1024,
    messages=[{"role": "user", "content": invoice_text}],
    output_config={
        "format": {
            "type": "json_schema",
            "schema": Invoice.model_json_schema(),
        }
    },
)

invoice = resp.parsed_output   # 스키마 검증까지 끝난 결과

함수 호출로 같은 걸 하려면, Claude에서는 도구에 strict: true를 주고 tool_choice로 강제한다. 이 방식은 도구 인자 객체가 스키마를 정확히 만족하는 걸 보장한다.

resp = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    tools=[{
        "name": "save_invoice",
        "description": "추출한 인보이스 필드를 저장",
        "strict": True,                      # 스키마 100% 강제
        "input_schema": {
            "type": "object",
            "properties": {
                "vendor":   {"type": "string"},
                "total":    {"type": "number"},
                "currency": {"type": "string"},
                "line_items": {"type": "array", "items": {"type": "string"}},
            },
            "required": ["vendor", "total", "currency", "line_items"],
            "additionalProperties": False,   # strict의 필수 조건
        },
    }],
    tool_choice={"type": "tool", "name": "save_invoice"},  # 반드시 호출
    messages=[{"role": "user", "content": invoice_text}],
)

args = next(b.input for b in resp.content if b.type == "tool_use")

파싱 실패와 재시도 — 스키마 강제를 못 쓸 때의 안전망

스키마 강제를 지원하는 모델을 쓸 수 있다면 파싱 실패는 거의 안 난다. 문제는 항상 그럴 순 없다는 것이다. 오래된 모델, 로컬 모델, 혹은 스키마 강제가 안 먹는 복잡한 스키마(재귀 구조, 문자열 길이 제약 등)에서는 여전히 "받아서 검증하고, 틀리면 다시 요청"하는 루프가 필요하다.

핵심은 실패 이유를 모델에게 그대로 되먹이는 것이다. 그냥 "다시 해줘"가 아니라 검증 에러 메시지를 붙여줘야 모델이 무엇을 고쳐야 하는지 안다. 이건 문서에 잘 안 나오는데, 재시도 성공률을 체감상 크게 끌어올린다.

from pydantic import ValidationError

def extract_invoice(text: str, max_retries: int = 3) -> Invoice:
    messages = [
        {"role": "system", "content": "인보이스에서 JSON만 출력. 설명 금지."},
        {"role": "user", "content": text},
    ]
    for attempt in range(max_retries):
        raw = call_llm(messages)            # 문자열 응답을 받는다고 가정
        try:
            return Invoice.model_validate_json(raw)   # 파싱 + 검증
        except ValidationError as e:
            # 실패한 응답과 에러를 대화에 붙여 재요청
            messages.append({"role": "assistant", "content": raw})
            messages.append({
                "role": "user",
                "content": f"검증 실패:\n{e}\n스키마에 맞게 JSON만 다시 출력해.",
            })
    raise RuntimeError(f"{max_retries}회 재시도 후에도 검증 실패")

몇 가지 실전 팁:

  • 재시도는 2~3회면 충분하다. 3회에도 실패하면 그건 모델 탓이 아니라 스키마나 프롬프트가 잘못됐을 가능성이 높다.
  • 429(레이트리밋)·5xx 같은 네트워크 에러검증 실패는 재시도 정책을 분리해라. 전자는 지수 백오프, 후자는 에러 되먹임.
  • instructor 같은 라이브러리는 이 재시도+검증 루프를 Pydantic 기반으로 감싸준다. 직접 짜기 귀찮으면 검토해볼 만하다.
주의 응답이 max_tokens에 걸려 잘리면 JSON이 중간에 끊긴다. 이건 스키마 강제로도 못 막는다(문법상 유효한 접두부까지만 생성되고 멈춘다). 긴 출력이 예상되면 max_tokens를 넉넉히 주고, stop_reasonmax_tokens인지 반드시 확인해라. 잘린 걸 성공으로 착각하면 조용히 데이터가 유실된다.

검증 — 스키마가 통과시켜도 값은 틀릴 수 있다

여기서 많이들 착각한다. 스키마 강제는 형식을 보장하지 의미를 보장하지 않는다. total이 숫자인 건 강제할 수 있어도, 그 숫자가 실제 합계와 맞는지, 음수가 아닌지, 존재하는 통화코드인지는 모른다. 모델은 스키마에 맞으면서도 그럴듯하게 틀린 값을 넣을 수 있다.

그래서 나는 Pydantic 검증기를 스키마와 같은 클래스에 붙여둔다. 형식 검증(LLM용 스키마)과 의미 검증(비즈니스 규칙)을 한 파일에서 관리하는 셈이다.

from pydantic import BaseModel, field_validator

VALID_CURRENCIES = {"KRW", "USD", "EUR", "JPY"}

class Invoice(BaseModel):
    vendor: str
    total: float
    currency: str
    line_items: list[str]

    @field_validator("total")
    @classmethod
    def total_positive(cls, v: float) -> float:
        if v <= 0:
            raise ValueError("total은 0보다 커야 한다")
        return v

    @field_validator("currency")
    @classmethod
    def known_currency(cls, v: str) -> str:
        if v not in VALID_CURRENCIES:
            raise ValueError(f"알 수 없는 통화코드: {v}")
        return v

이렇게 하면 의미 검증에서 걸린 에러가 그대로 앞서 만든 재시도 루프의 ValidationError로 흘러 들어간다. 모델은 "total은 0보다 커야 한다"는 피드백을 받고 다시 시도한다. 형식과 의미가 하나의 검증 흐름으로 통합되는 거다. 개인적으로 이 통합이 구조화 출력 도입에서 가장 만족스러운 부분이었다.

현장에서 자주 밟는 함정들

  • optional을 그냥 optional로 만든다. strict 모드에선 모든 필드가 required여야 한다. 진짜 선택 필드는 null 허용 타입으로 표현하고, 모델에게 "없으면 null"이라고 설명해라.
  • enum을 프롬프트로만 설명한다. "상태는 open/closed 중 하나"라고 프롬프트에 쓰지 말고 스키마의 enum으로 박아라. 그래야 오타난 라벨이 원천 봉쇄된다.
  • 스키마 강제가 지원하지 않는 제약을 믿는다. minimum/maximum, minLength, 재귀 스키마 등은 대체로 디코딩 단계에서 강제되지 않는다. 이런 건 Pydantic 검증기로 잡아야 한다.
  • 거부(refusal)를 파싱 실패로 착각한다. 모델이 안전상 거부하면 스키마와 무관한 응답이 온다. refusal/stop_reason을 먼저 확인하고, 그 경우는 재시도가 아니라 별도 처리해야 한다.
  • 첫 요청 지연을 성능 문제로 오해한다. 새 스키마는 컴파일 지연이 한 번 붙는다(이후 캐시). 벤치마킹할 땐 두 번째 요청부터 재라.

언제 구조화 출력을 쓰지 말아야 하나

만능은 아니다. 몇 가지 경우엔 오히려 방해가 된다. 출력이 자유로운 산문(글쓰기, 요약, 대화)이어야 할 때 억지로 JSON에 가두면 품질이 떨어진다. 스키마가 매 요청마다 크게 바뀌는 경우에도 강제의 이점(스키마 캐시)이 사라진다.

또 하나, 제약 디코딩은 미묘하게 모델의 추론 여지를 좁힌다. 아주 복잡한 추론 뒤에 구조화 결과가 필요하다면, "먼저 자유롭게 생각(thinking/추론)하게 두고 → 마지막에만 구조화 출력으로 정리"하는 2단계 구성이 더 낫다. 나는 어려운 추출은 대개 이렇게 나눈다.

자주 묻는 질문

JSON 모드와 스키마 강제(Structured Outputs)의 차이가 뭔가요?

JSON 모드는 출력이 유효한 JSON 문법임만 보장하고, 필드·타입·필수값은 보장하지 않습니다. 스키마 강제는 여기에 더해 정의한 스키마를 정확히 따르도록 디코딩 단계에서 강제합니다. DB에 넣거나 다음 시스템에 넘길 데이터라면 스키마 강제를 쓰세요.

함수 호출과 구조화 출력, 언제 뭘 쓰나요?

모델이 외부 도구·API를 실제로 호출해야 하면 함수 호출을, 단순히 정해진 형식의 데이터만 뽑으면 되면 구조화 출력을 쓰세요. 다만 둘의 내부 원리는 같아서, 추출 작업에 tool_choice로 도구를 강제해 인자만 받아 쓰는 패턴도 흔합니다.

스키마 강제를 켰는데도 재시도 로직이 필요한가요?

형식 파싱 실패용으로는 거의 필요 없습니다. 하지만 max_tokens로 응답이 잘리는 경우, 스키마가 표현 못 하는 의미 규칙(합계 검증 등)에서 걸리는 경우, 모델이 거부하는 경우는 여전히 처리해야 합니다. 검증 실패 재시도와 네트워크 에러 재시도는 정책을 분리하세요.

선택(optional) 필드는 스키마에서 어떻게 표현하나요?

strict 모드에서는 모든 필드를 required에 넣어야 하는 경우가 많습니다. 진짜 선택 필드는 타입을 ["string", "null"]처럼 null 허용으로 정의하고, 값이 없을 땐 null을 넣도록 프롬프트에 명시하세요. Pydantic이면 Optional[...]이 대개 이 형태로 변환됩니다.

스키마 강제를 쓰면 모델 성능(추론)이 떨어지나요?

복잡한 추론이 필요한 작업에서는 제약이 약간의 손해를 줄 수 있습니다. 이럴 땐 모델이 먼저 자유롭게 추론하도록 두고, 마지막 정리 단계에서만 구조화 출력을 적용하는 2단계 방식이 안전합니다. 단순 추출·분류는 처음부터 강제해도 문제없습니다.

숫자 범위나 문자열 길이 제약도 스키마로 강제되나요?

대부분 아닙니다. minimum/maximum, minLength/maxLength, multipleOf 같은 값 제약과 재귀 스키마는 디코딩 단계에서 강제되지 않는 경우가 많습니다. 이런 규칙은 Pydantic·Zod 검증기로 애플리케이션 쪽에서 확인하고, 실패 시 재시도 루프로 되먹이세요.

첫 요청이 유독 느린데 정상인가요?

네, 새 스키마는 한 번 컴파일 지연이 붙습니다(이후 24시간 캐시되는 게 일반적). 같은 스키마를 반복 사용하면 두 번째 요청부터는 빨라집니다. 성능을 측정할 땐 첫 요청을 제외하고 재세요.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록