🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Structured Output 구조화된 출력 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 1. · 71 Views

Structured Output 구조화된 출력 완벽 가이드

LLM의 응답을 원하는 형식으로 정확하게 받아내는 방법을 배웁니다. Pydantic 모델부터 에러 핸들링까지, 실무에서 바로 활용할 수 있는 구조화된 출력 기법을 다룹니다.


목차

  1. response_format 파라미터
  2. Pydantic BaseModel 정의
  3. ProviderStrategy 네이티브 지원
  4. ToolStrategy 도구 기반
  5. Union 타입으로 다중 스키마
  6. 에러 핸들링과 재시도

1. response format 파라미터

김개발 씨는 오늘 챗봇 프로젝트에서 이상한 문제를 만났습니다. LLM에게 "사용자 정보를 JSON으로 달라"고 했는데, 어떤 때는 JSON이 오고, 어떤 때는 마크다운 코드 블록으로 감싸져 오고, 심지어 설명 문장까지 덧붙여서 오는 것이었습니다.

파싱 코드가 매번 깨지니 머리가 아파왔습니다.

response_format 파라미터는 LLM에게 "이 형식으로만 대답해"라고 강제하는 설정입니다. 마치 서류 양식을 건네주며 "이 칸에만 적어주세요"라고 말하는 것과 같습니다.

이것을 사용하면 LLM의 응답이 항상 예측 가능한 구조로 돌아오기 때문에 파싱 오류를 원천 차단할 수 있습니다.

다음 코드를 살펴봅시다.

from langchain_openai import ChatOpenAI

# response_format으로 JSON 모드 활성화
llm = ChatOpenAI(
    model="gpt-4o",
    temperature=0,
    model_kwargs={
        "response_format": {"type": "json_object"}  # JSON 형식 강제
    }
)

# 이제 LLM은 반드시 유효한 JSON만 반환합니다
response = llm.invoke("사용자 이름: 홍길동, 나이: 25를 JSON으로 변환해주세요")
print(response.content)  # {"name": "홍길동", "age": 25}

김개발 씨는 입사 6개월 차 백엔드 개발자입니다. 최근 회사에서 AI 챗봇 프로젝트를 맡게 되었는데, LLM의 응답을 파싱하는 것이 생각보다 까다로웠습니다.

분명히 프롬프트에 "JSON으로 응답해줘"라고 적었는데, LLM은 마음대로 형식을 바꿔버리곤 했습니다. 어느 날은 깔끔한 JSON이 왔습니다.

그런데 다음 날 같은 요청에 "네, JSON으로 변환해드리겠습니다"라는 문장과 함께 응답이 왔습니다. 파싱 코드는 당연히 에러를 뱉었습니다.

선배 개발자 박시니어 씨가 김개발 씨의 한숨 소리를 듣고 다가왔습니다. "LLM한테 형식을 '부탁'하고 있구나.

그러면 안 돼요. '강제'해야 해요." 그렇다면 response_format이란 정확히 무엇일까요?

쉽게 비유하자면, response_format은 마치 은행 창구의 서류 양식과 같습니다. 은행에서 계좌를 개설할 때 직원이 "그냥 아무 종이에 적어주세요"라고 하지 않습니다.

정해진 양식을 주고 "여기 칸에 맞춰서 적어주세요"라고 합니다. 그래야 정보가 일관되게 수집되니까요.

response_format도 마찬가지입니다. LLM에게 "JSON 양식으로만 응답해"라고 API 레벨에서 강제하는 것입니다.

프롬프트에 적는 것과는 차원이 다릅니다. 프롬프트는 '부탁'이고, response_format은 '규칙'입니다.

이 기능이 없던 시절에는 어땠을까요? 개발자들은 온갖 꼼수를 동원해야 했습니다.

프롬프트 끝에 "반드시 JSON만 출력하세요. 다른 텍스트는 절대 포함하지 마세요"라고 강조했습니다.

그래도 LLM은 가끔 말을 듣지 않았습니다. 더 심각한 문제는 프로덕션 환경에서 터졌습니다.

개발 환경에서는 잘 되던 것이 실제 서비스에서 갑자기 파싱 에러를 내뿜었습니다. 사용자들의 다양한 입력이 LLM의 응답 패턴을 예측 불가능하게 만들었기 때문입니다.

바로 이런 문제를 해결하기 위해 OpenAI는 response_format 파라미터를 도입했습니다. 이제 type을 json_object로 설정하면 LLM은 무조건 유효한 JSON만 반환합니다.

중간에 설명 문장을 끼워넣거나 마크다운으로 감싸는 일이 없어집니다. 위의 코드를 살펴보겠습니다.

ChatOpenAI를 생성할 때 model_kwargs에 response_format을 설정합니다. type을 json_object로 지정하면 JSON 모드가 활성화됩니다.

이제 invoke를 호출하면 응답의 content가 항상 파싱 가능한 JSON 문자열입니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 고객 문의를 분류하는 시스템을 만든다고 가정해봅시다. LLM이 문의 내용을 분석해서 카테고리, 긴급도, 담당 부서를 JSON으로 반환해야 합니다.

response_format을 사용하면 이 구조가 절대 깨지지 않습니다. 하지만 주의할 점도 있습니다.

response_format의 json_object 모드는 "유효한 JSON"만 보장합니다. 우리가 원하는 특정 필드가 있는지는 보장하지 않습니다.

name 필드를 원했는데 username이 올 수도 있습니다. 이 문제는 다음에 배울 Pydantic으로 해결할 수 있습니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언대로 response_format을 적용한 김개발 씨는 더 이상 파싱 에러로 고통받지 않게 되었습니다.

"이렇게 간단한 거였군요!"

실전 팁

💡 - response_format은 JSON 구조만 보장하고, 스키마는 보장하지 않습니다. 정확한 스키마가 필요하면 Pydantic을 함께 사용하세요.

  • JSON 모드를 쓸 때는 프롬프트에도 JSON을 요청하는 내용이 있어야 합니다. 그렇지 않으면 API 에러가 발생할 수 있습니다.

2. Pydantic BaseModel 정의

김개발 씨는 response_format 덕분에 유효한 JSON을 받게 되었습니다. 하지만 새로운 문제가 생겼습니다.

어떤 응답에는 "age"가 숫자로 오고, 어떤 응답에는 "나이"라는 키로 오고, 심지어 "25살"처럼 문자열로 오기도 했습니다. JSON은 맞지만, 구조가 제각각이었습니다.

Pydantic BaseModel은 데이터의 정확한 구조와 타입을 정의하는 클래스입니다. 마치 건축 설계도처럼 "이 자리에는 문자열이, 저 자리에는 숫자가 와야 한다"고 명확히 규정합니다.

LangChain의 with_structured_output 메서드와 결합하면 LLM이 이 설계도에 맞는 응답만 생성하도록 강제할 수 있습니다.

다음 코드를 살펴봅시다.

from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI

# 원하는 출력 구조를 Pydantic 모델로 정의
class UserInfo(BaseModel):
    """사용자 정보를 담는 모델"""
    name: str = Field(description="사용자의 이름")
    age: int = Field(description="사용자의 나이 (숫자)")
    email: str = Field(description="이메일 주소")

llm = ChatOpenAI(model="gpt-4o", temperature=0)
# with_structured_output으로 Pydantic 모델 연결
structured_llm = llm.with_structured_output(UserInfo)

# 이제 응답이 UserInfo 객체로 직접 반환됩니다
result = structured_llm.invoke("홍길동, 25세, hong@email.com 정보를 추출해줘")
print(f"이름: {result.name}, 나이: {result.age}")  # 타입이 보장됨

김개발 씨의 고민은 깊어졌습니다. JSON은 받았는데, 그 안의 내용이 너무 들쭉날쭉했습니다.

프론트엔드 팀에서 "age 필드가 가끔 문자열로 와서 에러가 나요"라고 항의했습니다. 박시니어 씨가 다시 등장했습니다.

"JSON은 그릇일 뿐이야. 그 안에 뭘 담을지도 정해줘야 해." 그렇다면 Pydantic BaseModel이란 정확히 무엇일까요?

쉽게 비유하자면, Pydantic 모델은 마치 아파트 설계도와 같습니다. 설계도에는 "여기는 거실 20평, 저기는 방 8평"처럼 각 공간의 용도와 크기가 명확히 정해져 있습니다.

시공사가 마음대로 거실을 없애거나 방을 창고로 바꿀 수 없습니다. Pydantic 모델도 마찬가지입니다.

"name은 문자열, age는 정수, email은 문자열"처럼 각 필드의 이름과 타입을 명확히 정의합니다. LLM이 이 규격에 맞지 않는 응답을 생성하려고 하면 시스템이 이를 바로잡거나 에러를 발생시킵니다.

Pydantic의 진정한 강점은 Field 함수에 있습니다. Field의 description 파라미터는 단순한 주석이 아닙니다.

LLM에게 전달되어 "이 필드에는 이런 내용을 채워야 한다"고 안내하는 역할을 합니다. 코드를 자세히 살펴보겠습니다.

먼저 UserInfo 클래스를 정의합니다. BaseModel을 상속받고, 각 필드에 타입 힌트를 붙입니다.

name: str는 "name 필드는 문자열이어야 한다"는 뜻입니다. age: int는 "age 필드는 정수여야 한다"는 뜻입니다.

Field의 description은 매우 중요합니다. "사용자의 나이 (숫자)"라고 적어두면 LLM이 "25살"이 아닌 25로 응답하도록 유도됩니다.

이것이 프롬프트 엔지니어링과 데이터 모델링의 결합입니다. with_structured_output 메서드가 마법을 부립니다.

이 메서드에 Pydantic 모델을 전달하면, LangChain이 내부적으로 모델 스키마를 LLM에게 전달하고, 응답을 받아서 해당 클래스의 인스턴스로 변환합니다. 더 이상 JSON 파싱 코드를 직접 작성할 필요가 없습니다.

실무에서는 이 패턴이 정말 자주 쓰입니다. 이커머스 서비스에서 상품 리뷰를 분석한다고 가정해봅시다.

ReviewAnalysis 모델에 sentiment(감정), keywords(키워드 목록), rating_prediction(예상 평점) 같은 필드를 정의하면, LLM의 분석 결과가 항상 일관된 구조로 돌아옵니다. 주의할 점이 있습니다.

너무 복잡한 모델을 정의하면 LLM이 모든 필드를 제대로 채우지 못할 수 있습니다. 처음에는 필수 필드만 정의하고, 점진적으로 확장하는 것이 좋습니다.

Optional 타입을 활용하면 선택적 필드도 지정할 수 있습니다. 김개발 씨는 UserInfo 모델을 적용한 후 프론트엔드 팀의 항의가 사라졌음을 깨달았습니다.

"타입이 보장되니까 안심이 되네요."

실전 팁

💡 - Field의 description은 LLM에게 전달되는 힌트입니다. 명확하고 구체적으로 작성하세요.

  • 복잡한 구조는 중첩 모델로 표현할 수 있습니다. 예를 들어 Address 모델을 만들고 User 모델 안에 address: Address로 포함시킬 수 있습니다.

3. ProviderStrategy 네이티브 지원

이제 김개발 씨는 구조화된 출력의 기본을 익혔습니다. 그런데 궁금증이 생겼습니다.

"LangChain이 내부적으로 어떻게 처리하는 거지? 모든 LLM 제공자가 같은 방식으로 작동하나?" 선배에게 물어보니 "그건 Strategy 패턴을 알아야 해"라는 답이 돌아왔습니다.

ProviderStrategy는 LLM 제공자가 기본적으로 지원하는 구조화된 출력 기능을 활용하는 전략입니다. OpenAI의 JSON Schema 모드나 Anthropic의 tool_use 등 각 제공자의 네이티브 기능을 사용합니다.

마치 각 자동차 회사의 순정 부품을 사용하는 것처럼, 해당 제공자에 최적화된 방식으로 작동합니다.

다음 코드를 살펴봅시다.

from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI

class MovieReview(BaseModel):
    """영화 리뷰 분석 결과"""
    title: str = Field(description="영화 제목")
    sentiment: str = Field(description="감정: positive, negative, neutral 중 하나")
    score: int = Field(description="1-10 사이의 평점")
    summary: str = Field(description="한 줄 요약")

llm = ChatOpenAI(model="gpt-4o", temperature=0)
# method="json_schema"로 OpenAI 네이티브 JSON Schema 모드 사용
structured_llm = llm.with_structured_output(
    MovieReview,
    method="json_schema"  # ProviderStrategy: OpenAI 네이티브 지원
)

result = structured_llm.invoke("인셉션은 정말 대단한 영화였어. 꿈 속의 꿈이라니!")
print(f"{result.title}: {result.sentiment} ({result.score}/10)")

김개발 씨는 with_structured_output이 마법처럼 작동하는 것에 감탄했습니다. 하지만 개발자로서의 호기심이 발동했습니다.

"대체 어떤 원리로 작동하는 거지?" 박시니어 씨가 화이트보드 앞으로 김개발 씨를 불렀습니다. "LangChain의 구조화된 출력은 크게 두 가지 전략이 있어.

오늘은 첫 번째인 ProviderStrategy를 설명해줄게." ProviderStrategy란 무엇일까요? 자동차에 비유해봅시다.

차가 고장 나면 정비소에 가서 부품을 교체합니다. 이때 선택지가 두 가지 있습니다.

하나는 해당 자동차 회사의 순정 부품을 사용하는 것이고, 다른 하나는 범용 호환 부품을 사용하는 것입니다. ProviderStrategy는 순정 부품에 해당합니다.

OpenAI가 제공하는 JSON Schema 모드, Anthropic이 제공하는 tool_use 기능 등 각 LLM 제공자가 자체적으로 지원하는 구조화된 출력 기능을 그대로 활용합니다. OpenAI의 경우 JSON Schema 모드라는 강력한 기능이 있습니다.

Pydantic 모델을 JSON Schema로 변환해서 API에 전달하면, GPT가 해당 스키마에 정확히 맞는 JSON을 생성합니다. 이것은 프롬프트 수준의 지시가 아니라 모델 내부에서 작동하는 제약 조건입니다.

코드에서 method="json_schema"를 지정하면 이 모드가 활성화됩니다. LangChain이 MovieReview 모델을 JSON Schema로 변환하고, OpenAI API의 response_format에 전달합니다.

모델은 이 스키마를 "철칙"으로 받아들입니다. ProviderStrategy의 장점은 무엇일까요?

첫째, 속도가 빠릅니다. 네이티브 기능이므로 추가적인 처리 단계가 없습니다.

둘째, 정확도가 높습니다. 모델이 스키마를 인지하고 있으므로 형식 오류가 거의 발생하지 않습니다.

셋째, 토큰을 절약합니다. 별도의 시스템 프롬프트로 형식을 설명할 필요가 없습니다.

하지만 단점도 있습니다. 모든 LLM 제공자가 이 기능을 지원하는 것은 아닙니다.

오래된 모델이나 일부 제공자는 JSON Schema 모드를 지원하지 않습니다. 이럴 때는 다음에 배울 ToolStrategy를 사용해야 합니다.

실무에서 OpenAI의 gpt-4o나 gpt-4o-mini를 사용한다면 json_schema 모드를 적극 활용하세요. 특히 정형화된 데이터 추출 작업에서 빛을 발합니다.

이력서에서 정보 추출, 계약서 분석, 로그 파싱 등의 작업에 최적입니다. 김개발 씨는 고개를 끄덕였습니다.

"순정 부품을 쓰면 안정적이지만, 차종에 따라 선택지가 다르군요."

실전 팁

💡 - OpenAI의 gpt-4o 계열을 사용한다면 method="json_schema"를 명시적으로 지정하세요. 기본값보다 더 정확합니다.

  • 지원 여부가 불확실한 모델을 사용할 때는 method 파라미터를 생략하면 LangChain이 자동으로 최적의 전략을 선택합니다.

4. ToolStrategy 도구 기반

김개발 씨가 새 프로젝트에서 Claude 모델을 사용하게 되었습니다. 익숙한 대로 json_schema 모드를 적용했는데 에러가 발생했습니다.

"이 모델은 JSON Schema 모드를 지원하지 않습니다." 당황한 김개발 씨에게 박시니어 씨가 말했습니다. "그럴 땐 ToolStrategy를 써야 해."

ToolStrategy는 LLM의 함수 호출(Function Calling) 또는 도구 사용(Tool Use) 기능을 활용해서 구조화된 출력을 얻는 전략입니다. Pydantic 모델을 "도구"로 위장해서 LLM에게 전달하고, LLM이 이 도구를 "호출"하는 형태로 구조화된 데이터를 반환하게 합니다.

대부분의 현대 LLM이 지원하므로 범용성이 뛰어납니다.

다음 코드를 살펴봅시다.

from pydantic import BaseModel, Field
from langchain_anthropic import ChatAnthropic

class TaskExtraction(BaseModel):
    """할 일 추출 결과"""
    task: str = Field(description="해야 할 일")
    priority: str = Field(description="우선순위: high, medium, low")
    deadline: str = Field(description="마감일 (없으면 'none')")

# Anthropic Claude 모델 사용
llm = ChatAnthropic(model="claude-sonnet-4-20250514", temperature=0)
# method를 지정하지 않으면 자동으로 ToolStrategy 선택
# 또는 명시적으로 method="function_calling" 지정 가능
structured_llm = llm.with_structured_output(TaskExtraction)

result = structured_llm.invoke("내일까지 보고서 제출해야 하는데 급해!")
print(f"할 일: {result.task}, 우선순위: {result.priority}")

김개발 씨는 난감했습니다. 회사에서 비용 절감을 위해 일부 기능을 Claude로 마이그레이션하기로 했는데, 기존 코드가 작동하지 않았습니다.

박시니어 씨가 커피를 건네며 말했습니다. "ProviderStrategy가 순정 부품이라면, ToolStrategy는 범용 어댑터야.

어떤 차에든 연결할 수 있지." ToolStrategy는 어떤 원리로 작동할까요? 현대 LLM들은 대부분 함수 호출(Function Calling) 또는 도구 사용(Tool Use) 기능을 지원합니다.

원래 이 기능은 LLM이 외부 API를 호출하거나 특정 작업을 수행하기 위해 설계되었습니다. 예를 들어 "서울 날씨 알려줘"라고 하면 LLM이 날씨 API를 호출하는 방식입니다.

ToolStrategy는 이 기능을 영리하게 재활용합니다. Pydantic 모델을 마치 도구인 것처럼 LLM에게 소개합니다.

"TaskExtraction이라는 도구가 있어. 이 도구는 task, priority, deadline 파라미터를 받아." 그러면 LLM은 사용자의 입력을 분석해서 이 "도구를 호출"하는 형태로 응답합니다.

물론 실제로 도구가 호출되는 것은 아닙니다. LangChain이 LLM의 "도구 호출 응답"을 가로채서 Pydantic 객체로 변환할 뿐입니다.

LLM 입장에서는 도구를 호출한 것이고, 우리 입장에서는 구조화된 데이터를 받은 것입니다. 코드를 살펴보면, ChatAnthropic을 사용하고 있습니다.

Anthropic의 Claude는 json_schema 모드를 직접 지원하지 않지만, tool_use 기능은 훌륭하게 지원합니다. method를 지정하지 않으면 LangChain이 자동으로 이를 감지하고 ToolStrategy를 선택합니다.

ToolStrategy의 장점은 범용성입니다. OpenAI, Anthropic, Google, Mistral 등 대부분의 현대 LLM이 함수 호출 기능을 지원합니다.

따라서 모델을 바꿔도 같은 코드가 작동합니다. 멀티 모델 환경에서 특히 유용합니다.

단점도 있습니다. ProviderStrategy에 비해 약간의 오버헤드가 있습니다.

도구 정의를 프롬프트에 포함시켜야 하므로 토큰을 더 소모합니다. 또한 아주 드물게 LLM이 도구 호출 형식을 완벽하게 따르지 않는 경우가 있습니다.

실무에서는 어떻게 선택할까요? 단일 제공자를 사용하고 해당 제공자가 네이티브 구조화 출력을 지원한다면 ProviderStrategy를 선택하세요.

여러 제공자를 오가거나, 지원 여부가 불확실하다면 ToolStrategy가 안전한 선택입니다. 김개발 씨는 method를 지정하지 않고 Claude에서 테스트해봤습니다.

완벽하게 작동했습니다. "LangChain이 알아서 처리해주니 편하네요."

실전 팁

💡 - method를 지정하지 않으면 LangChain이 최적의 전략을 자동 선택합니다. 특별한 이유가 없다면 자동 선택을 신뢰하세요.

  • 함수 호출 기능이 없는 아주 오래된 모델에서는 ToolStrategy도 작동하지 않습니다. 이 경우 프롬프트에 JSON 예시를 포함하는 방식을 사용해야 합니다.

5. Union 타입으로 다중 스키마

김개발 씨의 프로젝트가 복잡해졌습니다. 챗봇이 사용자 질문을 분석해서 "일반 질문"이면 답변을, "데이터 조회 요청"이면 쿼리 파라미터를, "오류 신고"면 버그 리포트를 생성해야 했습니다.

하나의 Pydantic 모델로는 이 세 가지를 표현할 수 없었습니다.

Union 타입을 사용하면 LLM이 상황에 따라 여러 스키마 중 하나를 선택해서 응답할 수 있습니다. 마치 레스토랑 메뉴에서 메인 요리를 고르듯이, LLM이 입력을 분석해서 가장 적절한 응답 형식을 자동으로 결정합니다.

분류와 구조화된 출력을 한 번에 처리할 수 있는 강력한 패턴입니다.

다음 코드를 살펴봅시다.

from pydantic import BaseModel, Field
from typing import Union
from langchain_openai import ChatOpenAI

class GeneralAnswer(BaseModel):
    """일반적인 질문에 대한 답변"""
    answer: str = Field(description="질문에 대한 답변")
    confidence: float = Field(description="확신도 0.0-1.0")

class DataQuery(BaseModel):
    """데이터 조회 요청"""
    table_name: str = Field(description="조회할 테이블명")
    filters: dict = Field(description="필터 조건")

class BugReport(BaseModel):
    """버그 신고"""
    severity: str = Field(description="심각도: critical, major, minor")
    description: str = Field(description="버그 설명")

# Union으로 여러 스키마 중 하나 선택 가능
ResponseType = Union[GeneralAnswer, DataQuery, BugReport]

llm = ChatOpenAI(model="gpt-4o", temperature=0)
structured_llm = llm.with_structured_output(ResponseType)

# LLM이 입력을 분석해서 적절한 타입 선택
result = structured_llm.invoke("주문 테이블에서 오늘 주문 내역 보여줘")
print(type(result).__name__)  # DataQuery
print(result.table_name)  # "orders" 또는 유사한 값

김개발 씨는 머리를 쥐어짰습니다. 고객 문의 챗봇이 단순 질문, 데이터 조회, 버그 신고를 모두 처리해야 했습니다.

처음에는 if-else로 분기하려 했지만, 입력만 보고 어떤 타입인지 구분하는 것 자체가 어려웠습니다. 박시니어 씨가 힌트를 주었습니다.

"LLM에게 분류까지 맡겨버려. Union 타입을 써봐." Union 타입이란 무엇일까요?

뷔페 레스토랑을 떠올려봅시다. 손님이 들어오면 한식, 중식, 양식 코너 중 하나를 선택합니다.

각 코너마다 제공되는 음식의 종류와 형태가 다릅니다. 하지만 손님은 어떤 코너를 선택하든 "식사"를 한다는 공통점이 있습니다.

Union 타입도 비슷합니다. GeneralAnswer, DataQuery, BugReport는 모두 "응답"이라는 공통점이 있지만, 각각의 구조는 완전히 다릅니다.

Union[GeneralAnswer, DataQuery, BugReport]는 "이 세 가지 중 하나"라는 의미입니다. LLM에게 이 Union 타입을 전달하면 흥미로운 일이 벌어집니다.

LLM은 세 가지 선택지를 모두 인지하고, 사용자 입력을 분석해서 가장 적절한 타입을 자동으로 선택합니다. "주문 테이블에서 조회해줘"라는 입력이 들어오면 DataQuery를, "앱이 자꾸 멈춰요"라는 입력이 들어오면 BugReport를 선택합니다.

코드를 살펴보면, 먼저 세 개의 독립적인 Pydantic 모델을 정의합니다. 각 모델은 해당 케이스에 필요한 필드만 가지고 있습니다.

그다음 Union으로 이들을 묶어 ResponseType을 만듭니다. with_structured_output에 이 ResponseType을 전달하면, LLM은 응답할 때 세 가지 중 하나를 선택해야 한다는 것을 이해합니다.

반환된 result의 타입을 확인하면 어떤 클래스인지 알 수 있고, 그에 맞는 로직을 실행할 수 있습니다. 이 패턴의 진정한 강점은 분류와 추출을 한 번에 처리한다는 것입니다.

기존에는 1단계로 분류하고, 2단계로 해당 타입에 맞는 정보를 추출해야 했습니다. Union을 사용하면 한 번의 LLM 호출로 두 가지를 모두 수행합니다.

비용과 지연 시간이 절반으로 줄어듭니다. 실무에서는 고객 서비스 봇, 명령어 파서, 의도 분류기 등에서 이 패턴을 활용합니다.

특히 사용자 입력이 다양한 형태로 들어오는 경우에 유용합니다. 주의할 점이 있습니다.

Union에 포함된 타입이 너무 많거나 서로 비슷하면 LLM이 혼란을 겪을 수 있습니다. 각 타입의 용도가 명확히 구분되도록 설계하고, 필요하다면 클래스의 docstring에 사용 기준을 명시하세요.

김개발 씨는 Union 타입을 적용하고 나서 코드가 훨씬 깔끔해졌음을 느꼈습니다. "분기 로직을 LLM이 알아서 처리해주니 편하네요."

실전 팁

💡 - 각 Pydantic 모델의 docstring은 LLM이 타입을 선택하는 데 중요한 힌트가 됩니다. 명확하게 작성하세요.

  • Union의 타입이 5개를 넘어가면 성능이 저하될 수 있습니다. 필요하다면 2단계 분류로 나누는 것을 고려하세요.

6. 에러 핸들링과 재시도

김개발 씨의 서비스가 드디어 프로덕션에 배포되었습니다. 며칠간 순조롭게 작동하더니 어느 날 새벽에 알람이 울렸습니다.

"OutputParserException: Failed to parse output" 로그가 쏟아져 나왔습니다. LLM이 가끔 예상치 못한 형식으로 응답했고, 시스템 전체가 멈춰버린 것입니다.

에러 핸들링과 재시도는 구조화된 출력에서 필수적인 안전장치입니다. LLM은 확률 기반으로 동작하므로 아무리 잘 설계해도 가끔 형식 오류가 발생합니다.

적절한 예외 처리와 재시도 로직을 구현하면 이런 상황에서도 시스템이 안정적으로 동작합니다. 마치 자동차의 안전벨트처럼 평소에는 눈에 띄지 않지만 위기 상황에서 빛을 발합니다.

다음 코드를 살펴봅시다.

from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
from langchain_core.exceptions import OutputParserException
from tenacity import retry, stop_after_attempt, wait_exponential

class ProductInfo(BaseModel):
    """상품 정보"""
    name: str = Field(description="상품명")
    price: int = Field(description="가격 (원)")
    category: str = Field(description="카테고리")

# 재시도 로직 적용
@retry(
    stop=stop_after_attempt(3),  # 최대 3번 시도
    wait=wait_exponential(min=1, max=10)  # 지수 백오프
)
def extract_product_info(text: str) -> ProductInfo:
    llm = ChatOpenAI(model="gpt-4o", temperature=0)
    structured_llm = llm.with_structured_output(ProductInfo)

    try:
        result = structured_llm.invoke(text)
        return result
    except OutputParserException as e:
        print(f"파싱 실패, 재시도 중: {e}")
        raise  # tenacity가 재시도 처리

# 사용 예시
result = extract_product_info("맥북 프로 16인치 299만원 전자기기")

김개발 씨는 새벽에 울린 알람 때문에 잠을 설쳤습니다. 로그를 확인해보니 수천 건의 요청 중 단 3건에서 파싱 에러가 발생했습니다.

그런데 에러 처리가 없어서 그 3건이 전체 서비스를 마비시킨 것입니다. 다음 날 박시니어 씨가 김개발 씨를 불렀습니다.

"LLM은 100% 신뢰하면 안 돼. 항상 방어적으로 프로그래밍해야 해." 왜 구조화된 출력에서도 에러가 발생할까요?

LLM은 본질적으로 확률 기반 모델입니다. 아무리 정교하게 스키마를 정의해도 0.01%의 확률로 예상치 못한 응답이 나올 수 있습니다.

예를 들어 price 필드에 숫자 대신 "삼백만원"이라는 문자열이 들어올 수 있습니다. Pydantic 검증에서 이것이 걸리면 예외가 발생합니다.

문제는 프로덕션 환경에서 이런 예외 하나가 전체 서비스에 영향을 줄 수 있다는 것입니다. 하루에 만 건의 요청을 처리한다면, 0.01%의 에러율이라도 하루에 한 건씩 문제가 발생합니다.

해결책은 **재시도(Retry)**입니다. 대부분의 LLM 에러는 일시적입니다.

같은 입력으로 다시 요청하면 정상적인 응답이 돌아올 가능성이 높습니다. 코드에서 tenacity 라이브러리를 사용했습니다.

이것은 Python의 대표적인 재시도 라이브러리입니다. @retry 데코레이터를 함수에 붙이면, 함수 내에서 예외가 발생했을 때 자동으로 재시도합니다.

stop_after_attempt(3)은 최대 3번까지 시도한다는 의미입니다. 3번 모두 실패하면 그때서야 예외를 발생시킵니다.

wait_exponential(min=1, max=10)은 지수 백오프입니다. 첫 번째 재시도는 1초 후에, 두 번째는 2초 후에, 세 번째는 4초 후에...

이런 식으로 대기 시간이 점점 늘어납니다. 이렇게 하면 일시적인 API 과부하 상황에서도 안정적으로 복구할 수 있습니다.

try-except 블록에서 OutputParserException을 잡고 있습니다. 이것은 LangChain이 구조화된 출력 파싱에 실패했을 때 발생시키는 예외입니다.

로그를 남기고 다시 raise하면 tenacity가 재시도를 처리합니다. 실무에서는 이보다 더 정교한 에러 핸들링이 필요할 수 있습니다.

예를 들어 특정 유형의 에러는 재시도하고, 다른 유형은 바로 실패 처리하는 로직을 추가할 수 있습니다. 또한 Sentry 같은 모니터링 도구와 연동하면 에러 패턴을 분석하고 근본 원인을 파악할 수 있습니다.

몇 가지 추가 팁이 있습니다. temperature를 0으로 설정하면 응답의 일관성이 높아져서 파싱 에러가 줄어듭니다.

또한 Pydantic 모델의 validator를 활용하면 더 세밀한 데이터 검증이 가능합니다. 김개발 씨는 재시도 로직을 적용한 후 새벽 알람에서 해방되었습니다.

0.01%의 에러가 발생해도 시스템은 묵묵히 재시도하고, 대부분 두 번째 시도에서 성공했습니다.

실전 팁

💡 - temperature를 0으로 설정하면 응답 일관성이 높아지고 파싱 에러가 줄어듭니다.

  • 재시도 횟수는 3회 정도가 적당합니다. 너무 많으면 응답 시간이 길어지고, 너무 적으면 일시적 오류를 놓칠 수 있습니다.
  • 모든 에러를 재시도하지 말고, API 키 오류 같은 영구적 에러는 바로 실패 처리하세요.

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#LangChain#StructuredOutput#Pydantic#LLM#AIEngineering#AI,LLM,Python,LangChain

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스