Published on

Python async 데코레이터와 @asynccontextmanager 버그 해결

Authors

비동기 코드에서 로깅, 트레이싱, 재시도, 타임아웃 같은 횡단 관심사를 데코레이터로 묶고, 리소스 관리는 @asynccontextmanager로 추상화하는 패턴은 매우 흔합니다. 문제는 이 둘을 “조합”하는 순간, 겉보기엔 정상인데 운영에서만 간헐적으로 깨지는 버그가 자주 발생한다는 점입니다.

대표 증상은 다음과 같습니다.

  • TypeError: '...AsyncGeneratorContextManager' object is not callable 혹은 TypeError: 'coroutine' object is not callable
  • 컨텍스트가 열렸는데 닫히지 않아 커넥션/락이 누수됨
  • CancelledError가 삼켜져서 취소가 전파되지 않거나, 반대로 cleanup이 실행되지 않음
  • 데코레이터가 함수 시그니처를 망가뜨려 DI/프레임워크(FastAPI 등)에서 인자 주입 실패

이 글에서는 “왜 깨지는지”를 재현 코드로 설명하고, 안전하게 고치는 조합 패턴을 제시합니다. 비동기 취소/정리 문제는 다른 런타임에서도 비슷한 형태로 나타납니다. 예를 들어 Kotlin Flow combine 최신 문법과 역압·취소 버그에서 다루는 취소 전파 이슈와 결이 유사합니다.

문제 1: @asynccontextmanager는 “함수”가 아니라 “컨텍스트 팩토리”다

contextlib.asynccontextmanager로 만든 것은 호출하면 “비동기 컨텍스트 매니저 객체”를 반환합니다. 즉, 아래는 정상입니다.

from contextlib import asynccontextmanager

@asynccontextmanager
async def session():
    print("open")
    try:
        yield "S"
    finally:
        print("close")

async def main():
    async with session() as s:
        print(s)

하지만 이걸 데코레이터처럼 함수에 붙이는 순간, 의미가 완전히 달라집니다.

잘못된 예: @session을 데코레이터로 사용

from contextlib import asynccontextmanager

@asynccontextmanager
async def session():
    yield

@session  # 버그: session()이 아니라 session 자체를 데코레이터로 착각
async def handler():
    return 1

여기서 @session은 “함수를 받아서 다른 함수를 반환”하는 데코레이터가 아니라, “컨텍스트 매니저를 만드는 팩토리”이기 때문에 handler 자리에 컨텍스트 매니저가 들어가거나, 호출 시점에 이상한 타입 에러가 발생합니다.

해결: 컨텍스트를 데코레이터로 감싸는 “브리지”를 만든다

컨텍스트 매니저를 이용해 함수를 감싸는 데코레이터는 다음처럼 작성합니다.

import functools
from contextlib import asynccontextmanager


def with_async_cm(cm_factory):
    """`cm_factory()`를 `async with`로 감싸서 실행하는 데코레이터."""
    def decorator(fn):
        @functools.wraps(fn)
        async def wrapper(*args, **kwargs):
            async with cm_factory():
                return await fn(*args, **kwargs)
        return wrapper
    return decorator


@asynccontextmanager
async def session():
    print("open")
    try:
        yield
    finally:
        print("close")


@with_async_cm(session)
async def handler():
    print("work")
    return 1

핵심은 cm_factory()를 “호출”하고, 그 결과를 async with로 감싼다는 점입니다.

문제 2: 데코레이터가 await를 빼먹으면 코루틴이 누수된다

비동기 함수 데코레이터를 작성할 때 가장 흔한 실수는 내부에서 원본 함수를 호출만 하고 await하지 않는 것입니다.

import functools

def bad_deco(fn):
    @functools.wraps(fn)
    async def wrapper(*args, **kwargs):
        # 버그: await가 없음
        result = fn(*args, **kwargs)
        return result
    return wrapper

이 경우 호출자는 코루틴 객체를 받게 되고, 컨텍스트 매니저와 결합되면 더 위험해집니다.

  • async with 블록이 끝나며 리소스를 닫아버림
  • 그런데 실제 작업은 아직 실행되지 않았거나, 나중에 실행되며 “닫힌 리소스”를 사용

해결: 데코레이터는 원본이 async면 반드시 await한다

import functools


def ensure_awaited(fn):
    @functools.wraps(fn)
    async def wrapper(*args, **kwargs):
        return await fn(*args, **kwargs)
    return wrapper

실무에서는 “원본이 sync/async 혼재”할 수 있으니 inspect.iscoroutinefunction 등으로 분기하는 유틸을 두기도 합니다.

문제 3: CancelledError 처리 순서가 cleanup을 깨뜨린다

Python 3.8+에서 asyncio.CancelledErrorException 계열이지만, 취소 전파를 망가뜨리는 패턴이 많습니다.

흔한 버그: 광범위한 예외 처리로 취소를 삼킴

import functools


def swallow_errors(fn):
    @functools.wraps(fn)
    async def wrapper(*args, **kwargs):
        try:
            return await fn(*args, **kwargs)
        except Exception:
            # 버그: CancelledError까지 잡혀서 취소가 전파되지 않을 수 있음
            return None
    return wrapper

여기에 @asynccontextmanager가 붙은 리소스 관리가 섞이면, “취소되었는데도” 정상 종료처럼 보여 cleanup 타이밍이 꼬이거나, 상위 태스크가 취소된 사실을 모른 채 계속 진행합니다.

해결: CancelledError는 재발생시키고, cleanup은 finally에서

import asyncio
import functools


def safe_errors(fn):
    @functools.wraps(fn)
    async def wrapper(*args, **kwargs):
        try:
            return await fn(*args, **kwargs)
        except asyncio.CancelledError:
            # 취소는 반드시 전파
            raise
        except Exception as e:
            # 필요한 로깅 후 처리
            raise
    return wrapper

그리고 컨텍스트 매니저 내부 cleanup은 반드시 finally에 둡니다. except에서 조건부로 닫는 방식은 취소/예외 조합에서 누수가 생깁니다.

문제 4: @asynccontextmanager와 “파라미터 있는 데코레이터”가 충돌하는 방식

실무에서는 보통 다음처럼 “파라미터 있는 데코레이터”를 씁니다.

def traced(name):
    def decorator(fn):
        async def wrapper(*args, **kwargs):
            ...
            return await fn(*args, **kwargs)
        return wrapper
    return decorator

여기에 @asynccontextmanager를 섞어 “컨텍스트를 열고 닫는 traced”를 만들고 싶어집니다. 이때 가장 안전한 방법은 “컨텍스트 팩토리”를 먼저 만들고, 그걸 데코레이터로 브리징하는 것입니다.

권장 패턴: cm_factory + with_async_cm 조합

import functools
from contextlib import asynccontextmanager


def with_async_cm(cm_factory):
    def decorator(fn):
        @functools.wraps(fn)
        async def wrapper(*args, **kwargs):
            async with cm_factory(*args, **kwargs):
                return await fn(*args, **kwargs)
        return wrapper
    return decorator


def traced(name: str):
    @asynccontextmanager
    async def cm(*args, **kwargs):
        print(f"trace start: {name}")
        try:
            yield
        finally:
            print(f"trace end: {name}")

    return with_async_cm(cm)


@traced("handler")
async def handler(x: int) -> int:
    return x + 1

포인트는 두 가지입니다.

  • 컨텍스트는 “함수 호출 시점”의 인자에 접근할 수 있도록 cm(*args, **kwargs) 형태로 설계
  • 데코레이터는 functools.wraps로 메타데이터를 보존(프레임워크 호환성)

문제 5: 컨텍스트 매니저를 “재사용”하려다 생기는 상태 공유 버그

@asynccontextmanager로 만든 컨텍스트 매니저는 대개 “한 번 들어가고 한 번 나오는” 사용을 가정합니다. 그런데 다음처럼 객체를 만들어 재사용하면, 동시 실행에서 상태가 섞입니다.

cm = session()  # 위험: 단일 인스턴스를 공유

async def a():
    async with cm:
        ...

async def b():
    async with cm:
        ...

이건 락/DB 세션/트랜잭션 같은 리소스에서 치명적입니다. 멀티 태스크에서 레이스가 나며, 간헐적 장애로 나타납니다. 이런 “간헐성”은 에이전트/워크플로우 계열에서도 자주 보이는데, AutoGPT 멀티에이전트 레이스·중복 실행 잡기에서 다루는 증상과 유사한 디버깅 난이도를 가집니다.

해결: 컨텍스트는 항상 “팩토리 호출”로 새 인스턴스 생성

  • async with session(): 처럼 매번 호출
  • 데코레이터도 cm_factory()를 매번 호출

통합 예제: “리소스 컨텍스트 + 로깅 데코레이터 + 취소 안전”

아래는 실무에서 바로 가져다 쓸 수 있는 형태로, 다음을 모두 만족합니다.

  • 컨텍스트를 안전하게 열고 닫음
  • 취소는 전파
  • 예외 로깅은 하되 cleanup은 보장
  • 시그니처/메타데이터 보존
import asyncio
import functools
from contextlib import asynccontextmanager


@asynccontextmanager
async def resource(name: str):
    print(f"open {name}")
    try:
        yield {"name": name}
    finally:
        print(f"close {name}")


def use_resource(name: str):
    def decorator(fn):
        @functools.wraps(fn)
        async def wrapper(*args, **kwargs):
            async with resource(name) as r:
                try:
                    return await fn(*args, resource=r, **kwargs)
                except asyncio.CancelledError:
                    # 취소는 반드시 전파
                    raise
                except Exception as e:
                    # 로깅 후 재발생(혹은 정책에 맞게 변환)
                    print(f"error in {fn.__name__}: {e}")
                    raise
        return wrapper
    return decorator


@use_resource("db")
async def handler(x: int, resource=None) -> int:
    await asyncio.sleep(0.01)
    return x + 1

이 패턴의 장점은 “리소스 주입”까지 한 번에 해결된다는 점입니다. FastAPI 같은 프레임워크에선 의존성 주입과 섞일 수 있으니, resource를 키워드 인자로 주입하는 방식은 팀 컨벤션에 맞춰 조정하세요.

테스트로 재발 방지: 누수/취소 전파를 자동 검증하기

버그가 간헐적이라면, 다음 두 가지를 테스트로 고정하는 게 효과적입니다.

  1. 컨텍스트의 finally가 반드시 실행되는지
  2. 취소가 상위로 전파되는지

pytest-asyncio를 쓴다고 가정한 예시입니다.

import asyncio
import pytest
from contextlib import asynccontextmanager


@pytest.mark.asyncio
async def test_cancel_propagates_and_cleanup_runs():
    events = []

    @asynccontextmanager
    async def cm():
        events.append("open")
        try:
            yield
        finally:
            events.append("close")

    async def worker():
        async with cm():
            await asyncio.sleep(10)

    task = asyncio.create_task(worker())
    await asyncio.sleep(0)  # 스케줄링
    task.cancel()

    with pytest.raises(asyncio.CancelledError):
        await task

    assert events == ["open", "close"]

이 테스트가 깨진다면, 데코레이터/예외 처리에서 취소를 삼키고 있거나, finally가 아닌 경로로 cleanup을 하고 있을 가능성이 큽니다.

체크리스트: 조합 버그를 빠르게 찾는 7가지 질문

  • @asynccontextmanager로 만든 것을 데코레이터로 직접 붙이지 않았나
  • 컨텍스트 매니저 인스턴스를 전역/클로저에 저장해 재사용하고 있지 않나
  • 데코레이터 내부에서 원본 async 함수를 await하고 있나
  • except Exception에서 CancelledError까지 잡아먹고 있지 않나
  • cleanup이 finally에 있는가(조건부 close가 아닌가)
  • functools.wraps로 시그니처/메타데이터를 보존했나
  • 동시 실행 시 컨텍스트 내부 상태(예: 현재 세션)가 공유되지 않나

마무리

async 데코레이터와 @asynccontextmanager는 각각은 단순하지만, 결합하면 “호출 시점”과 “진입/종료 시점”이 엇갈리면서 타입 에러, 누수, 취소 전파 실패 같은 버그가 쉽게 생깁니다. 해결의 핵심은 컨텍스트를 데코레이터처럼 직접 쓰지 말고, cm_factory()를 매번 호출해 async with로 감싸는 브리지 패턴을 표준화하는 것입니다.

운영에서만 터지는 간헐적 장애는 대개 취소/동시성/정리 타이밍 문제로 귀결됩니다. 비슷한 종류의 “재검증 타이밍” 문제를 다룬 글로는 Next.js App Router 캐시 꼬임·재검증 버그 해결도 참고할 만합니다. 비동기 런타임이 달라도, 타이밍 버그를 다루는 접근은 놀랄 만큼 닮아 있습니다.