coredot.today
팀 프로젝트 폴더 구조 — 단일 파일에서 도메인형까지 (FastAPI 입문 7편)
블로그로 돌아가기
FastAPI프로젝트 구조폴더 구조도메인형계층형APIRouterDepends의존성 주입servicerepositorypytestdependency_overridespyproject.tomluvruffCORSpydantic-settings협업팀 규칙입문튜토리얼

팀 프로젝트 폴더 구조 — 단일 파일에서 도메인형까지 (FastAPI 입문 7편)

한 파일짜리 할 일 API를 여러 사람이 동시에 고쳐도 충돌하지 않는 구조로 옮긴다. 단일 파일·계층형·도메인형 세 가지 폴더 구조를 비교하고 고르는 기준을 세운 뒤, 권장하는 도메인형 구조를 사용자·할 일 두 도메인으로 실제로 만들어 테스트까지 통과시킨다. 라우터는 얇게, 규칙은 service에, 저장은 repository에 두는 법, 도메인 사이 import 규칙, APIRouter·Depends·dependency_overrides, pyproject.toml·uv.lock·.env.example 같은 루트 파일, 팀 규칙과 PR 체크리스트까지 — FastAPI 입문 시리즈의 마지막 편이다. 댓글 기능 하나를 추가할 때 구조마다 무엇을 건드리는지 보여 주는 폴더 탐험기 위젯을 포함한다.

코어닷투데이2026-09-2769분

들어가며 — main.py 한 파일의 한계

함께 만드는 FastAPI 프로젝트크게 보기

6편에서 완성한 할 일 API는 main.py 한 파일, 약 110줄이다. 혼자 연습하기에는 완벽하다. 그런데 팀 프로젝트가 되면 곧 이런 일이 생긴다.

  • 두 사람이 각각 “사용자 API”와 “댓글 API”를 추가하다가 같은 파일에서 Git 충돌이 난다.
  • 모델을 찾으려고 800줄짜리 main.py를 스크롤한다.
  • 테스트를 돌리면 앞 테스트가 만든 데이터 때문에 뒤 테스트가 깨진다.
  • “찾고 없으면 404” 코드가 스무 군데에 복사돼 있어서, 메시지 하나 바꾸려면 스무 곳을 고쳐야 한다.
  • 새로 온 팀원이 “이메일 중복 검사는 어디 있어요?”라고 묻는데 아무도 바로 답하지 못한다.

폴더 구조는 “이 코드는 어디에 있어야 하는가”에 대한 팀의 약속이다. 약속이 있으면 파일을 찾는 데 시간이 안 들고, 두 사람이 같은 파일을 고칠 일이 줄고, 리뷰가 짧아진다. 이번 편에서는 구조를 고르는 기준을 세우고, 권장 구조로 사용자·할 일 두 기능을 실제로 만들어 테스트까지 통과시킨다.

📚

1. 세 가지 구조 — 무엇을 기준으로 나누나

단일 파일, 계층형, 도메인형크게 보기

FastAPI 프로젝트의 폴더 구조는 크게 세 가지다. 차이는 무엇을 기준으로 파일을 나누느냐에 있다.

구조나누는 기준폴더 모양 (예)잘 맞는 곳
① 단일 파일나누지 않음main.py 하나학습·시제품, 라우트 10개 이하, 혼자
② 계층형 (layer)코드의 역할routers/ · schemas/ · services/ · repositories/ 아래에 도메인별 파일API 몇십 개, 2~3명, 도메인 2~3개
③ 도메인형 (feature)기능(도메인)users/ · tasks/ · comments/ 아래에 역할별 파일여러 명이 동시에, 기능이 계속 늘어나는 팀 프로젝트

계층형과 도메인형은 같은 파일들을 다르게 묶은 것이다. 계층형은 “라우터끼리, 모델끼리” 모으고, 도메인형은 “할 일에 관한 것끼리, 사용자에 관한 것끼리” 모은다. 파일의 종류(router·schemas·service·repository)는 둘이 같다.

아래 위젯에서 세 구조를 바꿔 가며 파일을 눌러 보자. 그리고 ‘댓글 기능 추가해 보기’ 를 켜서 새 기능 하나를 넣을 때 각 구조에서 무엇을 건드려야 하는지 비교해 보자.

위젯의 “댓글 기능 추가 비용”이 이 편의 핵심이다.

구조새로 만드는 파일고치는 기존 파일손대는 폴더협업에 미치는 영향
① 단일 파일0main.py (모두가 고치는 파일)1같은 시간에 일하는 동료와 거의 반드시 충돌
② 계층형5 (네 폴더 + 테스트에 흩어짐)main.py 한 줄6충돌은 적지만 리뷰어가 여러 폴더를 오간다
③ 도메인형6 (comments/ 폴더 하나 + 테스트)main.py 한 줄3남의 폴더를 건드리지 않는다. 지우기도 폴더째

1-1. 어떻게 고를까

혼자, 연습·시제품
단일 파일로 시작한다. 이 시리즈 2~6편이 그랬다. 파일이 300줄을 넘거나 두 번째 사람이 합류하면 옮길 때다.
2~3명, 기능이 몇 개로 정해져 있다
계층형도 충분하다. FastAPI 공식 튜토리얼의 “Bigger Applications” 예제가 이 모양(routers/, dependencies.py)이라 자료를 찾기 쉽다.
여러 명, 기능이 계속 늘어난다
도메인형을 권한다. 기능마다 담당자가 있고, 담당자는 자기 폴더에서 일한다. 이 편의 나머지는 이 구조로 만든다.
💡
처음부터 완벽할 필요는 없다. 계층형에서 도메인형으로 옮기는 일은 “파일 이동 + import 경로 수정”이 전부라 반나절이면 된다. 그보다 중요한 건 한 프로젝트 안에서 한 가지 방식으로 통일하는 것이다. 어떤 기능은 계층형, 어떤 기능은 도메인형으로 섞이면 셋 중 가장 나쁜 구조가 된다.

2. 권장 구조 — 전체 모습

사용자(users)와 할 일(tasks) 두 도메인을 가진 프로젝트다. 할 일에는 주인(owner_id)이 있어서, 할 일을 만들 때 그 사용자가 실제로 있는지 확인한다. 두 도메인이 서로 대화하는 법을 보여 주기 위해서다.

todo-api/

📁 app/ — 애플리케이션 패키지

    📄 __init__.py · 📄 main.py — 앱 생성과 조립만

    📁 core/ — 앱 전체 설정 · 📄 config.py

    📁 common/ — 도메인 공용 도구 · 📄 pagination.py

    📁 users/ — 사용자 도메인 · 📄 router.py · schemas.py · service.py · repository.py · dependencies.py

    📁 tasks/ — 할 일 도메인 · 📄 router.py · schemas.py · service.py · repository.py · dependencies.py

📁 tests/ — 📄 conftest.py · 📁 users/test_router.py · 📁 tasks/test_router.py

📄 pyproject.toml · uv.lock · .python-version · .env.example · .gitignore · README.md

모든 폴더(app/, app/core/, app/users/ … tests/users/)에는 빈 __init__.py를 하나씩 둔다. 파이썬이 폴더를 패키지로 인식해서 from app.users.schemas import User처럼 불러올 수 있게 하는 표시다.

2-1. 도메인 폴더 안의 다섯 파일

도메인 폴더는 모두 같은 다섯 파일로 이뤄진다. 이름이 같으니 처음 보는 도메인도 바로 읽을 수 있다.

파일담는 것담지 않는 것비유
router.py주소·메서드·상태 코드·response_model. 받아서 service에 넘기고 돌려준다비즈니스 규칙, 저장 코드안내 데스크
schemas.pyPydantic 모델 (입력·출력)DB 테이블 정의 (DB를 쓰면 models.py로 따로)검수 기준표
service.py규칙: 이메일 중복이면 409, 주인이 없으면 404, 권한 확인HTTP 주소, 저장 방식작업 지침
repository.py데이터 저장·조회 (지금은 메모리, 나중엔 DB)규칙 판단창고
dependencies.pyDepends용 함수: 저장소 꺼내기, “찾고 없으면 404”—준비 담당

라우터·스키마·서비스로 나눈 건물크게 보기

한 층씩 떨어져 있으니 한 층을 공사해도 다른 층은 멀쩡하다. 저장소를 메모리에서 PostgreSQL로 바꿀 때는 repository.py만, 이메일 중복 규칙을 바꿀 때는 service.py만 고친다.

3. 구조를 지키는 다섯 가지 규칙

폴더만 나눠서는 금방 무너진다. 아래 규칙을 README에 적어 두자.

①
기능 하나 = 폴더 하나
새 기능은 새 도메인 폴더로 추가한다. 도메인 이름은 복수형 명사(users, tasks)로 API 주소와 맞춘다.
②
라우터는 얇게
라우트 함수는 3줄 안팎이 적당하다. if가 생기기 시작하면 그 판단은 service로 옮긴다. 그래야 같은 규칙을 다른 라우트나 배치 작업에서도 쓸 수 있다.
③
다른 도메인은 service로만, 한 방향으로
tasks가 사용자를 확인할 때는 users.service를 부른다. users의 repository를 직접 뒤지지 않는다. 그리고 tasks → users는 되지만 users → tasks는 금지처럼 방향을 정한다. 양쪽이 서로를 import하면 순환 import 에러가 난다.
④
공용 폴더는 작게, 이름은 분명하게
앱 전체 설정은 core/, 여러 도메인이 쓰는 작은 도구는 common/. utils.py 같은 이름은 무엇이든 들어가는 쓰레기통이 되니 피한다. 공용 파일을 고칠 때는 리뷰를 꼭 받는다.
⑤
tests는 app을 그대로 따라간다
app/tasks/의 테스트는 tests/tasks/에. 파일을 찾을 때 고민이 없고, 도메인을 지울 때 테스트도 같이 지운다.

4. 코드로 보기 — 사용자 도메인

실제로 만들어 테스트한 프로젝트의 코드를 그대로 옮긴다. 먼저 단순한 사용자 도메인이다.

4-1. app/users/schemas.py

4편에서 본 EmailStr로 이메일 형식을 검사한다.

python
from pydantic import BaseModel, EmailStr, Field


class UserCreate(BaseModel):
    email: EmailStr
    name: str = Field(min_length=1, max_length=50)


class User(BaseModel):
    id: int
    email: EmailStr
    name: str

4-2. app/users/repository.py — 창고

5·6편의 딕셔너리와 global next_id를 클래스 하나로 감쌌다. 바깥에서는 add·get·find_by_email만 부른다. 나중에 DB로 바꿀 때 이 파일만 고치면 되고, 테스트에서는 새 저장소를 만들어 끼우면 된다(6절).

python
from app.users.schemas import User, UserCreate


class UserRepository:
    def __init__(self) -> None:
        self._rows: dict[int, User] = {}
        self._next_id = 1

    def add(self, data: UserCreate) -> User:
        user = User(id=self._next_id, **data.model_dump())
        self._rows[user.id] = user
        self._next_id += 1
        return user

    def get(self, user_id: int) -> User | None:
        return self._rows.get(user_id)

    def find_by_email(self, email: str) -> User | None:
        return next((u for u in self._rows.values() if u.email == email), None)


user_repo = UserRepository()

4-3. app/users/service.py — 규칙

“이미 가입된 이메일이면 409 Conflict”라는 규칙이 여기 산다. 이메일 중복 검사가 어디 있냐는 질문의 답은 이제 항상 users/service.py다.

python
from fastapi import HTTPException, status

from app.users.repository import UserRepository
from app.users.schemas import User, UserCreate


def create_user(repo: UserRepository, data: UserCreate) -> User:
    if repo.find_by_email(data.email):
        raise HTTPException(status.HTTP_409_CONFLICT, detail="이미 가입된 이메일입니다")
    return repo.add(data)


def get_user_or_404(repo: UserRepository, user_id: int) -> User:
    user = repo.get(user_id)
    if user is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"{user_id}번 사용자가 없습니다")
    return user

4-4. app/users/dependencies.py와 router.py

python
from typing import Annotated

from fastapi import Depends

from app.users.repository import UserRepository, user_repo


def get_user_repo() -> UserRepository:
    return user_repo


UserRepoDep = Annotated[UserRepository, Depends(get_user_repo)]
python
from fastapi import APIRouter, status

from app.users import service
from app.users.dependencies import UserRepoDep
from app.users.schemas import User, UserCreate

router = APIRouter(prefix="/users", tags=["사용자"])


@router.post("", response_model=User, status_code=status.HTTP_201_CREATED, summary="사용자 만들기")
def create_user(payload: UserCreate, repo: UserRepoDep):
    return service.create_user(repo, payload)


@router.get("/{user_id}", response_model=User, summary="사용자 하나 보기")
def read_user(user_id: int, repo: UserRepoDep):
    return service.get_user_or_404(repo, user_id)

라우트 함수가 한 줄씩이다. 받고(payload), 준비된 저장소를 받고(repo: UserRepoDep), service에 넘기고, 돌려준다.

5. 코드로 보기 — 할 일 도메인과 Depends

5-1. Depends — 반복되는 준비 작업을 한곳에

FastAPI의 의존성 주입(Dependency Injection) 은 이름은 어렵지만 뜻은 단순하다. “이 함수를 실행하기 전에 저 함수를 먼저 실행해서, 그 결과를 인자로 넣어 줘.” 할 일 도메인의 dependencies.py다.

python
from typing import Annotated

from fastapi import Depends, HTTPException, status

from app.tasks.repository import TaskRepository, task_repo
from app.tasks.schemas import Task


def get_task_repo() -> TaskRepository:
    return task_repo


TaskRepoDep = Annotated[TaskRepository, Depends(get_task_repo)]


def get_task_or_404(task_id: int, repo: TaskRepoDep) -> Task:
    task = repo.get(task_id)
    if task is None:
        raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"{task_id}번 할 일이 없습니다")
    return task


TaskDep = Annotated[Task, Depends(get_task_or_404)]
의존성하는 일라우트에서 쓰면
TaskRepoDep저장소 객체를 꺼내 준다tasks: TaskRepoDep — 라우트가 전역 변수를 직접 import하지 않는다. 테스트에서 갈아 끼울 수 있는 틈이 생긴다
TaskDep경로의 task_id로 할 일을 찾고, 없으면 404task: TaskDep — 함수가 시작될 때 이미 존재가 확인된 Task를 받는다
PageDep (common/pagination.py)skip·limit 쿼리를 읽고 범위를 검사한다page: PageDep — 목록 API 열 개가 같은 페이지 규칙을 쓴다
  • 의존성이 의존성을 쓴다. get_task_or_404는 인자로 repo: TaskRepoDep를 받는다. FastAPI가 순서대로(저장소 → 할 일 찾기 → 라우트 함수) 알아서 실행한다.
  • 의존성의 매개변수도 문서에 나온다. PageDep의 skip·limit은 라우트 함수에 직접 적지 않았지만 /docs에 쿼리 매개변수로 정확히 나오고, 검증도 똑같이 된다.
  • Annotated[..., Depends(...)]에 이름(TaskRepoDep)을 붙여 두는 것은 FastAPI 공식 문서가 권하는 방식이다.

common/pagination.py:

python
from typing import Annotated

from fastapi import Depends, Query


class Pagination:
    def __init__(
        self,
        skip: Annotated[int, Query(ge=0)] = 0,
        limit: Annotated[int, Query(ge=1, le=100)] = 20,
    ) -> None:
        self.skip = skip
        self.limit = limit


PageDep = Annotated[Pagination, Depends()]

5-2. schemas·repository·service

python
from datetime import datetime

from pydantic import BaseModel, Field, field_validator


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=100, examples=["보고서 초안 쓰기"])
    priority: int = Field(default=3, ge=1, le=5)
    owner_id: int


class TaskPatch(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=100)
    priority: int | None = Field(default=None, ge=1, le=5)
    done: bool | None = None

    @field_validator("title", "priority", "done")
    @classmethod
    def reject_null(cls, value):
        if value is None:
            raise ValueError("이 필드는 null로 지울 수 없습니다")
        return value


class Task(BaseModel):
    id: int
    title: str
    priority: int
    done: bool
    owner_id: int
    created_at: datetime

6편의 reject_null 검증기가 그대로 들어 있다. 저장소는 사용자 쪽과 같은 모양이다. 시각은 시간대가 붙은 UTC로 저장한다(datetime.UTC는 Python 3.11+).

python
from datetime import UTC, datetime

from app.tasks.schemas import Task, TaskCreate


class TaskRepository:
    """지금은 메모리 딕셔너리. DB로 바꿀 때 이 파일만 고친다."""

    def __init__(self) -> None:
        self._rows: dict[int, Task] = {}
        self._next_id = 1

    def add(self, data: TaskCreate) -> Task:
        task = Task(id=self._next_id, done=False, created_at=datetime.now(UTC), **data.model_dump())
        self._rows[task.id] = task
        self._next_id += 1
        return task

    def list(self, done: bool | None = None) -> list[Task]:
        rows = list(self._rows.values())
        return rows if done is None else [t for t in rows if t.done == done]

    def get(self, task_id: int) -> Task | None:
        return self._rows.get(task_id)

    def save(self, task: Task) -> Task:
        self._rows[task.id] = task
        return task

    def remove(self, task_id: int) -> None:
        self._rows.pop(task_id, None)


task_repo = TaskRepository()

service에서 규칙 ③(다른 도메인은 service로만) 이 보인다.

python
from app.tasks.repository import TaskRepository
from app.tasks.schemas import Task, TaskCreate, TaskPatch
from app.users import service as user_service
from app.users.repository import UserRepository


def create_task(tasks: TaskRepository, users: UserRepository, data: TaskCreate) -> Task:
    user_service.get_user_or_404(users, data.owner_id)  # 다른 도메인은 service를 통해서만
    return tasks.add(data)


def update_task(tasks: TaskRepository, task: Task, data: TaskPatch) -> Task:
    changes = data.model_dump(exclude_unset=True)
    return tasks.save(task.model_copy(update=changes))

5-3. router.py

python
from fastapi import APIRouter, status

from app.common.pagination import PageDep
from app.tasks import service
from app.tasks.dependencies import TaskDep, TaskRepoDep
from app.tasks.schemas import Task, TaskCreate, TaskPatch
from app.users.dependencies import UserRepoDep

router = APIRouter(prefix="/tasks", tags=["할 일"])


@router.post("", response_model=Task, status_code=status.HTTP_201_CREATED, summary="할 일 만들기")
def create_task(payload: TaskCreate, tasks: TaskRepoDep, users: UserRepoDep):
    return service.create_task(tasks, users, payload)


@router.get("", response_model=list[Task], summary="할 일 목록")
def list_tasks(tasks: TaskRepoDep, page: PageDep, done: bool | None = None):
    return tasks.list(done)[page.skip : page.skip + page.limit]


@router.get("/{task_id}", response_model=Task, summary="할 일 하나 보기")
def read_task(task: TaskDep):
    return task


@router.patch("/{task_id}", response_model=Task, summary="할 일 일부 수정")
def update_task(task: TaskDep, payload: TaskPatch, tasks: TaskRepoDep):
    return service.update_task(tasks, task, payload)


@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT, summary="할 일 삭제")
def delete_task(task: TaskDep, tasks: TaskRepoDep):
    tasks.remove(task.id)

6편과 비교해 보자. read_task는 한 줄이다. 찾기와 404는 TaskDep가 이미 했다. @router.get 대신 @app.get을 쓰던 것만 다르다. APIRouter는 작은 app이다. 라우트를 모아 뒀다가 나중에 본 app에 통째로 붙인다.

  • prefix="/tasks": 이 라우터의 모든 주소 앞에 /tasks가 붙는다. @router.get("/{task_id}")는 /tasks/{task_id}, 목록 @router.get("")은 /tasks.
  • tags=["할 일"]: 3편의 tags를 라우트마다 적지 않고 라우터에 한 번만.

6. 조립 — main.py, 설정, CORS

6-1. app/core/config.py

환경마다 다른 값(로컬·스테이징·운영)을 코드에 박지 않고 환경 변수나 .env에서 읽는다. fastapi[standard]에 함께 설치되는 pydantic-settings의 BaseSettings도 Pydantic 모델이라 타입 검사가 된다.

python
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")

    app_env: str = "local"
    cors_origins: list[str] = ["http://localhost:3000"]

    @property
    def is_prod(self) -> bool:
        return self.app_env == "production"


settings = Settings()

.env에 APP_ENV=staging, CORS_ORIGINS=["https://dev.example.com"]을 적으면 실제로 그 값이 읽힌다.

6-2. app/main.py — 조립만 한다

python
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.core.config import settings
from app.tasks.router import router as tasks_router
from app.users.router import router as users_router

app = FastAPI(
    title="할 일 API",
    version="1.0.0",
    docs_url=None if settings.is_prod else "/docs",
    redoc_url=None if settings.is_prod else "/redoc",
    openapi_url=None if settings.is_prod else "/openapi.json",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origins,
    allow_methods=["*"],
    allow_headers=["*"],
)

api_v1 = APIRouter(prefix="/api/v1")
api_v1.include_router(users_router)
api_v1.include_router(tasks_router)
app.include_router(api_v1)


@app.get("/health", tags=["운영"], summary="서버 상태 확인")
def health():
    return {"status": "ok"}
  • api_v1 라우터 안에 도메인 라우터를 넣는다. 최종 주소는 /api/v1 + /tasks + /{task_id}다. 실제 /openapi.json의 경로 목록은 /api/v1/users, /api/v1/users/{user_id}, /api/v1/tasks, /api/v1/tasks/{task_id}, /health다. 주소에 버전을 넣어 두면 나중에 응답 모양을 크게 바꿔야 할 때 /api/v2를 새로 열 수 있다.
  • 운영에서는 문서를 닫는다(3편 6절). APP_ENV=production으로 실행하면 /docs와 /openapi.json은 404, /health는 200이다(실제 확인).
  • CORS: 프론트엔드가 localhost:3000에서 localhost:8000의 API를 부르면, 서버가 멀쩡해도 브라우저 콘솔에 CORS 에러가 뜬다. 브라우저는 다른 출처(포트가 달라도 다른 출처다)로의 요청을 서버가 허락했을 때만 허용한다. allow_origins가 그 허락 명단이다.
⚠️
allow_origins=["*"]로 전부 여는 건 개발용으로만. 운영에서는 실제 프론트엔드 주소만 적는다. 그리고 CORS는 브라우저만 지키는 규칙이다. curl이나 서버 간 호출은 CORS와 무관하게 통과하므로 CORS가 인증을 대신하지 못한다.

실행 명령은 경로를 안 줘도 된다. fastapi dev는 main.py, app.py, api.py, app/main.py 등을 차례로 찾는데, 이 프로젝트에서 실제로 Using import string: app.main:app (auto-discovered)라고 찾아냈다.

bash
uv run fastapi dev

7. 테스트 — 구조가 주는 선물

tests/ 폴더는 app/을 따라간다. 공용 준비물은 conftest.py에 둔다. pytest가 이 파일의 fixture를 모든 테스트에 자동으로 넣어 준다.

python
import pytest
from fastapi.testclient import TestClient

from app.main import app
from app.tasks.dependencies import get_task_repo
from app.tasks.repository import TaskRepository
from app.users.dependencies import get_user_repo
from app.users.repository import UserRepository


@pytest.fixture
def client():
    tasks, users = TaskRepository(), UserRepository()
    app.dependency_overrides[get_task_repo] = lambda: tasks
    app.dependency_overrides[get_user_repo] = lambda: users
    yield TestClient(app)
    app.dependency_overrides.clear()


@pytest.fixture
def user_id(client):
    return client.post("/api/v1/users", json={"email": "kim@example.com", "name": "김철수"}).json()["id"]

핵심은 이 줄이다.

python
app.dependency_overrides[get_task_repo] = lambda: tasks

“get_task_repo 의존성을 부르는 곳이 있으면, 진짜 저장소 대신 이 테스트 전용 빈 저장소를 줘라.” 5절에서 라우트가 전역 저장소를 직접 쓰지 않고 TaskRepoDep를 거치게 만든 이유가 이것이다. 테스트마다 새 저장소를 받으니 앞 테스트의 데이터가 뒤 테스트에 섞이지 않는다. 실제 DB를 쓰게 되면 같은 방법으로 테스트용 DB 세션을 끼운다.

python
def test_create_user(client):
    res = client.post("/api/v1/users", json={"email": "lee@example.com", "name": "이영희"})
    assert res.status_code == 201


def test_duplicate_email_is_409(client, user_id):
    res = client.post("/api/v1/users", json={"email": "kim@example.com", "name": "또철수"})
    assert res.status_code == 409


def test_invalid_email_is_422(client):
    res = client.post("/api/v1/users", json={"email": "not-an-email", "name": "누구"})
    assert res.status_code == 422
python
def test_create_and_read(client, user_id):
    res = client.post("/api/v1/tasks", json={"title": "보고서 쓰기", "owner_id": user_id})
    assert res.status_code == 201
    task_id = res.json()["id"]
    assert client.get(f"/api/v1/tasks/{task_id}").json()["title"] == "보고서 쓰기"


def test_unknown_owner_is_404(client):
    res = client.post("/api/v1/tasks", json={"title": "주인 없는 일", "owner_id": 999})
    assert res.status_code == 404


def test_patch_keeps_other_fields(client, user_id):
    task_id = client.post("/api/v1/tasks", json={"title": "회의록", "priority": 2, "owner_id": user_id}).json()["id"]
    res = client.patch(f"/api/v1/tasks/{task_id}", json={"done": True})
    assert res.json()["done"] is True and res.json()["priority"] == 2


def test_patch_null_title_is_422(client, user_id):
    task_id = client.post("/api/v1/tasks", json={"title": "제목", "owner_id": user_id}).json()["id"]
    assert client.patch(f"/api/v1/tasks/{task_id}", json={"title": None}).status_code == 422


def test_delete_then_404(client, user_id):
    task_id = client.post("/api/v1/tasks", json={"title": "지울 일", "owner_id": user_id}).json()["id"]
    assert client.delete(f"/api/v1/tasks/{task_id}").status_code == 204
    assert client.get(f"/api/v1/tasks/{task_id}").status_code == 404

실행 결과(실제 출력):

uv run pytest

collected 8 items

tests/tasks/test_router.py .....

tests/users/test_router.py ...

8 passed in 0.07s

입문 단계라면 라우트마다 이 넷만 챙기자.

성공
정상 요청의 상태 코드와 핵심 필드 (201, 200, 204)
검증 실패
경계값: 빈 문자열, 잘못된 이메일, null → 422
규칙 위반
없는 주인 → 404, 중복 이메일 → 409
부작용
PATCH 후 안 보낸 필드가 유지되는지, DELETE 후 정말 없는지

8. 루트의 파일들 — 팀 환경을 똑같이

폴더 밖, 프로젝트 맨 위에 있는 파일들이 “내 컴퓨터에선 되는데요”를 막는다.

파일역할Git
pyproject.toml파이썬 버전 하한, 의존성, 도구 설정(ruff·pytest)커밋
uv.lock모든 패키지의 정확한 버전. 팀원·서버가 똑같은 환경을 재현커밋 (손으로 고치지 않음)
.python-version파이썬 버전(3.13) — 1편 7절커밋
.env.example필요한 환경 변수 이름 목록 (값은 비우거나 예시)커밋
.env실제 값 — 비밀번호·API 키절대 커밋 금지
.venv/가상 환경 (uv가 만듦)커밋 금지
.gitignore.env, .venv/, __pycache__/, .pytest_cache/, .ruff_cache/커밋
README.md실행 방법, 팀 규칙 링크커밋

이 프로젝트의 pyproject.toml은 uv로 만들었다(uv init --python 3.13 → uv add "fastapi[standard]" → uv add --dev pytest ruff). 마지막 두 설정만 손으로 더했다.

toml
[project]
name = "todo-api"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
    "fastapi[standard]>=0.141.1",
]

[dependency-groups]
dev = [
    "pytest>=9.1.1",
    "ruff>=0.16.9",
]

[tool.ruff]
line-length = 120

[tool.pytest.ini_options]
testpaths = ["tests"]
  • requires-python = ">=3.13" — 더 낮은 파이썬에서는 설치를 거부한다.
  • [dependency-groups] dev — 개발할 때만 필요한 도구(테스트·린터). 운영 서버에는 설치하지 않을 수 있다.
  • [tool.ruff] line-length = 120 — 코드 스타일 도구 ruff의 줄 길이. 기본값(88)을 쓰면 한국어 메시지가 든 줄이 자꾸 접혀서 팀에서 120으로 정했다고 가정했다. 이런 결정을 파일에 적어 두면 리뷰에서 스타일 논쟁이 사라진다.

새 팀원이 할 일은 이것뿐이다.

README.md — 시작하기

git clone … && cd todo-api

uv sync — .python-version의 파이썬과 uv.lock의 패키지를 그대로 설치

cp .env.example .env — 값 채우기

uv run fastapi dev — 개발 서버, http://127.0.0.1:8000/docs

uv run pytest — 테스트

uv run ruff check . && uv run ruff format . — 검사와 자동 정렬

9. 자주 묻는 질문

질문답
schemas.py와 models.py는 뭐가 달라요?관례상 schemas.py는 API의 모양(Pydantic), models.py는 DB 테이블(SQLAlchemy·SQLModel)이다. DB를 붙이면 도메인 폴더에 models.py가 하나 더 생긴다. 둘을 섞지 않으면 “DB 칼럼을 추가했더니 API 응답에 비밀 필드가 새어 나갔다” 같은 사고를 막을 수 있다.
service가 너무 얇아요. 꼭 있어야 하나요?규칙이 없는 도메인(단순 CRUD)이라면 라우터가 repository를 바로 불러도 된다. 다만 규칙이 하나라도 생기는 순간 service를 만들어 거기에 두자. 규칙이 라우터에 쌓이면 다시 꺼내기 어렵다.
ImportError: cannot import name … (most likely due to a circular import)가 나요.두 도메인이 서로를 import하고 있다. 규칙 ③의 방향을 정하고, 반대쪽이 꼭 필요하면 공통 부분을 common/으로 내리거나 함수 안에서 import한다.
src/app/처럼 src 폴더를 두기도 하던데요?패키지로 배포할 라이브러리에서 흔한 “src 레이아웃”이다. 서버 애플리케이션에서는 app/을 루트에 두는 경우가 더 많고, FastAPI 공식 예제도 그렇다. 어느 쪽이든 팀에서 하나로 정하면 된다.
도메인이 20개가 넘으면요?관련 도메인을 한 단계 더 묶는다(app/billing/invoices/, app/billing/payments/). 그쯤 되면 서비스를 나눌지(마이크로서비스)도 논의할 때지만, 대부분의 팀은 그 전에 도메인 폴더만으로 충분하다.

10. 팀이 합의해 둘 API 규칙

코드 구조보다 더 중요한 것은 규칙의 합의다. 프로젝트 초반에 아래 항목을 README나 위키에 적어 두면, 리뷰 대화가 “취향”이 아니라 “규칙”으로 짧아진다. 이 시리즈에서 다룬 내용을 모았다.

항목권장 규칙 (예시)다룬 편
파이썬 버전3.13 고정 (.python-version + requires-python)1편
폴더 구조도메인형. 도메인 폴더는 router·schemas·service·repository·dependencies7편
주소복수형 명사(/tasks), 소문자, 단어 구분은 하이픈(/task-groups), 동사 금지5편
버전모든 API 앞에 /api/v17편
끝 슬래시붙이지 않는다 (/tasks O, /tasks/ X) — 307 리다이렉트 방지2편
필드 이름snake_case. 프론트엔드가 camelCase를 원하면 별칭(alias_generator)으로 모든 모델에 한 번에4편
수정 메서드PATCH를 기본으로. PUT은 “전체 교체”가 정말 필요할 때만6편
상태 코드만들기 201, 삭제 204, 없음 404, 검증 422, 중복 4092·5·6·7편
에러 본문직접 내는 에러는 {"detail": "사람이 읽는 한국어 메시지"} — FastAPI 기본 모양과 통일5편
모델입력(XxxCreate·XxxPatch)과 출력(Xxx) 분리. 출력엔 항상 response_model. PATCH엔 null 거부4·5·6편
목록항상 limit 상한(예: 100)이 있는 페이지 처리 (PageDep)5·7편
문서tags·summary 한국어, 필드 description·examples, 404 등 에러는 responses=3편
테스트새 라우트마다 성공·422·404 최소 세 개, tests/는 app/ 구조를 따른다7편
스타일ruff check·format 통과, 줄 길이 1207편

PR을 올리기 전 확인 목록:

PR 전 체크리스트

☐ uv run pytest가 모두 통과한다

☐ uv run ruff check .와 uv run ruff format --check .가 통과한다

☐ 새 코드가 올바른 도메인 폴더에 있다. 규칙은 service에, 라우터는 얇게

☐ /docs를 열어 새 라우트가 올바른 tag 아래에 한국어 summary로 보인다

☐ Try it out으로 성공 1번, 실패(422·404) 1번씩 직접 실행해 봤다

☐ API 모양(schemas.py)이 바뀌었다면 프론트엔드 담당자를 리뷰어로 넣었다

☐ 새 패키지를 추가했다면 pyproject.toml과 uv.lock을 같이 커밋했다

☐ .env나 비밀 값이 커밋에 섞이지 않았다

11. 시리즈를 마치며 — 다음에 배울 것

일곱 편 동안 한 일을 돌아보자.

1편
왜 파이썬이고 왜 FastAPI인가, 어떤 파이썬 버전으로 돌릴까
2편
라우팅 — 경로·쿼리 매개변수, 422 읽는 법, 라우트 순서
3편
/docs — Swagger UI·ReDoc·OpenAPI를 팀의 계약서로
4편
Pydantic — 모델, 필수·선택·null, 변환 규칙, validator, dump/validate
5편
GET·POST — 입력·출력 모델 분리, response_model, 201·404
6편
PUT·PATCH·DELETE — 전체 교체와 부분 수정, null 함정, 멱등성
7편
폴더 구조 — 도메인형, service·repository, Depends, 테스트, 루트 파일, 팀 규칙

여기까지 익혔다면 사내 FastAPI 프로젝트의 코드를 읽고, 도메인을 추가하고, /docs로 동료와 소통하는 데 부족함이 없다. 다음으로 배우면 좋은 주제는 이렇다.

주제무엇을이 시리즈와의 연결
데이터베이스SQLModel 또는 SQLAlchemy + PostgreSQL, 마이그레이션(Alembic)도메인마다 models.py를 더하고 repository.py만 DB 버전으로 바꾼다. 테스트는 dependency_overrides로 테스트 DB를 끼운다
인증OAuth2 비밀번호 흐름, JWT 토큰, Security“현재 사용자”를 Depends(get_current_user) 의존성으로 만든다. 2편의 /users/me가 그 자리다
비동기 심화async def + 비동기 DB 드라이버, 백그라운드 작업2편 6절의 def/async def 규칙에서 출발
배포도커 이미지, fastapi run --workers N, 리버스 프록시5편에서 본 “메모리 저장소는 워커끼리 공유 안 됨”이 여기서 중요해진다
🎉
FastAPI 입문 시리즈를 마쳤다. 처음 질문으로 돌아가 보자. “그 API 주소가 뭐였죠?” — 이제 답은 하나다. “/docs 열어 보세요.” 그리고 “그 규칙은 어디 있어요?”에도 답할 수 있다. “그 도메인/service.py요.”
ℹ️
실행 환경. 이 글의 모든 코드는 실제 프로젝트(app/ 18개·tests/ 6개 파이썬 파일, __init__.py 포함)로 구성해 FastAPI 0.141.1 · Pydantic 2.13.5 · Python 3.13 · uv로 실행했다. uv run pytest(8 passed), ruff check·ruff format --check 통과, fastapi dev의 자동 탐지, /openapi.json 경로 목록, APP_ENV=production에서 문서 404·health 200은 모두 실제 결과다. 같은 테스트를 Python 3.10~3.14에서도 돌려 모두 통과했다(1편 7절). 세 구조의 비교와 팀 규칙 표는 필자가 권하는 예시이며 팀 사정에 맞게 바꾸면 된다. 위젯의 댓글(comments) 도메인은 설명을 위한 가상의 예다. 삽화는 코어닷투데이가 생성했다.