드디어 마지막 편이다. 지난 열한 편 동안 우리는 부품을 하나씩 만들어 왔다 — 타입힌트로 입력을 받고(2·3편), 의존성을 주입하고(5편), 라우터로 나누고(6편), DB 에 저장하고(7·8편), 토큰으로 잠그고(9편), 테스트하고(10편), 배포하는(11편) 법까지. 하나하나는 익혔지만, 한 프로젝트에서 어떻게 맞물리는지는 통째로 본 적이 없다.
12편은 그 조각들을 꺼내 하나로 조립한다. 만들 것은 사용자별 할 일(To-Do) REST API — 로그인한 사람이 자기 할 일을 만들고, 보고, 끝내고, 지우는 작지만 완결된 서비스다. 새 문법은 거의 없다. "이게 이렇게 합쳐지는구나" 를 보는 게 전부다.
1. 프로젝트 개요와 폴더 구조
먼저 만들 것의 윤곽부터. To-Do API 는 사용자(User)와 할 일(Todo) 두 테이블을 가진다. 한 사용자는 여러 할 일을 가질 수 있고, 각 할 일에는 주인(owner) 이 있다. 그래서 김철수가 로그인하면 김철수의 할 일만 보이고, 남의 것은 보지도 고치지도 못한다. 이 "본인 것만" 규칙이 9편 인증을 실전에서 쓰는 핵심 장면이다.
6편의 권장 구조를 그대로 따른다 — 파일마다 한 가지 일만 맡는다.
todo-api/
├── app/
│ ├── main.py # 앱 + 라우터 연결 (6편)
│ ├── database.py # 엔진·세션·get_db (7편)
│ ├── models.py # 테이블 User·Todo (7편)
│ ├── schemas.py # 입력·출력 스키마 (3편)
│ ├── auth.py # 해싱·JWT·get_current_user (9편)
│ └── routers/
│ ├── users.py # 회원가입·로그인 (8·9편)
│ └── todos.py # 할 일 CRUD + 인증 (8·9편)
├── tests/test_todos.py # TestClient + pytest (10편)
├── requirements.txt
└── Dockerfile # 컨테이너 배포 (11편)
이 트리가 시리즈 전체의 지도다. 주석의 편 번호를 따라가면 각 조각이 어디서 왔는지 한눈에 보인다 — 이 매핑이 머리에 박히면 어떤 FastAPI 프로젝트를 봐도 길을 잃지 않는다.
2. 모델과 스키마 — 데이터의 뼈대
출발점은 데이터의 모양이다. 7편의 SQLAlchemy 모델 두 개부터 적는다. User 는 9편 인증을 위해 hashed_password 를 가지고, Todo 는 owner_id 로 주인을 가리킨다. 두 테이블은 relationship 으로 묶는다.
# app/models.py
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey
from sqlalchemy.orm import relationship
from database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True)
hashed_password = Column(String) # 평문 금지 — 9편
todos = relationship("Todo", back_populates="owner")
class Todo(Base):
__tablename__ = "todos"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
done = Column(Boolean, default=False)
owner_id = Column(Integer, ForeignKey("users.id")) # 할 일의 주인
owner = relationship("User", back_populates="todos")
핵심은 owner_id = Column(Integer, ForeignKey("users.id")) 한 줄이다. 이게 할 일을 사용자에 묶는 외래 키(foreign key) 로, "이 Todo 의 주인은 users 의 누구인가" 를 숫자 하나로 가리킨다. relationship 은 그 연결을 파이썬 객체로 타고 다니게 해 준다 — user.todos 로 그 사람의 할 일을, todo.owner 로 주인을 곧장 꺼낼 수 있다.
이제 3편의 Pydantic 스키마. 8편에서 강조한 대로 입력용과 출력용을 분리한다. 클라이언트는 title 만 보내면 되고(id·owner_id 는 서버가 정한다), 응답에는 비밀번호 같은 게 새지 않게 출력 스키마로 거른다.
# app/schemas.py
from pydantic import BaseModel, ConfigDict
class TodoCreate(BaseModel): # 입력용 — 클라이언트가 보내는 것
title: str
class TodoOut(BaseModel): # 출력용 — 응답으로 돌려줄 것
id: int
title: str
done: bool
owner_id: int
model_config = ConfigDict(from_attributes=True) # ORM 객체 → 스키마 자동 변환
왜 둘로 나누나 — 입력 스키마에 id 를 넣으면 클라이언트가 멋대로 정해 보낼 수 있고, 출력 스키마가 없으면 민감 필드가 응답에 새어 나간다. from_attributes=True(Pydantic v2) 는 SQLAlchemy 객체를 TodoOut 으로 자동 변환해 주는 스위치라, 라우트에서 return todo 한 줄이면 FastAPI 가 알아서 JSON 으로 빚어 준다.
3. CRUD 라우터 + 인증 보호 — 본인 것만
이제 이 프로젝트의 심장이다. 8편의 CRUD 와 9편의 인증이 여기서 만난다. 9편에서 만든 인증 조각 — 토큰을 검증해 사용자를 돌려주는 get_current_user — 을 auth.py 에 그대로 두고, 모든 라우트가 "로그인한 본인의 할 일만" 다루게 만든다.
핵심은 routers/todos.py 다. CRUD 네 동작을 적되 모든 라우트 인자에 user: models.User = Depends(get_current_user) 를 끼운다. 이 한 줄 덕분에 본문이 실행되기 전에 토큰 검사가 끝나 있고, user 에는 지금 로그인한 사람 이 담겨 들어온다. 그다음 모든 질의를 owner_id == user.id 로 걸기만 하면 된다.
# app/routers/todos.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from auth import get_current_user
import models, schemas
router = APIRouter(prefix="/todos", tags=["todos"])
# Create — 주인은 로그인 사용자
@router.post("", response_model=schemas.TodoOut, status_code=201)
def create_todo(payload: schemas.TodoCreate, db: Session = Depends(get_db),
user: models.User = Depends(get_current_user)):
todo = models.Todo(title=payload.title, owner_id=user.id)
db.add(todo); db.commit(); db.refresh(todo)
return todo
# Read 목록 — 내 할 일만
@router.get("", response_model=list[schemas.TodoOut])
def list_my_todos(db: Session = Depends(get_db),
user: models.User = Depends(get_current_user)):
return db.query(models.Todo).filter(models.Todo.owner_id == user.id).all()
나머지 수정·삭제도 패턴이 같다 — 먼저 찾고, 없으면 404, 남의 것이면 403, 그제야 손을 댄다. 이 "내 것 확인" 을 작은 헬퍼로 빼면 라우트가 한결 깔끔해진다.
# app/routers/todos.py (이어서)
# 헬퍼 — 없으면 404, 남의 것이면 403
def get_owned_todo(todo_id, db, user) -> models.Todo:
todo = db.get(models.Todo, todo_id)
if todo is None:
raise HTTPException(status_code=404, detail="할 일을 찾을 수 없습니다")
if todo.owner_id != user.id: # 남의 것 — 인가 실패
raise HTTPException(status_code=403, detail="권한이 없습니다")
return todo
# Update — 제목 수정 + 완료 토글
@router.put("/{todo_id}", response_model=schemas.TodoOut)
def update_todo(todo_id: int, payload: schemas.TodoCreate,
db: Session = Depends(get_db),
user: models.User = Depends(get_current_user)):
todo = get_owned_todo(todo_id, db, user)
todo.title = payload.title
todo.done = not todo.done # 호출마다 완료/미완료 토글
db.commit(); db.refresh(todo)
return todo
# Delete — 본문 없이 204
@router.delete("/{todo_id}", status_code=204)
def delete_todo(todo_id: int, db: Session = Depends(get_db),
user: models.User = Depends(get_current_user)):
db.delete(get_owned_todo(todo_id, db, user)); db.commit()
404 와 403 을 나눈 이유 — 할 일이 아예 없으면 404(Not Found), 있는데 남의 것이면 403(Forbidden)이다. 9편에서 말한 인증과 인가의 차이 가 코드로 드러나는 지점이다. 토큰 검사를 통과해 "누구인지(인증)" 는 알았지만 이 할 일을 만질 "자격(인가)" 이 없으니 막는다. get_current_user 가 인증을, owner_id != user.id 한 줄이 인가를 책임진다.
마지막으로 6편처럼 main.py 에서 라우터를 합치고, 7편처럼 테이블을 만든다. 본체는 다시 가벼워진다.
# app/main.py
from fastapi import FastAPI
from database import Base, engine
from routers import users, todos
import models # 모델을 import 해야 메타데이터에 등록된다
Base.metadata.create_all(bind=engine) # 없는 테이블만 생성 (7편)
app = FastAPI(title="To-Do API")
app.include_router(users.router) # /users/signup · /users/login
app.include_router(todos.router) # /todos ...
@app.get("/")
def health():
return {"status": "ok"}
4. 실행 · 테스트 · 다음 단계
코드가 다 모였으니 돌려 본다. 9편에서 SECRET_KEY 는 환경변수로만 둔다고 했으니, 키를 내보내고 1편 그대로 개발 서버를 띄운다.
$ export SECRET_KEY="아무도-모르는-긴-랜덤-문자열"
$ fastapi dev app/main.py
Serving at http://127.0.0.1:8000
API docs at http://127.0.0.1:8000/docs
/docs 에 들어가면 9편에서 본 Authorize 버튼 이 보인다. ① POST /users/signup 으로 가입하고, ② Authorize 에 로그인하면 토큰이 자동으로 끼워진다. ③ POST /todos 로 할 일을 만들면, ④ GET /todos 는 내 것만 돌려준다. 다른 계정으로 로그인하면 그 할 일은 목록에 뜨지 않는다 — 인증과 인가가 살아 움직이는 순간이다.
눈으로 확인했으면 10편의 자동 테스트로 못을 박는다. TestClient 로 서버를 띄우지 않고도 라우트를 호출해, "토큰 없이 부르면 401 인가" 같은 핵심 규칙을 코드로 굳힌다.
# tests/test_todos.py (10편)
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_todos_require_auth():
# 토큰 없이 호출하면 보호된 라우트는 401 이어야 한다
res = client.get("/todos")
assert res.status_code == 401
실전으로 가기 전 체크 — 지금 코드는 학습용이라 SQLite 파일 하나(app.db)를 쓴다. 서비스로 올리기 전엔 ① SECRET_KEY 를 깃에 올리지 말고 환경변수로, ② DB 를 PostgreSQL 로 바꾸고(7편대로 연결 문자열 한 줄만 교체), ③ 목록이 길어지면 limit·offset 으로 페이지네이션 을 붙인다. 거의 모든 실전 API 가 거치는 관문이다.
마지막으로 11편의 Docker 로 어디서든 똑같이 돌게 포장한다. 이미지 하나면 내 노트북이든 클라우드든 환경 차이로 깨지지 않는다.
# Dockerfile (11편)
FROM python:3.12-slim
WORKDIR /code
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ./app ./app
# 운영에서는 fastapi dev 가 아니라 uvicorn 으로
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
여기서 멈추지 말고 한 발 더 나가 보자. 프론트엔드(Next.js·React)를 붙여 화면 있는 앱으로 키우거나, DB 를 PostgreSQL 로 바꾸거나, 검색·정렬을 더하면 그대로 포트폴리오가 된다. 이 API 가 그 모든 확장의 출발점이다.
요약
시리즈 12편, 완주를 축하한다. 1편의 {"message": "안녕, FastAPI!"} 한 줄로 시작한 여정이, 이제 인증된 사용자가 자기 데이터를 안전하게 다루고 테스트·Docker 로 단단히 묶인 완결된 API 로 끝났다. 큰 줄기를 되감으면 이렇다 — 타입힌트로 받고 → Pydantic 으로 검증하고 → 자동 문서로 드러내고 → SQLAlchemy 로 저장하고 → JWT 로 본인만 잠그고 → pytest 로 검증하고 → Docker 로 배포한다. FastAPI 의 거의 모든 실전 코드가 이 일곱 단계 위에 선다. 새 프로젝트를 만나도 "이건 어느 단계지?" 만 물으면 길이 보인다. 여기까지 온 당신은 이제 FastAPI 로 무엇이든 시작할 수 있다.
시리즈 완결 — 이제 당신 차례
To-Do 를 포크해 프론트엔드를 붙이고, PostgreSQL 로 바꾸고, 세상에 배포해 보세요. 가장 빨리 느는 길은 직접 만드는 것입니다.