- Published on
Python async 컨텍스트 매니저로 DB 세션 누수 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 비동기로 동작할수록 DB 세션(정확히는 커넥션/트랜잭션/세션 객체)의 생명주기 관리는 더 까다로워집니다. 요청이 많아질 때 커넥션 풀이 고갈되거나, 예외가 터진 뒤 세션이 닫히지 않아 점진적으로 리소스가 새는 현상은 운영에서 흔히 마주칩니다. 특히 asyncio 환경에서는 예외뿐 아니라 취소(CancelledError) 까지 고려해야 해서, “잘 닫았겠지”라는 기대는 쉽게 깨집니다.
이 글에서는 Python async 컨텍스트 매니저로 세션 생명주기를 강제하여 세션 누수를 구조적으로 막는 방법을 다룹니다. SQLAlchemy 2.x AsyncSession을 중심으로 설명하지만, 패턴 자체는 어떤 비동기 DB 드라이버에도 적용됩니다.
또한 DB가 PostgreSQL이라면, 세션 누수는 결국 커넥션 점유 시간 증가로 이어져 vacuum 지연이나 트랜잭션 누적 문제로 번질 수 있습니다. 운영 관점에서는 PostgreSQL autovacuum 튜닝으로 테이블 폭증 막기 같은 글과 함께 “트랜잭션을 짧게 유지”하는 습관이 같이 가야 합니다.
비동기에서 세션 누수가 더 자주 터지는 이유
1) await 사이에 제어권이 넘어간다
동기 코드에서는 함수가 끝나기 전까지 “현재 스코프에서 뭘 열었는지” 추적이 상대적으로 쉽습니다. 하지만 비동기에서는 await로 실행 흐름이 끊기고, 그 사이 다른 코루틴이 실행되면서 정리 코드가 뒤로 밀립니다.
2) 예외보다 무서운 건 취소다
웹 서버(예: FastAPI/Starlette, aiohttp)에서 클라이언트가 연결을 끊거나 타임아웃이 나면 요청 태스크가 취소될 수 있습니다. 취소는 일반 예외처럼 보이지만, 정리 로직이 finally에서 제대로 실행되지 않거나(잘못 작성된 경우), 취소 시점에 따라 커넥션이 애매한 상태로 남을 수 있습니다.
3) 커넥션 풀은 “닫지 않으면” 반드시 고갈된다
세션 객체를 “GC가 알아서 정리해주겠지”라고 두면, 풀에서 커넥션을 반납하지 못해 대기열이 쌓입니다. 증상은 보통 다음 순서로 나타납니다.
- p95/p99 응답시간 급증
- DB 커넥션 수 상한 도달
- 애플리케이션에서 풀 타임아웃 에러 증가
- 장애 시점에 DB는 CPU가 낮은데 앱만 느린 이상한 상태
목표: 세션 생명주기를 구조로 강제하기
핵심은 간단합니다.
- 세션을 만들었다면 반드시 닫는다
- 트랜잭션을 시작했다면 성공 시 커밋, 실패/취소 시 롤백
- 위 규칙을 “개발자 기억”이 아니라 언어 구조(
async with) 로 강제한다
이를 위해 asynccontextmanager 또는 클래스 기반 __aenter__/__aexit__를 사용합니다.
SQLAlchemy AsyncSession: 가장 안전한 기본 패턴
SQLAlchemy 2.x 기준으로 AsyncSession은 다음 조합이 가장 실수를 줄입니다.
async with async_sessionmaker() as session:으로 세션 생성/종료async with session.begin():으로 트랜잭션 스코프 생성(자동 커밋/롤백)
예제: 세션 + 트랜잭션 스코프를 한 번에 묶기
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost:5432/app"
engine = create_async_engine(
DATABASE_URL,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
)
SessionMaker = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
@asynccontextmanager
async def session_scope() -> AsyncSession:
async with SessionMaker() as session:
async with session.begin():
# 여기까지 오면 트랜잭션이 시작된 상태
# 블록이 정상 종료되면 commit, 예외/취소면 rollback
yield session
# begin 블록을 빠져나오면 트랜잭션 정리 완료
# SessionMaker 컨텍스트를 빠져나오면 세션 close 및 커넥션 반납
이제 호출부는 다음처럼 “닫는 코드”를 절대 작성하지 않게 됩니다.
from sqlalchemy import text
async def get_healthcheck_value() -> int:
async with session_scope() as session:
result = await session.execute(text("select 1"))
return int(result.scalar_one())
이 패턴의 장점은 다음과 같습니다.
- 커밋/롤백/close를 호출부에서 잊을 수 없다(구조가 강제)
- 예외가 발생해도 트랜잭션이 자동으로 정리된다
- 취소가 발생해도
__aexit__경로로 들어가며 정리가 수행된다
“읽기 전용” 요청에도 트랜잭션이 필요한가
많은 팀이 “SELECT는 커밋이 필요 없으니 트랜잭션 스코프를 안 써도 된다”고 생각합니다. 하지만 운영에서는 다음 문제가 자주 생깁니다.
- 실수로 읽기 함수에
UPDATE가 섞였는데 커밋/롤백이 누락됨 - 예외 발생 시 커넥션이 “에러 상태”로 남아 풀에 반환됨(드라이버/설정에 따라)
- 장시간 열린 트랜잭션이 autovacuum을 방해할 수 있음
따라서 일관된 규칙(항상 session.begin() 사용) 이 장기적으로 안전합니다. 정말로 읽기 전용 최적화가 필요하면, 그때 DB 레벨에서 READ ONLY 트랜잭션을 쓰거나(드라이버 지원 확인), 별도 최적화 레이어로 분리하는 편이 낫습니다.
흔한 안티패턴과 왜 위험한가
안티패턴 1) 세션을 전역으로 들고 있기
# 나쁜 예: 전역 세션 공유
session = SessionMaker()
async def handler():
await session.execute(...)
문제점:
- 동시 요청에서 같은 세션을 공유하면 내부 상태가 꼬일 수 있음
- 트랜잭션 경계가 불명확해지고 커밋/롤백 책임이 사라짐
- 예외 시 세션이 오염된 채로 계속 사용될 수 있음
안티패턴 2) try/finally로 닫지만 트랜잭션이 빠진 경우
async def handler():
session = SessionMaker()
try:
await session.execute(...)
# 커밋/롤백이 없음
finally:
await session.close()
세션은 닫더라도, 트랜잭션이 열린 채로 오래 유지되거나(드라이버에 따라), 예외 시점의 상태가 애매해질 수 있습니다. 게다가 호출부마다 try/finally를 반복하면 누락이 발생합니다.
안티패턴 3) 예외 처리에서 rollback을 빼먹기
async def handler():
async with SessionMaker() as session:
try:
await session.execute(...)
await session.commit()
except Exception:
# rollback 누락
raise
이 경우 커넥션이 풀로 돌아갔을 때 다음 사용자가 “이상한 트랜잭션 상태”를 물려받을 수 있습니다. session.begin() 컨텍스트를 쓰면 이런 실수를 구조적으로 제거합니다.
FastAPI에서 의존성으로 세션 스코프 강제하기
FastAPI에서는 Depends로 세션을 주입하는 경우가 많습니다. 이때도 핵심은 동일합니다: 의존성이 세션 생명주기를 소유해야 합니다.
from fastapi import Depends, FastAPI
from sqlalchemy import select
app = FastAPI()
async def get_session():
async with session_scope() as session:
yield session
@app.get("/users/{user_id}")
async def get_user(user_id: int, session=Depends(get_session)):
# 예: ORM 매핑이 있다고 가정
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
return {"id": user.id, "name": user.name} if user else None
이 구조의 장점:
- 핸들러는 “DB 세션을 닫아야 한다”는 책임이 없음
- 요청이 끝나면 무조건 정리됨
- 예외/취소에도 일관된 정리 경로를 가짐
취소(CancelledError)에서 특히 주의할 점
asyncio 취소는 보통 finally를 타지만, 다음 상황에서는 문제가 생길 수 있습니다.
rollback자체가await를 포함하고, 그await가 다시 취소될 수 있음- 네트워크 장애로 rollback/close가 지연되며 태스크가 강제 종료됨
실무에서는 다음을 권장합니다.
- 트랜잭션은 최대한 짧게 유지(특히 외부 API 호출을 트랜잭션 안에서 하지 않기)
- 세션 스코프는 한 곳에서만 만들기(중복 스코프 금지)
- DB 드라이버 타임아웃 설정(예: statement timeout)으로 “정리 단계가 영원히 걸리는 상황” 방지
트랜잭션 안에서 외부 호출을 섞으면, RTO/RPO 관점에서도 복잡도가 커집니다. 이벤트 기반 설계나 재처리 전략이 필요해질 수 있는데, 그런 관점은 DDD 이벤트 소싱 마이그레이션 - 중복·순서·재처리 같은 글의 사고방식과도 연결됩니다.
클래스 기반 async 컨텍스트 매니저로 정책을 더 강제하기
팀 규칙(예: 읽기/쓰기 구분, 커밋 금지 구간, 로깅/메트릭)을 강제하고 싶다면 클래스 기반도 좋습니다.
from dataclasses import dataclass
from sqlalchemy.ext.asyncio import AsyncSession
@dataclass
class UnitOfWork:
session: AsyncSession
async def __aenter__(self) -> "UnitOfWork":
self._tx = await self.session.begin()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
if exc is None:
await self._tx.commit()
else:
await self._tx.rollback()
@asynccontextmanager
async def uow_scope():
async with SessionMaker() as session:
async with UnitOfWork(session) as uow:
yield uow
호출부:
async def change_username(user_id: int, new_name: str) -> None:
async with uow_scope() as uow:
await uow.session.execute(
text("update users set name = :name where id = :id"),
{"name": new_name, "id": user_id},
)
이렇게 하면 “세션만 던져주는” 것보다 더 강하게 트랜잭션 정책을 통제할 수 있습니다.
운영에서 세션 누수 여부를 확인하는 체크리스트
코드 패턴을 적용했더라도, 실제 누수가 줄었는지 관측 가능해야 합니다.
- 앱 메트릭에서 커넥션 풀 사용량(checked out) 추이 확인
- 풀 타임아웃 에러(예:
QueuePool limit류) 발생률 확인 - DB에서 idle in transaction 세션이 늘어나는지 확인
- 요청 타임아웃/취소가 많을 때도 세션 수가 안정적인지 확인
PostgreSQL을 쓴다면 pg_stat_activity에서 state가 idle in transaction으로 오래 남는 세션이 있는지 보는 것만으로도 큰 힌트를 얻습니다.
정리: 누수는 “실수”가 아니라 “구조”로 막는다
비동기 환경에서 DB 세션 누수는 대개 개인의 실수가 아니라, 생명주기를 강제하지 않는 구조에서 반복적으로 발생합니다. 해결책은 다음 한 줄로 요약됩니다.
- 세션 생성과 트랜잭션 경계를
async with로 감싸서, 커밋/롤백/반납을 호출부에서 제거한다.
그리고 운영 안정성 측면에서 트랜잭션을 짧게 유지하고, DB 관측 지표(풀 사용량, idle in transaction)를 함께 모니터링하면 “가끔 터지는” 장애를 “예방 가능한” 문제로 바꿀 수 있습니다.
추가로, Next.js 같은 프론트/백엔드 경계에서 캐시나 데이터 일관성 문제가 함께 얽히는 구조라면, 세션 관리와 함께 데이터 갱신 전략도 점검해야 합니다. 관련해서는 Next.js 14 RSC 캐시로 데이터가 안 바뀔 때도 같이 참고해두면 전체 시스템 관점에서 병목을 찾는 데 도움이 됩니다.