FastAPI 교재 · 12편 · 미니 프로젝트 (완결)

FastAPI 미니 프로젝트 — To-Do API

흩어져 있던 11편의 조각을 한자리에 모은다. 모델·검증·인증·테스트·배포가 전부 들어간 사용자별 할 일 API 를 처음부터 완성한다.

할 일 목록 API 가 데이터베이스·인증·서버 블록으로 조립되어 완성되는 FastAPI 캡스톤 프로젝트 아이소메트릭 일러스트

드디어 마지막 편이다. 지난 열한 편 동안 우리는 부품을 하나씩 만들어 왔다 — 타입힌트로 입력을 받고(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 를 가지고, Todoowner_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 로 바꾸고, 세상에 배포해 보세요. 가장 빨리 느는 길은 직접 만드는 것입니다.

© 2026 주나이테크(주) @JUNAITECH