라우팅, Pydantic 모델, 의존성 주입
APIRouter로 라우트 분리, Pydantic v2 스키마로 입력/출력 정의, Depends로 공통 로직을 분리하는 패턴까지 정리합니다.
22장 FastAPI 시작과 셋업에서 app/main.py 한 파일에 라우트를 적었습니다. 본 챕터는 그 코드를 확장 가능한 구조로 나눕니다. 세 도구가 핵심입니다.
- APIRouter — 라우트를 모듈별로 분리
- Pydantic 모델 — 입력 / 출력 스키마, 자동 검증
Depends— 의존성 주입으로 공통 로직 (DB 세션, 인증 사용자 등) 분리
본 챕터는 Pydantic의 표층만 다룹니다. 검증 / 직렬화 / discriminated union 등의 깊이는 다음 24장 Pydantic v2 깊이에서 본격적으로 다룹니다.
APIRouter — 라우트 모듈화 #
라우트가 늘어나면 한 파일에 다 적기 어색합니다. **APIRouter**가 모듈별로 분리해줍니다.
from fastapi import APIRouter
router = APIRouter(prefix="/todos", tags=["todos"])
@router.get("/")
def list_todos():
return [{"id": 1, "title": "FastAPI 학습"}]
@router.get("/{todo_id}")
def get_todo(todo_id: int):
return {"id": todo_id, "title": "..."}from fastapi import FastAPI
from app.api import todos
app = FastAPI(title="Todo API")
app.include_router(todos.router)세 가지 효용:
prefix="/todos"— 모든 라우트 앞에 자동 부여.@router.get("/")가GET /todos/가 됨tags=["todos"]— Swagger UI에서 그룹핑- 모듈 분리 —
auth.py,users.py처럼 도메인 단위로 나누기 쉬움
Pydantic v2 모델 — 스키마의 정체 #
Pydantic은 dataclass + 검증 + 직렬화가 합쳐진 라이브러리입니다. 8장 dataclass에서 dataclass가 강한 검증 용도에는 안 어울린다고 했습니다. 그 영역을 Pydantic이 담당합니다.
from datetime import datetime
from pydantic import BaseModel, Field
class TodoCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str | None = None
class TodoUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = None
done: bool | None = None
class TodoOut(BaseModel):
id: int
title: str
description: str | None
done: bool
created_at: datetime
model_config = {"from_attributes": True}핵심 패턴:
- **입력용 (
TodoCreate,TodoUpdate)**과 **출력용 (TodoOut)**을 분리 - 입력은 클라이언트가 보낼 수 있는 것, 출력은 서버가 노출할 것
id,created_at같이 서버가 만드는 필드는 출력에만- 비밀번호 같이 외부에 노출되면 안 되는 필드는 출력에서 빠짐
본 분리가 보안과 명확성 두 가지를 동시에 잡습니다.
from_attributes=True — ORM 객체에서 직접
#
model_config = {"from_attributes": True} 옵션은 SQLAlchemy 모델 같은 객체에서 속성 접근으로 데이터를 읽을 수 있게 합니다 (옛 v1의 orm_mode). 25장 DB 연동 — SQLAlchemy 2.x + Alembic에서 자세히.
Pydantic v2의 빌트인 검증 #
from pydantic import BaseModel, EmailStr, HttpUrl, Field
from datetime import datetime
class UserCreate(BaseModel):
email: EmailStr # 이메일 형식
username: str = Field(min_length=3, max_length=30, pattern=r"^[a-zA-Z0-9_]+$")
password: str = Field(min_length=8)
website: HttpUrl | None = None # URL
birth_year: int = Field(ge=1900, le=2030) # 범위
bio: str = Field(max_length=500, default="")EmailStr, HttpUrl은 별도 패키지가 필요합니다. [standard] 옵션이면 이미 들어있습니다. Field()의 옵션:
min_length,max_length— 길이pattern— 정규식ge,le,gt,lt— 숫자 범위default,default_factory— 기본값description,example— Swagger 문서화
사용자 정의 검증 #
from pydantic import BaseModel, field_validator, model_validator
class TodoCreate(BaseModel):
title: str
priority: int = 1
@field_validator("title")
@classmethod
def title_no_html(cls, v: str) -> str:
if "<" in v or ">" in v:
raise ValueError("HTML 태그는 허용되지 않음")
return v.strip()
@model_validator(mode="after")
def check_priority_with_title(self):
if self.priority > 5 and "긴급" not in self.title:
raise ValueError("우선순위 5 초과는 제목에 '긴급' 필요")
return self@field_validator("name")— 한 필드 검증 / 변환@model_validator(mode="after")— 모든 필드 채워진 뒤 모델 단위 검증
검증 실패 시 자동으로 422 Unprocessable Entity 응답이 나갑니다. 검증 / 직렬화의 더 깊은 사용은 24장 Pydantic v2 깊이에서.
라우트에서 Pydantic 사용 #
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException
from app.schemas.todo import TodoCreate, TodoUpdate, TodoOut
router = APIRouter(prefix="/todos", tags=["todos"])
# 임시 인메모리 스토어 (25장에서 DB 로 교체)
_db: dict[int, TodoOut] = {}
_next_id = 1
@router.post("/", response_model=TodoOut, status_code=201)
def create_todo(payload: TodoCreate) -> TodoOut:
global _next_id
todo = TodoOut(
id=_next_id,
title=payload.title,
description=payload.description,
done=False,
created_at=datetime.now(timezone.utc),
)
_db[_next_id] = todo
_next_id += 1
return todo
@router.get("/{todo_id}", response_model=TodoOut)
def read_todo(todo_id: int) -> TodoOut:
if todo_id not in _db:
raise HTTPException(status_code=404, detail="Todo not found")
return _db[todo_id]
@router.patch("/{todo_id}", response_model=TodoOut)
def update_todo(todo_id: int, payload: TodoUpdate) -> TodoOut:
if todo_id not in _db:
raise HTTPException(status_code=404, detail="Todo not found")
current = _db[todo_id]
update_data = payload.model_dump(exclude_unset=True)
updated = current.model_copy(update=update_data)
_db[todo_id] = updated
return updated
@router.delete("/{todo_id}", status_code=204)
def delete_todo(todo_id: int) -> None:
if todo_id not in _db:
raise HTTPException(status_code=404, detail="Todo not found")
del _db[todo_id]체크포인트:
payload: TodoCreate— POST / PATCH 본문이 자동으로 검증 · 파싱됨response_model=TodoOut— 응답이 본 스키마로 직렬화. 다른 필드가 있어도 자동 필터링status_code=201— 응답 코드 명시payload.model_dump(exclude_unset=True)— 클라이언트가 명시한 필드만 가져옴 (PATCH의 핵심)
exclude_unset — PATCH의 정수
#
PATCH 요청에서 클라이언트가 title만 보냈다면, description=None으로 처리하면 안 됩니다 (덮어쓰기). 명시되지 않은 필드는 그대로 두는 게 PATCH 의미인데, exclude_unset=True가 그걸 풀어줍니다.
model_copy로 부분 업데이트
#
current.model_copy(update={...})가 새 인스턴스를 만들면서 일부 필드만 갱신합니다. 불변 객체 패턴.
의존성 주입 — Depends
#
같은 코드를 여러 라우트에서 반복하지 않으려면 의존성으로 빼냅니다.
from typing import Annotated
from fastapi import Depends, HTTPException, Header
def verify_token(authorization: Annotated[str | None, Header()] = None) -> dict:
if authorization is None:
raise HTTPException(401, "Authorization header required")
if not authorization.startswith("Bearer "):
raise HTTPException(401, "Invalid auth scheme")
token = authorization.split(" ", 1)[1]
# 실제 검증은 26장에서
return {"sub": "user123"}
CurrentUser = Annotated[dict, Depends(verify_token)]@router.post("/", response_model=TodoOut)
def create_todo(payload: TodoCreate, user: CurrentUser) -> TodoOut:
print("user:", user["sub"])
...Depends(fn)가 들어간 위치에 fn이 반환한 값이 주입됩니다. 두 가지 효용:
- 공통 로직 분리 — 인증, 권한, DB 세션, 페이지네이션 등을 한 곳에 두고 라우트는 받기만
- 테스트 시 교체 —
app.dependency_overrides[verify_token] = mock_verify로 한 줄 교체. 28장 테스트와 배포에서 자세히
본 패턴이 12장 데코레이터 패턴의 클래스 데코레이터와 20장 typing 고급의 Annotated가 합쳐진 결과물입니다.
의존성도 의존성을 가질 수 있음 #
def get_db_session() -> Iterator[Session]:
with SessionLocal() as session:
yield session
DBSession = Annotated[Session, Depends(get_db_session)]
def get_current_user(token: Annotated[str, Depends(...)], db: DBSession) -> User:
user = db.query(User).filter(User.id == token).first()
if not user:
raise HTTPException(401)
return userget_current_user가 get_db_session에 의존. FastAPI가 의존성 그래프를 자동으로 풀어 같은 요청 안에서 한 번만 실행합니다. 요청 단위 캐싱도 자동.
클래스 형태 의존성 — 매개변수가 있는 의존성 #
from dataclasses import dataclass
@dataclass
class Pagination:
skip: int = 0
limit: int = 100
PaginationDep = Annotated[Pagination, Depends()]
@router.get("/")
def list_todos(p: PaginationDep) -> list[TodoOut]:
return list(_db.values())[p.skip : p.skip + p.limit]Depends() 빈 괄호에 클래스만 넘기면, 해당 클래스의 필드가 쿼리 파라미터로 자동 변환됩니다. 페이지네이션처럼 여러 라우트에서 반복되는 매개변수를 한곳에 묶기에 깔끔.
표준 응답 모델 — 페이지네이션, 에러 #
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: list[T]
total: int
skip: int
limit: int
class ErrorResponse(BaseModel):
detail: str
code: str | None = None@router.get("/", response_model=Page[TodoOut])
def list_todos(p: PaginationDep) -> Page[TodoOut]:
items = list(_db.values())
return Page(
items=items[p.skip : p.skip + p.limit],
total=len(items),
skip=p.skip,
limit=p.limit,
)Page[TodoOut]처럼 제네릭 응답으로 타입 안전을 유지하면서 메타데이터를 같이 보냅니다. 9장 typing 본격의 Generic이 그대로 동작.
예외 핸들러 — 일관된 에러 형태 #
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class NotFoundError(Exception):
def __init__(self, resource: str, key: str):
self.resource = resource
self.key = key
app = FastAPI()
@app.exception_handler(NotFoundError)
async def not_found_handler(request: Request, exc: NotFoundError):
return JSONResponse(
status_code=404,
content={"detail": f"{exc.resource} {exc.key} 없음", "code": "not_found"},
)라우트에서:
@router.get("/{todo_id}")
def read_todo(todo_id: int) -> TodoOut:
if todo_id not in _db:
raise NotFoundError("Todo", str(todo_id))
return _db[todo_id]도메인 예외를 만들고, 그것이 HTTP 응답으로 변환되는 부분을 한 군데 모읍니다. 라우트가 깔끔해집니다. 6장 에러와 예외 처리의 사용자 정의 예외 계층 패턴이 그대로 살아납니다.
CORS — 프론트엔드와 분리 운영 #
브라우저 앱이 다른 도메인의 API를 호출하려면 CORS 설정이 필요합니다.
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "https://myapp.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)allow_origins=["*"]는 개발 단계에서만. 프로덕션은 명시적 도메인 리스트로 좁히세요.
연습문제 #
APIRouter로todos라우터를 별도 파일에 만들고app.include_router(...)로 등록하세요./docs에서 라우트가todos태그로 그룹핑되는지, prefix가 자동으로 붙는지 확인합니다.TodoCreate/TodoUpdate/TodoOut세 스키마를 작성하고 POST / PATCH / GET 세 라우트를 구현하세요. PATCH에서model_dump(exclude_unset=True)+model_copy(update=...)패턴이 동작하는지 (description만 보낸 요청이title을 덮어쓰지 않는지) 확인합니다.Depends로 페이지네이션 의존성Pagination(dataclass withskip,limit)을 만들고GET /todos가 쿼리 파라미터로 받도록 합니다.Depends()빈 괄호에 클래스를 넘기는 패턴이 Swagger UI에 어떻게 보이는지 확인합니다.
한 줄 요약:
APIRouter로 prefix + tags + 모듈 분리. Pydantic v2의 BaseModel + Field 검증,EmailStr/HttpUrl,@field_validator/@model_validator. 입력 / 출력 스키마 분리는 보안 + 명확성.response_model응답 직렬화, PATCH는model_dump(exclude_unset=True)+model_copy.Depends로 인증 / DB / 페이지네이션 공통 로직 분리, 의존성 그래프 자동 + 요청 단위 캐싱.Page[T]제네릭 응답,@app.exception_handler도메인 예외 → HTTP, CORS 미들웨어.
다음 챕터 #
다음 24장 Pydantic v2 깊이 ★신규 챕터에서는 본 챕터의 Pydantic 표층을 깊이로 확장합니다. v1 → v2의 변화, 검증 라이프사이클, 커스텀 직렬화, JSON Schema 생성 등 FastAPI의 핵심을 다룹니다.