Published on

async with가 안 되는 이유 - __aenter__·__aexit__

Authors

서버 코드나 크롤러, 비동기 I/O를 다루다 보면 async with 를 자연스럽게 쓰게 됩니다. DB 커넥션, HTTP 세션, 락, 트랜잭션처럼 “획득하고, 작업하고, 반드시 정리해야 하는” 자원에 특히 잘 맞기 때문입니다.

그런데 막상 적용하려고 하면 아래 같은 에러를 자주 만납니다.

  • TypeError: 'X' object does not support the asynchronous context manager protocol
  • TypeError: object __aenter__ must return an awaitable
  • AttributeError: __aenter__

결론부터 말하면, async with 가 동작하려면 객체가 비동기 컨텍스트 매니저 프로토콜을 만족해야 하고, 그 핵심이 __aenter____aexit__ 입니다. 이 글에서는 “왜 안 되는지”를 파이썬이 실제로 무엇을 기대하는지 관점에서 분해하고, 실전에서 안전하게 구현하는 패턴까지 정리합니다.

참고로 비동기 자원 관리는 운영 환경에서 권한/격리/정리와도 직결됩니다. 도구 연결과 권한 격리를 다룬 글도 함께 보면 관점이 확장됩니다: AutoGPT에 MCP 붙여 도구연결·권한격리 구현

withasync 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.asynccontextmanagertry/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)을 따라야 합니다.

디버깅 체크리스트

문제가 생기면 아래를 순서대로 확인하면 빠릅니다.

  1. type(obj) 는 무엇인가 (팩토리가 엉뚱한 것을 반환하지 않는가)
  2. hasattr(obj, "__aenter__")hasattr(obj, "__aexit__")True 인가
  3. obj.__aenter__obj.__aexit__async def 인가(최소한 awaitable 반환인가)
  4. __aexit__ 시그니처가 (exc_type, exc, tb) 를 받는가
  5. 예외 억제(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% 단축