FastAPI + SQLAlchemy 2.0 비동기 ORM 실전 패턴 - 코드픽 블로그
FastAPI + SQLAlchemy 2.0 비동기 ORM 실전 패턴
기술 가이드

FastAPI + SQLAlchemy 2.0 비동기 ORM 실전 패턴

2026년 3월 11일 41 views by 코드벤터

FastAPI + SQLAlchemy 2.0 비동기 ORM 실전 패턴

안녕하세요, 코드픽(codepick.kr) 기술 블로그 작가입니다. 오늘은 고성능 비동기 웹 애플리케이션 개발의 핵심 조합인 FastAPI와 SQLAlchemy 2.0의 비동기 ORM 활용 패턴에 대해 깊이 있게 다뤄보고자 합니다. Python 기반의 백엔드 개발에서 비동기 처리는 더 이상 선택이 아닌 필수가 되었죠. FastAPI는 이러한 비동기 패러다임을 완벽하게 지원하며, SQLAlchemy 2.0은 비동기 데이터베이스 연동을 위한 강력한 ORM 기능을 제공합니다. 이 둘을 효과적으로 결합하는 실전 전략을 함께 살펴보겠습니다.

서론 - 비동기 웹의 시대, 그리고 ORM의 진화

현대의 웹 서비스는 실시간성과 확장성을 요구합니다. 수많은 동시 요청을 효율적으로 처리하기 위해서는 블로킹(Blocking) I/O 작업, 특히 데이터베이스 접근을 비동기적으로 처리하는 것이 필수적입니다. FastAPI는 async/await 문법을 기본으로 채택하여 비동기 웹 애플리케이션을 직관적이고 빠르게 구축할 수 있도록 돕습니다.

하지만 웹 프레임워크가 비동기를 지원하더라도, 데이터베이스 ORM이 동기적으로 동작한다면 전체 시스템의 병목 현상이 발생할 수 있습니다. 과거 SQLAlchemy는 동기 방식에 주로 사용되었으나, 버전 1.4부터 asyncio를 통한 비동기 지원을 시작했고, SQLAlchemy 2.0에 이르러서는 AsyncEngine, AsyncSession 등 비동기 ORM 사용이 표준 패턴으로 자리 잡았습니다.

이 글에서는 FastAPI 프로젝트에서 SQLAlchemy 2.0의 비동기 ORM을 사용하여 데이터베이스 연결, 세션 관리, CRUD 작업 구현, 그리고 성능 최적화까지 아우르는 실전 패턴을 제시합니다.

1단계: 프로젝트 환경 설정

가장 먼저 FastAPI 및 SQLAlchemy 2.0 프로젝트를 위한 환경을 설정해야 합니다. poetry 또는 pip를 사용하여 필요한 의존성을 설치합니다.

bash
# poetry를 사용하는 경우
poetry new fastapi-sqlalchemy-project
cd fastapi-sqlalchemy-project
poetry add fastapi uvicorn sqlalchemy asyncpg pydantic python-dotenv

# pip를 사용하는 경우
mkdir fastapi-sqlalchemy-project
cd fastapi-sqlalchemy-project
python -m venv venv
source venv/bin/activate # Windows: .\venv\Scripts\activate
pip install fastapi uvicorn sqlalchemy asyncpg pydantic python-dotenv
  • fastapi: 웹 프레임워크
  • uvicorn: ASGI 서버 (FastAPI 애플리케이션 실행)
  • sqlalchemy: ORM 라이브러리
  • asyncpg: PostgreSQL 비동기 드라이버 (다른 DB 사용 시 aiomysql, aiosqlite 등으로 교체)
  • pydantic: 데이터 유효성 검사 및 설정 관리 (FastAPI의 핵심)
  • python-dotenv: .env 파일에서 환경 변수 로드

.env 파일을 생성하여 데이터베이스 연결 URL을 설정합니다.

ini
# .env 파일 예시
DATABASE_URL="postgresql+asyncpg://user:password@localhost:5432/mydatabase"

2단계: 비동기 데이터베이스 연결 및 세션 관리

FastAPI와 SQLAlchemy 2.0을 함께 사용할 때 가장 중요한 부분 중 하나는 비동기 데이터베이스 세션의 올바른 관리입니다. 각 요청마다 독립적인 세션을 제공하고, 요청 처리 완료 후 세션을 안전하게 닫는 패턴이 필요합니다.

database.py 파일 구조

데이터베이스 엔진, 세션 팩토리, 그리고 요청 스코프 세션 관리를 위한 async_scoped_session을 설정합니다.

python
# app/database.py
import os
from typing import AsyncGenerator

from dotenv import load_dotenv
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base, sessionmaker, scoped_session

# .env 파일 로드
load_dotenv()

# 환경 변수에서 DB URL 가져오기
DATABASE_URL = os.getenv("DATABASE_URL")

if not DATABASE_URL:
    raise ValueError("DATABASE_URL 환경 변수가 설정되지 않았습니다.")

# 비동기 엔진 생성
# pool_size: 연결 풀에 유지할 최소 연결 수
# max_overflow: pool_size를 초과하여 생성할 수 있는 최대 연결 수
# echo=True: SQL 쿼리를 로깅 (개발 환경에서 유용)
async_engine = create_async_engine(
    DATABASE_URL,
    echo=True,
    pool_size=10,
    max_overflow=20
)

# 비동기 세션 팩토리 생성
# expire_on_commit=False: 커밋 후에도 객체가 만료되지 않도록 설정 (세션 종료까지 유효)
AsyncSessionFactory = async_sessionmaker(
    async_engine,
    expire_on_commit=False,
    class_=AsyncSession # AsyncSession을 명시적으로 사용
)

# 요청 스코프 세션 관리를 위한 async_scoped_session
# contextvars를 사용하여 비동기 컨텍스트 내에서 세션을 관리합니다.
# 각 비동기 Task(요청)마다 고유한 세션을 보장합니다.
AsyncScopedSession = scoped_session(
    AsyncSessionFactory,
    scopefunc=None # asyncio의 contextvars를 자동으로 사용하므로 None으로 설정
)

Base = declarative_base()

# 데이터베이스 테이블 생성 및 삭제 함수
async def create_db_and_tables():
    async with async_engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    print("Database tables created.")

async def drop_db_and_tables():
    async with async_engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
    print("Database tables dropped.")

# FastAPI 의존성 주입을 위한 세션 제너레이터
async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with AsyncScopedSession() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise
        finally:
            # scoped_session은 .remove()를 통해 세션을 정리합니다.
            await AsyncScopedSession.remove()

여기서 AsyncScopedSession은 매우 중요합니다. contextvars를 기반으로 각 비동기 요청(Task)에 대해 독립적인 AsyncSession 인스턴스를 제공하고 관리합니다. 이렇게 하면 여러 요청이 동시에 들어와도 각 요청이 자신의 세션을 사용하여 데이터 일관성을 유지할 수 있습니다. get_db 함수는 FastAPI의 Depends에 주입되어 각 요청마다 세션을 생성하고, 요청 처리 후 커밋/롤백 및 세션 정리를 담당합니다.

models.py - SQLAlchemy 2.0 선언적 모델 정의

SQLAlchemy 2.0 스타일의 선언적 모델을 정의합니다. Mappedmapped_column을 사용하여 타입 힌팅을 강화하고, 모델 정의를 더욱 명확하게 할 수 있습니다.

python
# app/models.py
from datetime import datetime
from typing import Optional
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.orm import Mapped, mapped_column

from app.database import Base

class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
    username: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
    email: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
    hashed_password: Mapped[str] = mapped_column(String, nullable=False)
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
    updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now)

    def __repr__(self):
        return f"<User(id={self.id}, username={self.username}, email={self.email})>"

class Item(Base):
    __tablename__ = "items"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
    name: Mapped[str] = mapped_column(String, index=True, nullable=False)
    description: Mapped[Optional[str]] = mapped_column(String, nullable=True)
    price: Mapped[int] = mapped_column(Integer, nullable=False)
    owner_id: Mapped[int] = mapped_column(Integer, index=True) # 외래키 관계는 이후에 추가 가능

    def __repr__(self):
        return f"<Item(id={self.id}, name={self.name}, price={self.price})>"

# 모델 정의는 이 파일에 계속 추가할 수 있습니다.

3단계: FastAPI 라우터에서 비동기 ORM 활용

이제 main.py 파일에서 FastAPI 애플리케이션을 설정하고, 앞에서 정의한 get_db 의존성을 사용하여 라우터에서 AsyncSession을 주입받아 비동기 ORM 작업을 수행합니다.

의존성 주입 (Dependency Injection) 활용

FastAPI의 강력한 기능인 의존성 주입을 통해 각 라우터 함수가 AsyncSession 인스턴스를 쉽게 사용할 수 있도록 합니다.

python
# app/main.py
from typing import List
from fastapi import FastAPI, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select

from app.database import create_db_and_tables, get_db, async_engine
from app.models import User, Item
from app.schemas import UserCreate, UserResponse, ItemCreate, ItemResponse, UserUpdate, ItemUpdate

# FastAPI 애플리케이션 초기화
app = FastAPI(
    title="FastAPI + SQLAlchemy 2.0 Async ORM Example",
    description="FastAPI와 SQLAlchemy 2.0 비동기 ORM 실전 패턴을 보여주는 예제입니다.",
    version="1.0.0"
)

# 애플리케이션 시작 시 데이터베이스 테이블 생성
@app.on_event("startup")
async def on_startup():
    await create_db_and_tables()

# 애플리케이션 종료 시 DB 연결 종료 (선택 사항)
@app.on_event("shutdown")
async def on_shutdown():
    await async_engine.dispose()
    print("Database engine disposed.")

### User 관련 CRUD 라우터 ###

@app.post("/users/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(user: UserCreate, db: AsyncSession = Depends(get_db)):
    """새로운 사용자 생성"""
    # 사용자 이름이나 이메일 중복 검사
    existing_user = await db.execute(
        select(User).where((User.username == user.username) | (User.email == user.email))
    )
    if existing_user.scalars().first():
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="Username or Email already registered"
        )

    db_user = User(**user.model_dump())
    db.add(db_user)
    # get_db() 내에서 commit이 이루어지므로 여기서는 생략 가능하지만,
    # 명시적 트랜잭션 관리가 필요할 경우 async with db.begin(): 사용
    # await db.commit() # get_db()에서 처리
    await db.refresh(db_user) # DB에서 최신 데이터(id, created_at 등)를 로드
    return db_user

@app.get("/users/", response_model=List[UserResponse])
async def read_users(skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db)):
    """모든 사용자 조회"""
    result = await db.execute(select(User).offset(skip).limit(limit))
    users = result.scalars().all()
    return users

@app.get("/users/{user_id}", response_model=UserResponse)
async def read_user(user_id: int, db: AsyncSession = Depends(get_db)):
    """특정 ID의 사용자 조회"""
    result = await db.execute(select(User).where(User.id == user_id))
    user = result.scalars().first()
    if user is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
    return user

@app.put("/users/{user_id}", response_model=UserResponse)
async def update_user(user_id: int, user_update: UserUpdate, db: AsyncSession = Depends(get_db)):
    """특정 ID의 사용자 정보 업데이트"""
    result = await db.execute(select(User).where(User.id == user_id))
    db_user = result.scalars().first()
    if db_user is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")

    # Pydantic 모델의 dict() 대신 model_dump(exclude_unset=True) 사용
    update_data = user_update.model_dump(exclude_unset=True)
    for key, value in update_data.items():
        setattr(db_user, key, value)
    
    # get_db() 내에서 commit이 이루어지므로 여기서는 생략 가능
    # await db.commit()
    await db.refresh(db_user)
    return db_user

@app.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(user_id: int, db: AsyncSession = Depends(get_db)):
    """특정 ID의 사용자 삭제"""
    result = await db.execute(select(User).where(User.id == user_id))
    db_user = result.scalars().first()
    if db_user is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")

    await db.delete(db_user)
    # get_db() 내에서 commit이 이루어지므로 여기서는 생략 가능
    # await db.commit()
    return {"detail": "User deleted successfully"}

### Item 관련 CRUD 라우터 (생략, User와 유사한 패턴으로 구현) ###
# ...

# Pydantic 스키마 정의 (app/schemas.py)
# Pydantic은 FastAPI의 데이터 유효성 검사 및 직렬화/역직렬화에 사용됩니다.

# app/schemas.py
from typing import Optional
from datetime import datetime
from pydantic import BaseModel, Field

# User 스키마
class UserBase(BaseModel):
    username: str = Field(..., min_length=3, max_length=50)
    email: str = Field(..., pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
    is_active: Optional[bool] = True

class UserCreate(UserBase):
    hashed_password: str = Field(..., min_length=6)

class UserUpdate(UserBase):
    username: Optional[str] = Field(None, min_length=3, max_length=50)
    email: Optional[str] = Field(None, pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
    hashed_password: Optional[str] = Field(None, min_length=6)
    is_active: Optional[bool] = None

class UserResponse(UserBase):
    id: int
    created_at: datetime
    updated_at: datetime

    class Config:
        from_attributes = True # SQLAlchemy 모델로부터 Pydantic 모델 생성을 허용 (orm_mode 대신)

# Item 스키마
class ItemBase(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    description: Optional[str] = Field(None, max_length=500)
    price: int = Field(..., gt=0)
    owner_id: int

class ItemCreate(ItemBase):
    pass

class ItemUpdate(ItemBase):
    name: Optional[str] = Field(None, min_length=1, max_length=100)
    description: Optional[str] = Field(None, max_length=500)
    price: Optional[int] = Field(None, gt=0)
    owner_id: Optional[int] = None

class ItemResponse(ItemBase):
    id: int

    class Config:
        from_attributes = True

위 코드에서 주목할 점은 다음과 같습니다.

  • @app.on_event("startup"): 애플리케이션 시작 시 create_db_and_tables()를 호출하여 데이터베이스 스키마를 생성합니다. 실제 서비스에서는 Alembic과 같은 마이그레이션 도구를 사용해야 합니다.
  • db: AsyncSession = Depends(get_db): 각 라우터 함수는 get_db 의존성을 통해 AsyncSession 인스턴스를 주입받습니다. 이 세션은 요청이 시작될 때 생성되고, 요청이 완료될 때 (성공 시 커밋, 실패 시 롤백) 자동으로 정리됩니다.
  • await db.execute(select(Model).where(...)): SQLAlchemy 2.0에서는 session.query() 대신 session.execute(select(...)) 패턴을 사용하는 것이 권장됩니다. select()는 SQLAlchemy 2.0의 코어 문법으로, ORM과 코어의 경계를 허물고 더 강력한 쿼리를 가능하게 합니다.
  • .scalars().first() / .scalars().all(): execute의 결과는 Result 객체이며, 여기서 실제 ORM 객체를 추출하기 위해 .scalars()를 사용합니다. 단일 객체는 .first(), 여러 객체는 .all()로 가져옵니다.
  • await db.refresh(db_user): db.add() 또는 db.update() 후 객체에 DB에서 생성된 ID나 기본값, 또는 onupdate로 변경된 값이 반영되지 않을 수 있습니다. 이 경우 refresh()를 호출하여 객체의 상태를 DB와 동기화합니다.

4단계: async_scoped_session과 미들웨어의 실전 적용

앞서 database.py에서 AsyncScopedSessionget_db 의존성 주입 패턴을 살펴보았습니다. 이 패턴은 FastAPI의 미들웨어와 결합될 때 더욱 강력하고 견고한 세션 관리 시스템을 구축할 수 있습니다.

get_db 의존성 상세 설명

get_db 함수는 FastAPI의 yield 기반 의존성 주입 패턴을 사용합니다. 이는 다음과 같은 이점을 제공합니다.

  1. 세션 생명 주기 관리: yield 이전 코드는 요청 시작 시 실행되어 세션을 생성하고, yield 이후 코드는 요청 처리 완료 후 실행되어 커밋/롤백 및 세션 정리를 담당합니다.
  2. 자동 트랜잭션 관리: try...except...finally 블록을 통해 요청 처리 중 예외 발생 시 자동으로 롤백하고, 성공적으로 완료되면 커밋합니다.
  3. 리소스 해제: finally 블록에서 AsyncScopedSession.remove()를 호출하여 현재 비동기 컨텍스트(Task)에 바인딩된 세션을 명확하게 해제합니다. 이는 연결 풀의 효율적인 관리에 필수적입니다.

이러한 패턴은 수동으로 미들웨어를 작성하는 것보다 간결하며, FastAPI의 의존성 주입 시스템과 자연스럽게 통합됩니다.

세션 관리 전략 비교 (표)

FastAPI + SQLAlchemy 2.0 환경에서 비동기 세션을 관리하는 다양한 접근 방식을 비교해 보겠습니다.

방법특징장점단점
`AsyncSession` 직접 생성/관리각 라우터에서 `AsyncSession()`을 직접 생성하고 `await session.close()`명시적인 제어, 간단한 스크립트에서 사용 용이각 라우터마다 세션 관리 로직 반복, 에러 처리 복잡, `close()` 누락 위험
**`async_scoped_session` + `Depends(get_db)` (권장)**`contextvars` 기반 요청 스코프 세션. FastAPI `Depends`로 주입, `yield`로 생명 주기 관리**가장 견고하고 유지보수 용이**, 자동 트랜잭션, 리소스 자동 해제, 코드 중복 감소초기 설정 필요, `contextvars` 이해 요구
커스텀 FastAPI 미들웨어`BaseHTTPMiddleware`를 사용하여 `request.state.db`에 세션 주입모든 요청에 일관된 세션 관리 적용, 의존성 주입 대신 `request.state` 사용미들웨어 로직 구현 복잡, `Depends` 패턴에 비해 FastAPI 생태계 활용도 낮음

코드픽에서는 일반적으로 async_scoped_sessionDepends(get_db) 패턴을 가장 강력하고 유지보수성이 높은 방법으로 권장합니다. 이는 비동기 웹 환경에서 데이터베이스 세션 관리의 복잡성을 최소화하면서도 안정적인 동작을 보장하기 때문입니다.

5단계: 성능 최적화 및 주의사항

FastAPI와 SQLAlchemy 2.0을 사용할 때 성능을 극대화하고 잠재적인 문제를 방지하기 위한 몇 가지 팁입니다.

N+1 문제 해결

N+1 문제는 연관된 객체를 로드할 때 발생하는 흔한 성능 저하의 원인입니다. 예를 들어, 사용자 목록을 가져오면서 각 사용자의 아이템 목록도 가져와야 할 때, 사용자 수만큼 추가적인 쿼리가 발생하는 것입니다.

SQLAlchemy는 이를 해결하기 위한 selectinload (대부분의 경우 권장)와 joinedload를 제공합니다.

python
from sqlalchemy.orm import selectinload, relationship
# app/models.py 에 User와 Item 간의 관계 정의
class User(Base):
    # ...
    items: Mapped[List["Item"]] = relationship("Item", back_populates="owner")

class Item(Base):
    # ...
    owner: Mapped["User"] = relationship("User", back_populates="items")

# app/main.py 에서 N+1 문제 해결
@app.get("/users_with_items/", response_model=List[UserResponseWithItems]) # UserResponseWithItems는 Item 목록 포함 스키마
async def read_users_with_items(db: AsyncSession = Depends(get_db)):
    # selectinload를 사용하여 User를 가져올 때 연관된 Item도 한 번의 쿼리로 가져옴
    result = await db.execute(select(User).options(selectinload(User.items)))
    users = result.scalars().all()
    return users

트랜잭션 관리

get_db 함수에서 이미 기본적인 트랜잭션 관리를 하고 있지만, 여러 데이터베이스 작업을 하나의 원자적인 단위로 묶어야 할 때는 명시적인 트랜잭션 블록을 사용하는 것이 좋습니다.

python
async def create_user_and_item(user: UserCreate, item:

개발 의뢰 상담

AI 서비스나 플랫폼 개발을
고민 중이신가요?

CodePick에서는
기획 → 개발 → 운영까지 함께합니다.
아이디어만 있어도 상담 가능합니다.

CodeVenter 개발팀이 직접 담당 · 1~2 영업일 내 회신

✓ 스타트업 MVP 개발✓ AI 서비스 개발✓ 웹 플랫폼 개발✓ 기업 시스템 구축✓ 모바일 앱 개발

AI Development Studio

코드픽 by 코드벤터

  • 대표: 윤승환 · 사업자등록번호: 121-57-64983
  • 대구광역시 중구 국채보상로 586, 16층 · info@codeventer.com

© 2025 코드벤터. All rights reserved.