요청 데이터 검증과 응답 모델
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:
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:
웹 페이지에서 다음과 같은 <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()
응답 데이터 검증 및 변환
요청 데이터의 특징들이 응답 데이터에도 동일하게 적용됩니다:
- 지정 필드만 객체에 반영: 모델에 정의된 필드만 응답에 포함
- 지정 타입으로 자동 변환: 자동 타입 변환 수행
- 중첩된 객체도 자동 변환: 복잡한 데이터 구조도 처리 가능
- 데이터 구조 불일치 시 예외 발생:
- 요청 데이터 검증 실패: 422 Unprocessable Content 응답
- 응답 데이터 검증 실패: 500 Internal Server Error 응답
- API 문서 자동 생성:
- http://localhost:8000/docs (Swagger UI)
- http://localhost:8000/redoc (ReDoc)
- 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(),
}
응답 모델의 장점
응답 모델을 사용하면 다음과 같은 이점을 얻을 수 있습니다:
- 타입 안전성: 응답 데이터의 타입이 보장됩니다
- 자동 문서화: API 문서에 응답 형식이 자동으로 포함됩니다
- 데이터 필터링: 민감한 정보를 응답에서 자동으로 제외할 수 있습니다
- 일관성: 모든 엔드포인트에서 일관된 응답 형식을 유지할 수 있습니다
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)
로직 분리의 장점
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에서 각 필드의 검증 규칙까지 표시