Published on

Python async 데코레이터·asynccontextmanager 함정 7가지

Authors

서버 사이드 파이썬에서 asyncio 기반으로 I/O를 병렬화하다 보면, 공통 로깅/트레이싱/락/트랜잭션을 묶기 위해 async 데코레이터와 contextlib.asynccontextmanager를 적극 쓰게 됩니다. 문제는 이 둘이 “겉보기엔 단순”하지만, 예외·취소·동시성·자원 수명 관리가 얽히는 순간 아주 쉽게 함정으로 빠진다는 점입니다.

이 글은 프로덕션에서 자주 터지는 패턴을 기준으로, async 데코레이터와 asynccontextmanager의 대표 함정 7가지를 정리하고, 바로 복붙 가능한 안전 패턴을 함께 제공합니다. 분산 추적을 붙이는 경우라면 OpenTelemetry로 MSA 분산 트랜잭션 추적 실전도 같이 읽으면 연결이 잘 됩니다.

준비: 예제에서 사용할 기본 도구

import asyncio
import contextlib
import functools
import time


def now_ms() -> int:
    return int(time.time() * 1000)

함정 1) 데코레이터가 코루틴을 반환하는데 await를 빼먹음

가장 흔한 실수입니다. 데코레이터 내부에서 원본 함수를 호출하고 결과를 그대로 반환하면, 원본이 async def인 경우 “코루틴 객체”가 반환됩니다. 이 상태로 호출자가 await를 하지 않으면 경고(coroutine was never awaited) 또는 조용한 논리 오류가 납니다.

나쁜 예

def bad_deco(fn):
    @functools.wraps(fn)
    async def wrapper(*args, **kwargs):
        # 실수: await 없이 코루틴을 리턴
        return fn(*args, **kwargs)
    return wrapper


@bad_deco
async def work():
    await asyncio.sleep(0.1)
    return 1

안전 패턴

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

추가로, 데코레이터를 “sync 함수에도 적용 가능하게” 만들고 싶다면, inspect.iscoroutinefunction으로 분기하거나 애초에 적용 대상을 명확히 제한하세요. “둘 다 지원”은 대개 더 큰 함정(함정 2)으로 이어집니다.

함정 2) sync/async 겸용 데코레이터를 어설프게 만들면 호출 규약이 깨짐

많은 팀이 같은 데코레이터를 sync 함수와 async 함수에 모두 붙이고 싶어 합니다. 그런데 wrapper를 async def로 만들면 sync 함수도 코루틴처럼 변해버립니다. 반대로 wrapper를 def로 만들면 async 함수에서 await 체인이 깨집니다.

권장: 명시적으로 분리

import inspect


def timing(fn):
    if inspect.iscoroutinefunction(fn):
        @functools.wraps(fn)
        async def aw(*args, **kwargs):
            start = now_ms()
            try:
                return await fn(*args, **kwargs)
            finally:
                print(f"{fn.__name__} took {now_ms() - start}ms")
        return aw

    @functools.wraps(fn)
    def sw(*args, **kwargs):
        start = now_ms()
        try:
            return fn(*args, **kwargs)
        finally:
            print(f"{fn.__name__} took {now_ms() - start}ms")
    return sw

이 패턴은 “호출자가 기대하는 타입”을 유지합니다. 즉, sync 함수는 여전히 sync, async 함수는 여전히 async로 남습니다.

함정 3) finally가 있어도 취소(CancelledError)를 잘못 처리하면 장애가 커짐

asyncio에서 태스크 취소는 예외로 전파됩니다. 과거엔 CancelledErrorException을 상속했기 때문에 except Exception으로 잡히는 경우가 많았고, 최근 버전에서는 상속 관계가 달라지며(환경에 따라) 더 혼란스러워졌습니다.

핵심은 두 가지입니다.

  • 취소는 “에러”가 아니라 “제어 흐름”인 경우가 많습니다.
  • 취소를 잡았다면 대개 다시 raise 해야 합니다(취소 전파).

나쁜 예: 취소를 삼켜서 태스크가 계속 살아있다고 오해

def swallow_cancel(fn):
    @functools.wraps(fn)
    async def wrapper(*args, **kwargs):
        try:
            return await fn(*args, **kwargs)
        except Exception as e:
            # CancelledError까지 잡힐 수 있고, 여기서 로그만 찍고 끝내면
            # 상위에서는 취소가 성공한 줄 아는데 실제로는 흐름이 꼬일 수 있음
            print("error:", e)
            return None
    return wrapper

안전 패턴: 취소는 재전파

def safe_errors(fn):
    @functools.wraps(fn)
    async def wrapper(*args, **kwargs):
        try:
            return await fn(*args, **kwargs)
        except asyncio.CancelledError:
            # 정리 로그 정도만 하고 반드시 재전파
            print("cancelled")
            raise
        except Exception:
            # 여기서 필요한 변환/로깅
            raise
    return wrapper

추적/로깅을 붙이는 목적이라면, except보다 try/finally 중심으로 설계하는 편이 취소 시나리오에서 더 안전합니다.

함정 4) asynccontextmanager에서 yield 전에 예외가 나면 정리가 안 된다고 착각

@asynccontextmanager는 내부적으로 yield 기준으로 “진입”과 “종료”를 나눕니다.

  • yield 이전: __aenter__ 구간
  • yield 이후: __aexit__ 구간

yield 이전에 예외가 나면, 종료 구간이 실행되지 않습니다. 즉, “yield 전에 확보한 자원”이 있다면 그 자원은 반드시 yield 이전 단계에서 자체적으로 정리되거나, 확보 순서를 바꿔야 합니다.

나쁜 예: yield 전에 자원을 잡고, 같은 함수의 finally에만 정리를 기대

@contextlib.asynccontextmanager
async def bad_resource():
    lock = asyncio.Lock()
    await lock.acquire()          # 여기서 잡음
    # 여기서 예외가 나면 yield 이후 정리 구간이 아예 실행되지 않음
    raise RuntimeError("boom")
    try:
        yield
    finally:
        lock.release()

안전 패턴: yield 전 실패 가능 구간을 최소화

@contextlib.asynccontextmanager
async def resource_lock(lock: asyncio.Lock):
    await lock.acquire()
    try:
        yield
    finally:
        lock.release()

그리고 yield 전에 예외가 날 수 있는 로직(예: 네트워크 핸드셰이크, 설정 로드, 인증)은 가능하면 yield 이후로 옮기거나, “실패 시 즉시 정리 가능한 구조”로 분리하세요.

함정 5) asynccontextmanager에서 예외를 먹어버려서 장애가 조용히 누락됨

asynccontextmanageryield 이후 구간에서 예외를 처리할 수 있습니다. 여기서 예외를 잡고 아무것도 하지 않으면, 호출자는 정상 종료로 인식합니다. 특히 트랜잭션/락/세마포어 같은 제어 구조에서 이 실수는 치명적입니다.

나쁜 예: 예외를 삼키고 종료

@contextlib.asynccontextmanager
async def swallow_errors():
    try:
        yield
    except Exception as e:
        print("ignored:", e)
        # 예외를 다시 던지지 않음

안전 패턴: 예외는 기록하되 전파

@contextlib.asynccontextmanager
async def log_and_reraise(name: str):
    try:
        yield
    except asyncio.CancelledError:
        print(f"{name}: cancelled")
        raise
    except Exception as e:
        print(f"{name}: error {e!r}")
        raise

실제로는 로깅 대신 OpenTelemetry span status를 세팅하거나, 메트릭을 올린 뒤 재전파하는 형태가 많이 쓰입니다. 이 흐름은 OpenTelemetry로 MSA 분산 트랜잭션 추적 실전에서 소개하는 패턴과도 잘 맞습니다.

함정 6) 데코레이터에서 태스크를 “백그라운드로 던져놓고” 정리를 안 함

데코레이터로 “부가 작업(메트릭 flush, 로그 전송, 캐시 갱신)”을 asyncio.create_task로 던지고 본 작업만 반환하는 경우가 있습니다. 이때 태스크 참조를 저장하지 않으면 다음 문제가 생깁니다.

  • 예외가 발생해도 어디에도 전파되지 않음(조용히 Task exception was never retrieved)
  • 종료 시점에 태스크가 살아있어 리소스 누수/경고
  • 테스트에서 간헐적 실패(이전 테스트의 백그라운드 태스크가 다음 테스트에 영향)

나쁜 예

def fire_and_forget(fn):
    @functools.wraps(fn)
    async def wrapper(*args, **kwargs):
        asyncio.create_task(asyncio.sleep(0.01))
        return await fn(*args, **kwargs)
    return wrapper

안전 패턴: 태스크 수명 관리(최소한 예외 회수)

def background(fn):
    @functools.wraps(fn)
    async def wrapper(*args, **kwargs):
        t = asyncio.create_task(asyncio.sleep(0.01))
        try:
            return await fn(*args, **kwargs)
        finally:
            # 끝까지 기다릴 필요가 없더라도, 예외는 회수
            if not t.done():
                t.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await t
    return wrapper

정말로 “요청 수명과 분리된 백그라운드 작업”이 필요하다면, 데코레이터가 아니라 작업 큐(예: Celery, Redis stream)나 명시적인 백그라운드 워커로 분리하는 편이 예측 가능성이 높습니다. 캐시/키 트래픽 문제를 다루는 경우 Redis 핫키로 QPS 폭주? LFU로 5분 진단 같은 운영 관점 글도 같이 보면 도움이 됩니다.

함정 7) asynccontextmanager를 데코레이터처럼 쓰면서 “중첩 순서”를 잘못 잡음

asynccontextmanagerasync with로 자원 수명을 명확히 할 때 강력합니다. 그런데 이를 데코레이터처럼 감싸서 여러 개를 중첩하다 보면, 진입/종료 순서가 의도와 다르게 되어 락 경합, 트랜잭션 범위 오류, 타임아웃 증가 같은 문제가 생깁니다.

중첩 규칙은 단순합니다.

  • 진입은 위에서 아래 순서
  • 종료는 아래에서 위 순서(스택 언와인딩)

예를 들어 “트랜잭션을 열고 그 안에서 락을 잡는다”가 의도라면, 반대로 “락을 잡고 트랜잭션을 여는” 형태가 되지 않게 구조를 강제해야 합니다.

안전 패턴: 컨텍스트를 한 곳에서 선언해 순서를 고정

@contextlib.asynccontextmanager
async def transaction(name: str):
    print("tx begin", name)
    try:
        yield
        print("tx commit", name)
    except Exception:
        print("tx rollback", name)
        raise


@contextlib.asynccontextmanager
async def locked(lock: asyncio.Lock):
    await lock.acquire()
    try:
        yield
    finally:
        lock.release()


async def handler(lock: asyncio.Lock):
    async with transaction("order"), locked(lock):
        # 진입: transaction -> locked
        # 종료: locked -> transaction
        await asyncio.sleep(0.01)

데코레이터로 합치고 싶다면: “합성 데코레이터”에서 순서를 명시

def with_tx_and_lock(lock: asyncio.Lock):
    def deco(fn):
        @functools.wraps(fn)
        async def wrapper(*args, **kwargs):
            async with transaction(fn.__name__), locked(lock):
                return await fn(*args, **kwargs)
        return wrapper
    return deco

이렇게 하면 호출부에서 async with를 매번 쓰지 않아도 되지만, 중요한 것은 “중첩 순서를 wrapper 내부에서 고정”한다는 점입니다.

실전 체크리스트(요약)

  • 데코레이터 wrapper에서 원본이 async면 반드시 await로 호출한다.
  • sync/async 겸용 데코레이터는 분기 구현으로 호출 규약을 보존한다.
  • CancelledError는 로깅만 하고 삼키지 말고 재전파한다.
  • asynccontextmanageryield 이전 실패가 정리 구간을 건너뛴다는 점을 전제로 설계한다.
  • 컨텍스트 종료 구간에서 예외를 먹지 말고 전파한다.
  • create_task를 쓰면 태스크 수명과 예외 회수 전략을 반드시 포함한다.
  • 여러 컨텍스트를 중첩할 때 진입/종료 순서를 코드 한 곳에서 고정한다.

마무리: “편의 기능”일수록 수명 관리가 핵심

async 데코레이터와 asynccontextmanager는 코드 중복을 줄이는 강력한 도구지만, 결국은 “자원 수명(락, 커넥션, 트랜잭션, span)”을 다루는 문법 설탕입니다. 수명 관리가 흐려지는 순간, 장애는 대개 간헐적이고 재현이 어렵게 나타납니다.

특히 관측 가능성(트레이싱/메트릭/로그)을 붙이려는 목적이라면, 예외/취소/타임아웃이 섞인 경로에서 절대 정보를 잃지 않도록 설계해야 합니다. 분산 추적을 실제 운영에 적용 중이라면 OpenTelemetry로 MSA 분산 트랜잭션 추적 실전를 함께 참고해 “취소 전파 + span 종료”를 일관되게 맞춰두는 것을 권합니다.