- Published on
Python 데코레이터+컨텍스트 매니저 합치기 5패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/배치/데이터 파이프라인을 만들다 보면 “함수 호출 전후로 무언가를 보장”해야 하는 요구가 반복됩니다. 예를 들어 트랜잭션 시작·커밋, 타이머 측정, 임시 환경 변수 주입, 락 획득·해제, 예외 변환 같은 것들입니다.
이때 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는 별도 래퍼로 안전하게
이렇게 정리해두면 팀 코드에서 전후처리 로직이 중복되지 않고, 운영 장애 시에도 어디서 상태가 열리고 닫히는지 추적이 쉬워집니다.