ExitStack — 동적 컨텍스트 매니저 스택
contextlib.ExitStack으로 런타임에 결정된 개수의 컨텍스트 매니저를 동적으로 쌓고 LIFO 순서로 해제하는 방법, callback 등록, 소유권 이전 패턴을 설명합니다.
지난 글에서 suppress, redirect_stdout 같은 contextlib 유틸리티를 살펴봤다. 이번에는 contextlib의 가장 강력한 도구인 **ExitStack**을 다룬다. 컨텍스트 매니저의 개수가 컴파일 시점에 정해지지 않는 경우를 우아하게 처리한다.
ExitStack이 필요한 이유
with open(a) as f1, open(b) as f2: 처럼 중첩 with 문은 파일 수가 고정일 때는 잘 동작한다. 그런데 파일 이름 목록이 런타임에 결정된다면?
# 이렇게는 못 쓴다 — 개수가 동적이라서
files = [open(fn) for fn in filenames] # 예외 발생 시 앞 파일이 닫히지 않음
수동으로 try/finally를 중첩하면 코드가 복잡해진다. ExitStack은 이 문제를 깔끔하게 해결한다.
기본 사용법
from contextlib import ExitStack
filenames = ["a.csv", "b.csv", "c.csv"]
with ExitStack() as stack:
files = [stack.enter_context(open(fn)) for fn in filenames]
# 블록 종료 시 등록 역순(LIFO)으로 __exit__ 호출
process(files)
enter_context(cm)은 cm.__enter__()를 호출하고 반환값을 돌려주면서, cm을 스택에 등록한다. with 블록을 나가면 등록된 매니저들이 LIFO 순서로 __exit__을 호출한다.
callback — 임의 정리 함수 등록
컨텍스트 매니저가 아닌 일반 함수도 스택에 등록할 수 있다.
with ExitStack() as stack:
conn = db.connect()
stack.callback(conn.close) # 함수 참조만 전달
stack.callback(log.info, "done") # 인수도 전달 가능
do_work(conn)
# 블록 종료 시: log.info("done") → conn.close() 역순 실행
pop_all — 소유권 이전
pop_all()은 현재 스택의 등록 목록을 새 ExitStack으로 이전하고 기존 스택을 비운다. 팩토리 함수에서 자원을 안전하게 반환할 때 유용하다.
def open_multiple(filenames):
with ExitStack() as cleanup:
files = [cleanup.enter_context(open(fn)) for fn in filenames]
# 여기까지 오면 모든 파일이 열린 것
# cleanup 스택을 caller 에게 이전 (소유권 넘김)
return files, cleanup.pop_all()
files, stack = open_multiple(["a.csv", "b.csv"])
with stack:
process(files)
# 여기서 파일들 자동 닫힘
pop_all()이 없으면 open_multiple에서 예외가 나면 cleanup이 with 블록 종료로 파일을 닫아주지만, 정상 반환 시엔 정리 책임이 호출자에게 넘어가야 한다.
예외 처리
ExitStack은 각 __exit__ 호출의 예외를 개별 처리한다. 하나가 실패해도 나머지 __exit__는 계속 실행된다.
from contextlib import ExitStack, suppress
with ExitStack() as stack:
# 특정 매니저의 예외만 억제
stack.enter_context(suppress(OSError))
stack.enter_context(open("data.txt"))
stack.enter_context(open("log.txt", "w"))
# OSError 는 억제, 나머지 예외는 전파
AsyncExitStack
비동기 환경에서는 contextlib.AsyncExitStack을 쓴다. aenter_context와 async with를 사용한다는 점만 다르다.
from contextlib import AsyncExitStack
async def main():
async with AsyncExitStack() as stack:
conn = await stack.enter_async_context(async_db_connect())
await do_async_work(conn)
요약
ExitStack: 런타임에 결정된 개수의 컨텍스트 매니저를 LIFO로 관리enter_context(cm): 스택에 등록 +__enter__()반환값 반환callback(fn, *args): 일반 함수를 정리 콜백으로 등록pop_all(): 스택 소유권을 새 ExitStack으로 이전AsyncExitStack: 비동기 버전
지난 글: contextlib.suppress와 redirect
다음 글: 비동기 컨텍스트 매니저
읽어주셔서 감사합니다. 😊