포스트

FastAPI 프로젝트 구조 - 실무에서 쓰는 Best Practices

FastAPI 프로젝트 구조 - 실무에서 쓰는 Best Practices

들어가며

FastAPI로 여러 프로젝트를 진행하면서 정립한 프로젝트 구조와 패턴을 공유합니다.

작은 프로젝트부터 대규모 서비스까지 확장 가능한 구조입니다.


📁 프로젝트 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
my-fastapi-project/
├── app/
│   ├── __init__.py
│   ├── main.py              # FastAPI 앱 엔트리포인트
│   ├── config.py            # 설정 관리
│   ├── database.py          # DB 연결 설정
│   │
│   ├── api/                  # API 라우터
│   │   ├── __init__.py
│   │   ├── deps.py          # 의존성 주입
│   │   └── v1/
│   │       ├── __init__.py
│   │       ├── router.py    # v1 라우터 통합
│   │       ├── users.py
│   │       └── items.py
│   │
│   ├── core/                 # 핵심 로직
│   │   ├── __init__.py
│   │   ├── security.py      # 인증/보안
│   │   └── exceptions.py    # 커스텀 예외
│   │
│   ├── models/               # SQLAlchemy 모델
│   │   ├── __init__.py
│   │   └── user.py
│   │
│   ├── schemas/              # Pydantic 스키마
│   │   ├── __init__.py
│   │   └── user.py
│   │
│   └── services/             # 비즈니스 로직
│       ├── __init__.py
│       └── user_service.py
│
├── tests/
├── alembic/                  # DB 마이그레이션
├── .env
├── requirements.txt
└── docker-compose.yml

🔑 핵심 파일들

1. config.py - 환경변수 관리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pydantic_settings import BaseSettings
from functools import lru_cache

class Settings(BaseSettings):
    app_name: str = "My FastAPI App"
    debug: bool = False
    
    # Database
    database_url: str
    
    # JWT
    secret_key: str
    algorithm: str = "HS256"
    access_token_expire_minutes: int = 30
    
    class Config:
        env_file = ".env"

@lru_cache()
def get_settings():
    return Settings()

2. deps.py - 의존성 주입

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from typing import Generator, Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from jose import JWTError, jwt

from app.database import SessionLocal
from app.config import get_settings
from app.models.user import User

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login")

def get_db() -> Generator:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

async def get_current_user(
    db: Annotated[Session, Depends(get_db)],
    token: Annotated[str, Depends(oauth2_scheme)]
) -> User:
    settings = get_settings()
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
    )
    try:
        payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
        user_id: str = payload.get("sub")
        if user_id is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception
    
    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        raise credentials_exception
    return user

# Type alias for cleaner code
DBSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[User, Depends(get_current_user)]

3. 라우터 구성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# app/api/v1/users.py
from fastapi import APIRouter, HTTPException
from app.api.deps import DBSession, CurrentUser
from app.schemas.user import UserCreate, UserResponse
from app.services import user_service

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

@router.post("/", response_model=UserResponse)
async def create_user(user_in: UserCreate, db: DBSession):
    user = user_service.get_by_email(db, email=user_in.email)
    if user:
        raise HTTPException(status_code=400, detail="Email already registered")
    return user_service.create(db, user_in)

@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: CurrentUser):
    return current_user

💡 Best Practices

1. 서비스 레이어 분리

라우터에 비즈니스 로직을 넣지 마세요. 서비스 레이어로 분리하면 테스트가 쉬워집니다.

2. Pydantic v2 활용

1
2
3
4
5
6
7
from pydantic import BaseModel, ConfigDict

class UserBase(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    
    email: str
    name: str

3. 비동기 활용

I/O 바운드 작업은 async/await로 처리하세요.

1
2
3
4
5
6
7
8
# DB 작업은 동기로 (SQLAlchemy)
# HTTP 요청은 비동기로 (httpx)
import httpx

async def fetch_external_data():
    async with httpx.AsyncClient() as client:
        response = await client.get("https://api.example.com/data")
        return response.json()

4. 예외 처리 통일

1
2
3
4
5
6
7
8
9
10
# app/core/exceptions.py
from fastapi import HTTPException

class NotFoundError(HTTPException):
    def __init__(self, detail: str = "Resource not found"):
        super().__init__(status_code=404, detail=detail)

class UnauthorizedError(HTTPException):
    def __init__(self, detail: str = "Not authenticated"):
        super().__init__(status_code=401, detail=detail)

🚀 마무리

이 구조를 기반으로 프로젝트를 시작하면 확장성과 유지보수성을 모두 잡을 수 있습니다.

다음 글에서는 Alembic을 이용한 DB 마이그레이션 전략을 다뤄보겠습니다!

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.