Published on

Python ExitStack로 동적 리소스 누수 막기

Authors

리소스 누수는 흔히 with를 빼먹어서가 아니라, 리소스 개수가 실행 중 동적으로 변하는 상황에서 발생합니다. 예를 들어 설정에 따라 파일을 N개 열거나, 성공한 단계까지만 커밋하고 실패 시 되돌려야 하는 파이프라인을 만들 때가 그렇습니다.

파이썬의 with는 단일 컨텍스트에는 매우 강력하지만, 컨텍스트를 동적으로 쌓아야 하는 순간 코드가 급격히 지저분해집니다.

  • 반복문에서 with open(...)을 여러 번 사용하고 싶다
  • 어떤 리소스는 조건에 따라 열리고, 어떤 리소스는 열리지 않는다
  • 중간에 예외가 나면 이미 열린 것들만 역순으로 닫아야 한다
  • 실패 시 보상 동작(rollback)을 함께 등록하고 싶다

이 글에서는 contextlib.ExitStack로 위 문제를 정리하고, 예외가 섞여도 누수 없이 종료되는 구조를 만드는 방법을 다룹니다.

관련해서 “누수 없이 fan-in/fan-out 구성” 같은 주제에 관심이 있다면 Go 채널 팬아웃·팬인 - 누수 없이 구현도 함께 보면 관점이 확장됩니다.

ExitStack가 해결하는 핵심

contextlib.ExitStack는 한 줄로 요약하면 동적으로 __enter__/__exit__를 스택에 쌓아두고, 블록을 빠져나갈 때 역순으로 정리해주는 도구입니다.

  • stack.enter_context(cm)로 컨텍스트 매니저를 “등록”하고, __enter__ 결과를 돌려받습니다.
  • stack.callback(fn, *args, **kwargs)로 임의의 정리 함수를 “등록”할 수 있습니다.
  • 블록을 빠져나갈 때(정상 종료든 예외든) 등록된 것들이 **LIFO(후입선출)**로 실행됩니다.

즉, 리소스를 여러 개 열어도 with는 딱 한 번만 쓰고, 내부에서 필요한 만큼 쌓으면 됩니다.

왜 단순 반복 with로는 부족할까

아래처럼 파일을 여러 개 열고 처리하는 코드는 처음엔 자연스럽습니다.

paths = ["a.txt", "b.txt", "c.txt"]

handles = []
for p in paths:
    f = open(p, "rb")
    handles.append(f)

# ... 처리 ...

for f in handles:
    f.close()

문제는 중간에 예외가 나면 close() 루프까지 도달하지 못해 누수가 납니다. 물론 try/finally로 감쌀 수 있지만, 리소스가 여러 종류(파일, 락, 임시 디렉터리, 소켓)로 섞이기 시작하면 finally가 길어지고, “어디까지 열렸는지”를 추적하는 코드가 늘어납니다.

ExitStack는 이 추적을 스택 자체에 위임합니다.

기본 사용법: 동적 파일 오픈을 안전하게

from contextlib import ExitStack

paths = ["a.txt", "b.txt", "c.txt"]

with ExitStack() as stack:
    files = [stack.enter_context(open(p, "rb")) for p in paths]

    # 여기서 예외가 나도 이미 열린 파일은 모두 역순으로 close()됨
    payloads = [f.read() for f in files]

# 블록 종료 시점에 close() 완료

포인트는 두 가지입니다.

  1. open()은 컨텍스트 매니저이므로 enter_context()로 등록할 수 있습니다.
  2. ExitStack 블록 안에서 예외가 나면, 그 시점까지 등록된 파일만 정확히 닫힙니다.

조건부 리소스: “열렸으면 닫고, 아니면 말고”를 깔끔하게

조건에 따라 리소스를 열 수도 있고 안 열 수도 있는 경우, 흔히 None 체크가 늘어납니다.

ExitStack에서는 “열기로 결정한 것만 스택에 쌓는다”는 규칙을 적용하면 됩니다.

from contextlib import ExitStack

use_extra = True

with ExitStack() as stack:
    main = stack.enter_context(open("main.log", "a", encoding="utf-8"))

    if use_extra:
        extra = stack.enter_context(open("extra.log", "a", encoding="utf-8"))
        extra.write("extra enabled\n")

    main.write("always\n")

use_extraFalse면 애초에 extra는 등록되지 않으니, 정리 로직도 필요 없습니다.

callback()로 “컨텍스트가 아닌 정리”도 스택에 합치기

세상 모든 리소스가 컨텍스트 매니저를 제공하진 않습니다. 하지만 ExitStack는 임의의 정리 함수를 스택에 등록할 수 있습니다.

from contextlib import ExitStack
import os
import tempfile

with ExitStack() as stack:
    fd, path = tempfile.mkstemp(prefix="demo-")

    # fd는 정수 파일 디스크립터라서 close()를 직접 등록
    stack.callback(os.close, fd)

    # path 삭제도 콜백으로 등록
    stack.callback(os.remove, path)

    with open(path, "w", encoding="utf-8") as f:
        f.write("hello\n")

    # 여기서 예외가 나도 os.remove, os.close가 역순으로 실행됨

이 패턴은 특히 “임시 파일 생성 후 후처리” 같은 코드에서 누수 방지에 탁월합니다.

실패 시 보상(rollback)까지: 작은 사가(Saga)처럼 쓰기

여러 단계를 진행하다가 중간 실패 시 앞 단계들을 되돌려야 하는 경우가 있습니다. 데이터베이스 트랜잭션이 아닌 외부 시스템 호출(파일 이동, 캐시 갱신, 메시지 발행 등)은 보상 동작이 필요합니다.

ExitStack.callback()는 이런 보상 작업을 쌓아두는 데도 유용합니다.

from contextlib import ExitStack
import os
import shutil


def move_with_rollback(src: str, dst: str) -> None:
    with ExitStack() as stack:
        shutil.move(src, dst)
        # 실패하면 되돌리기
        stack.callback(shutil.move, dst, src)

        # 다음 단계들...
        os.utime(dst, None)  # 예시: 메타데이터 변경

        # 모든 단계가 성공했으면 rollback 콜백 제거
        stack.pop_all()


# 사용 예시
# move_with_rollback("a.txt", "archive/a.txt")

여기서 중요한 점은 stack.pop_all()입니다.

  • ExitStack는 기본적으로 “블록 종료 시 등록된 콜백을 실행”합니다.
  • 모든 단계가 성공했으면 rollback을 실행하면 안 되므로, pop_all()로 스택을 비워 “정상 커밋”처럼 동작하게 만들 수 있습니다.

분산 트랜잭션에서 보상 전략을 설계하는 관점은 MSA 사가 패턴 - 보상 트랜잭션 실패 복구 전략도 참고할 만합니다. ExitStack는 로컬 코드에서 이를 아주 작은 단위로 구현하는 느낌에 가깝습니다.

예외 처리 모델: ExitStack는 무엇을 보장하나

ExitStack는 다음을 보장합니다.

  • 블록 안에서 예외가 발생하면, 그 시점까지 성공적으로 등록된 종료 작업만 실행됩니다.
  • 종료 작업은 등록 역순으로 실행됩니다.
  • 각 컨텍스트 매니저의 __exit__ 시그니처에 맞춰 예외 정보를 전달하므로, 일반 with와 동일하게 “예외 억제” 동작도 가능합니다.

주의할 점도 있습니다.

  • 종료 작업(콜백) 자체에서 예외가 나면, 이후 종료 작업이 어떻게 처리되는지(추가 예외 체인)는 파이썬 버전과 상황에 따라 복잡해질 수 있습니다.
  • 따라서 콜백은 가능한 한 예외를 삼키거나 로깅 후 진행하도록 작성하는 것이 안전합니다.

실전 예제: 여러 파일을 열고 UTF-8 디코딩 실패에도 누수 없이 종료

로그 수집처럼 여러 파일을 읽는데, 일부 파일은 인코딩이 깨져 예외가 날 수 있습니다. 이때도 열린 파일 핸들이 누수되면 장시간 실행 프로세스에서 문제가 커집니다.

from contextlib import ExitStack


def read_many(paths: list[str]) -> dict[str, str]:
    out: dict[str, str] = {}

    with ExitStack() as stack:
        files = {}
        for p in paths:
            files[p] = stack.enter_context(open(p, "rb"))

        for p, f in files.items():
            data = f.read()
            # 일부가 깨졌더라도 파일 핸들은 ExitStack가 정리
            out[p] = data.decode("utf-8")

    return out

인코딩 예외를 더 잘 다루는 방법이 필요하면 Python UnicodeDecodeError 재현·원인별 5분 해결도 같이 보면 문제를 더 빨리 좁힐 수 있습니다.

ExitStack를 도입하기 좋은 체크리스트

다음 중 하나라도 해당하면 ExitStack를 고려할 만합니다.

  • 리소스를 for 루프에서 N개 열어야 한다
  • 조건 분기마다 열리는 리소스가 다르다
  • “성공한 단계까지만 정리/보상”이 필요하다
  • try/finally가 중첩되고, None 체크가 늘어나 가독성이 무너진다

반대로, 리소스가 1개 또는 고정 개수이고 흐름이 단순하다면 일반 with가 더 읽기 쉽습니다.

마무리

contextlib.ExitStack는 “동적 리소스”라는 파이썬 코드의 고질적인 복잡도를 낮춰줍니다. 핵심은 단순합니다.

  • 열기로 결정한 것만 enter_context()로 등록한다
  • 컨텍스트가 아닌 정리는 callback()으로 등록한다
  • 실패 시 보상까지 포함해 스택에 쌓고, 성공 시 pop_all()로 커밋한다

이 패턴을 익혀두면 파일, 락, 임시 리소스, 외부 핸들러 등 다양한 자원을 다루는 코드에서 리소스 누수를 구조적으로 차단할 수 있습니다.