티스토리 뷰

반응형

Python으로 공부하는 OpenAI 4편 — Pydantic으로 응답을 검증해야 진짜 백엔드 코드가 됩니다

OpenAI를 Python에서 붙여보면 처음엔 다 비슷합니다.
질문 보내고, 답 받고, 출력해보면 “오 된다” 싶죠.

근데 3편까지 따라오면서 슬슬 이런 생각이 들었을 겁니다.

“이제 이걸 서비스 코드에 넣고 싶은데?”
“JSON 문자열은 받았는데, 이걸 매번 dict로만 다뤄도 괜찮나?”
“키가 빠지거나 타입이 틀리면 어디서 막지?”

바로 그 지점에서 등장하는 게 Pydantic입니다.

솔직히 저는 이 단계부터가 진짜 백엔드 개발자의 영역이라고 생각합니다.
왜냐하면 여기서부터 AI 응답을 “그럴듯한 텍스트”가 아니라 검증 가능한 데이터 객체로 다루기 시작하거든요.

이번 글에서는 이 흐름을 잡아보겠습니다.

  • 왜 JSON만으로는 아직 부족한지
  • 왜 Pydantic이 OpenAI 응답 처리와 잘 맞는지
  • json.loads() 다음에 무엇을 해야 하는지
  • BaseModel, model_validate, validator를 어떻게 쓰는지
  • FastAPI와 왜 궁합이 좋은지
  • 실무에서 어떤 구조로 가져가면 깔끔한지

OpenAI 공식 Python 라이브러리는 응답 타입 자체를 Pydantic 모델로 제공하고 있고, Structured Outputs는 모델이 공급한 JSON Schema를 따르도록 설계되어 있습니다. 또 Pydantic v2에서는 model_validate()와 model_validate_json()가 핵심 검증 진입점입니다. 즉, OpenAI + Structured Outputs + Pydantic은 지금 Python 백엔드에서 꽤 자연스러운 조합입니다. (GitHub)


1. JSON으로 받았다고 끝난 게 아닙니다

3편에서 우리는 이런 흐름을 만들었습니다.

  1. OpenAI Responses API 호출
  2. JSON Schema로 구조 지정
  3. response.output_text 받기
  4. json.loads()로 파싱

여기까지도 꽤 좋습니다.
그런데 아직 문제가 남아 있어요.

예를 들어 이런 JSON이 왔다고 해봅시다.

{
  "topic": "파이썬 데코레이터",
  "difficulty": "beginner",
  "summary": "함수를 감싸서 기능을 확장하는 문법입니다.",
  "keywords": ["decorator", "wrapper", "python"]
}

겉으로는 멀쩡해 보입니다.
하지만 실무에서는 이런 일들이 흔합니다.

  • difficulty가 "easy"로 들어옴
  • keywords가 문자열 하나로 들어옴
  • summary가 비어 있음
  • topic이 너무 길거나 예상 범위를 벗어남
  • 키 하나가 누락됨
  • 나중에 다른 개발자가 구조를 바꿔버림

즉, JSON 파싱 성공은 끝이 아니라 시작입니다.
문자열이 dict가 됐다는 건 “읽을 수 있다”는 뜻이지, “안전하다”는 뜻은 아닙니다.

Pydantic은 바로 이 문제를 해결합니다. Pydantic 문서는 BaseModel이 타입이 지정된 필드를 갖는 모델 클래스이며, model_validate()로 입력 데이터를 검증해 해당 타입과 제약에 맞는 출력 모델을 생성한다고 설명합니다. (Pydantic)


2. 왜 OpenAI 응답 처리에 Pydantic이 잘 맞나

제가 이 조합을 좋아하는 이유는 딱 세 가지입니다.

첫째, 타입이 명확해집니다

dict[str, Any] 상태에서는 결국 사람이 구조를 기억해야 합니다.
반면 Pydantic 모델을 쓰면 코드가 구조를 설명합니다.

둘째, 검증 지점을 한 곳으로 모을 수 있습니다

OpenAI 호출 결과를 받은 직후 한 번만 검증하면, 그 아래 계층에서는 비교적 안심하고 쓸 수 있습니다.

셋째, FastAPI와 연결이 정말 자연스럽습니다

FastAPI도 Pydantic을 중심으로 request/response 모델을 다루기 때문에, OpenAI 결과를 Pydantic으로 받으면 API 계층과 서비스 계층이 깔끔하게 이어집니다.

그리고 이건 상징적인데, OpenAI 공식 Python 라이브러리 자체도 “응답은 Pydantic 모델”이라고 안내합니다. 응답 객체는 to_json(), to_dict() 같은 도우미 메서드도 제공합니다. 즉, SDK 철학 자체가 Python 쪽에서는 Pydantic 친화적입니다. (GitHub)


3. 먼저 가장 기본적인 Pydantic 모델부터 만들어봅시다

3편에서 썼던 구조를 그대로 가져오겠습니다.

  • topic
  • difficulty
  • summary
  • keywords

이걸 Pydantic으로 표현하면 이렇게 됩니다.

from pydantic import BaseModel
from typing import Literal

class TopicSummary(BaseModel):
    topic: str
    difficulty: Literal["beginner", "intermediate", "advanced"]
    summary: str
    keywords: list[str]

이 코드가 좋은 이유는 정말 단순합니다.

  • difficulty는 아무 문자열이나 받을 수 없음
  • keywords는 문자열 리스트여야 함
  • 구조가 코드로 명시됨
  • IDE 자동완성과 타입 힌트도 좋아짐

Pydantic 문서는 모델이 타입이 지정된 필드를 기반으로 검증되며, extra='forbid' 같은 정책으로 허용되지 않은 추가 필드를 금지할 수도 있다고 설명합니다. (Pydantic)


4. OpenAI 응답을 Pydantic으로 검증하는 가장 기본적인 흐름

반응형

여기서는 현재 Responses API 기준으로,
먼저 JSON 문자열을 받고 그다음 Pydantic으로 검증하는 방식을 쓰겠습니다.

이 방식이 중요한 이유는 하나예요.
현재 Responses API에서는 Chat Completions의 일부 parse 흐름처럼 Pydantic 모델을 바로 꽂아 native parse하는 경험이 아직 완전히 동일하지 않아서, 실무에서는 json.loads() 후 model_validate()로 검증하는 방식이 가장 이해하기 쉽고 안정적입니다. 이 차이는 공식 이슈와 문서 주변 사례에서도 확인됩니다. (GitHub)

아래는 실행 가능한 예제입니다.

import os
import json
from typing import Literal

from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel, ConfigDict, ValidationError

load_dotenv()

api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
    raise ValueError("OPENAI_API_KEY가 설정되지 않았습니다.")

client = OpenAI(api_key=api_key)

SCHEMA = {
    "type": "object",
    "properties": {
        "topic": {"type": "string"},
        "difficulty": {
            "type": "string",
            "enum": ["beginner", "intermediate", "advanced"]
        },
        "summary": {"type": "string"},
        "keywords": {
            "type": "array",
            "items": {"type": "string"}
        }
    },
    "required": ["topic", "difficulty", "summary", "keywords"],
    "additionalProperties": False
}

class TopicSummary(BaseModel):
    model_config = ConfigDict(extra="forbid")

    topic: str
    difficulty: Literal["beginner", "intermediate", "advanced"]
    summary: str
    keywords: list[str]

def generate_topic_summary(user_input: str) -> TopicSummary:
    response = client.responses.create(
        model="gpt-5.4-mini",
        instructions=(
            "당신은 주니어 개발자를 위한 Python 멘토입니다. "
            "반드시 주어진 JSON Schema에 맞는 결과만 반환하세요."
        ),
        input=user_input,
        text={
            "format": {
                "type": "json_schema",
                "name": "topic_summary",
                "schema": SCHEMA,
                "strict": True
            }
        }
    )

    raw_data = json.loads(response.output_text)
    return TopicSummary.model_validate(raw_data)

if __name__ == "__main__":
    try:
        result = generate_topic_summary("파이썬 제너레이터를 설명해줘")
        print(result.model_dump())
        print(result.topic)
        print(result.keywords)
    except ValidationError as e:
        print("검증 실패")
        print(e)

여기서 핵심은 두 줄입니다.

raw_data = json.loads(response.output_text)
return TopicSummary.model_validate(raw_data)

Pydantic v2에서는 model_validate()가 dict 같은 Python 객체 검증의 표준 진입점이고, raw JSON 문자열이면 model_validate_json()도 사용할 수 있습니다. (Pydantic)


5. 왜 extra="forbid"를 넣는가

이건 실무에서 굉장히 중요합니다.

가끔 모델이 구조에 없는 필드를 덧붙이기도 하고,
프롬프트를 바꾼 다른 개발자가 예상치 못한 키를 넣도록 만들 수도 있습니다.

예를 들어 우리는 이런 구조만 원합니다.

  • topic
  • difficulty
  • summary
  • keywords

그런데 응답이 이렇게 오면요?

{
  "topic": "JWT",
  "difficulty": "beginner",
  "summary": "토큰 기반 인증 방식",
  "keywords": ["jwt", "auth"],
  "note": "내가 추가한 필드"
}

이때 extra="forbid"가 있으면 바로 걸러집니다.
Pydantic 문서도 extra 동작을 ignore, forbid, allow로 설명하며, forbid는 추가 데이터를 허용하지 않는다고 안내합니다. (Pydantic)

개인적으로 OpenAI 응답 검증 모델에는 이 옵션을 꽤 자주 넣습니다.
예측 가능성이 정말 좋아지거든요.


6. validator를 넣으면 “타입 검증”을 넘어 “비즈니스 규칙 검증”까지 갈 수 있습니다

여기서부터 재밌어집니다.

Pydantic의 진짜 힘은 단순 타입 체크만이 아닙니다.
필드 validator를 넣어서 “우리 서비스만의 기준”도 걸 수 있어요.

예를 들어 summary가 너무 짧으면 안 된다고 해보겠습니다.

from pydantic import BaseModel, ConfigDict, field_validator
from typing import Literal

class TopicSummary(BaseModel):
    model_config = ConfigDict(extra="forbid")

    topic: str
    difficulty: Literal["beginner", "intermediate", "advanced"]
    summary: str
    keywords: list[str]

    @field_validator("summary")
    @classmethod
    def validate_summary(cls, value: str) -> str:
        if len(value.strip()) < 20:
            raise ValueError("summary는 최소 20자 이상이어야 합니다.")
        return value

    @field_validator("keywords")
    @classmethod
    def validate_keywords(cls, value: list[str]) -> list[str]:
        if len(value) < 2:
            raise ValueError("keywords는 최소 2개 이상이어야 합니다.")
        return value

이제는 단순히 “문자열인가?”만 보는 게 아닙니다.

  • 요약이 너무 짧지 않은가
  • 키워드 개수가 최소 기준을 넘는가
  • 우리 서비스 품질 기준을 만족하는가

이런 것까지 함께 볼 수 있습니다.

Pydantic 공식 문서도 field validator가 값을 받아 검사, 변환, 오류 발생 등을 수행할 수 있다고 설명합니다. (Pydantic)


7. 실무에서는 OpenAI 스키마와 Pydantic 모델이 서로 어긋나지 않게 관리해야 합니다

여기서 초보자가 한 번 더 실수합니다.

  • OpenAI에 준 JSON Schema
  • Python의 Pydantic 모델

이 둘이 따로 놀기 시작하는 거죠.

예를 들어 OpenAI 스키마엔 difficulty가 enum 3개인데,
Pydantic에선 그냥 str로 열어둔다?
이러면 검증 강도가 달라집니다.

반대로 Pydantic에선 keywords를 list[str]로 받는데,
OpenAI 스키마에서 items 타입을 안 적어두면 또 흔들립니다.

그래서 실무에서는 보통 아래 원칙이 좋습니다.

  • OpenAI Structured Output 스키마는 최대한 엄격하게
  • Pydantic 모델도 거의 같은 수준으로 엄격하게
  • 두 구조는 같은 파일 혹은 아주 가까운 위치에서 관리
  • 바뀔 때 함께 수정

즉, AI 출력 계약과 애플리케이션 내부 계약을 맞춘다는 생각이 필요합니다.

Structured Outputs는 공급한 JSON Schema를 따르도록 설계되었고, Pydantic은 타입/제약 검증을 담당하므로 둘을 맞춰 두는 게 가장 자연스럽습니다. (OpenAI Developers)


8. model_validate_json()도 알아두면 좋습니다

우리는 지금 이렇게 했죠.

raw_data = json.loads(response.output_text)
result = TopicSummary.model_validate(raw_data)

이건 충분히 좋습니다.
그런데 JSON 문자열을 바로 검증하고 싶다면 Pydantic v2의 model_validate_json()도 쓸 수 있습니다.

result = TopicSummary.model_validate_json(response.output_text)

Pydantic 마이그레이션 문서는 v2에서 parse_raw 대신 model_validate_json()을 사용하는 흐름을 안내합니다. (Pydantic)

저는 개인적으로 디버깅이 쉬워서 json.loads()를 한 번 거치는 편인데,
코드 스타일에 따라 model_validate_json()을 선호하는 팀도 꽤 있습니다.


9. FastAPI와 왜 이 조합이 잘 맞는가

이 부분은 Python 백엔드 하는 분들이 특히 좋아할 만합니다.

FastAPI는 request body, response model, settings, validation 전반에 Pydantic을 깊게 사용합니다.
그러니까 OpenAI 응답도 Pydantic으로 받아두면, 서비스 내부 흐름이 훨씬 통일됩니다.

예를 들면 이런 구조가 됩니다.

  • Controller: 요청 받기
  • Service: OpenAI 호출
  • Validator/Pydantic: 결과 검증
  • Controller: 검증된 객체를 그대로 응답 모델로 반환

이렇게요.

from fastapi import FastAPI, HTTPException
from pydantic import ValidationError

app = FastAPI()

@app.get("/ai/topic-summary", response_model=TopicSummary)
def get_topic_summary(q: str):
    try:
        return generate_topic_summary(q)
    except ValidationError as e:
        raise HTTPException(status_code=500, detail=f"응답 검증 실패: {e.errors()}")
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

이 구조가 좋은 이유는,
FastAPI가 결국 Pydantic 모델을 아주 잘 이해하기 때문입니다.
OpenAI 응답을 dict로 흩어놓지 않고 모델로 고정해두면 API 문서화, 응답 검증, 유지보수까지 한꺼번에 좋아집니다.


10. 실무 구조는 보통 이렇게 가져가면 깔끔합니다

입문자일수록 파일을 막 섞어놓기 쉬운데,
저는 아래 정도 구조를 추천합니다.

app/
├─ main.py
├─ schemas/
│  └─ ai_summaries.py
├─ services/
│  └─ openai_summary_service.py
├─ api/
│  └─ topic_routes.py
└─ core/
   └─ config.py

schemas/ai_summaries.py

  • Pydantic 모델들
  • validator
  • 필요하면 TypedDict/TypeAdapter

services/openai_summary_service.py

  • OpenAI 호출
  • Structured Output 스키마
  • 응답 파싱
  • model_validate()

api/topic_routes.py

  • FastAPI 엔드포인트
  • HTTP 예외 처리
  • response_model 지정

이렇게 나누면 “AI 호출 코드”와 “검증 모델”과 “API 레이어”가 섞이지 않아서 좋습니다.


11. 지금 단계에서 꼭 알아둘 현실적인 포인트 하나

이건 꽤 중요합니다.

많은 사람이 “OpenAI SDK에서 Pydantic 모델로 바로 딱 파싱되면 제일 편하지 않나?”라고 생각합니다. 맞는 말입니다.
그런데 현재 Responses API 기준에서는 실무에서 여전히 Structured Output + JSON 파싱 + Pydantic 검증 흐름을 이해해두는 게 좋습니다. 공식 이슈에서도 Responses API 쪽의 Pydantic 파싱 경험이 Chat Completions parse와 동일하지 않다는 논의가 있었습니다. (GitHub)

저는 오히려 이게 나쁘지 않다고 봅니다.
이 흐름을 이해하면 SDK 편의 기능에 덜 의존하고,
문제가 생겼을 때 어디서 깨졌는지 더 명확히 볼 수 있거든요.

즉,

  • OpenAI가 구조를 잘 만들었는가
  • JSON 파싱은 성공했는가
  • Pydantic 검증은 통과했는가

이 세 단계를 분리해서 보는 게 디버깅에 유리합니다.


12. 실전 예제: 블로그 초안 메타데이터 생성기

이제 조금 더 실무 느낌으로 가보겠습니다.
기술 블로그를 만든다고 할 때, 본문 전체를 바로 생성하기보다 먼저 “메타데이터”를 구조화해서 받는 경우가 많습니다.

예를 들면 이런 모델이 가능합니다.

from pydantic import BaseModel, ConfigDict, field_validator
from typing import Literal

class BlogDraftMeta(BaseModel):
    model_config = ConfigDict(extra="forbid")

    title: str
    audience: Literal["beginner", "junior", "mid-level", "senior"]
    summary: str
    section_titles: list[str]
    tags: list[str]

    @field_validator("section_titles")
    @classmethod
    def validate_sections(cls, value: list[str]) -> list[str]:
        if len(value) < 3:
            raise ValueError("section_titles는 최소 3개 이상이어야 합니다.")
        return value

    @field_validator("tags")
    @classmethod
    def validate_tags(cls, value: list[str]) -> list[str]:
        if len(value) < 5:
            raise ValueError("tags는 최소 5개 이상이어야 합니다.")
        return value

이 구조가 좋은 이유는 명확합니다.

  • 제목과 독자층을 먼저 정하고
  • 목차를 구조화해서 받고
  • 태그를 분리해서 저장하고
  • 나중에 본문 생성을 별도 단계로 분리할 수 있음

즉, AI 결과를 “한 번에 긴 글”이 아니라 “단계별 생산물”로 나누기 쉬워집니다.


13. 초보자가 자주 하는 실수

실수 1. Pydantic을 안 쓰고 dict로 끝까지 간다

처음엔 편합니다.
나중엔 키 오타, 누락, 타입 불일치가 다 런타임에서 터집니다.

실수 2. OpenAI Structured Output만 믿고 서버 검증을 생략한다

모델이 구조를 잘 지키도록 설계돼 있어도,
애플리케이션 경계에서는 항상 한 번 더 검증하는 게 좋습니다. Structured Outputs는 강력하지만, 서버 검증은 별도 안전망입니다. (OpenAI Developers)

실수 3. validator를 너무 늦게 붙인다

타입만 맞으면 된다고 생각하다가,
나중에 비즈니스 규칙이 퍼져서 코드가 지저분해집니다.

실수 4. extra="forbid"를 빼먹는다

의도하지 않은 필드가 슬그머니 들어오기 시작하면,
한두 달 뒤부터 응답 형태가 흐려집니다.

실수 5. 검증 실패 예외를 안 잡는다

Pydantic은 ValidationError를 꽤 자세히 줍니다.
이걸 로그로 남기고 관찰하는 습관이 있어야 프롬프트/스키마 품질도 빨리 좋아집니다.


14. 오늘 꼭 해보면 좋은 실습

이번 편은 손으로 돌려보면 차이가 확 옵니다.

실습 1

difficulty를 일부러 잘못된 값으로 만들어보세요.

예:

{"difficulty": "easy"}

그다음 model_validate()가 어떻게 실패하는지 보세요.

실습 2

keywords에 문자열 하나만 넣어보세요.

예:

{"keywords": "python"}

리스트 검증이 어떻게 걸리는지 확인해보세요.

실습 3

summary validator에 최소 길이를 50자로 올려보세요.
모델 품질 기준을 Pydantic이 어떻게 강제하는지 감이 옵니다.

실습 4

FastAPI 엔드포인트에서 response_model=TopicSummary를 걸어보세요.
OpenAI 응답 검증 모델이 API 응답 모델과 자연스럽게 이어지는 걸 체감할 수 있습니다.


15. 오늘 글의 핵심 요약

이번 글에서 진짜로 가져가야 할 건 이것입니다.

JSON은 구조화의 시작이고, Pydantic은 그 구조를 서버 코드에서 믿을 수 있게 만드는 장치다.

즉, 실무 흐름은 이렇게 잡으면 됩니다.

  1. OpenAI Structured Outputs로 응답 모양을 최대한 고정한다.
  2. JSON 문자열을 받는다.
  3. Pydantic BaseModel로 검증한다.
  4. validator로 비즈니스 규칙까지 확인한다.
  5. 검증된 객체만 서비스 내부로 흘려보낸다.

OpenAI 공식 Python SDK는 응답 타입을 Pydantic 모델로 제공하고, Structured Outputs는 supplied JSON Schema를 따르는 출력을 목표로 합니다. Pydantic v2는 model_validate()와 validator 체계를 통해 이런 결과를 애플리케이션 수준에서 다시 검증하기 좋습니다. 이 조합은 지금 Python 백엔드에서 꽤 정석적인 방향입니다. (GitHub)


다음 편 예고

다음 글에서는 이제 진짜 API 서버 냄새가 나는 단계로 가보겠습니다.

FastAPI에서 OpenAI 서비스 계층 만들기

이 주제로,

  • service 계층 분리
  • 의존성 주입 구조
  • 설정 분리
  • API 라우터 구성
  • OpenAI 호출 실패/검증 실패 에러 처리
  • 주니어도 바로 따라할 수 있는 프로젝트 구조

까지 이어가보겠습니다.


출처

  • OpenAI 공식 Python SDK README — SDK 응답은 Pydantic 모델이며 to_json(), to_dict() 같은 도우미를 제공. (GitHub)
  • OpenAI Structured Outputs 가이드 — Structured Outputs는 supplied JSON Schema를 따르는 응답을 보장하도록 설계됨. (OpenAI Developers)
  • Pydantic Models 문서 — model_validate()와 extra 정책(forbid, ignore, allow) 설명. (Pydantic)
  • Pydantic Validators 문서 — field validator 사용 방식 설명. (Pydantic)
  • Pydantic v2 Migration 문서 — parse_raw 대신 model_validate_json() 사용 흐름 안내. (Pydantic)
  • OpenAI Python 이슈 — Responses API의 Pydantic parse 경험이 Chat Completions parse와 완전히 동일하지 않다는 논의. (GitHub)

 

Python, OpenAI, Pydantic, FastAPI, Structured Outputs, JSON Schema, OpenAI Python SDK, Responses API, AI 백엔드, 생성형 AI, LLM, Python 검증, BaseModel, model_validate, 주니어 개발자

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/03   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함
반응형