자유로운 답변은 앱에서 바로 쓰기 어렵다
LLM은 문장을 잘 만든다. 하지만 애플리케이션이 필요한 것은 종종 문장이 아니라 데이터다.
예를 들어 고객 문의를 읽고 다음 값을 얻어야 한다고 해 보자.
문의 유형:
billing,technical,account중 하나긴급도:
low,medium,high중 하나담당자에게 넘길지 여부:
true또는false요약문: 한 문장
모델이 "이 문의는 결제 문제로 보이며 꽤 급합니다"라고 답하면 사람은 이해할 수 있다. 하지만 프로그램은 이 문장을 안전하게 처리하기 어렵다. 결제 팀으로 라우팅하려면 category == "billing" 같은 조건문이 필요하다. 데이터베이스에 저장하려면 필드명과 타입도 일정해야 한다.
그래서 실무 앱에서는 "그럴듯한 답변"보다 "믿고 처리할 수 있는 출력"이 중요하다. LangChain의 출력 파서와 구조화 출력 기능은 모델 응답을 문자열, JSON, Pydantic 객체처럼 프로그램이 다루기 쉬운 형태로 바꿔 준다. LangChain 문서에서도 구조화 출력의 목적을 자유 텍스트 대신 예측 가능한 형식의 데이터를 반환하는 것으로 설명한다.1 출력 파서는 LLM 응답에서 필요한 값을 뽑아 애플리케이션 데이터로 바꾸는 대표적인 방법이다.2
AI 생성 이미지
가장 단순한 파서: StrOutputParser
앞 장에서 살펴본 LCEL 체인은 보통 다음 형태로 시작한다.
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prompt = ChatPromptTemplate.from_messages([
("system", "너는 초보자를 돕는 Python 튜터다."),
("human", "다음 개념을 두 문장으로 설명해 줘: {topic}"),
])
chain = prompt | model | StrOutputParser()
result = chain.invoke({"topic": "리스트 컴프리헨션"})
print(type(result))
print(result)model.invoke()의 결과는 보통 AIMessage 형태다. 여기에는 본문뿐 아니라 토큰 사용량, 응답 메타데이터 같은 부가 정보도 함께 들어 있다. StrOutputParser는 그중 실제 답변 텍스트만 꺼내 str로 반환한다.
문장형 챗봇, 요약, 초안 작성처럼 사람이 바로 읽는 결과에는 이 정도면 충분하다. 그러나 분류, 저장, 조건 분기, API 호출처럼 다음 코드가 결과를 처리해야 한다면 문자열만으로는 부족하다.
JSON으로 받기: JsonOutputParser
JSON은 언어와 시스템을 가리지 않고 널리 쓰이는 데이터 형식이다. Python에서는 JSON 객체를 dict처럼 다룰 수 있으므로 후속 처리에 편리하다.
from langchain_core.output_parsers import JsonOutputParser
parser = JsonOutputParser()
prompt = ChatPromptTemplate.from_messages([
("system", "너는 제품 리뷰에서 핵심 정보를 추출하는 분석기다."),
("human", """
다음 리뷰를 분석해 아래 키를 가진 JSON 객체 하나로만 반환해라.
설명 문장이나 마크다운 코드 블록은 붙이지 마라.
필드:
- product_name: 제품명. 알 수 없으면 null
- rating: 1부터 5까지의 정수 별점
- pros: 장점 문자열 목록
- cons: 단점 문자열 목록
- summary: 한 문장 요약
리뷰:
{review}
{format_instructions}
"""),
]).partial(format_instructions=parser.get_format_instructions())
chain = prompt | model | parser
result = chain.invoke({
"review": "배터리는 오래가고 화면도 밝지만, 무게가 조금 무겁습니다. 별점은 4점입니다."
})
print(result)
print(result["rating"])핵심은 {format_instructions}다. 파서가 요구하는 출력 형식 안내문을 프롬프트에 넣어 모델이 JSON 형태로 답하도록 유도한다. 이 값을 매번 입력으로 넘기지 않기 위해 partial()로 미리 채워 두었다.
여기서 한 가지를 구분해야 한다. JsonOutputParser()만 사용하면 "JSON으로 파싱 가능한가"가 주된 관심사다. 어떤 키가 필요한지, 각 키의 값이 어떤 의미인지까지는 프롬프트에 분명히 적어 주는 편이 좋다. 위 예제에서 product_name, rating, pros, cons, summary를 따로 적은 이유가 여기에 있다.
JSON 파서는 다음과 같은 상황에 유용하다.
간단한 키-값 데이터를 추출할 때
결과를 API 응답으로 그대로 내려줄 때
데이터베이스의 JSON 컬럼에 저장할 때
다음 체인의 입력으로 넘길 때
다만 JSON 파서는 "형식"을 맞추는 데 초점이 있다. rating이 정말 숫자인지, category가 허용된 값 중 하나인지, 필수 필드가 모두 있는지까지 강하게 검증하려면 스키마가 필요하다.
Pydantic으로 스키마를 명확히 하기
Pydantic은 Python에서 데이터 구조와 검증 규칙을 정의할 때 자주 쓰는 라이브러리다. LangChain의 PydanticOutputParser를 사용하면 모델 출력이 우리가 정한 필드명, 타입, 제약 조건을 만족하는지 확인할 수 있다.
from typing import Literal
from pydantic import BaseModel, Field
from langchain_core.output_parsers import PydanticOutputParser
class ProductReview(BaseModel):
product_name: str = Field(description="제품명")
rating: int = Field(ge=1, le=5, description="1부터 5까지의 정수 별점")
pros: list[str] = Field(default_factory=list, description="장점 목록")
cons: list[str] = Field(default_factory=list, description="단점 목록")
summary: str = Field(description="한 문장 요약")
parser = PydanticOutputParser(pydantic_object=ProductReview)
prompt = ChatPromptTemplate.from_messages([
("system", "너는 제품 리뷰를 정해진 스키마로 정리하는 분석기다."),
("human", """
다음 리뷰를 분석해라.
리뷰:
{review}
{format_instructions}
"""),
]).partial(format_instructions=parser.get_format_instructions())
chain = prompt | model | parser
review = chain.invoke({
"review": "노트북 X는 화면이 선명하고 성능도 좋습니다. 다만 팬 소음이 있고 가격이 높습니다. 별점은 4점입니다."
})
print(review.product_name)
print(review.rating)
print(review.pros)이제 결과는 단순한 딕셔너리가 아니라 ProductReview 객체다. review.rating처럼 점 표기법으로 접근할 수 있고, rating이 1보다 작거나 5보다 크면 검증 오류가 발생한다.
Field(description=...)도 중요하다. 이 설명은 사람을 위한 문서일 뿐 아니라 모델에게 각 필드의 의미를 알려 주는 힌트가 된다. 스키마를 잘 쓰면 프롬프트에 장황한 설명을 반복하지 않아도 된다.
분류와 태깅 예제
구조화 출력이 가장 빛나는 곳은 분류와 라우팅이다. 고객 문의를 읽고 담당 부서와 우선순위를 정하는 체인을 만들어 보자.
class SupportTicket(BaseModel):
category: Literal["billing", "technical", "account", "other"] = Field(
description="문의 유형"
)
urgency: Literal["low", "medium", "high"] = Field(
description="긴급도"
)
summary: str = Field(description="한 문장 요약")
needs_human: bool = Field(description="사람 상담원이 처리해야 하면 true")
tags: list[str] = Field(default_factory=list, description="검색과 분류에 쓸 태그")
parser = PydanticOutputParser(pydantic_object=SupportTicket)
prompt = ChatPromptTemplate.from_messages([
("system", "너는 고객 지원 요청을 분류하는 업무 도우미다."),
("human", """
고객 메시지를 분석해 지원 티켓으로 변환해라.
고객 메시지:
{message}
{format_instructions}
"""),
]).partial(format_instructions=parser.get_format_instructions())
chain = prompt | model | parser
ticket = chain.invoke({
"message": "이번 달에 결제가 두 번 된 것 같습니다. 환불을 빨리 받고 싶습니다."
})
if ticket.category == "billing" and ticket.urgency == "high":
print("결제 긴급 큐로 전달")
elif ticket.needs_human:
print("상담원에게 전달")
else:
print("자동 응답 후보")여기서 중요한 점은 모델의 판단을 코드가 바로 사용할 수 있다는 것이다. Literal로 허용 값을 제한했기 때문에 payment, bill, 결제처럼 제각각인 값이 들어오는 문제를 줄일 수 있다. 물론 Literal이 모델의 판단 자체를 항상 맞게 만들어 주는 것은 아니다. 다만 애플리케이션 입장에서는 최소한 예상한 값의 집합 안에서 분기할 수 있게 된다.
정보 추출과 요약 저장 예제
회의록, 상담 기록, 이메일처럼 긴 텍스트를 저장 가능한 데이터로 바꾸는 작업에도 같은 패턴을 쓴다.
class ActionItem(BaseModel):
owner: str = Field(description="담당자")
task: str = Field(description="해야 할 일")
due_date: str | None = Field(default=None, description="마감일. 없으면 null")
class MeetingRecord(BaseModel):
title: str = Field(description="회의 제목")
decisions: list[str] = Field(default_factory=list, description="결정 사항")
action_items: list[ActionItem] = Field(default_factory=list, description="후속 작업")
summary: str = Field(description="전체 회의 요약")이런 구조로 결과를 받으면 곧바로 데이터베이스에 저장하거나, 담당자별 작업 목록을 만들거나, 마감일이 있는 항목만 캘린더 API로 보낼 수 있다. 구조화 출력은 "답변을 예쁘게 만드는 기술"이 아니라 "LLM 결과를 업무 시스템에 연결하는 기술"에 가깝다.
다만 날짜처럼 중요한 값은 문자열 하나로만 받아도 되는지 먼저 생각해야 한다. 내부 시스템이 YYYY-MM-DD 형식을 요구한다면 description에 그 형식을 명시하고, 저장 전에 한 번 더 검증하는 편이 안전하다.
형식 오류는 반드시 발생한다고 가정하기
출력 파서를 쓴다고 해서 오류가 사라지는 것은 아니다. 모델이 JSON 앞에 설명 문장을 붙이거나, 쉼표를 잘못 넣거나, 스키마에 없는 값을 만들 수 있다. Pydantic 검증에서 타입 오류가 날 수도 있다.
따라서 실무 코드에는 복구 전략이 필요하다.
첫째, 한 번 재시도한다. 일시적인 형식 오류는 같은 요청을 다시 보내는 것만으로 해결되기도 한다.
둘째, 잘못된 출력을 모델에게 다시 주고 "스키마에 맞게 고쳐라"라고 요청한다.
from langchain_core.exceptions import OutputParserException
raw_chain = prompt | model | StrOutputParser()
bad_output = raw_chain.invoke({"message": "로그인이 안 되고 비밀번호 재설정 메일도 오지 않습니다."})
try:
ticket = parser.parse(bad_output)
except OutputParserException as e:
fix_prompt = ChatPromptTemplate.from_messages([
("system", "너는 잘못된 모델 출력을 정해진 스키마에 맞게 수정한다. 설명 없이 결과만 반환해라."),
("human", """
스키마 안내:
{format_instructions}
잘못된 출력:
{bad_output}
오류:
{error}
"""),
]).partial(format_instructions=parser.get_format_instructions())
fix_chain = fix_prompt | model | parser
ticket = fix_chain.invoke({
"bad_output": bad_output,
"error": str(e),
})이 예제에서는 같은 프롬프트와 모델을 쓰되, 마지막에 바로 파서를 붙이지 않고 StrOutputParser()로 원문 문자열을 먼저 받았다. 그래야 실패한 출력을 parser.parse()에 직접 넣어 보고, 오류가 나면 그 내용을 수정용 프롬프트에 넘길 수 있다.
셋째, 기본값을 둔다. 자동 처리가 실패했을 때 앱 전체가 멈추면 안 된다. 예를 들어 분류 실패 시 category="other", urgency="low", needs_human=True로 저장하고 사람이 확인하게 만들 수 있다.
def fallback_ticket(message: str) -> SupportTicket:
return SupportTicket(
category="other",
urgency="low",
summary=message[:80],
needs_human=True,
tags=["parse_failed"],
)복구 전략은 비용과 정확도의 균형 문제다. 재시도는 API 비용과 지연 시간을 늘린다. 반대로 기본값 처리는 빠르지만 자동화 품질이 낮아질 수 있다. 중요한 업무일수록 "재시도 → 수정 요청 → 사람 검토"처럼 단계적으로 설계하는 편이 안전하다.
파서와 네이티브 구조화 출력의 선택
파서 기반 방식은 이해하기 쉽고 LCEL에 자연스럽게 연결된다. 여러 모델 제공자에서 비슷한 방식으로 쓸 수 있다는 장점도 있다.
하지만 모델 제공자가 JSON 모드, 함수 호출, 네이티브 구조화 출력을 지원한다면 그 기능을 우선 검토하는 것이 좋다. 제공자 수준에서 스키마를 강제하면 단순 텍스트를 나중에 파싱하는 방식보다 안정적인 경우가 많다. LangChain도 구조화 응답을 위해 제공자 네이티브 방식과 도구 호출 방식을 구분해 지원한다.1
최근 LangChain에서는 모델이나 에이전트에 스키마를 직접 넘겨 구조화 응답을 받는 방식도 자주 사용된다. 이때 모델 제공자가 네이티브 구조화 출력을 지원하면 제공자 전략을 쓰고, 그렇지 않으면 도구 호출 전략을 쓰는 식으로 동작할 수 있다. 이 장에서 다룬 출력 파서는 그보다 한 단계 낮은 수준에서 "모델이 낸 텍스트를 어떻게 해석할 것인가"를 직접 다루는 방법이라고 이해하면 된다.
정리하면 다음 기준으로 선택하면 된다.
사람이 읽는 답변이면
StrOutputParser간단한 키-값 결과면
JsonOutputParser필드, 타입, 허용 값 검증이 필요하면
PydanticOutputParser모델 제공자가 스키마 강제 기능을 안정적으로 지원하면 네이티브 구조화 출력 또는 도구 호출 방식 검토
구조화 출력 설계 기준
좋은 구조화 출력은 모델이 아니라 앱의 요구사항에서 출발한다.
필드가 너무 많으면 오류 가능성이 커진다. 처음에는 꼭 필요한 필드만 정의한다. description에는 모호한 설명보다 판단 기준을 짧게 적는다. 허용 값이 정해져 있다면 자유 문자열 대신 Literal을 쓴다. 숫자에는 범위를 둔다. 없을 수 있는 값은 None을 허용한다.
또한 구조화 결과를 그대로 신뢰하지 말아야 한다. LLM이 만든 값은 검증된 형식일 수는 있어도 항상 사실이라는 뜻은 아니다. 금액, 날짜, 법률 판단, 의료 판단처럼 중요한 값은 원문 근거를 함께 저장하거나 사람 검토 단계를 둬야 한다.
이번 장의 핵심은 단순하다. LLM 앱은 문장을 생성하는 데서 끝나지 않는다. 다음 코드가 사용할 수 있는 데이터로 바뀌어야 비로소 제품의 일부가 된다. 출력 파싱과 구조화 응답은 그 연결 지점을 단단하게 만드는 기술이다.
참고
1Structured output - Docs by LangChain
2LangChain Output Parsers: Extract Structured Data from LLM Responses
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.