- Published on
async with가 안 되는 이유 - __aenter__·__aexit__
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드나 크롤러, 비동기 I/O를 다루다 보면 async with 를 자연스럽게 쓰게 됩니다. DB 커넥션, HTTP 세션, 락, 트랜잭션처럼 “획득하고, 작업하고, 반드시 정리해야 하는” 자원에 특히 잘 맞기 때문입니다.
그런데 막상 적용하려고 하면 아래 같은 에러를 자주 만납니다.
TypeError: 'X' object does not support the asynchronous context manager protocolTypeError: object __aenter__ must return an awaitableAttributeError: __aenter__
결론부터 말하면, async with 가 동작하려면 객체가 비동기 컨텍스트 매니저 프로토콜을 만족해야 하고, 그 핵심이 __aenter__ 와 __aexit__ 입니다. 이 글에서는 “왜 안 되는지”를 파이썬이 실제로 무엇을 기대하는지 관점에서 분해하고, 실전에서 안전하게 구현하는 패턴까지 정리합니다.
참고로 비동기 자원 관리는 운영 환경에서 권한/격리/정리와도 직결됩니다. 도구 연결과 권한 격리를 다룬 글도 함께 보면 관점이 확장됩니다: AutoGPT에 MCP 붙여 도구연결·권한격리 구현
with 와 async with 는 프로토콜이 다르다
동기 컨텍스트 매니저는 아래 두 메서드를 구현합니다.
__enter__(self) -> Any__exit__(self, exc_type, exc, tb) -> bool | None
비동기 컨텍스트 매니저는 이름만 a 가 붙는 게 아니라, 반환 타입 요구사항이 다릅니다.
__aenter__(self) -> Awaitable[Any]__aexit__(self, exc_type, exc, tb) -> Awaitable[bool | None]
즉 async with 는 내부적으로 await obj.__aenter__() 와 await obj.__aexit__(...) 를 호출할 수 있어야 합니다.
파이썬이 async with 를 어떻게 풀어쓰는가
다음 코드를 보겠습니다.
async with cm as value:
await do_something(value)
위 코드는 개념적으로 아래와 비슷하게 동작합니다.
cm_obj = cm
value = await cm_obj.__aenter__()
try:
await do_something(value)
except BaseException as e:
suppress = await cm_obj.__aexit__(type(e), e, e.__traceback__)
if not suppress:
raise
else:
await cm_obj.__aexit__(None, None, None)
여기서 보듯 __aenter__ 와 __aexit__ 는 반드시 await 가능한 것을 반환해야 합니다. 이 지점이 대부분의 실패 원인입니다.
에러 메시지로 원인 빠르게 찾기
1) ... does not support the asynchronous context manager protocol
가장 흔합니다. 의미는 단순합니다.
- 객체에
__aenter__또는__aexit__가 없다 - 또는 이름은 있는데 비동기 프로토콜로 인식되지 않는다
예시: 동기 컨텍스트 매니저를 async with 로 사용한 경우
from contextlib import contextmanager
@contextmanager
def sync_cm():
yield "ok"
async def main():
async with sync_cm() as v:
print(v)
sync_cm() 는 __enter__/__exit__ 만 제공하므로 async with 로는 사용할 수 없습니다.
해결은 두 가지 중 하나입니다.
- 동기라면
with를 쓰기 - 비동기 자원이라면
@asynccontextmanager로 만들기
from contextlib import asynccontextmanager
@asynccontextmanager
async def async_cm():
yield "ok"
async def main():
async with async_cm() as v:
print(v)
2) object __aenter__ must return an awaitable
이 에러는 “메서드는 있는데 await 할 수 없다”는 뜻입니다.
대표 실수는 __aenter__ 를 def 로 만들어서 일반 값을 반환하는 경우입니다.
class Bad:
def __aenter__(self):
return self # await 불가
async def __aexit__(self, exc_type, exc, tb):
return None
async def main():
async with Bad():
pass
해결: __aenter__ 도 async def 로 만들고, 필요한 비동기 준비 작업을 수행한 뒤 반환합니다.
class Good:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
3) AttributeError: __aenter__
이건 더 직접적입니다. 해당 객체에 __aenter__ 속성이 없습니다.
- 라이브러리 객체가 비동기 컨텍스트 매니저를 지원하지 않는데
async with로 감쌌다 - 팩토리 함수가 실제 컨텍스트 매니저가 아닌 다른 타입을 반환한다
예: async with make_client(): 를 기대했는데 make_client() 가 None 을 반환하는 버그
def make_client():
return None
async def main():
async with make_client():
pass
이 경우는 구현을 추적해서 “컨텍스트 매니저를 반환하는지”부터 확인해야 합니다.
__aenter__·__aexit__ 제대로 구현하기
직접 클래스로 구현할 때 가장 중요한 포인트는 아래입니다.
__aenter__에서 자원 획득(연결 열기, 락 획득 등)__aexit__에서 예외가 있든 없든 정리(연결 닫기, 락 해제 등)__aexit__의 반환값으로 예외 억제 여부를 제어
예제: 비동기 리소스(가짜 커넥션) 안전하게 닫기
class AsyncResource:
def __init__(self):
self.opened = False
async def open(self):
self.opened = True
async def close(self):
self.opened = False
async def __aenter__(self):
await self.open()
return self
async def __aexit__(self, exc_type, exc, tb):
await self.close()
# 예외를 숨기지 않음
return False
async def main():
async with AsyncResource() as r:
assert r.opened is True
# 블록을 나가면 close 호출
__aexit__ 가 예외를 “삼키는” 조건
__aexit__ 가 True 를 반환하면 예외를 억제합니다. 실무에서는 신중해야 합니다.
class SuppressKeyError:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return exc_type is KeyError
async def main():
async with SuppressKeyError():
{}["missing"] # KeyError지만 억제됨
print("still running")
예외를 숨기면 장애 탐지가 늦어지기 때문에, 정말 의도한 경우에만 사용하세요.
contextlib.asynccontextmanager 가 더 안전한 이유
직접 __aenter__·__aexit__ 를 구현하면 유연하지만, 실수할 여지도 큽니다. 특히 “중간에 예외가 나면 정리가 되나?” 같은 조건을 매번 검증해야 합니다.
contextlib.asynccontextmanager 는 try/finally 구조를 강제해 정리 누락을 줄여줍니다.
from contextlib import asynccontextmanager
@asynccontextmanager
async def managed_resource():
# acquire
res = AsyncResource()
await res.open()
try:
yield res
finally:
# release
await res.close()
async def main():
async with managed_resource() as r:
assert r.opened
이 패턴은 “정리 코드가 반드시 실행되는가”라는 질문에 대해 가장 강한 보장을 제공합니다.
자주 하는 실수 5가지
1) __aexit__ 시그니처를 잘못 구현
__aexit__ 는 인자 3개를 받습니다. 라이브러리/프레임워크에서 호출할 때 이 시그니처를 기대합니다.
class BadExit:
async def __aenter__(self):
return self
async def __aexit__(self): # 인자 누락
return None
올바른 형태:
class GoodExit:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
2) __aenter__ 에서 self 대신 잘못된 객체 반환
as x 로 바인딩되는 값은 __aenter__ 의 반환값입니다. 종종 내부 핸들(세션, 커서)을 반환해야 하는데 self 를 반환해 사용처가 꼬입니다.
class SessionWrapper:
def __init__(self, session):
self._session = session
async def __aenter__(self):
return self._session # 사용자는 session을 받게 됨
async def __aexit__(self, exc_type, exc, tb):
await self._session.close()
3) __aexit__ 에서 예외 정보를 무시하고 로깅/정리를 누락
예외가 발생했을 때만 추가 정리가 필요한 리소스도 있습니다(트랜잭션 롤백 등). exc_type 를 보고 분기하세요.
class Tx:
async def __aenter__(self):
await self.begin()
return self
async def __aexit__(self, exc_type, exc, tb):
if exc_type is None:
await self.commit()
else:
await self.rollback()
return False
async def begin(self):
...
async def commit(self):
...
async def rollback(self):
...
이런 “성공 시 커밋, 실패 시 롤백” 패턴은 트랜잭션 전파/롤백 함정과도 맞닿아 있습니다. 자바 진영의 사례지만 사고 방식은 비슷합니다: Spring Boot 3 @Transactional 전파·롤백 함정
4) 동기 락을 async with 로 감싸기
threading.Lock 같은 동기 락은 async with 로 못 씁니다. asyncio.Lock 를 사용해야 합니다.
import asyncio
lock = asyncio.Lock()
async def main():
async with lock:
# critical section
await asyncio.sleep(0.1)
5) “비동기 함수 호출 결과”와 “비동기 컨텍스트 매니저”를 혼동
예를 들어 어떤 라이브러리는 connect() 가 코루틴이고, 그 결과로 컨텍스트 매니저가 아니라 “연결 객체”를 반환할 수 있습니다. 이때는 await connect() 와 async with 의 순서를 분리해야 합니다.
conn = await connect()
async with conn: # conn이 __aenter__/__aexit__를 제공할 때만 가능
...
만약 conn 이 컨텍스트 매니저가 아니라면, 그 라이브러리가 제공하는 올바른 사용법(예: async with connect(...) as conn)을 따라야 합니다.
디버깅 체크리스트
문제가 생기면 아래를 순서대로 확인하면 빠릅니다.
type(obj)는 무엇인가 (팩토리가 엉뚱한 것을 반환하지 않는가)hasattr(obj, "__aenter__")와hasattr(obj, "__aexit__")가True인가obj.__aenter__와obj.__aexit__가async def인가(최소한 awaitable 반환인가)__aexit__시그니처가(exc_type, exc, tb)를 받는가- 예외 억제(
return True)가 의도치 않게 켜져 있지 않은가
간단한 런타임 점검 코드도 도움이 됩니다.
import inspect
def debug_async_cm(obj):
print("type:", type(obj))
print("has __aenter__:", hasattr(obj, "__aenter__"))
print("has __aexit__:", hasattr(obj, "__aexit__"))
if hasattr(obj, "__aenter__"):
print("__aenter__ is coroutinefunction:", inspect.iscoroutinefunction(obj.__aenter__))
if hasattr(obj, "__aexit__"):
print("__aexit__ is coroutinefunction:", inspect.iscoroutinefunction(obj.__aexit__))
정리
async with 가 안 되는 이유는 대부분 “비동기 컨텍스트 매니저 프로토콜을 만족하지 못해서”입니다. 즉 객체에 __aenter__·__aexit__ 가 없거나, 있어도 awaitable 을 반환하지 않거나, 시그니처/반환값(예외 억제)이 잘못되어 런타임이 기대한 동작을 하지 못합니다.
실무에서는 가능하면 contextlib.asynccontextmanager 를 우선 고려하고, 클래스로 구현해야 한다면 __aenter__ 에서 획득, __aexit__ 에서 정리, 예외 억제는 최소화라는 원칙을 지키는 편이 안전합니다.
비동기 자원 관리는 결국 “정리의 신뢰성” 싸움이고, 이 신뢰성이 쌓여야 CI나 운영 자동화에서도 예측 가능한 동작을 얻을 수 있습니다. 자동화 파이프라인 자체를 최적화하는 관점이 궁금하다면 다음 글도 같이 읽어볼 만합니다: GitHub Actions 병렬·매트릭스로 CI 50% 단축