Published on

Python async 데코레이터에서 await 누락 버그 잡기

Authors

서버 사이드 Python에서 asyncio 기반 코드를 쓰다 보면, 로깅/트레이싱/재시도 같은 횡단 관심사를 데코레이터로 묶는 순간이 많습니다. 문제는 async def 데코레이터를 만들 때 await 한 줄을 빠뜨리면 버그가 조용히 숨어버린다는 점입니다. 어떤 경우엔 RuntimeWarning: coroutine was never awaited가 뜨지만, 프로덕션에서는 로그가 묻히거나 테스트가 빈약하면 그대로 배포되기도 합니다.

이 글은 await 누락이 실제로 어떤 형태로 터지는지를 재현하고, 정적 분석(타입/린트) + 테스트 + 런타임 가드로 재발을 막는 패턴을 정리합니다. (컨텍스트 전파까지 포함한 올바른 데코레이터 패턴은 Python async 데코레이터로 컨텍스트 깨짐 해결도 같이 참고하면 좋습니다.)

1) await 누락이 만드는 대표 증상 3가지

1-1. 호출자는 await 했는데, 내부 로직이 안 돈다

데코레이터가 코루틴을 그대로 반환해 버리면, 호출자가 한 번 await 했을 때 “데코레이터 내부 코루틴”만 실행되고, 실제 함수 코루틴은 실행되지 않거나(혹은 추가 await가 필요해지는) 이상한 상태가 됩니다.

1-2. coroutine was never awaited 경고가 뜬다

이건 가장 친절한 케이스지만, 경고는 테스트 러너/로깅 설정에 따라 쉽게 놓칩니다.

1-3. 타입이 Any로 퍼지면서 정적 분석이 무력화된다

데코레이터가 제대로 타이핑되지 않거나 Callable[..., Any]로 뭉개면, await 누락이 있어도 타입체커가 잡아내지 못합니다.

2) 최소 재현: “겉보기엔 동작”하지만 실제론 안 도는 버그

아래는 흔히 나오는 “로깅 데코레이터”의 실수 버전입니다. 핵심은 func(*args, **kwargs)를 호출만 하고 await 하지 않는 부분입니다.

import asyncio
from functools import wraps

def broken_log(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        print("[before]")
        result = func(*args, **kwargs)  # await 누락! result는 coroutine
        print("[after]")
        return result
    return wrapper

@broken_log
async def work(x: int) -> int:
    await asyncio.sleep(0.1)
    print("work runs")
    return x + 1

async def main():
    v = await work(10)
    print("value:", v)

asyncio.run(main())

겉으로 보면 await work(10)을 했으니 work가 실행될 것 같지만, 실제 출력은 대개 이런 식으로 나옵니다.

  • [before]
  • [after]
  • value: <coroutine object work ...>

즉, 호출자는 “값”을 받았다고 생각했는데, 받은 건 값이 아니라 코루틴 객체입니다. 이 상태에서 코드 어딘가가 그 코루틴을 다시 await 하지 않으면, work는 영영 실행되지 않습니다.

3) 올바른 구현: await + 예외 처리 + wraps

정답은 단순합니다. func가 async 함수라면 반환값은 Awaitable이므로 반드시 await 해야 합니다.

import asyncio
from functools import wraps

def log_async(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        print("[before]")
        try:
            result = await func(*args, **kwargs)
            return result
        finally:
            print("[after]")
    return wrapper

@log_async
async def work(x: int) -> int:
    await asyncio.sleep(0.1)
    print("work runs")
    return x + 1

여기서 finally를 쓰는 이유는, 예외가 나도 [after] 같은 후처리를 보장하려는 목적입니다(락 해제, span 종료, metric 기록 등).

4) 타입으로 await 누락을 “컴파일 타임”에 잡기

실무에서 await 누락은 리뷰에서 놓치기 쉽습니다. 그래서 데코레이터를 정확히 타이핑하면, await 없이 코루틴을 반환하는 순간 타입이 어긋나면서 잡히는 경우가 많습니다.

4-1. ParamSpec/TypeVar로 시그니처 보존

from __future__ import annotations

from functools import wraps
from typing import Awaitable, Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def traced(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
    @wraps(func)
    async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        # 여기서 func(...)는 Awaitable[R]
        return await func(*args, **kwargs)

    return wrapper

이 형태로 만들어두면, 아래처럼 await를 빼먹는 순간 wrapper의 반환 타입이 R이 아니라 Awaitable[R]가 되어 타입체커가 불평합니다.

def traced_broken(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
    @wraps(func)
    async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return func(*args, **kwargs)  # 타입 오류 유발 가능

    return wrapper
  • mypy/pyright 설정이 엄격할수록 잡을 확률이 올라갑니다.
  • 프로젝트에서 데코레이터를 유틸로 공용화한다면, 이 타이핑은 투자 대비 효과가 큽니다.

4-2. “sync/async 겸용” 데코레이터는 더 위험하다

동기 함수와 비동기 함수를 동시에 받는 데코레이터는 구현이 복잡해지며 await 누락이 더 자주 발생합니다. 가능하면 async 전용 데코레이터sync 전용 데코레이터를 분리하세요.

부득이하게 겸용이 필요하면, 런타임에서 반환값이 Awaitable인지 검사해 처리하는 패턴을 쓰는데, 이 경우도 타입 오버로드를 제대로 작성하지 않으면 Any가 퍼집니다.

5) 테스트로 잡기: “코루틴을 반환하면 실패” 규칙

타입체커가 없는 프로젝트/구간도 있으니, 테스트에서 강제하는 게 안전합니다. 핵심은 “데코레이터 적용 후 호출 결과가 코루틴이면 실패” 같은 규칙을 넣는 것입니다.

5-1. pytest-asyncio 예시

import asyncio
import inspect
import pytest

@pytest.mark.asyncio
async def test_decorated_returns_value_not_coroutine():
    async def f():
        await asyncio.sleep(0)
        return 123

    decorated = log_async(f)
    result = await decorated()

    assert result == 123
    assert not inspect.isawaitable(result)

위 테스트는 단순하지만 효과가 좋습니다.

  • await decorated()의 결과가 값이어야 한다
  • 값이 inspect.isawaitable이면(즉 코루틴이면) 실패

만약 await 누락 버전 데코레이터라면, result가 코루틴이 되어 두 번째 assert에서 바로 걸립니다.

5-2. 경고를 에러로 승격

coroutine was never awaited는 경고로만 남는 경우가 많습니다. 테스트 환경에서 경고를 에러로 바꾸면 놓칠 확률이 크게 줄어듭니다.

pytest.ini 예시:

[pytest]
filterwarnings =
    error:coroutine .* was never awaited

환경에 따라 메시지 패턴이 달라질 수 있으니, 실제 경고 문구에 맞춰 조정하세요.

6) 런타임 가드: “데코레이터가 코루틴을 반환하면 즉시 폭발”

테스트가 부족하거나, 특정 경로에서만 await 누락이 발생하는 경우(조건 분기, 예외 처리 분기 등)에는 런타임 가드가 도움이 됩니다.

import inspect
from functools import wraps

def ensure_awaited(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        if inspect.isawaitable(result):
            # 여기서 await를 강제해 "값"으로 만든다
            return await result
        return result
    return wrapper

이 패턴은 “async 함수에만 쓰는 데코레이터” 관점에서는 정석이라기보다 안전망에 가깝습니다.

  • 장점: 실수로 await를 빼먹어도 결과적으로 동작이 맞춰짐
  • 단점: 원래 sync 함수를 넣어도 통과해 버려 설계가 흐려질 수 있음

따라서 공용 라이브러리라면, ensure_awaited 같은 데코레이터는 이름부터 의도를 강하게 드러내고(예: auto_await_return), 적용 범위를 제한하는 게 좋습니다.

7) 실전에서 자주 터지는 지점: try/except 분기에서만 await 누락

아래처럼 성공 경로에서는 await를 하고, 예외 경로에서만 실수로 await를 빼먹는 케이스가 꽤 흔합니다.

from functools import wraps

def retry_once(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        try:
            return await func(*args, **kwargs)
        except Exception:
            # 실수: 재시도 경로에서 await를 빼먹음
            return func(*args, **kwargs)
    return wrapper

이건 테스트가 “성공 케이스만” 있으면 절대 안 잡힙니다. 반드시 실패 케이스(첫 호출에서 예외가 나고 재시도로 성공하는 케이스)를 넣어야 합니다.

올바른 버전:

def retry_once(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        try:
            return await func(*args, **kwargs)
        except Exception:
            return await func(*args, **kwargs)
    return wrapper

8) 체크리스트: await 누락을 조직적으로 줄이는 방법

  • 데코레이터는 가능하면 async 전용으로 분리한다
  • ParamSpec/TypeVar시그니처와 반환 타입을 보존한다
  • mypy 또는 pyright를 CI에 넣고, 데코레이터 유틸 모듈은 특히 엄격하게 설정한다
  • pytest에서 coroutine was never awaited 경고를 에러로 승격한다
  • 테스트에 “예외 분기 재시도”처럼 분기 커버리지를 포함한다
  • 필요하면 inspect.isawaitable 기반 런타임 가드를 안전망으로 둔다

비동기 데코레이터는 한 번 잘 만들어두면 프로젝트 전반의 품질을 끌어올리지만, 반대로 한 줄(await) 실수로 장애를 만들기도 합니다. 특히 로깅/트레이싱/권한체크 같은 공통 레이어에 들어가면 영향 범위가 커지므로, 위의 타입/테스트/가드 조합으로 “실수해도 CI에서 터지게” 만드는 것이 가장 비용 대비 효과가 좋습니다.

추가로, async 데코레이터를 도입하면서 contextvars 기반 컨텍스트가 깨지는 문제까지 함께 겪고 있다면, 원인과 해결 패턴을 Python async 데코레이터로 컨텍스트 깨짐 해결에서 함께 정리해두었습니다.