- Published on
asynccontextmanager로 누수 잡기 - 세션·락 안전패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 비동기로 바뀌면서 자주 겪는 장애가 있습니다. 에러는 겉으로는 timeout 이나 pool exhausted 로 보이는데, 원인은 단순합니다. close() 나 release() 를 호출하지 못한 채 함수가 중간에 빠져나가 자원이 누수되는 겁니다. 특히 HTTP 클라이언트 세션, DB 세션, 트랜잭션, 분산락, asyncio 락 같은 것들은 누수가 누적되면 응답 지연이 아니라 “모두 멈춤”으로 이어집니다.
파이썬에서는 동기 코드에 with 가 있듯, 비동기 코드에는 async with 가 있습니다. 그리고 asynccontextmanager 를 잘 쓰면 “반드시 정리되는 구조”를 함수 레벨로 강제할 수 있습니다. 이 글은 asynccontextmanager 로 세션·락을 안전하게 다루는 패턴을, 누수 방지 관점에서 정리합니다.
왜 비동기에서 누수가 더 잘 생기나
비동기 코드에서 누수는 보통 아래 조합에서 발생합니다.
- 중간
return또는 예외로 인해close()가 실행되지 않음 try/finally를 매번 쓰다 보니 누락되는 경로가 생김- 여러 자원을 함께 잡는 과정에서 “획득은 했는데 해제는 못한” 상태가 남음
- 타임아웃, 취소(
CancelledError)가 섞이면서 정리 코드가 건너뛰어짐
특히 커넥션 풀 기반 자원(DB 커넥션, HTTP 커넥션)은 누수가 곧 풀 고갈로 이어집니다. 풀 고갈은 스레드/코루틴이 대기열에 쌓이며 지연이 폭발하는 전형적인 병목입니다. 같은 맥락으로, Java 진영에서 커넥션 풀 고갈을 진단하듯 파이썬에서도 “자원 수명주기 강제”가 핵심입니다. 참고로 풀 고갈이 어떤 증상으로 나타나는지 감을 잡고 싶다면 Spring Boot 대용량 트래픽 - HikariCP 풀 고갈 진단 같은 글의 관점을 그대로 가져오면 도움이 됩니다.
asynccontextmanager 핵심: 수명주기를 함수로 캡슐화
contextlib.asynccontextmanager 는 async def 제너레이터에서 yield 를 기준으로 “획득/해제”를 나눠줍니다.
yield이전: 자원 획득yield이후: 자원 해제(항상 실행되도록 보장)
기본 골격은 다음입니다.
from contextlib import asynccontextmanager
@asynccontextmanager
async def managed_resource():
resource = await acquire()
try:
yield resource
finally:
await release(resource)
이 구조의 장점은 두 가지입니다.
- 호출자는
async with managed_resource()한 줄로 “반드시 정리”를 얻습니다. - 자원 정리 정책(취소 처리, 타임아웃, 로깅, 메트릭)을 한 곳에 모을 수 있습니다.
패턴 1: HTTP 클라이언트 세션 누수 방지
aiohttp.ClientSession 같은 세션은 닫지 않으면 커넥션이 남고, 이벤트 루프 종료 시점에 경고가 뜨거나 실제로 FD가 고갈됩니다.
다음은 세션을 안전하게 생성하고, 요청 실패/취소에서도 닫히도록 보장하는 패턴입니다.
from contextlib import asynccontextmanager
import aiohttp
@asynccontextmanager
async def http_session(**kwargs):
session = aiohttp.ClientSession(**kwargs)
try:
yield session
finally:
await session.close()
async def fetch_json(url: str) -> dict:
async with http_session(timeout=aiohttp.ClientTimeout(total=5)) as session:
async with session.get(url) as resp:
resp.raise_for_status()
return await resp.json()
여기서 포인트는 “세션”과 “요청 응답 컨텍스트”를 둘 다 async with 로 감싼다는 점입니다. 응답 바디를 끝까지 읽지 못해도 컨텍스트가 닫히며 커넥션이 풀로 반환됩니다.
패턴 2: DB 세션과 트랜잭션을 한 번에 안전화
SQLAlchemy AsyncSession 을 예로 들면, 실무에서 흔한 실수는 아래 둘입니다.
- 커밋/롤백이 빠짐
- 세션 close가 누락되어 커넥션이 풀로 반환되지 않음
이를 asynccontextmanager 로 고정하면 호출부에서 실수할 여지가 크게 줄어듭니다.
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
SessionFactory = async_sessionmaker(..., expire_on_commit=False)
@asynccontextmanager
async def session_scope() -> AsyncSession:
async with SessionFactory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def create_user(user_dto):
async with session_scope() as session:
session.add(User(**user_dto))
이 패턴은 “성공하면 커밋, 실패하면 롤백, 그리고 항상 close”를 강제합니다. 특히 커넥션 풀이 타이트한 환경에서 이 차이가 큽니다.
추가로, 데이터베이스 이슈가 “락 경합”인지 “인덱스 미사용”인지도 종종 섞여서 보입니다. 쿼리 자체가 느려서 커넥션이 오래 점유되면 풀 고갈처럼 보일 수 있으니, 인덱스 관점 점검은 PostgreSQL 인덱스 안타는 이유 9가지와 해결 같은 체크리스트가 도움이 됩니다.
패턴 3: asyncio 락 누수 방지와 “락 순서” 고정
asyncio.Lock 는 예외가 나도 async with lock: 이면 해제가 보장됩니다. 문제는 락을 여러 개 잡을 때입니다. 락 순서가 섞이면 데드락이 발생할 수 있고, “락을 잡은 채로 await가 길게 걸리는 작업”이 있으면 전체 병목이 됩니다.
여러 락을 잡아야 한다면 다음 두 가지 원칙을 권합니다.
- 락 획득 순서를 정렬해서 고정(키 기반)
- 락을 잡은 구간을 최대한 짧게(락 안에서 I/O 최소화)
이를 코드로 강제하는 asynccontextmanager 예시입니다.
from contextlib import asynccontextmanager
import asyncio
from typing import Iterable
@asynccontextmanager
async def acquire_locks(*locks: asyncio.Lock):
# id 기준 정렬로 순서 고정(프로세스 내에서만 의미)
ordered = sorted(locks, key=id)
for lock in ordered:
await lock.acquire()
try:
yield
finally:
for lock in reversed(ordered):
lock.release()
# 사용 예
user_lock = asyncio.Lock()
billing_lock = asyncio.Lock()
async def update_user_and_billing():
async with acquire_locks(user_lock, billing_lock):
# 락 안에서는 공유 상태의 짧은 수정만
...
# I/O는 락 밖에서
...
여기서 finally 로 역순 해제를 보장해 “부분 획득 후 예외” 같은 케이스에서도 누수를 막습니다.
패턴 4: 분산락(예: Redis)에서 취소와 타임아웃을 고려하기
분산락은 더 까다롭습니다. 네트워크 I/O가 끼고, 락 TTL, 연장, 소유권 검증 같은 문제가 생깁니다. 흔한 사고는 “락은 잡았는데 작업이 길어져 TTL이 만료되고, 다른 워커가 락을 다시 잡아 중복 실행”입니다.
여기서는 라이브러리마다 API가 다르므로, 형태만 일반화해 보겠습니다.
from contextlib import asynccontextmanager
import asyncio
@asynccontextmanager
async def distributed_lock(lock_client, key: str, ttl_sec: int = 10):
token = await lock_client.acquire(key, ttl=ttl_sec)
if token is None:
raise TimeoutError("failed to acquire lock")
try:
yield
finally:
# 소유권(token) 확인 후 해제하는 구현을 권장
try:
await lock_client.release(key, token=token)
except Exception:
# 해제 실패는 로깅/메트릭으로 반드시 관측
# (네트워크 문제로 해제 실패해도 TTL로 회복되게 설계)
pass
async def run_job(lock_client):
async with distributed_lock(lock_client, key="job:daily", ttl_sec=30):
await asyncio.sleep(1)
...
실무 팁은 다음입니다.
- 해제 실패는 “조용히 무시”하면 안 됩니다. 최소한 카운팅/알람이 필요합니다.
- TTL 기반 락은 해제 실패를 흡수하지만, TTL 만료 전 작업이 끝난다는 보장이 있어야 합니다.
- 작업 시간이 길다면 “연장(renew)”을 별도 태스크로 돌리되, 종료 시점에 반드시 취소하고 정리해야 합니다.
패턴 5: 취소(CancelledError)가 섞인 환경에서 정리 보장하기
ASGI 서버(uvicorn 등)에서는 클라이언트 연결이 끊기면 핸들러 코루틴이 취소될 수 있습니다. 이때 정리 코드가 실행되지 않으면 세션/락이 남습니다.
파이썬에서 finally 는 취소 상황에서도 실행됩니다. 다만 정리 코드 자체가 또 await 를 포함하므로, 정리 중 취소가 전파되면 중간에 끊길 수 있습니다. 중요한 정리는 asyncio.shield 로 보호하는 전략을 고려할 수 있습니다.
from contextlib import asynccontextmanager
import asyncio
@asynccontextmanager
async def safe_cleanup(resource):
await resource.open()
try:
yield resource
finally:
# 정리 과정이 취소로 끊기지 않게 보호
await asyncio.shield(resource.close())
주의할 점은, 무조건 shield 를 남발하면 “취소가 안 먹는” 코드가 될 수 있다는 것입니다. 정말로 반드시 닫혀야 하는 자원(커넥션 반환, 락 해제) 같은 최소 범위에만 적용하세요.
안티패턴: try/finally 를 여기저기 복붙하기
다음 코드는 얼핏 안전해 보이지만, 유지보수 단계에서 쉽게 깨집니다.
async def handler():
session = await make_session()
try:
lock = await acquire_lock()
try:
...
finally:
await release_lock(lock)
finally:
await session.close()
문제는 “자원이 늘어날수록 중첩이 깊어지고”, 누군가 중간에 return 을 추가하거나 예외 처리를 끼워 넣으면서 누락 경로가 생긴다는 점입니다. 컨텍스트 매니저로 분리하면 호출부는 단순해지고, 정책은 한 곳에 모입니다.
관측 포인트: 누수를 ‘증상’이 아니라 ‘지표’로 잡기
asynccontextmanager 로 구조를 잡아도, 운영에서는 “정말로 누수가 줄었는지”를 확인해야 합니다.
- DB 풀: 사용 중 커넥션 수, 대기 시간, 타임아웃 횟수
- HTTP 클라이언트: 커넥션 수, DNS/커넥션 에러율
- 락: 획득 대기 시간, 획득 실패율, TTL 만료/연장 실패
그리고 락 경합이 실제 데드락으로 번지는 케이스라면 DB 레벨 로그가 결정적입니다. MySQL 기반이라면 MySQL InnoDB 데드락(1213) 로그로 범인 찾기처럼 “누가 어떤 순서로 락을 잡았는지”를 추적하는 관점이 그대로 적용됩니다.
정리: async with 로 자원 사용을 문법으로 고정하라
비동기 환경에서 누수는 대부분 “정리 호출을 잊는” 실수에서 시작하지만, 결과는 커넥션 풀 고갈, 타임아웃 폭증, 락 경합으로 확대됩니다. asynccontextmanager 를 도입하면 다음을 얻습니다.
- 자원 획득/해제 규칙을 함수로 캡슐화
- 성공/실패/취소 경로에서 정리를 강제
- 호출부 코드에서 실수할 여지를 제거
결국 목표는 하나입니다. 세션·락 같은 핵심 자원은 “개발자 기억력”이 아니라 “구조”로 안전하게 만든다. asynccontextmanager 는 그 구조를 가장 저렴하게 제공하는 도구입니다.