SQLAlchemy async 비동기 설정 완전 정복 — FastAPI 실전 패턴

목차

SQLAlchemy async 비동기 설정은 동기 코드와 모양은 거의 같지만 내부 동작이 전혀 다른 이중 구조의 ORM이다. FastAPI 프로젝트에 그대로 옮겨 넣으면 처음 며칠은 잘 돈다. 트래픽이 조금만 늘면 그때부터 갈라지기 시작한다.

실제로, 5년차쯤 되면 "잘 도는 코드는 안 건드린다"가 신조가 된다. 그런데 이 영역은 안 건드려도 알아서 깨진다. 동기에서 멀쩡하던 패턴 몇 개가 비동기에서는 그대로 지뢰가 된다.

처음에 만난 에러 두 개

게다가, 데모 환경에서는 잘 돌았다. 사내 트래픽을 받자마자 두 가지 에러가 동시에 떨어졌다.

sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called;
can't call await_only() here.
QueuePool limit of size 5 overflow 10 reached, connection timed out,
timeout 30.00

결국, 전자는 코드 어딘가에서 동기 호출이 비동기 컨텍스트로 새는 신호다. 후자는 커넥션을 제때 반환 못 했다는 뜻이다. 원인은 다르지만 같은 뿌리에서 나온다. AsyncSession의 라이프사이클을 어디서 끊는지 정해두지 않으면 둘 다 터진다.

SQLAlchemy 2.0의 async가 일하는 방식

SQLAlchemy 2.0(2023년 1월 정식 출시, 2025년 11월 기준 안정 버전 2.0.36)은 sync 엔진과 async 엔진을 거의 같은 API로 노출한다. 같지만 같지 않다.

async 쪽은 내부적으로 greenlet으로 동기 코드를 비동기 컨텍스트에 묶어 실행한다. await session.execute(...)를 호출하면 SQLAlchemy 내부의 동기 함수가 greenlet 안에서 실행되고, 거기서 발생하는 I/O는 asyncio 루프에 반환된다.

이 구조에서 가장 흔히 깨지는 지점은 lazy loading이다. 동기 모드에서는 ORM 객체의 관계 속성을 그냥 접근하면 알아서 추가 쿼리가 나갔다. async에서는 그게 안 된다. 그래서 첫 에러가 떴던 것이다. user.posts를 그냥 읽었더니 SQLAlchemy가 이걸 동기 lazy load로 보고 greenlet 없는 컨텍스트에서 await을 시도해 죽었다.

selectin/joined load를 명시한다

그러나, 비동기 환경에서 관계 데이터를 가져오려면 옵션을 명시해야 한다.

from sqlalchemy.orm import selectinload

stmt = select(User).options(selectinload(User.posts)).where(User.id == 1)
result = await session.execute(stmt)
user = result.scalar_one()

# 이 시점에 user.posts는 이미 로드되어 있다
for p in user.posts:
    print(p.title)

selectinload는 별도 IN 쿼리로 자식 데이터를 한 번에 가져온다. N+1을 피하면서 비동기에서도 안전하다. joinedload는 JOIN으로 처리하는데, 컬렉션이 크면 row가 폭증하므로 신중히 골라야 한다. 기본 선택지는 selectin이 무난하다.

속성 접근 시점을 트랜잭션 안으로

그런데, 또 하나의 함정은 expire_on_commit이다. 기본값으로 두면 commit 직후 ORM 객체의 모든 속성이 만료되고, 다음 접근에서 다시 쿼리가 나간다. 비동기에서는 이 재조회가 lazy load와 같은 문제를 만든다. sessionmaker에 expire_on_commit=False를 박아두는 게 거의 표준이다.

두 번째 에러: 풀이 막힌 진짜 이유

QueuePool 에러가 떴을 때 처음 한 짓이 부끄럽다. 풀 사이즈를 20, 50까지 올렸다. 문제는 해결되지 않고 미뤄지기만 했다. 원인은 사이즈가 아니라 세션을 닫지 않는 코드였다.

# 이런 함수가 사방에 있었다
async def get_user(user_id: int):
    engine = create_async_engine(DATABASE_URL)
    async_session = async_sessionmaker(engine)
    session = async_session()
    user = await session.get(User, user_id)
    return user

세 가지가 동시에 잘못됐다. 엔진을 매 요청마다 새로 만들고, 세션을 컨텍스트 매니저 없이 열고, 명시적 close도 안 했다. 엔진은 풀을 들고 있는 무거운 객체다. 요청마다 새로 만들면 풀이 누적된다. 세션은 닫히지 않으면 커넥션을 점유한 채로 GC를 기다린다.

엔진은 앱 라이프사이클 동안 하나

엔진은 모듈 레벨에서 한 번만 만든다. FastAPI는 lifespan에서 종료 시 dispose까지 처리한다.

from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

engine = create_async_engine(
    DATABASE_URL,
    pool_size=10,
    max_overflow=20,
    pool_pre_ping=True,
    pool_recycle=1800,
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)

@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await engine.dispose()

app = FastAPI(lifespan=lifespan)

pool_pre_ping=True는 풀에서 커넥션을 꺼낼 때 살아있는지 확인한다. DB가 재시작되거나 idle timeout으로 잘려도 다음 요청에서 복구된다. pool_recycle=1800은 30분 이상 묵은 커넥션을 강제로 새로 만든다. MySQL의 wait_timeout이나 RDS의 idle 종료에 걸리지 않는 마지노선이다.

Depends로 세션을 요청에 묶는다

세션은 요청 단위로 묶여야 한다. FastAPI의 Depends가 정확히 이 역할에 맞다.

from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession

async def get_session() -> AsyncGenerator[AsyncSession, None]:
    async with SessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

@app.get("/users/{user_id}")
async def read_user(user_id: int, session: AsyncSession = Depends(get_session)):
    user = await session.get(User, user_id)
    return user

물론, 이 패턴은 요청 시작 시 세션을 열고 종료 시 commit 또는 rollback, 그리고 close까지 처리한다. 핸들러 코드에서 commit()을 일일이 부르지 않아도 된다. 예외가 나면 자동으로 롤백되고, 커넥션은 풀로 반환된다.

트랜잭션 경계를 명시해야 할 때

반면, 읽기 위주 핸들러에서는 위 패턴으로 충분하다. 쓰기가 섞인 작업에서는 트랜잭션 경계를 명시적으로 그어야 할 때가 있다.

async def transfer_credits(session: AsyncSession, from_id: int, to_id: int, amount: int):
    async with session.begin():
        sender = await session.get(Account, from_id, with_for_update=True)
        receiver = await session.get(Account, to_id, with_for_update=True)
        if sender.balance < amount:
            raise ValueError("insufficient balance")
        sender.balance -= amount
        receiver.balance += amount

session.begin() 블록을 빠져나오면 자동 commit되고, 예외가 나면 rollback된다. with_for_update=True는 행 잠금이다. 동시에 여러 요청이 같은 계좌를 건드릴 때 잔액이 깨지는 걸 막는다.

중첩 트랜잭션이 필요하면 begin_nested()로 SAVEPOINT를 쓴다. 대량 import 같은 작업에서 일부 행만 실패해도 전체를 롤백하지 않고 그 row만 건너뛰는 패턴이다. 자주 쓸 일은 없다. 필요할 때만.

풀 파라미터를 어떻게 정하나

그러나, 기본값은 작은 트래픽에 맞춰져 있다. 실무에서는 인스턴스 수, DB의 max_connections, 평균 쿼리 응답 시간을 보고 맞춘다. 다음은 가장 자주 만지는 파라미터의 의미와 기본값이다.

파라미터 기본값 의미 실무 기준
pool_size 5 풀에 상시 유지되는 커넥션 수 인스턴스당 5~20
max_overflow 10 풀이 꽉 찼을 때 추가 생성 한도 pool_size의 1~2배
pool_timeout 30s 커넥션 대기 시간 5~10s 권장
pool_recycle -1 커넥션 강제 재생성 주기 1800s
pool_pre_ping False 사용 전 살아있는지 확인 True 권장

pool_timeout은 의외로 짧게 잡는 게 낫다. 30초를 기다리면 사용자는 이미 떠났다. 5초 안에 못 받으면 빠르게 503을 던지고 클라이언트가 재시도하게 두는 편이 운영상 깔끔하다.

pool_size + max_overflow의 총합이 DB의 max_connections를 인스턴스 수로 나눈 값을 넘으면 안 된다. PostgreSQL의 기본 max_connections가 100이고 앱 인스턴스가 5개라면 인스턴스당 20이 안전선이다. 백그라운드 워커가 별도 풀을 쓰면 거기도 함께 계산해야 한다.

마이그레이션과 raw 영역

ORM만으로 안 되는 영역이 있다. Alembic 마이그레이션은 sync 엔진에서 도는 게 일반적이다. async 엔진을 그대로 넘겨도 되지만 설정이 까다롭다. 보통은 sync URL을 별도로 두고 Alembic은 sync로 돌린다. 운영 DB 접속 정보를 둘 다 만드는 게 번거롭지만, 마이그레이션이 동기로 도는 편이 디버깅이 훨씬 편하다.

raw SQL이 필요할 때는 session.execute(text("..."))로 풀어 쓰면 된다. 대량 INSERT는 ORM보다 빠르다. connection.execute(table.insert(), [dict, dict, ...]) 형태의 executemany가 가장 빠른 경로다. 체감상 ORM add_all 대비 수 배 차이가 난다. 정확한 수치는 행 크기와 DB에 따라 다르니 자기 환경에서 한 번 측정해보는 게 맞다.

흔히 빠지는 함정

한 요청에서 여러 세션을 만들면 트랜잭션이 갈라진다. 한 요청에 한 세션이 원칙이다.

백그라운드 태스크에 핸들러의 세션을 넘기지 마라. 핸들러가 끝나면 세션이 닫힌다. 백그라운드에서는 별도 세션을 새로 만들어야 한다.

결국, 테스트에서 expire_on_commit=False를 빼먹으면 commit 후 객체 접근에서 또 쿼리가 나가 픽스처가 꼬인다. 운영 코드와 테스트 코드가 sessionmaker 설정을 공유하게 만들어두는 편이 사고를 줄인다.

await session.refresh(obj)로 강제 리프레시할 수 있다. 다른 트랜잭션이 같은 row를 바꿨을 때 유용하다. 단, 비용이 든다. 꼭 필요한 경로에서만 호출한다.

지금 손볼 것 세 가지

실제로, 지금 돌고 있는 프로젝트가 있다면 당장 확인할 게 셋이다.

  1. 엔진이 모듈 레벨 싱글톤인지 — 함수 안에서 create_async_engine을 호출하는 코드가 있는지 grep해 본다.
  2. pool_pre_ping=Truepool_recycle=1800이 엔진 설정에 들어가 있는지 — 둘 다 없으면 idle 커넥션 잘림 사고가 시간 문제다.
  3. sessionmaker에 expire_on_commit=False가 박혀 있는지 — 안 박혀 있으면 commit 후 lazy load로 greenlet 에러가 튄다.

(공식 문서: SQLAlchemy AsyncIO Support v2.0, FastAPI SQL Databases 가이드 — 2025년 11월 확인)

관련 글