목차
23 장

라우팅, Pydantic 모델, 의존성 주입

APIRouter로 라우트 분리, Pydantic v2 스키마로 입력/출력 정의, Depends로 공통 로직을 분리하는 패턴까지 정리합니다.

22장 FastAPI 시작과 셋업에서 app/main.py 한 파일에 라우트를 적었습니다. 본 챕터는 그 코드를 확장 가능한 구조로 나눕니다. 세 도구가 핵심입니다.

  • APIRouter — 라우트를 모듈별로 분리
  • Pydantic 모델 — 입력 / 출력 스키마, 자동 검증
  • Depends — 의존성 주입으로 공통 로직 (DB 세션, 인증 사용자 등) 분리

본 챕터는 Pydantic의 표층만 다룹니다. 검증 / 직렬화 / discriminated union 등의 깊이는 다음 24장 Pydantic v2 깊이에서 본격적으로 다룹니다.

APIRouter — 라우트 모듈화 #

라우트가 늘어나면 한 파일에 다 적기 어색합니다. **APIRouter**가 모듈별로 분리해줍니다.

app/api/todos.py
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": "..."}
app/main.py
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이 담당합니다.

app/schemas/todo.py
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 문서화

사용자 정의 검증 #

@field_validator / @model_validator
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 사용 #

app/api/todos.py
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 #

같은 코드를 여러 라우트에서 반복하지 않으려면 의존성으로 빼냅니다.

app/api/deps.py
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이 반환한 값이 주입됩니다. 두 가지 효용:

  1. 공통 로직 분리 — 인증, 권한, DB 세션, 페이지네이션 등을 한 곳에 두고 라우트는 받기만
  2. 테스트 시 교체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 user

get_current_userget_db_session에 의존. FastAPI가 의존성 그래프를 자동으로 풀어 같은 요청 안에서 한 번만 실행합니다. 요청 단위 캐싱도 자동.

클래스 형태 의존성 — 매개변수가 있는 의존성 #

page params 클래스
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() 빈 괄호에 클래스만 넘기면, 해당 클래스의 필드가 쿼리 파라미터로 자동 변환됩니다. 페이지네이션처럼 여러 라우트에서 반복되는 매개변수를 한곳에 묶기에 깔끔.

표준 응답 모델 — 페이지네이션, 에러 #

app/schemas/common.py
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이 그대로 동작.

예외 핸들러 — 일관된 에러 형태 #

app/main.py
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 설정이 필요합니다.

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=["*"]는 개발 단계에서만. 프로덕션은 명시적 도메인 리스트로 좁히세요.

연습문제 #

  1. APIRoutertodos 라우터를 별도 파일에 만들고 app.include_router(...)로 등록하세요. /docs에서 라우트가 todos 태그로 그룹핑되는지, prefix가 자동으로 붙는지 확인합니다.
  2. TodoCreate / TodoUpdate / TodoOut 세 스키마를 작성하고 POST / PATCH / GET 세 라우트를 구현하세요. PATCH에서 model_dump(exclude_unset=True) + model_copy(update=...) 패턴이 동작하는지 (description만 보낸 요청이 title을 덮어쓰지 않는지) 확인합니다.
  3. Depends로 페이지네이션 의존성 Pagination (dataclass with skip, 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의 핵심을 다룹니다.

X