Skip to content

요청 데이터 검증과 응답 모델

FastAPI의 핵심 기능 중 하나는 Pydantic을 활용한 요청 데이터 변환/검증 및 응답 데이터 검증/변환입니다. 이를 통해 타입 안전성을 보장하고 자동으로 API 문서를 생성할 수 있습니다.

요청 데이터 직접 처리

가장 기본적인 방법으로는 Request 객체를 통해 원시 요청 데이터에 직접 접근할 수 있습니다:

from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/posts/")
async def post_new(request: Request):
    raw_body: bytes = await request.body()
    print(repr(raw_body))
    return {
        "raw_body_length": len(raw_body),
    }

JSON 요청 처리

일반적인 API에서 사용하는 JSON 형식의 요청:

curl -X POST http://localhost:8000/posts/ \
    -H "Content-Type: application/json" \
    -d '{"title": "Hello", "content": "This is a JSON submission"}'

FastAPI 서버에서 받은 요청 body:

b'{"title": "Hello", "content": "This is a JSON submission"}'

JSON 문자열이므로 파이썬의 기본 json.loads() 메서드를 통해 손쉽게 파이썬 객체로 변환할 수 있습니다. 다만 유효성 검증은 별도 로직으로 직접 구현해야 합니다.

Form 요청 처리

웹페이지에서 사용하는 form urlencoded 형식의 요청:

curl -X POST http://localhost:8000/posts/ \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "title=Hello&content=This is a form submission"

FastAPI 서버에서 받은 요청 body:

b'title=Hello&content=This is a form submission'

웹 페이지에서 다음과 같은 <form>을 통해 요청할 경우, 서버에서는 위와 같은 형식으로 요청을 받게 됩니다:

<form method="post">
    <input type="text" name="title" />
    <textarea name="content"></textarea>
    <button type="submit">Submit</button>
</form>

요청 데이터 자동 변환

Pydantic을 통해 요청 데이터를 파이썬 객체로의 변환 및 입력값에 대한 유효성 검사를 한 번에 수행할 수 있습니다.

JSON 요청 처리

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class PostCreate(BaseModel):
    title: str
    content: str = ""

@app.post("/posts/")
async def post_new(post: PostCreate):
    print(repr(post))
    return post

이 방식은 JSON 요청에 대한 변환만을 지원합니다:

curl -X POST http://localhost:8000/posts/ \
    -H "Content-Type: application/json" \
    -d '{"title": "Hello", "content": "This is a JSON submission"}'

urlencoded 요청은 JSON 변환에 실패하여 다음과 같은 응답을 받게 됩니다. 이때 엔드포인트 함수는 호출되지 않습니다:

{
    "detail": [
        {
            "type": "model_attributes_type",
            "loc": ["body"],
            "msg": "Input should be a valid dictionary or object to extract fields from",
            "input": "title=Hello&content=This is a form submission"
        }
    ]
}

Form 요청 처리

Form() 의존성을 주입하여 폼 데이터 처리를 지시할 수 있습니다:

from fastapi import FastAPI, Form
from pydantic import BaseModel

app = FastAPI()

class PostCreate(BaseModel):
    title: str
    content: str = ""

@app.post("/posts/")
async def post_new(post: PostCreate = Form()):
    print(repr(post))
    return post

이 방식은 urlencoded 요청에 대한 변환만을 지원합니다:

curl -X POST http://localhost:8000/posts/ \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "title=Hello&content=This is a form submission"

JSON 요청은 URL Encoded 변환에 실패하여 다음과 같은 응답을 받게 됩니다:

{
    "detail": [
        {
            "type": "missing",
            "loc": ["body", "title"],
            "msg": "Field required",
            "input": {"content": ""}
        }
    ]
}

요청 데이터 검증

Pydantic BaseModel은 인스턴스가 생성될 때 즉시 자동으로 유효성 검사가 수행됩니다.

Django와의 차이점

Django의 Form/Serializer는 .is_valid() 메서드를 명시적으로 호출할 때 유효성 검사를 수행합니다.

필드 검증 규칙

pydantic.Field에서 매개변수를 지정하여 유효성 검사를 추가할 수 있습니다:

from pydantic import BaseModel, Field

class PostCreate(BaseModel):
    title: str = Field(..., min_length=1, max_length=200, description="포스트 제목")
    content: str = Field(default="", description="포스트 내용")
    tags: list[str] = Field(default=[], max_items=10, description="태그 목록")
    priority: int = Field(ge=1, le=5, description="우선순위 (1-5)")

주요 검증 옵션들:

  • 문자열 길이: min_length, max_length
  • 숫자 범위: gt (초과), ge (이상), lt (미만), le (이하)
  • 정규식: pattern (정규식 패턴)
  • 리스트: min_items, max_items (최소/최대 항목 수), unique_items (중복 방지)
  • 문서화: description (필드 설명)

특수 타입 활용

Pydantic은 다양한 특수 타입을 제공합니다:

from pydantic import BaseModel, EmailStr, HttpUrl, Field
from datetime import datetime
from typing import Optional

class UserCreate(BaseModel):
    username: str = Field(..., min_length=3, max_length=50)
    email: EmailStr = Field(..., description="유효한 이메일 주소")
    website: Optional[HttpUrl] = Field(None, description="개인 웹사이트")
    birth_date: Optional[datetime] = Field(None, description="생년월일")
    age: int = Field(ge=0, le=150, description="나이")

주요 특수 타입:

  • EmailStr: 기본 이메일 유효성 검사
  • HttpUrl: HTTP URL 유효성 검사 (최대 2083자, http/https 스키마 허용)
  • datetime: ISO 형식 날짜/시간 자동 파싱
  • UUID: UUID 형식 검증

더 많은 검증 옵션

Pydantic에서는 다양한 방법으로 유효성 검사를 지원합니다. 자세한 내용은 공식문서를 참고하세요.

커스텀 검증자

복잡한 검증 로직이 필요한 경우 커스텀 검증자를 작성할 수 있습니다:

from pydantic import BaseModel, Field, validator

class PostCreate(BaseModel):
    title: str = Field(..., min_length=1, max_length=200)
    content: str = Field(default="")

    @validator('title')
    def validate_title(cls, v):
        if not v.strip():
            raise ValueError('제목은 공백일 수 없습니다')
        if '욕설' in v:  # 예시
            raise ValueError('부적절한 내용이 포함되어 있습니다')
        return v.strip()

    @validator('content')
    def validate_content(cls, v):
        if len(v.strip()) > 0 and len(v.strip()) < 10:
            raise ValueError('내용은 최소 10자 이상이어야 합니다')
        return v.strip()

응답 데이터 검증 및 변환

요청 데이터의 특징들이 응답 데이터에도 동일하게 적용됩니다:

  1. 지정 필드만 객체에 반영: 모델에 정의된 필드만 응답에 포함
  2. 지정 타입으로 자동 변환: 자동 타입 변환 수행
  3. 중첩된 객체도 자동 변환: 복잡한 데이터 구조도 처리 가능
  4. 데이터 구조 불일치 시 예외 발생:
  5. 요청 데이터 검증 실패: 422 Unprocessable Content 응답
  6. 응답 데이터 검증 실패: 500 Internal Server Error 응답
  7. API 문서 자동 생성:
  8. http://localhost:8000/docs (Swagger UI)
  9. http://localhost:8000/redoc (ReDoc)
  10. http://localhost:8000/openapi.json (OpenAPI 스키마)

응답 모델 정의

from fastapi import FastAPI
from pydantic import BaseModel
from datetime import datetime

app = FastAPI()

class PostCreate(BaseModel):
    title: str
    content: str = ""

class PostResponse(BaseModel):
    id: int
    title: str
    content: str
    created_at: datetime
    updated_at: datetime

@app.post("/posts/", response_model=PostResponse)
async def post_new(post: PostCreate):
    # TODO: 데이터베이스에 저장한 후, 저장된 레코드에서 필요한 값만 반환
    return {
        "id": 123,  # 데이터베이스에 저장된 레코드의 기본 키
        "title": post.title,
        "content": post.content,
        "created_at": datetime.now(),
        "updated_at": datetime.now(),
    }

응답 모델의 장점

응답 모델을 사용하면 다음과 같은 이점을 얻을 수 있습니다:

  1. 타입 안전성: 응답 데이터의 타입이 보장됩니다
  2. 자동 문서화: API 문서에 응답 형식이 자동으로 포함됩니다
  3. 데이터 필터링: 민감한 정보를 응답에서 자동으로 제외할 수 있습니다
  4. 일관성: 모든 엔드포인트에서 일관된 응답 형식을 유지할 수 있습니다
class UserResponse(BaseModel):
    id: int
    username: str
    email: str
    created_at: datetime
    # password는 의도적으로 제외하여 보안 유지

@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
    # 데이터베이스에서 사용자 정보를 조회 (password 포함)
    user_data = {
        "id": user_id,
        "username": "johndoe",
        "email": "john@example.com",
        "password": "hashed_password",  # 이 필드는 응답에서 자동 제외
        "created_at": datetime.now(),
    }
    return user_data  # password는 응답에 포함되지 않음

FastAPI의 계층별 로직 분리

FastAPI의 핵심 철학 중 하나는 관심사의 분리(Separation of Concerns)입니다. 각 요청은 명확하게 구분된 3단계를 거쳐 처리됩니다:

3단계 처리 흐름

from fastapi import FastAPI, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from database import get_db_session
from models import Post

app = FastAPI()

# 1단계: 요청 검증 모델
class PostCreate(BaseModel):
    title: str = Field(..., min_length=1, max_length=200)
    content: str = Field(default="")

# 3단계: 응답 변환 모델  
class PostResponse(BaseModel):
    id: int
    title: str
    content: str
    created_at: datetime

# 2단계: 비즈니스 로직 (엔드포인트 함수)
@app.post("/posts/", response_model=PostResponse)
async def post_new(
    post: PostCreate,  # <- 1단계: 이미 검증 완료된 데이터
    db: Session = Depends(get_db_session)
):
    # 2단계: 비즈니스 로직 실행
    db_post = Post(
        title=post.title,
        content=post.content
    )
    db.add(db_post)
    db.commit()
    db.refresh(db_post)

    return db_post  # <- 3단계: 자동으로 PostResponse 형태로 변환

각 단계별 역할과 특징

1단계: 요청 검증 (자동 처리) - Pydantic 모델이 요청 데이터를 자동으로 검증 - 타입 변환, 필드 검증, 필수값 확인 등이 자동 수행 - 검증 실패 시 422 에러와 함께 상세한 오류 정보 반환 - 엔드포인트 함수 호출 전에 완료

2단계: 비즈니스 로직 (개발자 구현) - 실제 비즈니스 로직 처리 (데이터베이스 저장, 외부 API 호출 등) - 이미 검증된 데이터만 받아서 안전하게 처리 - 복잡한 비즈니스 규칙이나 권한 검사 수행 - 개발자가 직접 구현하는 유일한 부분

3단계: 응답 생성 (자동 처리) - response_model에 따라 응답 데이터 자동 변환 - 민감한 정보 자동 필터링 - JSON 직렬화 및 HTTP 응답 생성 - 엔드포인트 함수 반환 후 자동 처리

Django와의 비교

Django에서는 View 함수 내에서 각 단계를 나눠 구현하며, 클래스 기반 뷰를 통해 중복을 줄이고 더 체계적인 구조를 만들 수 있습니다.

def post_create(request):
    # 1단계: 요청 데이터 검증
    serializer = PostSerializer(data=request.data)
    if not serializer.is_valid():
        return Response(serializer.errors, status=400)

    # 2단계: 비즈니스 로직
    post = serializer.save()

    # 3단계: 응답 생성
    response_serializer = PostResponseSerializer(post)
    return Response(response_serializer.data, status=201)
# FastAPI - 1단계와 3단계가 자동화
@app.post("/posts/", response_model=PostResponse)
async def post_new(post: PostCreate, db: Session = Depends(get_db_session)):
    # 2단계: 비즈니스 로직에만 집중
    db_post = Post(**post.dict())
    db.add(db_post)
    db.commit()
    db.refresh(db_post)
    return db_post
# Django Ninja - 1단계와 3단계가 자동화
@api.post("/posts/", response=PostResponse)
def post_new(request, post: PostCreate):
    # 2단계: 비즈니스 로직에만 집중
    db_post = Post.objects.create(**post.dict())
    return db_post

로직 분리의 장점

1. 코드 간소화 - 반복적인 검증/변환 코드 제거 - 핵심 비즈니스 로직에만 집중 가능

2. 타입 안전성 - 컴파일 타임에 타입 오류 발견 - IDE의 자동완성과 타입 힌트 지원

3. 테스트 용이성

# 각 계층을 독립적으로 테스트 가능
def test_post_validation():
    # 1단계 검증 로직만 테스트
    with pytest.raises(ValidationError):
        PostCreate(title="")  # 빈 제목으로 검증 오류 테스트

def test_business_logic():
    # 2단계 비즈니스 로직만 테스트
    valid_post = PostCreate(title="Test", content="Content")
    result = create_post_in_db(valid_post)
    assert result.id is not None

def test_response_format():
    # 3단계 응답 형식만 테스트
    response = client.post("/posts/", json={"title": "Test"})
    assert "id" in response.json()

4. 재사용성

# 동일한 검증 모델을 여러 엔드포인트에서 재사용
@app.post("/posts/", response_model=PostResponse)
async def create_post(post: PostCreate): ...

@app.put("/posts/{post_id}", response_model=PostResponse) 
async def update_post(post_id: int, post: PostCreate): ...

5. 문서화 자동 생성 - 요청/응답 모델이 자동으로 OpenAPI 스키마에 반영 - Swagger UI에서 각 필드의 검증 규칙까지 표시

Comments