한 파일짜리 할 일 API를 여러 사람이 동시에 고쳐도 충돌하지 않는 구조로 옮긴다. 단일 파일·계층형·도메인형 세 가지 폴더 구조를 비교하고 고르는 기준을 세운 뒤, 권장하는 도메인형 구조를 사용자·할 일 두 도메인으로 실제로 만들어 테스트까지 통과시킨다. 라우터는 얇게, 규칙은 service에, 저장은 repository에 두는 법, 도메인 사이 import 규칙, APIRouter·Depends·dependency_overrides, pyproject.toml·uv.lock·.env.example 같은 루트 파일, 팀 규칙과 PR 체크리스트까지 — FastAPI 입문 시리즈의 마지막 편이다. 댓글 기능 하나를 추가할 때 구조마다 무엇을 건드리는지 보여 주는 폴더 탐험기 위젯을 포함한다.
6편에서 완성한 할 일 API는 main.py 한 파일, 약 110줄이다. 혼자 연습하기에는 완벽하다. 그런데 팀 프로젝트가 되면 곧 이런 일이 생긴다.
두 사람이 각각 “사용자 API”와 “댓글 API”를 추가하다가 같은 파일에서 Git 충돌이 난다.
모델을 찾으려고 800줄짜리 main.py를 스크롤한다.
테스트를 돌리면 앞 테스트가 만든 데이터 때문에 뒤 테스트가 깨진다.
“찾고 없으면 404” 코드가 스무 군데에 복사돼 있어서, 메시지 하나 바꾸려면 스무 곳을 고쳐야 한다.
새로 온 팀원이 “이메일 중복 검사는 어디 있어요?”라고 묻는데 아무도 바로 답하지 못한다.
폴더 구조는 “이 코드는 어디에 있어야 하는가”에 대한 팀의 약속이다. 약속이 있으면 파일을 찾는 데 시간이 안 들고, 두 사람이 같은 파일을 고칠 일이 줄고, 리뷰가 짧아진다. 이번 편에서는 구조를 고르는 기준을 세우고, 권장 구조로 사용자·할 일 두 기능을 실제로 만들어 테스트까지 통과시킨다.
FastAPI 프로젝트의 폴더 구조는 크게 세 가지다. 차이는 무엇을 기준으로 파일을 나누느냐에 있다.
구조
나누는 기준
폴더 모양 (예)
잘 맞는 곳
① 단일 파일
나누지 않음
main.py 하나
학습·시제품, 라우트 10개 이하, 혼자
② 계층형 (layer)
코드의 역할
routers/ · schemas/ · services/ · repositories/ 아래에 도메인별 파일
API 몇십 개, 2~3명, 도메인 2~3개
③ 도메인형 (feature)
기능(도메인)
users/ · tasks/ · comments/ 아래에 역할별 파일
여러 명이 동시에, 기능이 계속 늘어나는 팀 프로젝트
계층형과 도메인형은 같은 파일들을 다르게 묶은 것이다. 계층형은 “라우터끼리, 모델끼리” 모으고, 도메인형은 “할 일에 관한 것끼리, 사용자에 관한 것끼리” 모은다. 파일의 종류(router·schemas·service·repository)는 둘이 같다.
아래 위젯에서 세 구조를 바꿔 가며 파일을 눌러 보자. 그리고 ‘댓글 기능 추가해 보기’ 를 켜서 새 기능 하나를 넣을 때 각 구조에서 무엇을 건드려야 하는지 비교해 보자.
위젯의 “댓글 기능 추가 비용”이 이 편의 핵심이다.
구조
새로 만드는 파일
고치는 기존 파일
손대는 폴더
협업에 미치는 영향
① 단일 파일
0
main.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
한 층씩 떨어져 있으니 한 층을 공사해도 다른 층은 멀쩡하다. 저장소를 메모리에서 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/에. 파일을 찾을 때 고민이 없고, 도메인을 지울 때 테스트도 같이 지운다.
from pydantic import BaseModel, EmailStr, Field
classUserCreate(BaseModel):
email: EmailStr
name: str = Field(min_length=1, max_length=50)
classUser(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
classUserRepository:
def__init__(self) -> None:
self._rows: dict[int, User] = {}
self._next_id = 1defadd(self, data: UserCreate) -> User:
user = User(id=self._next_id, **data.model_dump())
self._rows[user.id] = user
self._next_id += 1return user
defget(self, user_id: int) -> User | None:
returnself._rows.get(user_id)
deffind_by_email(self, email: str) -> User | None:
returnnext((u for u inself._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
defcreate_user(repo: UserRepository, data: UserCreate) -> User:
if repo.find_by_email(data.email):
raise HTTPException(status.HTTP_409_CONFLICT, detail="이미 가입된 이메일입니다")
return repo.add(data)
defget_user_or_404(repo: UserRepository, user_id: int) -> User:
user = repo.get(user_id)
if user isNone:
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
defget_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="사용자 만들기")defcreate_user(payload: UserCreate, repo: UserRepoDep):
return service.create_user(repo, payload)
@router.get("/{user_id}", response_model=User, summary="사용자 하나 보기")defread_user(user_id: int, repo: UserRepoDep):
return service.get_user_or_404(repo, user_id)
.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=Noneif settings.is_prod else"/docs",
redoc_url=Noneif settings.is_prod else"/redoc",
openapi_url=Noneif 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="서버 상태 확인")defhealth():
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를 새로 열 수 있다.
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.fixturedefclient():
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.fixturedefuser_id(client):
return client.post("/api/v1/users", json={"email": "kim@example.com", "name": "김철수"}).json()["id"]
“get_task_repo 의존성을 부르는 곳이 있으면, 진짜 저장소 대신 이 테스트 전용 빈 저장소를 줘라.” 5절에서 라우트가 전역 저장소를 직접 쓰지 않고 TaskRepoDep를 거치게 만든 이유가 이것이다. 테스트마다 새 저장소를 받으니 앞 테스트의 데이터가 뒤 테스트에 섞이지 않는다. 실제 DB를 쓰게 되면 같은 방법으로 테스트용 DB 세션을 끼운다.
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·dependencies
7편
주소
복수형 명사(/tasks), 소문자, 단어 구분은 하이픈(/task-groups), 동사 금지
5편
버전
모든 API 앞에 /api/v1
7편
끝 슬래시
붙이지 않는다 (/tasks O, /tasks/ X) — 307 리다이렉트 방지
2편
필드 이름
snake_case. 프론트엔드가 camelCase를 원하면 별칭(alias_generator)으로 모든 모델에 한 번에
4편
수정 메서드
PATCH를 기본으로. PUT은 “전체 교체”가 정말 필요할 때만
6편
상태 코드
만들기 201, 삭제 204, 없음 404, 검증 422, 중복 409
2·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 통과, 줄 길이 120
7편
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) 도메인은 설명을 위한 가상의 예다. 삽화는 코어닷투데이가 생성했다.