종합 실습 — TODO API 완성하기
1~28장의 패턴을 하나의 동작하는 서비스로 엮습니다. 인증된 사용자별 TODO CRUD, 태그 필터, 페이지네이션, 백그라운드 알림, 테스트, 배포까지.
4부의 마지막 챕터이자 책 1~28장의 모든 도구를 한 서비스에 묶는 종합 실습입니다. 22~28장에서 FastAPI의 각 도구 (라우팅 / Pydantic / DB / 인증 / 비동기 / 테스트 / 배포)를 개별로 봤다면, 본 챕터는 그것들이 실제 한 프로젝트에서 어떻게 맞물리는지를 단계별로 만들면서 살펴보겠습니다.
본 챕터에서 만든 결과물은 다음 5부의 30장 타입체커 설정과 CI 통합, 31장 logging과 관측성, 33장 CLI 도구 만들기 (Typer)의 실전 적용 대상이 됩니다. 그래서 본 챕터는 4부와 5부를 이어주는 다리 역할도 합니다.
완성될 서비스 #
TODO API — 인증된 사용자가 자기의 할 일을 관리하는 작은 백엔드.
| 기능 | 적용 챕터 |
|---|---|
| 회원가입 / 로그인 (JWT) | 26 |
| TODO CRUD | 23, 24, 25 |
| 태그 필터 + 페이지네이션 | 23 |
| 우선순위 (1~5) | 24 |
| 마감 임박 알림 (백그라운드) | 27 |
| 헬스체크 + 메트릭 | 22, 31 |
| 통합 테스트 | 28 |
| Docker + 클라우드 배포 | 28 |
엔드포인트 표:
| 메소드 | 경로 | 의도 |
|---|---|---|
| POST | /auth/register | 회원가입 |
| POST | /auth/login | 로그인, JWT 발급 |
| GET | /me | 현재 사용자 정보 |
| POST | /todos | TODO 생성 |
| GET | /todos | TODO 목록 (필터 + 페이지네이션) |
| GET | /todos/{id} | TODO 단건 |
| PATCH | /todos/{id} | TODO 부분 업데이트 |
| DELETE | /todos/{id} | TODO 삭제 |
| GET | /health | 헬스체크 |
본 챕터는 단계별로 하나씩 구축하면서 각 단계에서 어떤 챕터의 패턴이 들어가는지 박스로 표기합니다.
0. 최종 프로젝트 구조 #
먼저 결과물의 구조를 보고 시작.
todo-api/
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── .dockerignore
├── alembic.ini
├── alembic/
│ ├── env.py
│ └── versions/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── core/
│ │ ├── config.py
│ │ ├── security.py
│ │ └── logging.py
│ ├── api/
│ │ ├── deps.py
│ │ ├── auth.py
│ │ ├── todos.py
│ │ └── health.py
│ ├── models/
│ │ ├── base.py
│ │ ├── user.py
│ │ └── todo.py
│ ├── schemas/
│ │ ├── common.py
│ │ ├── user.py
│ │ └── todo.py
│ ├── services/
│ │ ├── user.py
│ │ └── todo.py
│ ├── db/
│ │ └── session.py
│ └── tasks/
│ └── notifier.py
└── tests/
├── conftest.py
├── test_auth.py
└── test_todos.py7장 모듈, 패키지와 pyproject.toml의 패키지 분리 패턴이 그대로 살아납니다. 본 구조가 책 전체에서 “모듈을 잘 나누는 게 왜 중요한가"의 종합 답입니다.
1. 프로젝트 셋업 [22장] #
적용 챕터: 1장 시작과 uv 셋업, 7장 모듈, 패키지와 pyproject.toml, 22장 FastAPI 시작과 셋업
uv init todo-api --python 3.14
cd todo-api
uv add "fastapi[standard]" sqlalchemy "sqlalchemy[asyncio]" aiosqlite asyncpg
uv add argon2-cffi pyjwt pydantic-settings
uv add --dev pytest pytest-asyncio pytest-cov httpx pytest-httpx
uv add --dev pyright ruff alembicfrom pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Todo API"
database_url: str = "sqlite+aiosqlite:///./todo.db"
jwt_secret: str = "dev-only-change-me"
jwt_expire_minutes: int = 60
notification_check_interval: int = 60 # 백그라운드 작업 간격 (초)
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
settings = Settings()from fastapi import FastAPI
from app.core.config import settings
from app.api import auth, todos, health
app = FastAPI(title=settings.app_name, version="0.1.0")
app.include_router(auth.router)
app.include_router(todos.router)
app.include_router(health.router)2. DB 모델 설계 [25장] #
적용 챕터: 8장 dataclass, 25장 DB 연동
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
passfrom sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(200), unique=True, index=True)
hashed_password: Mapped[str]
todos: Mapped[list["Todo"]] = relationship(back_populates="owner", cascade="all, delete-orphan")from datetime import datetime, date
from sqlalchemy import String, Boolean, DateTime, Date, ForeignKey, Integer, JSON, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class Todo(Base):
__tablename__ = "todos"
id: Mapped[int] = mapped_column(primary_key=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
title: Mapped[str] = mapped_column(String(200))
description: Mapped[str] = mapped_column(default="")
done: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
priority: Mapped[int] = mapped_column(Integer, default=3)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
owner: Mapped["User"] = relationship(back_populates="todos")from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession
from app.core.config import settings
engine = create_async_engine(settings.database_url, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)cascade="all, delete-orphan" — 사용자 삭제 시 그 사용자의 모든 TODO도 함께 삭제. 도메인 정책을 한 줄로 명시.
tags: list[str]을 JSON 컬럼으로 — 작은 프로젝트에서 별도 태그 테이블 + 다대다 관계는 과한 경우. 운영 규모가 커지면 정규화 검토.
3. 인증 [26장] #
적용 챕터: 26장 인증 — OAuth2 + JWT
from datetime import datetime, timedelta, timezone
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from app.core.config import settings
_hasher = PasswordHasher()
ALGORITHM = "HS256"
def hash_password(plain: str) -> str:
return _hasher.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
try:
_hasher.verify(hashed, plain)
return True
except VerifyMismatchError:
return False
def create_access_token(subject: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": subject,
"iat": now,
"exp": now + timedelta(minutes=settings.jwt_expire_minutes),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.core.security import hash_password, verify_password
async def create(db: AsyncSession, email: str, password: str) -> User:
user = User(email=email, hashed_password=hash_password(password))
db.add(user)
await db.commit()
await db.refresh(user)
return user
async def get_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
user = await get_by_email(db, email)
if user is None or not verify_password(password, user.hashed_password):
return None
return user4. 의존성 주입으로 권한 + 세션 정리 [23장] #
적용 챕터: 23장 라우팅, Pydantic 모델, 의존성 주입
from typing import Annotated, AsyncIterator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jwt import InvalidTokenError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import AsyncSessionLocal
from app.core.security import decode_token
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
async def get_session() -> AsyncIterator[AsyncSession]:
async with AsyncSessionLocal() as session:
yield session
DBSession = Annotated[AsyncSession, Depends(get_session)]
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: DBSession,
) -> User:
exc = HTTPException(
status.HTTP_401_UNAUTHORIZED,
"유효하지 않은 토큰",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
user_id = int(payload["sub"])
except (InvalidTokenError, KeyError, ValueError):
raise exc
user = await db.get(User, user_id)
if user is None:
raise exc
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
# 페이지네이션 의존성
from dataclasses import dataclass
@dataclass
class Pagination:
skip: int = 0
limit: int = 50
PaginationDep = Annotated[Pagination, Depends()]DBSession, CurrentUser, PaginationDep 세 의존성이 거의 모든 라우트에 들어갑니다.
5. 인증 라우트 [26장] #
from pydantic import BaseModel, EmailStr, Field, ConfigDict, SecretStr
class UserCreate(BaseModel):
email: EmailStr
password: SecretStr = Field(min_length=8, max_length=128)
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: EmailStr
class Token(BaseModel):
access_token: str
token_type: str = "bearer"SecretStr 사용은 24장 Pydantic v2 깊이의 PII 마스킹 패턴. 입력은 평문이지만 로그 / repr에서 자동 마스킹.
from typing import Annotated
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm
from app.api.deps import DBSession, CurrentUser
from app.schemas.user import UserCreate, UserOut, Token
from app.services import user as user_service
from app.core.security import create_access_token
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserOut, status_code=201)
async def register(payload: UserCreate, db: DBSession) -> UserOut:
existing = await user_service.get_by_email(db, payload.email)
if existing:
raise HTTPException(409, "이미 가입된 이메일")
return await user_service.create(db, payload.email, payload.password.get_secret_value())
@router.post("/login", response_model=Token)
async def login(
form: Annotated[OAuth2PasswordRequestForm, Depends()],
db: DBSession,
) -> Token:
user = await user_service.authenticate(db, form.username, form.password)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Incorrect credentials")
token = create_access_token(subject=str(user.id))
return Token(access_token=token)
# /me 라우트도 같이
me_router = APIRouter(tags=["me"])
@me_router.get("/me", response_model=UserOut)
async def me(current: CurrentUser) -> UserOut:
return current6. TODO CRUD [23, 24, 25장] #
from datetime import datetime, date
from pydantic import BaseModel, Field, field_validator, ConfigDict
from typing import Annotated
Priority = Annotated[int, Field(ge=1, le=5)]
class TodoBase(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True)
title: str = Field(min_length=1, max_length=200)
description: str = ""
priority: Priority = 3
due_date: date | None = None
tags: list[str] = Field(default_factory=list)
@field_validator("tags")
@classmethod
def normalize_tags(cls, v: list[str]) -> list[str]:
# 중복 제거 + 소문자화 + 빈 태그 제거
return sorted({t.strip().lower() for t in v if t.strip()})
class TodoCreate(TodoBase):
pass
class TodoUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = None
done: bool | None = None
priority: Priority | None = None
due_date: date | None = None
tags: list[str] | None = None
class TodoOut(TodoBase):
model_config = ConfigDict(from_attributes=True)
id: int
done: bool
created_at: datetime
updated_at: datetimefrom pydantic import BaseModel
class Page[T](BaseModel):
items: list[T]
total: int
skip: int
limit: int본 Page[T]가 9장 typing 본격의 Generic + 24장 Pydantic v2 깊이의 Generic 모델 패턴.
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.todo import Todo
from app.schemas.todo import TodoCreate, TodoUpdate
async def create(db: AsyncSession, data: TodoCreate, owner_id: int) -> Todo:
todo = Todo(**data.model_dump(), owner_id=owner_id)
db.add(todo)
await db.commit()
await db.refresh(todo)
return todo
async def get(db: AsyncSession, todo_id: int, owner_id: int) -> Todo | None:
result = await db.execute(
select(Todo).where(Todo.id == todo_id, Todo.owner_id == owner_id)
)
return result.scalar_one_or_none()
async def list_filtered(
db: AsyncSession,
owner_id: int,
*,
skip: int = 0,
limit: int = 50,
done: bool | None = None,
tag: str | None = None,
) -> tuple[list[Todo], int]:
stmt = select(Todo).where(Todo.owner_id == owner_id)
if done is not None:
stmt = stmt.where(Todo.done == done)
if tag is not None:
# JSON 컬럼에서 tag 가 포함된 행 (DB 의존적 — SQLite/Postgres 모두 지원)
stmt = stmt.where(Todo.tags.contains([tag]))
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
items = list((await db.execute(
stmt.order_by(Todo.created_at.desc()).offset(skip).limit(limit)
)).scalars())
return items, total
async def update(db: AsyncSession, todo: Todo, data: TodoUpdate) -> Todo:
for field, value in data.model_dump(exclude_unset=True).items():
setattr(todo, field, value)
await db.commit()
await db.refresh(todo)
return todo
async def delete(db: AsyncSession, todo: Todo) -> None:
await db.delete(todo)
await db.commit()소유자 검증을 service 레이어에서 — owner_id가 일치하지 않으면 None 반환. 라우트는 None → 404로 변환.
from fastapi import APIRouter, HTTPException, Query
from typing import Annotated
from app.api.deps import DBSession, CurrentUser, PaginationDep
from app.schemas.todo import TodoCreate, TodoUpdate, TodoOut
from app.schemas.common import Page
from app.services import todo as todo_service
router = APIRouter(prefix="/todos", tags=["todos"])
@router.post("", response_model=TodoOut, status_code=201)
async def create_todo(payload: TodoCreate, user: CurrentUser, db: DBSession) -> TodoOut:
return await todo_service.create(db, payload, owner_id=user.id)
@router.get("", response_model=Page[TodoOut])
async def list_todos(
user: CurrentUser,
db: DBSession,
pagination: PaginationDep,
done: Annotated[bool | None, Query()] = None,
tag: Annotated[str | None, Query()] = None,
) -> Page[TodoOut]:
items, total = await todo_service.list_filtered(
db, owner_id=user.id,
skip=pagination.skip, limit=pagination.limit,
done=done, tag=tag,
)
return Page(items=items, total=total, skip=pagination.skip, limit=pagination.limit)
@router.get("/{todo_id}", response_model=TodoOut)
async def read_todo(todo_id: int, user: CurrentUser, db: DBSession) -> TodoOut:
todo = await todo_service.get(db, todo_id, owner_id=user.id)
if todo is None:
raise HTTPException(404, "Todo not found")
return todo
@router.patch("/{todo_id}", response_model=TodoOut)
async def update_todo(
todo_id: int,
payload: TodoUpdate,
user: CurrentUser,
db: DBSession,
) -> TodoOut:
todo = await todo_service.get(db, todo_id, owner_id=user.id)
if todo is None:
raise HTTPException(404, "Todo not found")
return await todo_service.update(db, todo, payload)
@router.delete("/{todo_id}", status_code=204)
async def delete_todo(todo_id: int, user: CurrentUser, db: DBSession) -> None:
todo = await todo_service.get(db, todo_id, owner_id=user.id)
if todo is None:
raise HTTPException(404, "Todo not found")
await todo_service.delete(db, todo)user: CurrentUser 한 줄이 모든 라우트의 인증 + 권한 처리를 분리합니다. 라우트 코드는 비즈니스 로직에 집중합니다.
7. 헬스체크 + 메트릭 [22, 31장] #
from fastapi import APIRouter
from app.api.deps import DBSession
from sqlalchemy import text
router = APIRouter(tags=["ops"])
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "healthy"}
@router.get("/health/db")
async def health_db(db: DBSession) -> dict[str, str]:
try:
await db.execute(text("SELECT 1"))
return {"status": "ok", "db": "connected"}
except Exception as e:
return {"status": "error", "db": str(e)}기본 헬스체크와 DB 헬스체크를 분리. 클라우드의 health probe (Railway / Fly 등)는 /health만 사용, /health/db는 운영자가 디버깅용.
8. 백그라운드 알림 [27장] #
import asyncio
from datetime import date, timedelta
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.todo import Todo
from app.core.config import settings
async def check_due_todos() -> list[int]:
"""마감이 오늘 또는 내일인 미완료 TODO id 목록."""
tomorrow = date.today() + timedelta(days=1)
async with AsyncSessionLocal() as db:
result = await db.execute(
select(Todo.id).where(
Todo.done == False,
Todo.due_date.between(date.today(), tomorrow),
)
)
return list(result.scalars())
async def notifier_loop():
"""주기적으로 마감 임박 TODO 확인 후 알림 시뮬레이션."""
while True:
ids = await check_due_todos()
for todo_id in ids:
print(f"[notifier] 마감 임박 TODO #{todo_id}") # 실제는 이메일/Slack 등
await asyncio.sleep(settings.notification_check_interval)from contextlib import asynccontextmanager
import asyncio
from fastapi import FastAPI
from app.tasks.notifier import notifier_loop
from app.api import auth, todos, health
@asynccontextmanager
async def lifespan(app: FastAPI):
# 시작 시 백그라운드 작업 시작
task = asyncio.create_task(notifier_loop())
app.state.notifier_task = task
yield
# 종료 시 정리
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title="Todo API", lifespan=lifespan)
app.include_router(auth.router)
app.include_router(todos.router)
app.include_router(health.router)27장 비동기와 백그라운드 작업의 lifespan + Task 패턴. 본 단순한 알림이 더 커지면 ARQ / Celery 같은 외부 큐로 옮기는 시점.
9. 테스트 [28, 30장] #
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.main import app
from app.api.deps import get_session, get_current_user
from app.models.base import Base
from app.models.user import User
from app.core.security import hash_password
TEST_URL = "sqlite+aiosqlite:///:memory:"
@pytest_asyncio.fixture
async def db_engine():
engine = create_async_engine(TEST_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(db_engine):
Session = async_sessionmaker(db_engine, expire_on_commit=False)
async with Session() as session:
yield session
@pytest_asyncio.fixture
async def test_user(db_session) -> User:
user = User(email="test@example.com", hashed_password=hash_password("password123"))
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest_asyncio.fixture
async def authed_client(db_session, test_user):
async def override_session():
yield db_session
async def override_user():
return test_user
app.dependency_overrides[get_session] = override_session
app.dependency_overrides[get_current_user] = override_user
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as c:
yield c
app.dependency_overrides.clear()import pytest
pytestmark = pytest.mark.asyncio
async def test_create_and_get_todo(authed_client):
create = await authed_client.post("/todos", json={"title": "테스트", "priority": 4})
assert create.status_code == 201
todo_id = create.json()["id"]
get = await authed_client.get(f"/todos/{todo_id}")
assert get.json()["title"] == "테스트"
assert get.json()["priority"] == 4
async def test_filter_by_done(authed_client):
await authed_client.post("/todos", json={"title": "open"})
closed = await authed_client.post("/todos", json={"title": "closed"})
await authed_client.patch(f"/todos/{closed.json()['id']}", json={"done": True})
open_only = await authed_client.get("/todos?done=false")
assert open_only.json()["total"] == 1
assert open_only.json()["items"][0]["title"] == "open"
async def test_tag_normalization(authed_client):
resp = await authed_client.post("/todos", json={
"title": "with tags",
"tags": ["Python", " fastapi ", "python", ""],
})
# 중복 제거 + 소문자 + 빈 태그 제거
assert sorted(resp.json()["tags"]) == ["fastapi", "python"]
async def test_404_on_other_user_todo(authed_client, db_session):
# 다른 사용자 + 그의 TODO 직접 삽입
from app.models.user import User
from app.models.todo import Todo
other = User(email="other@x.com", hashed_password="x")
db_session.add(other)
await db_session.commit()
foreign = Todo(title="not yours", owner_id=other.id)
db_session.add(foreign)
await db_session.commit()
resp = await authed_client.get(f"/todos/{foreign.id}")
assert resp.status_code == 404 # 다른 사용자의 TODO 는 보이지 않음소유권 분리 테스트가 가장 중요한 보안 케이스 중 하나. 반드시 포함하세요.
10. Docker + 배포 [28장] #
FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY app/ ./app/
COPY alembic/ ./alembic/
COPY alembic.ini ./
FROM python:3.14-slim AS runtime
RUN useradd --create-home --uid 1000 appuser
WORKDIR /app
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
USER appuser
EXPOSE 8000
# 마이그레이션 적용 후 서버 시작
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]작은 프로젝트는 마이그레이션을 컨테이너 시작 시 같이 — 큰 프로젝트는 28장의 옵션 2 (CI/CD 단계 분리) 권장.
services:
api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://todo:todo@db:5432/todo
JWT_SECRET: dev-secret-only
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: todo
POSTGRES_PASSWORD: todo
POSTGRES_DB: todo
healthcheck:
test: ["CMD-SHELL", "pg_isready -U todo"]
interval: 5s
retries: 5
volumes:
- pg-data:/var/lib/postgresql/data
volumes:
pg-data:docker compose up -d
curl http://localhost:8000/health # {"status":"healthy"}책 전체 패턴이 어디에 들어갔는가 — 체크리스트 #
본 종합 실습 한 프로젝트에서 책 전체의 도구가 어떻게 살아났는지 체크.
| 챕터 | 어디에 |
|---|---|
| 1장 uv | 1 — 프로젝트 셋업 |
| 2장 타입힌트 | 모든 함수 시그니처 |
| 3장 제어 흐름 | service 레이어 분기 |
| 4장 컬렉션 / 컴프리헨션 | tags 정규화 (set 컴프리헨션) |
| 5장 함수 인자 | service 함수의 keyword-only 인자 (*) |
| 6장 예외 처리 | HTTPException, JWT 디코딩 except |
| 7장 모듈 / pyproject | 프로젝트 구조 |
| 8장 dataclass | Pagination 의존성 |
| 9장 Generic / Protocol | Page[T] 응답 |
| 10장 컨텍스트 매니저 | DB 세션 의존성, lifespan |
| 11장 이터러블 / 제너레이터 | get_session async generator |
| 12장 데코레이터 | @router.post(...), Depends |
| 13장 패턴 매칭 | (선택 — 이벤트 처리 추가 시) |
| 14장 비동기 입문 | 모든 라우트 async, asyncio.sleep |
| 15장 매직 메소드 | Pydantic의 __init__ / __call__ 위에 |
| 16장 디스크립터 | SQLAlchemy Mapped[T] / Pydantic Field |
| 17장 메타클래스 | SQLAlchemy DeclarativeBase, Pydantic BaseModel |
| 18장 비동기 깊이 | lifespan의 create_task + cancel |
| 19장 GIL / 동시성 | async def 선택 근거 |
| 20장 typing 고급 | Annotated[int, Field(...)], Self |
| 21장 성능 | (운영 단계에서 — cProfile / py-spy) |
| 22장 FastAPI 셋업 | app/main.py |
| 23장 라우팅 / DI | APIRouter, Depends |
| 24장 Pydantic v2 | 모든 스키마, SecretStr, field_validator |
| 25장 DB SQLAlchemy | 모델 + 세션 + Alembic |
| 26장 인증 | argon2 + JWT + OAuth2 |
| 27장 비동기 / 백그라운드 | notifier_loop |
| 28장 테스트 / 배포 | conftest, Dockerfile, compose |
5부의 운영 챕터들이 본 종합 실습 위에 얹힙니다:
- 30장 타입체커 CI — 본 프로젝트에 mypy/pyright/ruff/pre-commit 정식 셋업
- 31장 logging —
app/core/logging.py추가, structlog + Sentry - 32장 라이브러리 배포 — 본 프로젝트의 일부를 라이브러리로 떼어 PyPI 배포
- 33장 CLI Typer — 본 프로젝트의 관리 명령 (사용자 생성 / 마이그레이션 등)을 CLI로
연습문제 #
- 위 코드를 직접 옮겨
todo-api를 동작시키세요.docker compose up -d→curl /health가 OK인지,register→login→POST /todos흐름이 끝까지 동작하는지 확인합니다. Swagger UI의 “Authorize"로 JWT를 입력해 인증된 호출도 직접 시도합니다. due_date가 오늘인 TODO가 있을 때 백그라운드 알림 로그가 출력되는지 확인합니다.notification_check_interval을 5초로 줄여 빠르게 테스트.- 본 종합 실습에 다음 기능 중 하나를 추가하세요: (1) TODO 검색 (
?q=쿼리로 title LIKE), (2) TODO 정렬 옵션 (?sort=priority/?sort=-due_date), (3) 통계 엔드포인트 (GET /stats— 전체 / 완료 / 미완료 수). 변경 후 테스트 케이스도 같이 추가합니다.
한 줄 요약: 종합 실습은 1~28장의 도구가 실제 프로젝트 안에서 어떻게 연결되는지 보여줍니다. 단계 = 프로젝트 셋업 → DB 모델 → 인증 → 의존성 / 라우트 → CRUD → 헬스체크 → 백그라운드 → 테스트 → Docker. 각 단계에서 어느 챕터의 패턴이 들어가는지를 의식하며 직접 옮겨 적는 것이 본 챕터의 핵심 활용입니다. 5부의 운영 챕터들은 본 종합 실습 위에 이어집니다.
다음 챕터 #
다음 30장 타입체커 설정과 CI 통합부터 5부 운영 · 패키징 · 테스트의 신규 4장이 시작됩니다. 본 종합 실습 프로젝트가 5부의 적용 대상이 됩니다.