모던 파이썬 중급 #3 컨텍스트 매니저 (with, contextlib)
기초 #6에서 try/finally보다 with가 더 깔끔하다고 짧게 짚고 넘어갔습니다. 이번 글이 그 주제입니다. with의 모든 패턴과, 사용자 정의 컨텍스트 매니저, contextlib의 도구들을 다룹니다.
with — 한 줄로 정리되는 자원
#
가장 기본적인 예는 파일 처리입니다.
f = open("data.txt")
try:
data = f.read()
finally:
f.close()with open("data.txt") as f:
data = f.read()
# 여기서 자동으로 f.close()with는 블록을 빠져나갈 때 정리 코드를 자동으로 호출 합니다. 정상 종료, 예외 발생, return 어떤 경로로 빠져나가든 정리는 일어납니다.
with가 어울리는 자원
#
| 자원 | 정리 동작 |
|---|---|
| 파일 | close() |
락 (Lock) | release() |
| DB 연결 / 트랜잭션 | commit() 또는 rollback() + close() |
| 임시 디렉터리 | 디렉터리 삭제 |
| 표준 출력 리다이렉트 | 원래 stdout 복구 |
| 환경 변수 변경 | 원래 값 복구 |
공통점: “잠깐 어떤 상태로 들어갔다가, 나갈 때 원래대로 되돌리고 싶음”. 이것이 컨텍스트 매니저가 풀어주는 문제입니다.
여러 자원을 한 번에 #
with open("input.txt") as src, open("output.txt", "w") as dst:
dst.write(src.read())3.10부터는 괄호로 감싸 여러 줄로 적기도 됩니다.
with (
open("input.txt") as src,
open("output.txt", "w") as dst,
Lock() as lock,
):
dst.write(src.read())매개변수가 많은 함수와 같은 모양이라 읽기 좋습니다.
직접 만들기 — __enter__ / __exit__
#
with가 동작하는 객체는 두 메소드를 가집니다.
__enter__(self)—with진입 시 호출,as뒤에 받을 값을 반환__exit__(self, exc_type, exc_value, traceback)— 빠져나갈 때 호출
가장 단순한 예 — 임시 chdir #
작업 디렉터리를 잠깐 바꾸고 싶을 때를 예로 들겠습니다.
import os
class chdir:
def __init__(self, path: str):
self.path = path
def __enter__(self):
self.old = os.getcwd()
os.chdir(self.path)
return self.path
def __exit__(self, exc_type, exc_value, tb):
os.chdir(self.old)
with chdir("/tmp"):
print(os.getcwd()) # /tmp
print(os.getcwd()) # 원래 디렉터리__exit__의 반환값 — 예외 흡수 여부
#
__exit__은 truthy를 반환하면 예외를 삼킵니다. 이걸 모르고 True를 반환하면 모든 예외가 사라져 디버그하기 어려워집니다.
class Bad:
def __enter__(self): return self
def __exit__(self, *args):
return True # ✗ 모든 예외를 삼킴대부분의 경우 **아무것도 반환하지 말거나 False/None**으로 두세요.
특정 예외만 의도적으로 흡수하려면 exc_type을 보고 판단합니다.
class IgnoreFileNotFound:
def __enter__(self): return self
def __exit__(self, exc_type, exc_value, tb):
return exc_type is not None and issubclass(exc_type, FileNotFoundError)
with IgnoreFileNotFound():
with open("missing.txt") as f:
...
# 예외 없이 통과이런 흔한 패턴은 표준 라이브러리에 이미 있습니다 (아래 suppress 참고).
@contextmanager — 한 함수로 짧게
#
__enter__/__exit__ 두 메소드를 가진 클래스를 매번 만드는 건 번거롭습니다. 함수 하나에 yield를 끼워서 컨텍스트 매니저를 만드는 방법이 있습니다.
from contextlib import contextmanager
@contextmanager
def chdir(path: str):
old = os.getcwd()
os.chdir(path)
try:
yield path # 이 시점에서 with 블록이 실행됨
finally:
os.chdir(old)이 한 함수가 위의 클래스 버전과 정확히 같은 일을 합니다.
읽는 법:
yield앞 =__enter__가 하는 일yield값 =as뒤에 받을 값yield뒤 =__exit__가 하는 일try/finally로 감싸야 예외에도 정리가 보장됨
예외도 받고 싶으면 #
@contextmanager
def transactional(conn):
tx = conn.begin()
try:
yield tx
except Exception:
tx.rollback()
raise
else:
tx.commit()yield 지점에서 with 블록이 예외를 던지면, 그게 제너레이터의 yield 지점에서 다시 던져집니다. 그래서 평범한 try/except로 잡을 수 있습니다.
contextlib의 다른 보조 도구들
#
suppress — 특정 예외 무시
#
from contextlib import suppress
import os
with suppress(FileNotFoundError):
os.remove("maybe-missing.txt")
# 파일이 없어도 그냥 통과위에서 만든 IgnoreFileNotFound 클래스 그대로 표준에 있습니다.
closing — close() 메소드만 가진 객체 감싸기
#
from contextlib import closing
from urllib.request import urlopen
with closing(urlopen("https://example.com")) as resp:
data = resp.read()
# resp.close() 자동urlopen의 응답 객체가 __exit__을 가지지 않던 옛 버전에 자주 썼습니다. 요즘은 응답 객체도 with를 직접 지원하는 경우가 많습니다.
redirect_stdout — 표준 출력 가로채기
#
import io
from contextlib import redirect_stdout
buf = io.StringIO()
with redirect_stdout(buf):
print("hello")
print("captured:", buf.getvalue())
# captured: hello테스트, 캡처, 로그 변환 같은 경우에 유용합니다. redirect_stderr도 같은 동작입니다.
nullcontext — 조건부로 with 쓰기
#
from contextlib import nullcontext
def process(path: str | None):
ctx = open(path) if path else nullcontext()
with ctx as f:
if f is None:
print("no file")
else:
print(f.read())“파일이 있으면 열고, 없으면 그냥 통과” 같은 경우입니다. with 뒤에는 항상 컨텍스트 매니저가 와야 하므로, 아무 일도 하지 않는 무해한 객체가 필요합니다. nullcontext가 그것입니다.
ExitStack — 동적으로 여러 자원 관리
#
자원의 개수가 런타임에 결정될 때 쓰는 도구입니다.
from contextlib import ExitStack
def merge(paths: list[str]) -> str:
with ExitStack() as stack:
files = [stack.enter_context(open(p)) for p in paths]
return "\n".join(f.read() for f in files)
# 모든 파일이 자동으로 closewith open(p) as f를 N 개 적을 수 없을 때 (paths 리스트 길이가 동적이면) ExitStack이 그 문제를 풀어줍니다. 모든 등록된 자원이 역순으로 정리됩니다.
ExitStack으로 __enter__가 아닌 일반 정리 함수도 등록할 수 있습니다.
def setup_temp_resources():
stack = ExitStack()
tmp_dir = create_tmp_dir()
stack.callback(remove_dir, tmp_dir)
# ... 더 많은 자원 등록
return stack
with setup_temp_resources() as stack:
...async 컨텍스트 매니저 — 잠깐 미리보기
#
비동기 자원에는 async with를 씁니다.
async with open_async_db() as conn:
await conn.execute(...)이건 메소드가 __aenter__/__aexit__입니다. @asynccontextmanager도 있고 사용법은 동기 버전과 같습니다. 자세한 내용은 #7 비동기 입문에서 다룹니다.
사용자 정의 — 클래스 vs @contextmanager
#
| 상황 | 클래스 | @contextmanager |
|---|---|---|
| 짧고 간단 | ⭕ | ✅ |
| 상태/메소드가 많음 | ✅ | ❌ |
| 예외 처리 분기가 복잡 | ✅ | ⭕ |
| 짧은 부수효과 (chdir, 환경변수 등) | ⭕ | ✅ |
| 라이브러리에서 사용자에게 노출 | ✅ | ⭕ |
의심스러우면 @contextmanager부터 시도하고, 복잡해지면 클래스로 옮기는 게 무난합니다.
자주 만나는 패턴 — 한곳에 #
임시 환경 변수 #
import os
from contextlib import contextmanager
@contextmanager
def set_env(**kwargs: str):
old = {}
for k, v in kwargs.items():
old[k] = os.environ.get(k)
os.environ[k] = v
try:
yield
finally:
for k, v in old.items():
if v is None:
del os.environ[k]
else:
os.environ[k] = v테스트에서 환경 변수 의존 코드를 검증할 때 자주 씁니다.
시간 측정 #
import time
from contextlib import contextmanager
@contextmanager
def timer(label: str):
start = time.perf_counter()
yield
elapsed = time.perf_counter() - start
print(f"{label}: {elapsed:.3f}s")
with timer("query"):
do_expensive_thing()
# query: 0.123s락 #
import threading
lock = threading.Lock()
with lock:
# 임계 영역
update_shared_state()Lock, RLock, Semaphore 모두 with를 지원합니다. acquire() / release()를 직접 부르는 코드는 거의 안 만듭니다.
정리 #
이번 글에서 잡은 도구들:
with— 자원 들어가고 나오는 경계를 한 줄로 정리, 예외에도 정리 보장- 다중 자원 —
with A, B:또는 괄호로 여러 줄 - 직접 만들기:
__enter__/__exit__,__exit__반환값은False가 일반적 @contextmanager— 함수 하나로 짧게 만드는 표준 도구,yield위/아래 = 진입/정리contextlib보조:suppress,closing,redirect_stdout,nullcontext,ExitStackExitStack— 자원 개수가 동적일 때, 콜백 등록도 가능- 짧은 부수효과(chdir, env, timer)는
@contextmanager가 가장 어울리는 경우 - 비동기는
async with+@asynccontextmanager
다음 글(#4 이터러블/제너레이터/yield from)에서는 컴프리헨션 너머의 도구 — **사용자 정의 이터러블, 제너레이터 함수, yield from**을 다룹니다. 위에서 본 @contextmanager도 사실 제너레이터 위에 만들어진 도구입니다.