Published on

Python async Context Manager 실수 7가지

Authors

서버 개발에서 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) withasync 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:
        ...

DBSessionManagerself.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__ 는 예외를 숨기지 말고, 취소에도 정리를 보장
  • 인스턴스 재사용과 공유 상태는 신중히
  • @asynccontextmanagertry/finally 가 필수

이 원칙만 지켜도 비동기 서비스의 안정성이 눈에 띄게 올라갑니다.