모던 파이썬 중급 #3 컨텍스트 매니저 (with, contextlib)

5 분 소요

기초 #6에서 try/finally보다 with가 더 깔끔하다고 짧게 짚고 넘어갔습니다. 이번 글이 그 주제입니다. with의 모든 패턴과, 사용자 정의 컨텍스트 매니저, contextlib의 도구들을 다룹니다.

with — 한 줄로 정리되는 자원 #

가장 기본적인 예는 파일 처리입니다.

with 없을 때
f = open("data.txt")
try:
    data = f.read()
finally:
    f.close()
with와 함께
with open("data.txt") as f:
    data = f.read()
# 여기서 자동으로 f.close()

with블록을 빠져나갈 때 정리 코드를 자동으로 호출 합니다. 정상 종료, 예외 발생, return 어떤 경로로 빠져나가든 정리는 일어납니다.

with가 어울리는 자원 #

자원정리 동작
파일close()
락 (Lock)release()
DB 연결 / 트랜잭션commit() 또는 rollback() + close()
임시 디렉터리디렉터리 삭제
표준 출력 리다이렉트원래 stdout 복구
환경 변수 변경원래 값 복구

공통점: “잠깐 어떤 상태로 들어갔다가, 나갈 때 원래대로 되돌리고 싶음”. 이것이 컨텍스트 매니저가 풀어주는 문제입니다.

여러 자원을 한 번에 #

여러 with — 콤마로
with open("input.txt") as src, open("output.txt", "w") as dst:
    dst.write(src.read())

3.10부터는 괄호로 감싸 여러 줄로 적기도 됩니다.

여러 줄 with (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를 끼워서 컨텍스트 매니저를 만드는 방법이 있습니다.

@contextmanager
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 — 특정 예외 무시 #

suppress
from contextlib import suppress
import os

with suppress(FileNotFoundError):
    os.remove("maybe-missing.txt")
# 파일이 없어도 그냥 통과

위에서 만든 IgnoreFileNotFound 클래스 그대로 표준에 있습니다.

closing — close() 메소드만 가진 객체 감싸기 #

closing
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 — 표준 출력 가로채기 #

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 쓰기 #

nullcontext
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 — 동적으로 여러 자원 관리 #

자원의 개수가 런타임에 결정될 때 쓰는 도구입니다.

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)
# 모든 파일이 자동으로 close

with 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
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

테스트에서 환경 변수 의존 코드를 검증할 때 자주 씁니다.

시간 측정 #

elapsed time
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

#

lock
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, ExitStack
  • ExitStack — 자원 개수가 동적일 때, 콜백 등록도 가능
  • 짧은 부수효과(chdir, env, timer)는 @contextmanager가 가장 어울리는 경우
  • 비동기는 async with + @asynccontextmanager

다음 글(#4 이터러블/제너레이터/yield from)에서는 컴프리헨션 너머의 도구 — **사용자 정의 이터러블, 제너레이터 함수, yield from**을 다룹니다. 위에서 본 @contextmanager도 사실 제너레이터 위에 만들어진 도구입니다.

X