Published on

Python 데코레이터+컨텍스트 매니저 합치기 5패턴

Authors

서버/배치/데이터 파이프라인을 만들다 보면 “함수 호출 전후로 무언가를 보장”해야 하는 요구가 반복됩니다. 예를 들어 트랜잭션 시작·커밋, 타이머 측정, 임시 환경 변수 주입, 락 획득·해제, 예외 변환 같은 것들입니다.

이때 Python에서는 보통 두 가지 도구를 씁니다.

  • 데코레이터: 함수 호출을 감싸서 공통 전후처리를 주입
  • 컨텍스트 매니저: with 블록의 진입/탈출 시점에 자원 수명 관리

문제는 팀 코드베이스에서 이 둘이 섞이기 시작하면 API가 갈라집니다.

  • 어떤 곳은 @timed 데코레이터
  • 어떤 곳은 with timed(): 컨텍스트
  • 어떤 곳은 둘 다 필요해서 중복 구현

아래 5가지 패턴은 “데코레이터와 컨텍스트 매니저를 합쳐서” 하나의 구현으로 두 사용법을 동시에 제공하거나, 두 개념을 안전하게 연결하는 방법을 다룹니다.

참고: 전후처리에서 인코딩/디코딩 문제가 섞이면 예외가 더 복잡해집니다. 파일/로그 처리 중 UnicodeDecodeError 를 자주 겪는다면 Python UnicodeDecodeError - utf-8 해결 7가지 도 함께 보세요.


패턴 1) ContextDecorator 로 “with와 @를 하나로”

가장 정석적인 합치기 방법입니다. 표준 라이브러리 contextlib.ContextDecorator 를 상속하면, 같은 객체를

  • with MyCtx(): ...
  • @MyCtx()

두 방식으로 쓸 수 있습니다.

언제 쓰나

  • 진입/탈출 시점이 명확하고, 함수/블록 모두에 적용하고 싶을 때
  • 로깅, 타이밍, 트랜잭션, 임시 설정 주입에 특히 적합

구현 예시: 타이머

from __future__ import annotations

import time
from contextlib import ContextDecorator


class timed(ContextDecorator):
    def __init__(self, name: str = ""):
        self.name = name
        self._start = 0.0

    def __enter__(self):
        self._start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc, tb):
        elapsed = time.perf_counter() - self._start
        label = self.name or "block"
        print(f"[timed] {label}: {elapsed:.6f}s")
        # 예외를 삼키지 않음
        return False


@timed("expensive")
def expensive_work():
    time.sleep(0.1)


def main():
    expensive_work()

    with timed("manual"):
        time.sleep(0.05)


if __name__ == "__main__":
    main()

주의점

  • __exit__ 에서 return True 를 하면 예외가 억제됩니다. 디버깅을 어렵게 만들 수 있으니 “의도적으로 삼킬 때만” 사용하세요.
  • 데코레이터로 쓸 때는 함수 호출마다 컨텍스트가 새로 만들어지는지(상태 공유 여부)를 확인하세요. 보통은 @timed() 처럼 호출 형태를 강제하는 편이 안전합니다.

패턴 2) @contextmanager 를 데코레이터로 감싸기

이미 컨텍스트 매니저가 @contextmanager 로 구현되어 있다면, 이를 “데코레이터로도 쓰게” 만드는 래퍼를 두는 방식이 깔끔합니다.

핵심은 “함수 호출 전체를 with ctx(...): 로 감싸는 데코레이터”를 하나 만들어 재사용하는 것입니다.

구현 예시: 공용 래퍼 with_ctx

from __future__ import annotations

from contextlib import contextmanager
from functools import wraps


def with_ctx(ctx_factory):
    """ctx_factory는 호출 시 컨텍스트 매니저를 반환하는 callable."""

    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            with ctx_factory(*args, **kwargs):
                return func(*args, **kwargs)

        return wrapper

    return decorator


@contextmanager
def request_scope(request_id: str):
    print(f"scope enter: {request_id}")
    try:
        yield
    finally:
        print(f"scope exit: {request_id}")


@with_ctx(lambda request_id: request_scope(request_id))
def handle(request_id: str):
    print(f"handling {request_id}")


def main():
    handle("req-1")
    with request_scope("req-2"):
        print("manual")


if __name__ == "__main__":
    main()

장점

  • 기존 with 기반 코드를 그대로 두고, 데코레이터 사용만 추가 가능
  • @contextmanager 기반 구현을 그대로 재사용

단점/함정

  • 위 예시는 ctx_factory 에 함수 인자를 그대로 넘기므로, “컨텍스트가 함수 인자를 그대로 필요로 하는” 경우에만 자연스럽습니다.
  • 함수 인자와 무관한 컨텍스트(예: with db.transaction():)라면 ctx_factory 를 단순 lambda: ... 로 두고, 래퍼에서 인자를 무시하도록 설계하세요.

패턴 3) “컨텍스트 매니저 팩토리”를 호출 가능한 객체로 만들기

패턴 1이 ContextDecorator 를 상속하는 정공법이라면, 이 패턴은 API를 더 유연하게 만듭니다.

  • 객체 자체는 컨텍스트 매니저 팩토리처럼 동작
  • 동시에 __call__ 로 데코레이터처럼도 동작

즉, 아래 두 형태를 모두 지원합니다.

  • with scope("x"):
  • @scope("x")

구현 예시: Scope 클래스

from __future__ import annotations

from contextlib import ContextDecorator


class Scope(ContextDecorator):
    def __init__(self, name: str):
        self.name = name

    def __enter__(self):
        print(f"enter {self.name}")
        return self

    def __exit__(self, exc_type, exc, tb):
        print(f"exit {self.name}")
        return False


class scope:
    """scope("x")가 컨텍스트 매니저이자 데코레이터가 되게 하는 팩토리."""

    def __init__(self, name: str):
        self._name = name

    def __enter__(self):
        return Scope(self._name).__enter__()

    def __exit__(self, exc_type, exc, tb):
        return Scope(self._name).__exit__(exc_type, exc, tb)

    def __call__(self, func):
        # ContextDecorator를 직접 쓰는 편이 더 간단하지만,
        # 여기서는 “팩토리 객체가 데코레이터로도 동작”하는 형태를 보여줌
        scoped = Scope(self._name)
        return scoped(func)


@scope("job")
def run():
    print("running")


def main():
    run()
    with scope("block"):
        print("in block")


if __name__ == "__main__":
    main()

코멘트

  • 위 코드는 개념을 보여주기 위해 Scope 를 매번 새로 만들고 있습니다. 실제로는 “상태 공유/동시성”을 고려해 Scope 생성 시점을 명확히 하세요.
  • 패턴 1의 ContextDecorator 가 더 간결하므로, 이 패턴은 “팩토리 객체에 추가 메서드(예: .with_tags(...))를 붙이고 싶다” 같은 확장 요구가 있을 때 유리합니다.

패턴 4) ExitStack 으로 여러 컨텍스트를 데코레이터처럼 조합

실무에서 전후처리는 하나로 끝나지 않습니다.

  • 트랜잭션
  • 타임아웃
  • 임시 환경 변수
  • 임시 디렉터리
  • feature flag

이걸 데코레이터 여러 개로 쌓기 시작하면 순서가 헷갈리고, 어떤 것은 with 로만 쓰고, 어떤 것은 데코레이터로만 쓰는 식으로 API가 분열합니다.

contextlib.ExitStack 은 “여러 컨텍스트를 동적으로 쌓고, 역순으로 정리”해줍니다. 이를 ContextDecorator 와 결합하면 “조합 가능한 단일 데코레이터/컨텍스트”를 만들 수 있습니다.

구현 예시: composed

from __future__ import annotations

from contextlib import ContextDecorator, ExitStack, contextmanager


@contextmanager
def envvar(key: str, value: str):
    import os

    old = os.environ.get(key)
    os.environ[key] = value
    try:
        yield
    finally:
        if old is None:
            os.environ.pop(key, None)
        else:
            os.environ[key] = old


@contextmanager
def traced(name: str):
    print(f"trace start: {name}")
    try:
        yield
    finally:
        print(f"trace end: {name}")


class composed(ContextDecorator):
    def __init__(self, *cms):
        self._cms = cms
        self._stack = None

    def __enter__(self):
        stack = ExitStack()
        for cm in self._cms:
            stack.enter_context(cm)
        self._stack = stack
        return self

    def __exit__(self, exc_type, exc, tb):
        assert self._stack is not None
        return self._stack.__exit__(exc_type, exc, tb)


@composed(traced("job"), envvar("MODE", "prod"))
def job():
    import os

    print("MODE=", os.environ.get("MODE"))


def main():
    job()

    with composed(traced("block"), envvar("MODE", "dev")):
        job()


if __name__ == "__main__":
    main()

장점

  • 전후처리의 순서를 한 곳에서 선언적으로 관리
  • 조건부로 컨텍스트를 추가하기 쉬움 (예: if debug: stack.enter_context(...))

실무 팁

  • 분산 트레이싱/로깅을 얹을 때는 “컨텍스트 변수”를 같이 쓰는 경우가 많습니다. 이런 조합은 요청 단위 캐시/상태가 꼬이기 쉬운데, 프론트엔드에서도 캐시 꼬임이 큰 이슈가 되듯 서버도 비슷합니다. 캐시/상태 일관성 관점은 Next.js 14 App Router RSC 캐시 꼬임 해결 처럼 “어디에서 상태를 만들고 어디에서 해제하는지”를 명확히 하는 것이 핵심입니다.

패턴 5) 비동기 async 에서의 결합: asynccontextmanager + 데코레이터

async def 함수에서는 with 대신 async with 가 필요하고, 컨텍스트도 __aenter__/__aexit__ 를 구현하거나 @asynccontextmanager 를 써야 합니다.

여기서 흔한 실수는 동기 데코레이터를 async def 에 적용해버려서

  • 코루틴을 반환만 하고 await 하지 않거나
  • 컨텍스트 종료가 보장되지 않거나
  • 예외 전파가 깨지는

문제가 생기는 것입니다.

구현 예시: async_with_ctx

from __future__ import annotations

from contextlib import asynccontextmanager
from functools import wraps


def async_with_ctx(ctx_factory):
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            async with ctx_factory(*args, **kwargs):
                return await func(*args, **kwargs)

        return wrapper

    return decorator


@asynccontextmanager
async def async_timed(name: str):
    import time

    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"[async_timed] {name}: {elapsed:.6f}s")


@async_with_ctx(lambda name: async_timed(name))
async def fetch(name: str):
    import asyncio

    await asyncio.sleep(0.05)
    return name


async def main():
    await fetch("io")


if __name__ == "__main__":
    import asyncio

    asyncio.run(main())

추가 함정: 동기 컨텍스트를 async 에서 쓰기

  • 동기 컨텍스트는 async with 로 쓸 수 없습니다.
  • 반대로 async 컨텍스트는 with 로 쓸 수 없습니다.

둘을 섞어야 한다면 다음 중 하나를 선택하세요.

  • 동기 컨텍스트를 비동기 컨텍스트로 감싸기 (스레드 오프로딩이 필요할 수 있음)
  • 비동기 로직을 동기화하지 말고, 호출 계층을 async 로 통일

클라우드 런타임에서 콜드스타트/타임아웃 이슈가 있는 서비스라면, 비동기 전후처리(예: 연결 풀 워밍업, 타임아웃 보호)가 성능에 직접 영향을 줍니다. 운영 관점에서는 GCP Cloud Run 504와 콜드스타트 지연 해결 가이드 의 “병목을 계측하고 완화하는 방식”이 데코레이터/컨텍스트 설계에도 그대로 적용됩니다.


선택 가이드: 어떤 패턴을 언제 쓰나

  • 패턴 1 ContextDecorator: 가장 추천. 구현 단순, 표준적, 팀 합의에 좋음
  • 패턴 2 @contextmanager + 데코레이터 래퍼: 이미 컨텍스트가 많고, 데코레이터 제공만 추가하고 싶을 때
  • 패턴 3 호출 가능한 팩토리 객체: 옵션이 많고 DSL처럼 확장하고 싶을 때
  • 패턴 4 ExitStack 조합: 여러 전후처리를 순서대로 안정적으로 묶고 싶을 때
  • 패턴 5 비동기 전용 결합: async 코드에서는 별도 패턴이 사실상 필수

마무리: “전후처리”를 API로 승격시키기

데코레이터와 컨텍스트 매니저는 모두 전후처리를 표현하지만, 사용 위치(함수 vs 블록)가 다릅니다. 둘을 억지로 통일하려다 보면 오히려 예외 처리, 상태 공유, 동시성에서 버그가 납니다.

이 글의 핵심은 “두 사용법을 모두 제공하되, 구현은 하나로”입니다.

  • 표준적인 경우는 ContextDecorator
  • 조합이 늘어나면 ExitStack
  • async 는 별도 래퍼로 안전하게

이렇게 정리해두면 팀 코드에서 전후처리 로직이 중복되지 않고, 운영 장애 시에도 어디서 상태가 열리고 닫히는지 추적이 쉬워집니다.