- Published on
Python contextlib로 Context Manager 5패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드든 데이터 파이프라인이든, 리소스(파일, 소켓, 락, 트랜잭션, 임시 설정)는 “열고-쓰고-닫기”가 반복됩니다. 이때 예외가 섞이면 정리(cleanup) 로직이 누락되기 쉽고, 누락은 곧 장애로 이어집니다. Python의 with 문과 Context Manager는 이 문제를 언어 차원에서 해결합니다.
하지만 직접 __enter__/__exit__를 매번 구현하는 건 번거롭고, 패턴이 조금만 복잡해져도 가독성이 급격히 떨어집니다. 여기서 contextlib가 빛납니다. contextlib는 “간단한 것부터 고급 조합까지” Context Manager를 함수형으로 구성할 수 있게 해줍니다.
아래에서는 실무에서 자주 쓰이는 contextlib 기반 5가지 패턴을 정리합니다. 각 패턴은 단순 예시가 아니라, 운영 환경에서 마주치는 문제(예외 안전성, 임시 설정, 재시도, 다중 리소스, 테스트 편의성)와 연결해 설명합니다.
참고로 예외/정리 로직은 운영 안정성과 직결됩니다. 예를 들어 API 호출 재시도와 백오프를 제대로 구성하지 않으면 장애 시 연쇄 실패가 납니다. 관련해서는 OpenAI 429 RateLimitError 재시도·백오프 실전 글도 함께 보면 좋습니다.
패턴 1) @contextmanager로 빠르게 Context Manager 만들기
가장 기본이자 가장 많이 쓰는 패턴입니다. try/finally로 정리 로직을 강제하고, yield 앞은 진입(__enter__), yield 뒤는 종료(__exit__)로 동작합니다.
파일/리소스 래핑 예시
from contextlib import contextmanager
@contextmanager
def open_text(path: str, encoding: str = "utf-8"):
f = open(path, "r", encoding=encoding)
try:
yield f
finally:
f.close()
with open_text("app.log") as f:
print(f.readline())
이미 open() 자체가 Context Manager지만, 실무에서는 “표준 옵션 강제(encoding, newline)”나 “추가 검증/로깅/메트릭”을 끼워 넣고 싶을 때가 많습니다.
실패 시에도 정리되는지 확인
from contextlib import contextmanager
@contextmanager
def temp_resource(name: str):
print("acquire", name)
try:
yield {"name": name}
finally:
print("release", name)
try:
with temp_resource("R1"):
raise RuntimeError("boom")
except RuntimeError:
pass
finally는 예외가 나도 실행되므로, 리소스 누수 방지의 최소 단위로 매우 강력합니다.
패턴 2) ExitStack으로 “동적” 다중 리소스 관리
여러 리소스를 조건적으로 열어야 하는 경우가 있습니다.
- 설정에 따라 파일을 0~N개 열기
- 입력 데이터에 따라 DB 커넥션/락을 선택적으로 획득
- 중간 단계에서 실패하면 그때까지 열린 것만 역순으로 정리
이때 with A() as a, B() as b: 형태는 리소스 개수가 고정일 때만 깔끔합니다. 동적으로 늘었다 줄었다 하면 ExitStack이 정답입니다.
N개 파일을 안전하게 열기
from contextlib import ExitStack
paths = ["a.txt", "b.txt", "c.txt"]
with ExitStack() as stack:
files = [stack.enter_context(open(p, "w", encoding="utf-8")) for p in paths]
for i, f in enumerate(files):
f.write(f"file-{i}\n")
# 블록을 나가면 열린 파일들이 역순으로 모두 close
조건부 리소스도 동일하게 처리
from contextlib import ExitStack
use_lock = True
class DummyLock:
def __enter__(self):
print("lock acquired")
return self
def __exit__(self, exc_type, exc, tb):
print("lock released")
return False
with ExitStack() as stack:
if use_lock:
stack.enter_context(DummyLock())
# 여기서 예외가 나도, 위에서 enter한 것만 안전하게 정리됨
print("do work")
ExitStack은 “중간에 어디서 실패해도 지금까지 확보한 리소스를 안전하게 되돌린다”는 점에서 트랜잭션 롤백과 비슷한 안정감을 줍니다.
패턴 3) suppress로 “의도된 예외”만 조용히 무시하기
예외를 무조건 try/except: pass로 삼키면 디버깅이 지옥이 됩니다. 반대로, “없어도 되는 파일 삭제” 같은 경우는 예외가 정상 흐름입니다.
contextlib.suppress는 특정 예외만 명시적으로 무시하게 해줘서, 코드 의도가 선명해집니다.
파일이 없으면 그냥 넘어가기
from contextlib import suppress
with suppress(FileNotFoundError):
import os
os.remove("tmp.cache")
여러 예외를 한 번에
from contextlib import suppress
with suppress(KeyError, IndexError):
data = {"items": [10, 20]}
print(data["items"][5])
주의점도 있습니다.
suppress(Exception)같은 광범위 예외 무시는 지양- 무시하는 예외는 “정말 정상 시나리오인지”를 문서/주석으로 남기기
패턴 4) redirect_stdout/redirect_stderr로 출력 캡처 및 격리
레거시 라이브러리나 CLI 도구 래퍼는 print()를 남발하는 경우가 많습니다. 테스트에서 출력이 섞이면 로그가 오염되고, 운영에서는 노이즈가 됩니다.
contextlib.redirect_stdout와 redirect_stderr를 쓰면 특정 블록의 출력만 다른 스트림으로 보낼 수 있습니다.
stdout 캡처해서 문자열로 받기
from contextlib import redirect_stdout
from io import StringIO
buf = StringIO()
with redirect_stdout(buf):
print("hello")
print("world")
captured = buf.getvalue()
print("captured:")
print(captured)
stderr를 파일로 보내기
from contextlib import redirect_stderr
with open("err.log", "w", encoding="utf-8") as f:
with redirect_stderr(f):
import sys
print("warn: something", file=sys.stderr)
이 패턴은 테스트 격리에도 좋고, 서드파티 라이브러리의 소음을 “필요할 때만” 수집하는 데도 유용합니다.
패턴 5) AsyncExitStack으로 비동기 리소스까지 안전하게
FastAPI, aiohttp, async DB 드라이버 등 비동기 환경에서는 리소스 정리도 await가 필요합니다. 이때는 contextlib.AsyncExitStack이 ExitStack의 비동기 버전 역할을 합니다.
비동기 컨텍스트를 동적으로 쌓기
import asyncio
from contextlib import AsyncExitStack
class AsyncResource:
def __init__(self, name: str):
self.name = name
async def __aenter__(self):
print("acquire", self.name)
return self
async def __aexit__(self, exc_type, exc, tb):
print("release", self.name)
return False
async def main():
async with AsyncExitStack() as stack:
r1 = await stack.enter_async_context(AsyncResource("R1"))
r2 = await stack.enter_async_context(AsyncResource("R2"))
# 작업 중 예외가 나도 R2, R1 순서로 안전하게 정리
_ = (r1, r2)
asyncio.run(main())
실무 포인트
- 여러 외부 호출(HTTP 세션, DB 세션, 임시 파일)을 “상황에 따라” 열어야 할 때 특히 유리
- 비동기 코드에서
try/finally중첩이 깊어지는 문제를 완화
비동기 환경에서는 재시도/백오프도 함께 고려되는 경우가 많습니다. 네트워크 오류와 리소스 정리가 엮이면 더 복잡해지므로, 재시도 설계는 OpenAI 429 RateLimitError 재시도·백오프 실전 같은 패턴과 함께 묶어보는 것을 권합니다.
보너스: ContextDecorator로 “with 또는 데코레이터” 둘 다 지원
contextlib.ContextDecorator를 상속하면 동일한 로직을 with로도 쓰고, 함수 데코레이터로도 쓸 수 있습니다. 로깅/메트릭/성능 측정 같은 횡단 관심사에 잘 맞습니다.
import time
from contextlib import ContextDecorator
class timed(ContextDecorator):
def __init__(self, label: str):
self.label = label
def __enter__(self):
self.t0 = time.perf_counter()
return self
def __exit__(self, exc_type, exc, tb):
dt = time.perf_counter() - self.t0
print(f"{self.label}: {dt:.4f}s")
return False
@timed("job")
def work():
s = 0
for i in range(1000000):
s += i
return s
work()
with timed("block"):
work()
이 보너스 패턴은 위의 5개에 포함되진 않지만, contextlib를 “코드 구조화 도구”로 확장하는 대표 사례라 함께 알아두면 좋습니다.
언제 어떤 패턴을 선택할까
- 단일 리소스 + 진입/종료가 명확하다:
@contextmanager - 리소스 개수가 동적이다(조건/반복/부분 실패):
ExitStack - 특정 예외가 정상 흐름이다:
suppress - 출력/로그를 블록 단위로 격리하고 싶다:
redirect_stdout/redirect_stderr - 비동기에서 리소스를 동적으로 관리해야 한다:
AsyncExitStack
마무리
contextlib의 진짜 가치는 “예외 안전성”을 기본값으로 만들고, 코드의 의도를 더 명확하게 드러내는 데 있습니다. 특히 ExitStack/AsyncExitStack은 리소스 관리가 복잡해지는 순간(조건 분기, 중간 실패, 다단계 파이프라인) 코드 품질을 한 단계 끌어올려 줍니다.
운영에서 자주 터지는 문제 중 하나가 ‘정리 누락으로 인한 누수’와 ‘예외 처리의 일관성 부족’입니다. Context Manager 패턴을 표준화해두면, 장애 대응과 재현도 훨씬 쉬워집니다.