FastAPI로 자동 검증이 되는 REST API 만들기
1. 개요
REST API를 작성할 때, 대부분의 “방어적인” 코드는 input 검증에 있습니다: 필드가 필수인지, 숫자가 음수는 아닌지, 이메일이 유효한지 여부를 검사하는 것이죠. FastAPI는 이 작업을 Pydantic에게 맡깁니다. 데이터 타입과 제약을 선언하기만 하면, 프레임워크가 자동으로 request를 검증하고 구조화된 422 오류를 반환합니다.
이 글에서는 주문(Order)을 관리하는 API를 처음부터 만들어봅니다. 읽고 나면 body, path, query를 제약하는 방법을 알 수 있습니다. 커스텀 validator를 작성하고, 반환 데이터를 필터링하며, 오류 형식을 변경하고, pytest로 전체를 테스트하는 방법도 배우게 될 것입니다.
2. 설치
Python 3.10 이상이 필요합니다. virtualenv를 만든 후 FastAPI를 standard dependency 그룹과 함께 설치하세요:
python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" pytest
fastapi[standard] 패키지에는 이 글에서 필요한 것들이 모두 포함되어 있습니다:
uvicorn서버를 실행하기 위해.fastapi-clifastapi dev명령을 제공합니다.httpxTestClient를 위해.email-validatorEmailStr타입을 위해.
3. Pydantic 모델을 이용한 첫 번째 Endpoint
main.py 파일을 만드세요:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(title="Order API")
class OrderCreate(BaseModel):
customer_email: str
product_sku: str
quantity: int
@app.post("/orders", status_code=201)
def create_order(order: OrderCreate) -> OrderCreate:
return order
dev 모드에서 서버를 실행합니다 (자동 재로드 포함):
fastapi dev main.py
여기에는 if 문이 없습니다. order: OrderCreate 파라미터는 FastAPI에 세 가지를 알려줍니다:
- Request body에서 JSON을 읽습니다.
- JSON을
OrderCreate로 파싱합니다. - 데이터가 타입과 맞지 않으면
422를 반환합니다.
quantity를 숫자가 아닌 문자열로 보내봅시다:
curl -X POST localhost:8000/orders \
-H "Content-Type: application/json" \
-d '{"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":"abc"}'
{"detail":[{"type":"int_parsing","loc":["body","quantity"],
"msg":"Input should be a valid integer, unable to parse string as an integer",
"input":"abc"}]}
loc 필드는 오류의 정확한 위치를 나타냅니다: body에 있고, quantity 필드에 있습니다. 게다가 /docs의 OpenAPI 문서는 이 모델에서 자동으로 생성됩니다. 따라서 코드와 문서가 동기화되지 않는 일은 없습니다.
4. Field로 데이터 제약하기
타입 검사만으로는 충분하지 않습니다. 예를 들어 quantity = -5는 여전히 유효한 int입니다. Field를 사용하여 제약을 추가합니다, Annotated와 함께:
from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, EmailStr, Field
Sku = Annotated[str, Field(min_length=3, max_length=20, pattern=r"^[A-Za-z0-9-]+$")]
class OrderCreate(BaseModel):
customer_email: EmailStr
product_sku: Sku
quantity: Annotated[int, Field(gt=0, le=100)]
unit_price: Annotated[Decimal, Field(gt=0, max_digits=10, decimal_places=2)]
discount: Annotated[Decimal, Field(ge=0, decimal_places=2)] = Decimal("0")
note: Annotated[str | None, Field(max_length=200)] = None
각 줄이 다음을 수행합니다:
EmailStr은@문자가 있는지만 확인하는 것이 아니라 실제 이메일 형식을 검증합니다.Sku는 재사용 가능한 타입입니다. 3–20자 길이를 요구하고 문자, 숫자, 하이픈만 포함할 수 있습니다.quantity는 0보다 커야 하고 100을 초과할 수 없습니다.unit_price는 금액 계산 시 반올림 오류를 피하기 위해float대신Decimal을 사용합니다. 최대 10자리, 소수점 2자리입니다.discount와note는 기본값이 있으므로 선택 필드입니다. 기본값이 없는 필드는 필수 필드입니다.
quantity: 0을 보내면 다음을 받게 됩니다:
{"type":"greater_than","loc":["body","quantity"],"msg":"Input should be greater than 0","input":0,"ctx":{"gt":0}}
참고: 기본적으로 Pydantic은 lax 모드로 실행됩니다. 문자열
"5"는 오류 없이 숫자5로 강제 변환됩니다. 이를 거부하려면 모델에model_config = ConfigDict(strict=True)를 추가하거나 각 필드에Field(strict=True)를 사용하세요.
5. Path와 Query 파라미터 검증
Body뿐만 아니라 path와 query도 같은 메커니즘으로 검증됩니다:
from fastapi import HTTPException, Path, Query
orders_db: dict[int, dict] = {}
@app.get("/orders/{order_id}")
def get_order(order_id: Annotated[int, Path(gt=0)]):
if order_id not in orders_db:
raise HTTPException(status_code=404, detail="Order not found")
return orders_db[order_id]
@app.get("/orders")
def list_orders(
page: Annotated[int, Query(ge=1)] = 1,
size: Annotated[int, Query(ge=1, le=50)] = 10,
sku: Annotated[str | None, Query(min_length=3)] = None,
):
items = [o for o in orders_db.values() if sku is None or o["product_sku"] == sku]
start = (page - 1) * size
return items[start : start + size]
다양한 요청의 결과:
GET /orders/0은422를 반환합니다.order_id는 0보다 커야 하기 때문입니다.GET /orders?size=500은422를 반환합니다.size의 최대값은 50이기 때문입니다.GET /orders?page=2&size=5는 정상 실행됩니다.page와size값이 함수에 들어가기 전에int로 강제 변환되었습니다.
422와 404를 구분하세요. 422 오류는 요청 형식이 잘못되었다는 의미입니다. 404는 비즈니스 로직이므로 직접 HTTPException을 발생시켜야 합니다.
6. 커스텀 Validator
Field로 표현할 수 없는 규칙이 있습니다. 이 경우 단일 필드에 field_validator를 사용하거나 여러 필드에 관련된 규칙에 model_validator를 사용합니다.
from pydantic import field_validator, model_validator
FORBIDDEN_DOMAINS = {"mailinator.com", "tempmail.com"}
class OrderCreate(BaseModel):
# ... 필드들 (4번 항목 참고) ...
@field_validator("product_sku")
@classmethod
def normalize_sku(cls, value: str) -> str:
return value.upper()
@field_validator("customer_email")
@classmethod
def reject_disposable_email(cls, value: str) -> str:
domain = value.split("@")[-1].lower()
if domain in FORBIDDEN_DOMAINS:
raise ValueError("Disposable email is not allowed")
return value
@model_validator(mode="after")
def check_discount(self) -> "OrderCreate":
total = self.unit_price * self.quantity
if self.discount > total:
raise ValueError("Discount cannot exceed order total")
return self
각 validator 설명:
normalize_sku는Field가 패턴을 검증한 후 실행됩니다. SKU를 대문자로 정규화하므로"tshirt-01"이"TSHIRT-01"으로 저장됩니다. Validator는 새로운 값을return하는 한 값을 변환할 수 있습니다.reject_disposable_email은ValueError를 발생시킵니다. Pydantic은 이를 검증 오류로 변환하고 메시지에"Value error, "접두사가 붙습니다.check_discount는mode="after"로 실행되므로 모든 필드가 유효하고 올바른 타입을 가질 때 실행됩니다. 따라서Decimal을 안전하게 곱할 수 있습니다.return self를 잊지 마세요.
참고: Validator에서 데이터베이스나 외부 API를 호출하지 마세요. 예를 들어 “SKU가 재고에 있는가”를 확인하는 것은 비즈니스 로직입니다. 이를 service나 dependency에 배치하세요. Validator는 순수 함수이고 빠르고 테스트하기 쉬워야 합니다.
7. response_model: 반환 데이터 제어
보통 클라이언트에게 반환하려는 것보다 더 많은 정보를 저장합니다 (예: 내부 메모). Input과 output 모델을 분리하세요:
class OrderRead(BaseModel):
id: int
customer_email: EmailStr
product_sku: str
quantity: int
total: Decimal
@app.post("/orders", status_code=201, response_model=OrderRead)
def create_order(order: OrderCreate):
order_id = len(orders_db) + 1
record = {
"id": order_id,
**order.model_dump(),
"total": order.unit_price * order.quantity - order.discount,
"internal_flag": "manual-review",
}
orders_db[order_id] = record
return record
함수는 note과 internal_flag를 모두 포함하는 dict를 반환합니다. 그러나 response_model=OrderRead는 응답을 선언된 필드만으로 필터링합니다:
{"id":1,"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":2,"total":"39.98"}
주의할 두 가지 사항:
Decimal은 정확성을 유지하기 위해 문자열"39.98"으로 직렬화됩니다. 클라이언트가 이를 알아야 합니다.- Response도 검증됩니다.
total필드를 빠뜨리면 FastAPI는ResponseValidationError를 던지고422가 아닌500을 반환합니다. 이는 서버 오류이지 클라이언트 오류가 아닙니다.
8. 422 오류 형식 커스터마이징
기본 형식은 매우 상세합니다. 많은 팀이 frontend를 위해 간결하고 일관된 구조를 원합니다. RequestValidationError의 handler를 재정의하세요:
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = [
{
"field": ".".join(str(part) for part in err["loc"][1:]) or "body",
"message": err["msg"],
}
for err in exc.errors()
]
return JSONResponse(
status_code=422,
content={"code": "VALIDATION_FAILED", "errors": errors},
)
Handler가 각 오류를 처리하는 방법:
err["loc"][1:]은 첫 번째 요소(body,query,path)를 제거하고 나머지를 점으로 연결합니다.model_validator의 오류는 특정 필드와 연결되지 않습니다. 이 경우loc은("body",)만 있으므로 handler는"body"를 할당합니다.
quantity: 0과 일회용 이메일이 있는 주문을 보내면 다음을 받게 됩니다:
{"code":"VALIDATION_FAILED","errors":[
{"field":"customer_email","message":"Value error, Disposable email is not allowed"},
{"field":"quantity","message":"Input should be greater than 0"}]}
Pydantic은 첫 번째 오류에서 멈추지 않고 모든 필드의 모든 오류를 수집합니다. 따라서 클라이언트는 한 번에 모든 것을 수정할 수 있습니다.
9. pytest로 테스트
TestClient는 서버를 실행하지 않고도 앱을 직접 호출합니다. main.py 옆에 test_main.py 파일을 만드세요:
import pytest
from fastapi.testclient import TestClient
from main import app, orders_db
client = TestClient(app)
VALID_ORDER = {
"customer_email": "an@example.com",
"product_sku": "tshirt-01",
"quantity": 2,
"unit_price": "19.99",
}
@pytest.fixture(autouse=True)
def clear_db():
orders_db.clear()
def test_create_order_returns_normalized_sku_and_total():
response = client.post("/orders", json=VALID_ORDER)
assert response.status_code == 201
body = response.json()
assert body["product_sku"] == "TSHIRT-01"
assert body["total"] == "39.98"
assert "internal_flag" not in body
def test_invalid_quantity_and_email_are_reported_together():
payload = {**VALID_ORDER, "quantity": 0, "customer_email": "x@mailinator.com"}
response = client.post("/orders", json=payload)
assert response.status_code == 422
fields = {e["field"] for e in response.json()["errors"]}
assert fields == {"quantity", "customer_email"}
def test_discount_greater_than_total_is_rejected():
payload = {**VALID_ORDER, "discount": "100.00"}
response = client.post("/orders", json=payload)
assert response.status_code == 422
assert response.json()["errors"][0] == {
"field": "body",
"message": "Value error, Discount cannot exceed order total",
}
def test_page_size_over_limit_is_rejected():
assert client.get("/orders?size=500").status_code == 422
테스트를 실행합니다:
pytest -q
.... [100%]
4 passed in 0.31s
clear_db fixture는 각 테스트 전에 orders_db를 초기화하여 테스트를 서로 독립적으로 만듭니다. 또한 HTTP 없이 OrderCreate.model_validate(...)과 pytest.raises(ValidationError)를 사용하여 모델을 독립적으로 테스트할 수 있습니다. 이 방법은 모델에 복잡한 규칙이 많을 때 적합합니다.
10. 흔한 실수
- 금액에
float사용하기.0.1 + 0.2는0.3과 같지 않습니다.max_digits과decimal_places를 사용한Decimal을 사용하세요. @classmethod또는 데코레이터 순서 잊기.@field_validator는@classmethod위에 있어야 합니다. 그렇지 않으면 Pydantic v2는 import 시 오류를 보고합니다.- Validator에서
return잊기.field_validator가 return하지 않으면 필드에None이 할당됩니다.model_validator(mode="after")는self를 return해야 합니다. - Pydantic v1 구문 사용하기.
@validator,class Config,.dict()는 더 이상 사용되지 않습니다.@field_validator,model_config,.model_dump()를 사용하세요. 422와500혼동하기. 잘못된 입력은422를 생성합니다. Response가response_model과 일치하지 않으면500이 생성됩니다.500을 보면 반환된 데이터를 확인하세요.- Input과 output에 동일한 모델 사용하기. 이렇게 하면 내부 필드가 노출되기 쉽고 클라이언트가
id와 같은 필드를 보내야 합니다.OrderCreate와OrderRead를 분리하세요.
참고: API 계층의 검증은 데이터베이스 제약을 대체하지 않습니다.
UNIQUE나NOT NULL과 같은 제약은 여전히 필요합니다. Request는 백그라운드 job이나 migration 스크립트처럼 다양한 경로를 통해 시스템에 진입할 수 있습니다.
11. 결론
FastAPI와 Pydantic의 결합은 타입 힌트를 데이터 계약으로 변환하여 세 가지 주요 이점을 제공합니다:
- 잘못된 요청이 입구에서 차단됩니다.
- 오류가 완전하고 구조화된 형태로 반환됩니다.
- OpenAPI 문서는 항상 코드와 일치합니다.
이 글에서는 간단한 제약에 Field를, URL 파라미터에 Path와 Query를, 커스텀 규칙에 field_validator와 model_validator를 사용했습니다. 또한 response_model을 사용하여 output을 필터링하고, 422 오류 형식을 변경하고, TestClient로 전체를 테스트했습니다.
기억해야 할 원칙: validator가 데이터의 형태를 확인하도록 두고, 비즈니스 로직은 service 계층에 배치하세요.