반응형

FastAPI를 사용하여 CRUD(Create, Read, Update, Delete) 애플리케이션을 만드는 방법을 알아보겠습니다.
이번 포스팅에서는 SQLAlchemy를 사용하여 데이터베이스와 연동하는 방법을 설명합니다. Tortoise ORM을 사용하는 방법도 함께 설명하려 합니다.
필요 라이브러리 설치
먼저 FastAPI, SQLAlchemy, 데이터베이스 드라이버를 설치합니다. 여기서는 SQLite를 사용할 것입니다.
pip install fastapi uvicorn sqlalchemy databases[sqlite] pydantic추가 권장 라이브러리:
# 개발 환경을 위한 추가 패키지
pip install pytest pytest-asyncio httpxSQLAlchemy로 CRUD 애플리케이션 만들기
프로젝트 구조
fastapi-crud/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── models.py
│ ├── schemas.py
│ ├── crud.py
│ └── database.py
├── tests/
│ ├── __init__.py
│ └── test_crud.py
└── requirements.txt데이터베이스 설정
# app/database.py
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
# 환경 변수에서 데이터베이스 URL 가져오기
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./test.db")
# SQLite의 경우 check_same_thread=False 추가
if DATABASE_URL.startswith("sqlite"):
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False}
)
else:
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# 의존성 주입을 위한 함수
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()데이터베이스 모델 정의
# app/models.py
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
from sqlalchemy.sql import func
from .database import Base
class Item(Base):
__tablename__ = 'items'
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), index=True, nullable=False)
description = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True, index=True)
email = Column(String(100), unique=True, index=True, nullable=False)
username = Column(String(50), unique=True, index=True, nullable=False)
full_name = Column(String(100))
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())Pydantic 스키마 정의
# app/schemas.py
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
# Item 스키마
class ItemBase(BaseModel):
name: str
description: Optional[str] = None
is_active: bool = True
class ItemCreate(ItemBase):
pass
class ItemUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class Item(ItemBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True # Pydantic v2에서 orm_mode 대신 사용
# User 스키마
class UserBase(BaseModel):
email: EmailStr
username: str
full_name: Optional[str] = None
is_active: bool = True
class UserCreate(UserBase):
pass
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
username: Optional[str] = None
full_name: Optional[str] = None
is_active: Optional[bool] = None
class User(UserBase):
id: int
created_at: datetime
class Config:
from_attributes = TrueCRUD 함수 정의
# app/crud.py
from sqlalchemy.orm import Session
from sqlalchemy import and_
from typing import List, Optional
from . import models, schemas
# Item CRUD 함수들
def get_item(db: Session, item_id: int) -> Optional[models.Item]:
return db.query(models.Item).filter(models.Item.id == item_id).first()
def get_items(
db: Session,
skip: int = 0,
limit: int = 100,
search: Optional[str] = None
) -> List[models.Item]:
query = db.query(models.Item)
if search:
query = query.filter(
models.Item.name.contains(search) |
models.Item.description.contains(search)
)
return query.offset(skip).limit(limit).all()
def create_item(db: Session, item: schemas.ItemCreate) -> models.Item:
db_item = models.Item(**item.model_dump())
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
def update_item(
db: Session,
item_id: int,
item_update: schemas.ItemUpdate
) -> Optional[models.Item]:
db_item = get_item(db, item_id)
if not db_item:
return None
update_data = item_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_item, field, value)
db.commit()
db.refresh(db_item)
return db_item
def delete_item(db: Session, item_id: int) -> Optional[models.Item]:
db_item = get_item(db, item_id)
if not db_item:
return None
db.delete(db_item)
db.commit()
return db_item
# User CRUD 함수들
def get_user(db: Session, user_id: int) -> Optional[models.User]:
return db.query(models.User).filter(models.User.id == user_id).first()
def get_user_by_email(db: Session, email: str) -> Optional[models.User]:
return db.query(models.User).filter(models.User.email == email).first()
def get_user_by_username(db: Session, username: str) -> Optional[models.User]:
return db.query(models.User).filter(models.User.username == username).first()
def get_users(db: Session, skip: int = 0, limit: int = 100) -> List[models.User]:
return db.query(models.User).offset(skip).limit(limit).all()
def create_user(db: Session, user: schemas.UserCreate) -> models.User:
db_user = models.User(**user.model_dump())
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_userFastAPI 애플리케이션 구현
# app/main.py
from fastapi import FastAPI, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from . import crud, models, schemas
from .database import SessionLocal, engine, get_db
# 데이터베이스 테이블 생성
models.Base.metadata.create_all(bind=engine)
app = FastAPI(
title="FastAPI CRUD API",
description="SQLAlchemy를 사용한 CRUD 애플리케이션",
version="1.0.0"
)
# 헬스체크 엔드포인트
@app.get("/")
async def root():
return {"message": "FastAPI CRUD API is running!"}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
# Item CRUD 엔드포인트
@app.post("/items/", response_model=schemas.Item, status_code=201)
def create_item(item: schemas.ItemCreate, db: Session = Depends(get_db)):
"""새로운 아이템을 생성합니다."""
return crud.create_item(db=db, item=item)
@app.get("/items/", response_model=List[schemas.Item])
def read_items(
skip: int = Query(0, ge=0, description="건너뛸 레코드 수"),
limit: int = Query(100, ge=1, le=1000, description="가져올 레코드 수"),
search: Optional[str] = Query(None, description="검색어"),
db: Session = Depends(get_db)
):
"""아이템 목록을 조회합니다."""
items = crud.get_items(db, skip=skip, limit=limit, search=search)
return items
@app.get("/items/{item_id}", response_model=schemas.Item)
def read_item(item_id: int, db: Session = Depends(get_db)):
"""특정 아이템을 조회합니다."""
db_item = crud.get_item(db, item_id=item_id)
if db_item is None:
raise HTTPException(status_code=404, detail="Item not found")
return db_item
@app.put("/items/{item_id}", response_model=schemas.Item)
def update_item(
item_id: int,
item: schemas.ItemUpdate,
db: Session = Depends(get_db)
):
"""아이템을 업데이트합니다."""
db_item = crud.update_item(db, item_id=item_id, item_update=item)
if db_item is None:
raise HTTPException(status_code=404, detail="Item not found")
return db_item
@app.delete("/items/{item_id}", response_model=schemas.Item)
def delete_item(item_id: int, db: Session = Depends(get_db)):
"""아이템을 삭제합니다."""
db_item = crud.delete_item(db, item_id=item_id)
if db_item is None:
raise HTTPException(status_code=404, detail="Item not found")
return db_item
# User CRUD 엔드포인트
@app.post("/users/", response_model=schemas.User, status_code=201)
def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)):
"""새로운 사용자를 생성합니다."""
# 이메일 중복 확인
db_user = crud.get_user_by_email(db, email=user.email)
if db_user:
raise HTTPException(status_code=400, detail="Email already registered")
# 사용자명 중복 확인
db_user = crud.get_user_by_username(db, username=user.username)
if db_user:
raise HTTPException(status_code=400, detail="Username already taken")
return crud.create_user(db=db, user=user)
@app.get("/users/", response_model=List[schemas.User])
def read_users(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db)
):
"""사용자 목록을 조회합니다."""
users = crud.get_users(db, skip=skip, limit=limit)
return users
@app.get("/users/{user_id}", response_model=schemas.User)
def read_user(user_id: int, db: Session = Depends(get_db)):
"""특정 사용자를 조회합니다."""
db_user = crud.get_user(db, user_id=user_id)
if db_user is None:
raise HTTPException(status_code=404, detail="User not found")
return db_user애플리케이션 실행
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000서버가 실행되면 http://127.0.0.1:8000/docs에 접속하여 Swagger UI를 통해 API를 테스트할 수 있습니다.
Tortoise ORM으로 CRUD 애플리케이션 만들기
필요한 라이브러리 설치
pip install fastapi tortoise-orm aiosqlite uvicornTortoise ORM 모델 정의
# models.py
from tortoise import Model, fields
from tortoise.contrib.pydantic import pydantic_model_creator
class Item(Model):
id = fields.IntField(pk=True)
name = fields.CharField(max_length=100, index=True)
description = fields.TextField(null=True)
is_active = fields.BooleanField(default=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "items"
class User(Model):
id = fields.IntField(pk=True)
email = fields.CharField(max_length=100, unique=True, index=True)
username = fields.CharField(max_length=50, unique=True, index=True)
full_name = fields.CharField(max_length=100, null=True)
is_active = fields.BooleanField(default=True)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "users"
# Pydantic 모델 자동 생성
Item_Pydantic = pydantic_model_creator(Item, name="Item")
ItemIn_Pydantic = pydantic_model_creator(Item, name="ItemIn", exclude_readonly=True)
User_Pydantic = pydantic_model_creator(User, name="User")
UserIn_Pydantic = pydantic_model_creator(User, name="UserIn", exclude_readonly=True)FastAPI 애플리케이션 구현
# main.py
from fastapi import FastAPI, HTTPException, Query
from tortoise.contrib.fastapi import register_tortoise
from tortoise.exceptions import DoesNotExist
from typing import List, Optional
from models import Item, User, Item_Pydantic, ItemIn_Pydantic, User_Pydantic, UserIn_Pydantic
app = FastAPI(
title="FastAPI Tortoise ORM CRUD",
description="Tortoise ORM을 사용한 비동기 CRUD 애플리케이션",
version="1.0.0"
)
# 데이터베이스 설정
register_tortoise(
app,
db_url='sqlite://db.sqlite3',
modules={'models': ['models']},
generate_schemas=True,
add_exception_handlers=True,
)
@app.get("/")
async def root():
return {"message": "FastAPI Tortoise ORM CRUD API is running!"}
# Item CRUD 엔드포인트
@app.post("/items/", response_model=Item_Pydantic, status_code=201)
async def create_item(item: ItemIn_Pydantic):
"""새로운 아이템을 생성합니다."""
item_obj = await Item.create(**item.model_dump(exclude_unset=True))
return await Item_Pydantic.from_tortoise_orm(item_obj)
@app.get("/items/", response_model=List[Item_Pydantic])
async def get_items(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
search: Optional[str] = Query(None)
):
"""아이템 목록을 조회합니다."""
query = Item.all()
if search:
query = query.filter(
name__icontains=search
).union(
Item.filter(description__icontains=search)
)
items = await query.offset(skip).limit(limit)
return await Item_Pydantic.from_queryset(items)
@app.get("/items/{item_id}", response_model=Item_Pydantic)
async def get_item(item_id: int):
"""특정 아이템을 조회합니다."""
try:
item = await Item.get(id=item_id)
return await Item_Pydantic.from_tortoise_orm(item)
except DoesNotExist:
raise HTTPException(status_code=404, detail="Item not found")
@app.put("/items/{item_id}", response_model=Item_Pydantic)
async def update_item(item_id: int, item: ItemIn_Pydantic):
"""아이템을 업데이트합니다."""
try:
await Item.filter(id=item_id).update(**item.model_dump(exclude_unset=True))
updated_item = await Item.get(id=item_id)
return await Item_Pydantic.from_tortoise_orm(updated_item)
except DoesNotExist:
raise HTTPException(status_code=404, detail="Item not found")
@app.delete("/items/{item_id}", response_model=Item_Pydantic)
async def delete_item(item_id: int):
"""아이템을 삭제합니다."""
try:
item = await Item.get(id=item_id)
item_data = await Item_Pydantic.from_tortoise_orm(item)
await item.delete()
return item_data
except DoesNotExist:
raise HTTPException(status_code=404, detail="Item not found")
# User CRUD 엔드포인트
@app.post("/users/", response_model=User_Pydantic, status_code=201)
async def create_user(user: UserIn_Pydantic):
"""새로운 사용자를 생성합니다."""
# 이메일 중복 확인
if await User.filter(email=user.email).exists():
raise HTTPException(status_code=400, detail="Email already registered")
# 사용자명 중복 확인
if await User.filter(username=user.username).exists():
raise HTTPException(status_code=400, detail="Username already taken")
user_obj = await User.create(**user.model_dump(exclude_unset=True))
return await User_Pydantic.from_tortoise_orm(user_obj)
@app.get("/users/", response_model=List[User_Pydantic])
async def get_users(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000)
):
"""사용자 목록을 조회합니다."""
users = await User.all().offset(skip).limit(limit)
return await User_Pydantic.from_queryset(users)
@app.get("/users/{user_id}", response_model=User_Pydantic)
async def get_user(user_id: int):
"""특정 사용자를 조회합니다."""
try:
user = await User.get(id=user_id)
return await User_Pydantic.from_tortoise_orm(user)
except DoesNotExist:
raise HTTPException(status_code=404, detail="User not found")애플리케이션 실행
uvicorn main:app --reload테스트 코드 작성
SQLAlchemy 버전 테스트
# tests/test_crud.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.database import get_db, Base
# 테스트용 인메모리 데이터베이스
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
def override_get_db():
try:
db = TestingSessionLocal()
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
def test_create_item():
response = client.post(
"/items/",
json={"name": "Test Item", "description": "Test Description"}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Item"
assert data["description"] == "Test Description"
assert "id" in data
def test_read_items():
response = client.get("/items/")
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_read_item():
# 먼저 아이템 생성
create_response = client.post(
"/items/",
json={"name": "Test Item", "description": "Test Description"}
)
item_id = create_response.json()["id"]
# 아이템 조회
response = client.get(f"/items/{item_id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == item_id
def test_update_item():
# 먼저 아이템 생성
create_response = client.post(
"/items/",
json={"name": "Test Item", "description": "Test Description"}
)
item_id = create_response.json()["id"]
# 아이템 업데이트
response = client.put(
f"/items/{item_id}",
json={"name": "Updated Item", "description": "Updated Description"}
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Item"
def test_delete_item():
# 먼저 아이템 생성
create_response = client.post(
"/items/",
json={"name": "Test Item", "description": "Test Description"}
)
item_id = create_response.json()["id"]
# 아이템 삭제
response = client.delete(f"/items/{item_id}")
assert response.status_code == 200
# 삭제된 아이템 조회 시 404 확인
response = client.get(f"/items/{item_id}")
assert response.status_code == 404결론
FastAPI를 사용하여 SQLAlchemy 또는 Tortoise ORM으로 CRUD 애플리케이션을 만드는 방법을 알아보았습니다. 각 ORM의 특징을 이해하고, 필요한 기능에 맞게 선택하여 사용할 수 있습니다.
제가 함께 일해온 친구들의 면접에서 항상 하던 질문이 있습니다.
게시판을 만드는 데 얼마나 걸릴 것 같아요?
이 질문은 여러 가지 요소를 고려해야 합니다. 큰 기능은 아니지만 요구사항 수집, 분석, DB 설계, 화면 설계, 구현, 테스트, 배포 등 프로젝트의 전반적인 과정을 머릿속에 그려봐야 답을 낼 수 있습니다.
모든 프로그램은 'HELLO WORLD!'로 시작하고 결국 CRUD 작업을 하게 됩니다. 이것만 잘 구현되면 할 수 있는 것들이 정말 많아집니다.
핵심 포인트:
- SQLAlchemy vs Tortoise ORM 선택 기준과 장단점 이해
- Pydantic 스키마를 통한 데이터 검증과 직렬화
- 의존성 주입을 활용한 데이터베이스 세션 관리
- 에러 핸들링과 HTTP 상태 코드 적절한 사용
- 테스트 코드 작성으로 안정성 확보
반응형
'IT기술 > 파이썬 (python)' 카테고리의 다른 글
| FastAPI 완벽 가이드: 현대적이고 고성능 Python 웹 프레임워크 (0) | 2025.07.06 |
|---|---|
| FastAPI로 머신러닝 모델 서빙 완벽 가이드: Docker와 함께하는 프로덕션 배포 (0) | 2025.07.05 |
| FastAPI OAuth2와 JWT 인증 완벽 가이드: 안전한 웹 애플리케이션 구축하기 (2) | 2025.07.04 |
| FastAPI 성능 최적화 실전 가이드: 워커 설정부터 캐싱·DB·비동기 작업까지 (0) | 2025.04.30 |
| [FastAPI] 대규모 프로젝트 설계 가이드: 모듈화, 의존성 주입, 라우터 분리 (0) | 2025.04.28 |