FastAPI 의존성 주입에서 DB 세션이 새는 이유, yield 패턴부터 점검하기

FastAPI 의존성 주입에서

SQLAlchemy 커넥션 풀을 pool_size=5로 두고 운영하다가 로그에 QueuePool limit of size 5 overflow 10 reached, connection timed out이 찍히기 시작했다면, 원인은 거의 하나로 좁혀집니다. FastAPI 의존성 주입에서 만든 DB 세션이 요청이 끝난 뒤에도 반납되지 않고 계속 쌓이는 경우입니다. 이 글은 yield 패턴을 어떻게 써야 세션이 새지 않는지, 어떤 조건에서 그 원칙이 깨지는지를 실제 코드로 짚어 봅니다.

전제부터 밝혀두면, 아래 내용은 FastAPI 0.100 이상, SQLAlchemy 2.0 계열, Python 3.11 환경을 기준으로 합니다. 특히 백그라운드 작업과의 실행 순서 문제는 FastAPI 0.106.0 전후로 동작이 달라지므로 버전 확인이 꼭 필요합니다.

DB 세션이 새는 정확한 지점부터 짚어보겠습니다

FastAPI 의존성 주입에서 yield를 쓰는 함수는 내부적으로 제너레이터로 동작합니다. 요청이 들어오면 yield 이전 코드가 실행되어 세션을 만들고, 경로 함수가 끝나면 그 세션을 넘겨받아 처리한 뒤 yield 이후 코드가 실행되며 정리됩니다.

문제는 경로 함수 안에서 예외가 발생했을 때입니다. FastAPI는 그 예외를 의존성 제너레이터의 yield 지점으로 다시 던지는데, 이때 yield 이후에 정리 코드만 나열되어 있고 예외를 받아낼 장치가 없으면 그 코드는 실행되지 않고 예외가 그대로 위로 전파됩니다. 세션을 닫는 줄이 통째로 건너뛰어지는 셈입니다.

yield 뒤에 정리 코드만 두면 벌어지는 일

가장 흔하게 보이는 새는 코드는 이런 형태입니다.

def get_db():
    db = SessionLocal()
    yield db
    db.close()  # 예외 발생 시 실행되지 않음

데이터베이스 서버 연결 코드 화면

정상 흐름에서는 문제없이 동작합니다. 하지만 경로 함수에서 db.query(...)가 제약조건 위반이나 타임아웃으로 예외를 던지면 db.close() 줄까지 도달하지 못하고 함수가 끝나버립니다. 세션은 커넥션 풀에서 체크아웃된 채로 남고, 이런 요청이 반복되면 풀이 서서히 고갈됩니다.

이 코드가 왜 위험한지는 파이썬의 제너레이터 동작 원리를 보면 명확합니다. 제너레이터에 예외를 throw()하면 그 예외는 현재 멈춰 있는 지점에서 발생한 것처럼 처리되고, 이를 감싸는 try 블록이 없으면 그대로 밖으로 빠져나갑니다. 뒤에 남은 코드는 아예 도달할 기회가 없습니다.

비동기 세션에서는 왜 누수가 더 잦을까요

AsyncSession을 쓰는 프로젝트에서는 실수가 하나 더 겹칩니다. try/finally는 챙겼지만 await session.close()를 빠뜨리거나, 커밋 실패 후 롤백 없이 세션을 그대로 반환하는 경우입니다. 이 상태에서 세션이 재사용되면 이전 트랜잭션의 잔재가 다음 요청에 섞여 들어갈 수 있습니다.

가장 안전한 형태는 try/finally를 직접 쓰는 대신 컨텍스트 매니저에 정리를 위임하는 방식입니다.

async def get_db_session():
    async with AsyncSessionLocal() as session:
        yield session

async with은 내부적으로 예외 발생 여부와 상관없이 __aexit__을 호출하도록 보장되어 있어서, 직접 try/finally를 쓰는 것과 동일한 안전성을 코드 두 줄로 확보합니다. 세션 종료뿐 아니라 롤백 처리까지 AsyncSession이 알아서 맡아줍니다.

세 가지 코드 형태를 표로 비교해보세요

FastAPI 의존성 주입에서 자주 보이는 세 가지 패턴을 예외 발생 여부에 따라 정리하면 다음과 같습니다.

파이프 누수를 연상시키는 코드 흐름 추상 이미지

코드 형태 정상 응답 시 경로 함수 예외 발생 시
yield 뒤에 close()만 나열 세션 정상 종료 close() 건너뜀 → 세션 누수
try: yield / finally: close() 세션 정상 종료 finally에서 close() 실행
with SessionLocal() as db: yield db 세션 정상 종료 __exit__에서 자동 종료

표에서 보듯 안전한 두 가지는 결국 “예외가 나도 무조건 실행되는 블록” 안에 정리 코드를 두느냐로 갈립니다. FastAPI 공식 문서yield 의존성에는 반드시 try 블록을 함께 쓰라고 명시하고 있습니다.

이 방식이 안 통하는 경우와 트레이드오프

try/finally를 제대로 넣었다고 해서 모든 케이스가 해결되지는 않습니다. BackgroundTasks에서 같은 세션을 재사용하려는 코드가 대표적인 함정입니다.

FastAPI 0.106.0부터 의존성의 종료 코드가 실행되는 시점이 바뀌었습니다. 그 이전 버전에서는 백그라운드 작업이 끝난 뒤에 yield 이후 코드가 실행됐지만, 0.106.0 이후로는 응답이 전송된 직후, 즉 백그라운드 작업이 실행되기 전에 세션이 먼저 닫힙니다. 그 결과 백그라운드 작업 안에서 의존성으로 받은 세션에 접근하면 이미 닫힌 세션을 쓰다가 오류가 나는 경우가 생깁니다.

이 문제는 세션을 새로 열고 닫는 방식으로는 근본적으로 해결되지 않습니다. 백그라운드 작업에는 별도의 세션 팩토리를 호출해 독립된 세션을 새로 만들어 쓰는 편이 안전하며, 의존성으로 받은 세션을 그대로 넘기지 않는 것이 원칙입니다. 버전을 올릴 계획이 있다면 이 부분부터 먼저 테스트해 보시길 권합니다.

배포 전 점검 순서를 이렇게 짜보세요

정리하면 FastAPI 의존성 주입에서 DB 세션이 새는 원인은 대부분 yield 뒤 정리 코드가 예외 상황에서 실행되지 않는 구조에 있습니다. try/finallywith/async with으로 감싸져 있는지부터 코드베이스 전체에서 확인해 보시는 것이 첫 단계입니다.

다음 단계로는 커넥션 풀 지표를 직접 찍어보는 것을 권합니다. engine.pool.status()를 헬스체크 엔드포인트나 로그에 주기적으로 남겨두면, 체크아웃된 커넥션 수가 요청이 끝난 뒤에도 줄어들지 않는지 바로 확인할 수 있습니다. BackgroundTasks를 쓰는 코드가 있다면 FastAPI 버전을 확인하고, 백그라운드 작업 안에서 세션을 새로 여는 구조로 바꾸는 작업을 다음 스프린트에 넣어 보시기 바랍니다.

LLM 응답을 임베딩과 함께 저장할 때 자주 나오는 스키마 실수

Leave a Comment