Published on

Python generator 기반 contextmanager 누수·예외처리 7가지

Authors

서버 코드나 데이터 파이프라인에서 with 블록은 “자원 획득과 해제”를 가장 읽기 좋게 묶어줍니다. 파이썬에서는 클래스 기반 컨텍스트 매니저(__enter__/__exit__) 외에도 contextlib.contextmanager 데코레이터로 generator 하나만으로 컨텍스트를 만들 수 있어 매우 편리합니다.

하지만 generator 기반 컨텍스트 매니저는 “한 번 yield하고, 그 이후에 정리(cleanup) 코드가 실행된다”는 제어 흐름이 숨어 있어, 예외/취소/중첩/반환값 처리에서 미묘한 버그가 나기 쉽습니다. 특히 네트워크 소켓, 파일 핸들, DB 커넥션처럼 OS 자원을 다루면 누수는 곧 장애로 이어집니다.

아래는 현장에서 자주 보는 누수·예외처리 7가지 실패 패턴과, 각각의 안전한 대안입니다.

참고로 “누수”는 메모리만이 아니라 파일 디스크립터, 커넥션, 락 같은 해제되어야 할 자원 전반을 의미합니다. 비슷한 맥락의 비용/자원 관리 관점은 AutoGPT 메모리 누수? 벡터DB TTL로 비용 줄이기도 함께 보면 도움이 됩니다.

기본: generator 기반 contextmanager 동작 모델

@contextmanager는 내부적으로 generator를 실행해 yieldas 변수에 바인딩하고, with 블록이 끝나면 generator를 재개해 yield 이후 코드를 실행합니다. with 블록 내부에서 예외가 나면, 그 예외를 generator 쪽으로 던져서(throw) 정리 코드가 실행될 기회를 줍니다.

즉, 아래 형태가 사실상의 정석입니다.

from contextlib import contextmanager

@contextmanager
def managed_resource():
    # acquire
    res = acquire()
    try:
        yield res
    finally:
        # release (항상 실행)
        release(res)

이제부터는 이 “정석”이 깨지는 지점을 7가지로 나눠 보겠습니다.

1) try/finally 없이 yield만 쓰는 실수

가장 흔한 누수입니다. yield 아래에 정리 코드를 둬도, 예외/조기 반환/취소에서 실행되지 않는다고 착각하는 경우가 많습니다.

문제 코드

from contextlib import contextmanager

@contextmanager
def open_file_bad(path):
    f = open(path, "w")
    yield f
    f.close()  # 예외가 나면 여기에 도달 못할 수 있음

with 내부에서 예외가 나면 generator는 예외를 받지만, 위 코드는 예외 처리를 하지 않으니 f.close()까지 진행된다는 보장이 없습니다.

안전한 코드

from contextlib import contextmanager

@contextmanager
def open_file(path):
    f = open(path, "w")
    try:
        yield f
    finally:
        f.close()

핵심은 “정리 코드는 무조건 finally”입니다. except로 잡고 넘기는 방식은 오히려 원인 은폐를 만들 수 있어 주의가 필요합니다.

2) except Exception로 삼켜서 원인 은폐 + 상태 꼬임

정리 코드에서 예외를 잡는 것은 필요할 수 있지만, 무분별한 except Exception: pass는 장애를 “조용히” 만들고, 다음 호출에서 더 큰 문제로 터집니다.

문제 코드

from contextlib import contextmanager

@contextmanager
def lock_bad(lock):
    lock.acquire()
    try:
        yield
    except Exception:
        # 에러를 숨기면 호출자는 성공한 줄 안다
        pass
    finally:
        lock.release()

with 블록에서 터진 예외가 호출자에게 전달되지 않아, 트랜잭션/상태머신/재시도 로직이 모두 잘못된 가정 위에서 동작합니다.

권장 패턴

  • 예외를 로깅하고 반드시 재발생시키기
  • 정리 단계 예외는 별도로 처리하되, 원 예외를 가리지 않기
import logging
from contextlib import contextmanager

log = logging.getLogger(__name__)

@contextmanager
def lock_safe(lock):
    lock.acquire()
    try:
        yield
    except Exception:
        log.exception("error inside lock scope")
        raise
    finally:
        lock.release()

재시도/백오프 같은 정책이 필요한 경우는 예외를 숨기지 말고 상위로 올려서 정책 레이어에서 처리하는 편이 안전합니다. (재시도 설계 관점은 Claude 429 과금폭탄 막는 재시도·백오프 전략도 참고할 만합니다.)

3) 정리(cleanup) 단계에서 새 예외가 터져 “원 예외”를 덮어버림

with 블록 내부에서 발생한 예외 A가 진짜 원인인데, finally에서 예외 B가 나면 파이썬은 B를 최종 예외로 보고합니다. 그러면 디버깅이 매우 어려워집니다.

문제 코드

from contextlib import contextmanager

@contextmanager
def conn_bad(conn):
    conn.open()
    try:
        yield conn
    finally:
        conn.close()  # close()가 실패하면 원 예외가 묻힐 수 있음

개선: 정리 예외는 로깅하고 억제(또는 체이닝)

정리 예외를 완전히 무시하는 것도 위험하지만, 원 예외를 덮는 것도 위험합니다. 일반적으로는 다음 중 하나를 선택합니다.

  • 원 예외가 있는 경우: 정리 예외는 로깅만 하고 억제
  • 원 예외가 없는 경우: 정리 예외는 그대로 발생
import logging
from contextlib import contextmanager

log = logging.getLogger(__name__)

@contextmanager
def conn_safe(conn):
    conn.open()
    try:
        yield conn
    except Exception:
        # 원 예외가 있는 상태
        try:
            conn.close()
        except Exception:
            log.exception("cleanup failed while handling original error")
        raise
    else:
        # 정상 종료
        conn.close()

이 패턴은 다소 장황하지만 “원인 보존” 측면에서 실무에선 가치가 큽니다.

4) yield를 두 번 하거나, 아예 yield에 도달하지 못하는 버그

@contextmanager는 **정확히 한 번만 yield**해야 합니다. 두 번 yield하면 RuntimeError가 날 수 있고, yield 전에 예외가 발생하면 __enter__ 단계에서 실패하므로 __exit__ 정리도 기대할 수 없습니다(획득이 완료되지 않았기 때문).

문제 코드: 조건에 따라 두 번 yield

from contextlib import contextmanager

@contextmanager
def maybe_two_yields(flag):
    if flag:
        yield "A"
    yield "B"  # flag가 True면 두 번 yield

문제 코드: yield 이전에 위험한 작업

from contextlib import contextmanager

@contextmanager
def open_then_parse(path):
    f = open(path)
    data = risky_parse(f)  # 여기서 예외면 f가 닫히지 않을 수 있음
    try:
        yield data
    finally:
        f.close()

해결: yield 이전 단계도 try/finally로 보호

from contextlib import contextmanager

@contextmanager
def open_then_parse_safe(path):
    f = open(path)
    try:
        data = risky_parse(f)
        yield data
    finally:
        f.close()

포인트는 “획득 이후 yield까지의 모든 구간도 정리 보호 범위에 포함”시키는 것입니다.

5) return으로 컨텍스트를 조기 종료하려는 오해

generator 기반 컨텍스트 매니저에서 return은 generator 종료를 의미합니다. yield 이후에 return을 하거나, yield 이전에 return을 해버리면 컨텍스트 계약이 깨집니다.

문제 코드

from contextlib import contextmanager

@contextmanager
def cm_return_bad(enabled):
    if not enabled:
        return  # yield 없이 종료: with 진입 자체가 실패
    yield "ok"

이 경우 호출 측 with cm_return_bad(False):는 정상적인 컨텍스트 매니저로 동작하지 않습니다.

해결: “비활성 모드”는 더미 자원 yield로 표현

from contextlib import contextmanager

@contextmanager
def cm_optional(enabled):
    if not enabled:
        yield None
        return
    # 실제 자원 획득
    res = acquire()
    try:
        yield res
    finally:
        release(res)

이렇게 하면 호출자는 항상 with를 동일한 방식으로 사용할 수 있고, 분기 로직이 컨텍스트 외부로 새지 않습니다.

6) asyncio 취소(CancelledError)를 잘못 다뤄 누수/행 걸림

비동기 코드에서 가장 까다로운 지점입니다.

  • 동기 @contextmanager 안에서 비동기 작업을 억지로 처리하면 정리 타이밍이 꼬입니다.
  • asyncio.CancelledError를 잡아서 삼키면 태스크 취소가 전파되지 않아 시스템이 “멈춘 것처럼” 보이기도 합니다.

잘못된 예: CancelledErrorException으로 취급

파이썬 버전에 따라 CancelledError의 상속 구조가 다를 수 있어(일부 버전에서는 Exception 계열), 아래 같은 코드는 취소를 삼킬 위험이 있습니다.

import asyncio
from contextlib import asynccontextmanager

@asynccontextmanager
async def cm_cancel_bad():
    res = await acquire_async()
    try:
        yield res
    except Exception:
        # 취소까지 여기서 삼켜질 수 있음
        await rollback_async(res)
    finally:
        await release_async(res)

권장: 취소는 반드시 재발생

import asyncio
from contextlib import asynccontextmanager

@asynccontextmanager
async def cm_cancel_safe():
    res = await acquire_async()
    try:
        yield res
    except asyncio.CancelledError:
        # 필요한 정리만 하고 취소는 반드시 전파
        await rollback_async(res)
        raise
    except Exception:
        await rollback_async(res)
        raise
    finally:
        await release_async(res)

취소는 “에러 처리”라기보다 “제어 흐름”에 가깝습니다. 삼키지 않는 것이 원칙입니다.

7) 중첩 컨텍스트에서 부분 획득 후 실패 시 역순 정리가 누락됨

여러 자원을 순차적으로 획득하다가 중간에서 예외가 나면, 이미 획득한 앞 자원들을 정리해야 합니다. 이를 손으로 구현하면 누락이 잦습니다.

문제 코드

from contextlib import contextmanager

@contextmanager
def two_resources_bad():
    a = acquire_a()
    b = acquire_b()  # 여기서 예외면 a 정리 누락
    try:
        yield a, b
    finally:
        release_b(b)
        release_a(a)

해결 1: ExitStack로 “획득 성공한 것만” 자동 정리

contextlib.ExitStack은 동적 개수의 컨텍스트를 안전하게 관리합니다.

from contextlib import ExitStack, contextmanager

@contextmanager
def two_resources_exitstack():
    with ExitStack() as stack:
        a = stack.enter_context(cm_a())
        b = stack.enter_context(cm_b())
        yield a, b
  • cm_b() 진입에서 실패하면, cm_a()는 자동으로 __exit__가 호출됩니다.
  • 정리 순서는 역순(LIFO)이라 의도한 대로 동작합니다.

해결 2: 아예 클래스 기반 컨텍스트로 전환 고려

상태가 복잡해지고 “정리 단계의 예외 정책”이 중요해지면, generator 기반보다 __exit__에서 예외 인자(exc_type, exc, tb)를 명시적으로 다루는 클래스 기반이 더 명확할 때가 많습니다.

실전 점검 체크리스트

아래 항목을 코드 리뷰 체크리스트로 삼으면 generator 기반 컨텍스트의 사고를 많이 줄일 수 있습니다.

  1. yield는 정확히 한 번인가
  2. 획득 이후 yield까지의 모든 구간이 try/finally로 보호되는가
  3. 정리 코드는 finally에 있는가
  4. except로 예외를 잡는다면, 로깅 후 raise로 재전파하는가
  5. 정리 단계 예외가 원 예외를 덮지 않게 설계했는가
  6. 비동기에서는 @asynccontextmanager를 사용하고, CancelledError는 반드시 재발생시키는가
  7. 다중 자원은 ExitStack으로 부분 획득/부분 실패를 안전하게 처리하는가

간단 테스트 예시: 누수 방지 검증

파일 디스크립터 같은 누수는 테스트에서 빨리 잡을수록 좋습니다. 아래는 “예외가 나도 close가 호출되는지”를 모킹으로 검증하는 예시입니다.

from contextlib import contextmanager
from unittest.mock import Mock
import pytest

@contextmanager
def cm_under_test(res):
    res.open()
    try:
        yield res
    finally:
        res.close()


def test_close_called_on_exception():
    res = Mock()
    with pytest.raises(ValueError):
        with cm_under_test(res):
            raise ValueError("boom")
    res.close.assert_called_once()

이런 테스트는 단순하지만, 운영에서 “가끔씩 FD가 늘어난다” 같은 애매한 장애를 예방하는 데 매우 효과적입니다.

마무리

contextlib.contextmanager는 강력한 도구지만, generator 제어 흐름 특성 때문에 정리 코드가 실행되는 조건예외가 전파되는 방식을 정확히 이해해야 합니다. 특히 정리 단계 예외가 원 예외를 덮는 문제, 비동기 취소 전파, 다중 자원 부분 획득 실패는 실제 장애로 직결되는 빈도가 높습니다.

위 7가지를 팀 규칙으로 정리해두면, “짧고 예쁜 컨텍스트”가 “안전하고 예측 가능한 컨텍스트”로 바뀝니다.