- Published on
Python async Context Manager 실수 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 개발에서 async with 는 DB 커넥션, HTTP 세션, 락, 트레이싱 스팬 같은 “반드시 정리돼야 하는 자원”을 안전하게 다루는 핵심 도구입니다. 그런데 동기 with 감각으로 작성하면, 에러가 안 나더라도 조용히 누수되거나 타임아웃이 늘고, 취소(CancelledError) 상황에서 정리가 누락되는 문제가 생깁니다.
이 글은 Python async context manager에서 실무에서 가장 자주 보는 실수 7가지를 짚고, 어떤 코드가 위험한지와 어떻게 고쳐야 하는지를 예제로 정리합니다.
async Context Manager 기본 동작 다시보기
비동기 컨텍스트 매니저는 __aenter__ / __aexit__ 를 구현합니다.
async with cm as x:진입 시await cm.__aenter__()- 블록 종료 시(정상/예외/취소 포함)
await cm.__aexit__(exc_type, exc, tb)
즉, __aexit__ 는 “정리 로직”이 반드시 실행되는 마지막 방어선입니다. 여기서의 실수는 곧 자원 누수나 장애로 이어집니다.
실수 1) with 와 async with 를 혼용하기
동기 컨텍스트 매니저와 비동기 컨텍스트 매니저를 섞어 쓰다 보면, 다음과 같은 코드가 나옵니다.
# 잘못된 예: 비동기 리소스인데 with 사용
with aiohttp.ClientSession() as session:
...
aiohttp.ClientSession() 은 비동기 컨텍스트 매니저이므로 async with 가 필요합니다.
import aiohttp
async def fetch(url: str) -> str:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
return await resp.text()
반대로, 동기 컨텍스트 매니저를 async with 로 감싸는 것도 안 됩니다. 동기 리소스(예: open)는 with 를 쓰거나, 비동기 파일 IO 라이브러리를 사용하세요.
실수 2) __aenter__ 에서 “자원 자체”를 반환하지 않기
async with 는 보통 “관리되는 자원”을 as 로 받습니다. 그런데 __aenter__ 에서 실수로 self 나 엉뚱한 값을 반환하면, 호출부가 예상과 달라져 버그가 생깁니다.
class DBSessionManager:
def __init__(self, engine):
self.engine = engine
self.session = None
async def __aenter__(self):
self.session = await self.engine.open_session()
return self # 흔한 실수: 호출부는 session을 기대함
async def __aexit__(self, exc_type, exc, tb):
await self.session.close()
호출부:
async with DBSessionManager(engine) as session:
await session.execute("SELECT 1") # 여기서 session은 매니저 객체라서 실패
올바른 패턴은 __aenter__ 에서 “사용할 자원”을 반환하는 것입니다.
class DBSessionManager:
def __init__(self, engine):
self.engine = engine
self.session = None
async def __aenter__(self):
self.session = await self.engine.open_session()
return self.session
async def __aexit__(self, exc_type, exc, tb):
if self.session is not None:
await self.session.close()
실수 3) __aexit__ 에서 예외를 삼켜서 장애를 숨기기
__aexit__ 가 True 를 반환하면 예외가 억제됩니다. 이게 의도된 경우도 있지만(특정 예외만 무시), 대부분은 장애를 숨기는 결과를 만듭니다.
class SwallowAll:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
# 잘못된 예: 모든 예외를 무조건 무시
return True
이렇게 하면 DB 트랜잭션 실패, 네트워크 오류 등이 “성공한 것처럼” 보이고, 데이터 정합성이 깨집니다.
권장 패턴:
- 기본적으로
False(또는None) 반환 - 정말 필요한 경우에만 특정 예외만 억제
class IgnoreTimeoutOnly:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
if exc_type is TimeoutError:
return True
return False
실수 4) 취소(CancelledError)를 고려하지 않고 정리 로직이 중단되게 두기
비동기 환경에서는 작업이 언제든 취소될 수 있습니다. 문제는 __aexit__ 내부의 await 도 취소에 의해 중단될 수 있다는 점입니다. 그러면 “정리하다 말고” 끝나서 커넥션이 남거나 락이 풀리지 않을 수 있습니다.
해결책은 정리 구간을 asyncio.shield 로 보호하거나, 최소한 “반드시 실행되어야 하는 정리”를 취소로부터 안전하게 만드는 것입니다.
import asyncio
class SafeCloser:
def __init__(self, resource):
self.resource = resource
async def __aenter__(self):
await self.resource.open()
return self.resource
async def __aexit__(self, exc_type, exc, tb):
# 정리 로직은 취소로 중단되지 않게 보호
await asyncio.shield(self.resource.close())
return False
주의할 점:
shield는 “취소 전파를 막는” 도구이지, 무한정 걸리는 close를 해결해주지는 않습니다.- close가 오래 걸릴 수 있다면 타임아웃을 함께 설계해야 합니다.
타임아웃 설계는 RPC/HTTP에서도 동일하게 중요합니다. 데드라인 전파 관점은 이 글과 결이 비슷합니다: gRPC 타임아웃 지옥 탈출 - 데드라인 전파 설계
실수 5) 컨텍스트 매니저를 재사용하면서 내부 상태를 공유하기
컨텍스트 매니저 인스턴스를 전역으로 만들어 재사용하면, 동시에 여러 코루틴이 같은 인스턴스를 async with 로 사용하면서 상태가 섞일 수 있습니다.
# 잘못된 예: 전역 인스턴스 재사용
cm = DBSessionManager(engine)
async def handler():
async with cm as session:
...
DBSessionManager 가 self.session 같은 상태를 갖고 있으면 레이스 컨디션이 발생합니다.
해결책:
- 컨텍스트 매니저는 보통 “매번 새로 생성”
- 또는 상태를 인스턴스가 아니라 “컨텍스트 로컬”에 두기(
contextvars)
async def handler():
async with DBSessionManager(engine) as session:
...
실수 6) @asynccontextmanager 에서 yield 이후 정리 코드가 예외로 스킵되는 줄 착각하기
contextlib.asynccontextmanager 를 쓰면 구현이 간단해지지만, yield 이후가 사실상 __aexit__ 역할을 합니다. 여기서 예외/취소 상황을 제대로 처리하지 않으면 정리가 누락될 수 있습니다.
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan_resource():
r = await acquire()
try:
yield r
finally:
await release(r)
핵심은 try/finally 입니다. 이를 빼먹으면 아래처럼 됩니다.
from contextlib import asynccontextmanager
@asynccontextmanager
async def broken():
r = await acquire()
yield r
# 잘못된 예: 예외/취소가 나면 여기까지 도달하지 못함
await release(r)
또 한 가지 포인트는 yield 이후 정리 코드에서도 예외가 날 수 있다는 점입니다. 정리 중 예외가 발생하면 원래 예외를 덮어써서 디버깅이 어려워질 수 있으니, 정리 예외를 로깅하고 원래 예외를 보존할지 정책을 정하세요.
실수 7) 동시성 제어를 async with Lock() 로 매번 새로 만들어 무력화하기
락을 걸었다고 생각하지만 실제로는 아무 효과가 없는 전형적인 실수입니다.
import asyncio
# 잘못된 예: 매번 새 Lock을 생성하면 서로 다른 락이라 동기화가 안 됨
async def increment(counter: dict):
async with asyncio.Lock():
counter["n"] += 1
올바른 패턴은 공유 락을 재사용하는 것입니다.
import asyncio
lock = asyncio.Lock()
async def increment(counter: dict):
async with lock:
counter["n"] += 1
더 나아가, 락을 “전역”으로 두기 어렵다면 클래스 필드나 DI 컨테이너에 넣고 생명주기를 명확히 하세요.
실전 점검 체크리스트
운영에서 문제가 생겼을 때는 다음을 빠르게 확인하면 원인 범위를 줄일 수 있습니다.
async with블록에서 예외가 발생했을 때__aexit__가 실제로 호출되는지(로그/트레이싱)__aexit__내부의 close가 취소로 중단될 수 있는지- 컨텍스트 매니저 인스턴스가 공유되고 있지는 않은지(특히
self.*상태) __aexit__가 실수로True를 반환해 예외를 숨기고 있지 않은지@asynccontextmanager에서try/finally가 있는지
비동기 자원 누수는 종종 “직접적인 에러”가 아니라 지표 악화로 나타납니다. 예를 들어 로그가 폭주하거나 디스크가 차는 형태로 증상이 드러날 수 있는데, 이런 운영 관점 트러블슈팅은 다음 글도 함께 참고할 만합니다: Linux journalctl 로그 폭주로 디스크 100% 찰 때
예제: 안전한 DB 트랜잭션 async Context Manager
마지막으로 실무에서 많이 쓰는 “트랜잭션 스코프” 예제를 안전하게 구성해 보겠습니다.
요구사항:
- 정상 종료면
commit - 예외면
rollback - 어떤 경우에도 세션 close
- close/rollback/commit은 취소에 의해 중단되지 않게 방어
import asyncio
class Transaction:
def __init__(self, engine):
self.engine = engine
self.session = None
async def __aenter__(self):
self.session = await self.engine.open_session()
await self.session.begin()
return self.session
async def __aexit__(self, exc_type, exc, tb):
try:
if exc_type is None:
await asyncio.shield(self.session.commit())
else:
await asyncio.shield(self.session.rollback())
finally:
await asyncio.shield(self.session.close())
return False
이 패턴의 장점은 “업무 로직은 깔끔하게, 정리는 일관되게” 유지된다는 점입니다.
async def create_user(engine, user):
async with Transaction(engine) as session:
await session.insert_user(user)
await session.insert_audit_log({"action": "create_user"})
마무리
async with 는 단순한 문법 설탕이 아니라, 비동기 환경에서 자원 생명주기와 실패 모델(예외/취소/타임아웃)을 코드로 강제하는 장치입니다. 위 7가지 실수는 대부분 “당장 에러가 안 나서” 더 위험합니다.
__aenter__는 사용자가 쓸 자원을 반환__aexit__는 예외를 숨기지 말고, 취소에도 정리를 보장- 인스턴스 재사용과 공유 상태는 신중히
@asynccontextmanager는try/finally가 필수
이 원칙만 지켜도 비동기 서비스의 안정성이 눈에 띄게 올라갑니다.