FastAPI에서 비동기 DB 커넥션 풀 관리하기

문제 상황

운영 중인 FastAPI 서비스에서 트래픽이 증가하면서 PostgreSQL 연결 에러가 간헐적으로 발생했다. 로그를 확인하니 asyncpg.exceptions.TooManyConnectionsError였다.

기존에는 startup 이벤트에서 커넥션 풀을 생성하고, shutdown에서 닫는 단순한 구조였다.

@app.on_event("startup")
async def startup():
    app.state.pool = await asyncpg.create_pool(
        dsn=DATABASE_URL,
        min_size=5,
        max_size=10
    )

원인 분석

문제는 두 가지였다.

  1. 풀 사이즈 부족: max_size=10인데 동시 요청이 그 이상 들어오면 대기하다 타임아웃
  2. 커넥션 누수: 일부 엔드포인트에서 async with 없이 직접 커넥션을 가져와 사용 후 반환하지 않음

해결 방법

먼저 커넥션 사용 패턴을 정리했다.

async def get_user(user_id: int):
    async with request.app.state.pool.acquire() as conn:
        return await conn.fetchrow(
            "SELECT * FROM users WHERE id = $1", user_id
        )

모든 DB 접근 코드에서 async with로 커넥션을 확실히 반환하도록 수정했다.

풀 설정도 조정했다.

app.state.pool = await asyncpg.create_pool(
    dsn=DATABASE_URL,
    min_size=10,
    max_size=50,
    max_inactive_connection_lifetime=300,
    command_timeout=60
)
  • max_size: 10 → 50으로 증가 (RDS 인스턴스 max_connections 고려)
  • max_inactive_connection_lifetime: 5분간 미사용 시 커넥션 종료
  • command_timeout: 쿼리 타임아웃 명시적 설정

모니터링

커넥션 풀 상태를 확인할 수 있는 엔드포인트를 추가했다.

@app.get("/health/db")
async def db_health():
    pool = request.app.state.pool
    return {
        "size": pool.get_size(),
        "free": pool.get_idle_size(),
        "max": pool.get_max_size()
    }

CloudWatch에서 RDS 커넥션 수와 함께 모니터링하니 안정적으로 운영되고 있다.

교훈

비동기 커넥션 풀은 동기 방식과 달리 명시적인 acquire/release가 중요하다. async with 없이 사용하면 커넥션이 반환되지 않아 풀이 고갈된다. 적절한 풀 사이즈와 타임아웃 설정, 그리고 모니터링이 필수다.