Published on

데코레이터+컨텍스트 매니저로 리소스 누수 0

Authors

서버가 오래 돌수록 “조금씩” 새는 리소스는 결국 장애로 돌아옵니다. 파일 디스크립터가 쌓이고, DB 커넥션 풀이 고갈되고, 락이 풀리지 않아 스레드가 멈추고, 임시 디렉터리가 남아 디스크가 차는 식입니다. 문제는 이런 누수가 대부분 “정상 동작 중엔 티가 안 난다”는 점입니다. 트래픽 피크나 예외 경로에서만 드러나고, 그때는 이미 늦습니다.

이 글은 Python에서 컨텍스트 매니저로 “획득/해제”를 강제하고, 데코레이터로 “적용 범위”를 넓혀서, 리소스 누수를 구조적으로 0에 가깝게 만드는 패턴을 다룹니다. 특히 다음을 목표로 합니다.

  • 예외가 나도 반드시 정리(close, release, shutdown)된다
  • 호출자가 실수로 정리 코드를 빼먹을 여지가 없다
  • 중첩 호출, 재시도, 타임아웃, 부분 실패에서도 누수가 없다
  • 관측 가능성(로그/메트릭)까지 함께 넣을 수 있다

데코레이터 구현에서 functools.wrapsargs/kwargs 보존은 필수입니다. 관련해서는 Python 데코레이터 args/kwargs 깨짐 완벽 복구 도 함께 보면 좋습니다.

왜 “컨텍스트 매니저만”으로는 부족한가

컨텍스트 매니저(with)는 강력하지만, 실무에서는 다음 이유로 누수가 생깁니다.

  1. 호출자가 with 를 빼먹는다
  2. 함수 내부에서 여러 리소스를 다루며 정리 순서가 꼬인다
  3. 예외 경로가 많아 try/finally 가 누더기가 된다
  4. 비동기/스레드풀/프로세스풀처럼 종료가 애매한 리소스가 섞인다

컨텍스트 매니저는 “블록 단위”로 안전을 주지만, API 표면(함수 호출 자체) 에 안전을 강제하긴 어렵습니다. 여기서 데코레이터가 빛납니다.

  • 컨텍스트 매니저: 올바른 획득/해제 규약을 캡슐화
  • 데코레이터: 해당 규약을 함수 호출에 강제 적용

핵심 도구 1: contextlib.ExitStack 로 다중 리소스 안전하게 관리

리소스가 1개면 with 로 충분하지만, 2개 이상이면 ExitStack 이 압도적으로 깔끔합니다.

from contextlib import ExitStack

class FakeConn:
    def __init__(self, name: str):
        self.name = name
        self.closed = False

    def close(self):
        self.closed = True


def open_conn(name: str) -> FakeConn:
    return FakeConn(name)


def do_work():
    with ExitStack() as stack:
        c1 = stack.enter_context(_closing(open_conn("primary")))
        c2 = stack.enter_context(_closing(open_conn("replica")))
        # 여기서 예외가 나도 c2, c1 순서로 close 보장
        return (c1.name, c2.name)


from contextlib import contextmanager

@contextmanager
def _closing(obj):
    try:
        yield obj
    finally:
        obj.close()

ExitStack 은 “나중에 등록한 것부터 역순으로 정리”합니다. 트랜잭션, 락, 임시파일처럼 정리 순서가 중요한 리소스에 특히 유용합니다.

핵심 도구 2: 컨텍스트 매니저를 데코레이터로 승격시키기

목표는 호출자가 with 를 몰라도 안전하게 만드는 것입니다. 가장 단순한 형태는 “함수 실행을 컨텍스트로 감싸는 데코레이터”입니다.

패턴 A: @with_resource(cm_factory)

cm_factory 는 호출 시점에 컨텍스트 매니저를 만들어야 합니다. 그래야 인자에 따라 리소스를 선택할 수 있습니다.

from __future__ import annotations

from contextlib import contextmanager
from functools import wraps
from typing import Callable, Iterator, TypeVar, ParamSpec

P = ParamSpec("P")
R = TypeVar("R")


def with_resource(cm_factory: Callable[P, "_CM" ]):
    def decorator(fn: Callable[P, R]) -> Callable[P, R]:
        @wraps(fn)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            with cm_factory(*args, **kwargs):
                return fn(*args, **kwargs)
        return wrapper
    return decorator


class _CM:
    def __enter__(self): ...
    def __exit__(self, exc_type, exc, tb): ...


@contextmanager
def request_scope(*args, **kwargs) -> Iterator[None]:
    # 예: request_id 세팅, 임시 디렉터리 생성, 로깅 컨텍스트 주입 등
    try:
        yield
    finally:
        # 반드시 정리
        pass


@with_resource(request_scope)
def handler(user_id: str) -> str:
    return f"ok:{user_id}"

이 패턴의 장점은 “함수 호출마다 자동으로 컨텍스트가 생기고 사라진다”는 점입니다. 즉, 스코프 기반 정리를 함수 레벨로 강제합니다.

다만, 위 예제는 컨텍스트가 함수에 값을 주입하지 않습니다. 실무에서는 보통 커넥션/세션/락 같은 객체를 함수에 전달하고 싶습니다.

리소스를 함수 인자로 주입하는 “누수 0” 패턴

컨텍스트 매니저가 생성한 리소스를 함수에 주입하면, 호출자는 리소스 획득을 몰라도 됩니다. 중요한 건 “정리는 데코레이터가 책임진다”는 계약입니다.

패턴 B: @inject_resource(resource_cm, param=...)

from __future__ import annotations

from contextlib import contextmanager
from functools import wraps
from typing import Callable, Iterator, TypeVar, ParamSpec

P = ParamSpec("P")
R = TypeVar("R")


def inject_resource(
    resource_cm: Callable[P, Iterator[object]],
    *,
    param: str,
):
    def decorator(fn: Callable[P, R]) -> Callable[P, R]:
        @wraps(fn)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            if param in kwargs:
                raise TypeError(f"'{param}' is managed by decorator")

            with resource_cm(*args, **kwargs) as resource:
                kwargs[param] = resource
                return fn(*args, **kwargs)

        return wrapper

    return decorator


class Conn:
    def __init__(self):
        self.closed = False

    def close(self):
        self.closed = True


@contextmanager
def conn_cm(*args, **kwargs) -> Iterator[Conn]:
    c = Conn()
    try:
        yield c
    finally:
        c.close()


@inject_resource(conn_cm, param="conn")
def get_user(user_id: str, *, conn: Conn) -> dict:
    # conn 사용
    return {"user_id": user_id, "closed": conn.closed}

이제 get_user("42") 는 커넥션을 자동으로 열고, 함수가 끝나면 자동으로 닫습니다. 예외가 나도 finally 로 닫힙니다.

이 패턴이 누수를 막는 핵심

  • 리소스의 생명주기가 “함수 호출”에 묶임
  • 호출자가 close 를 호출할 기회 자체가 없음(실수 여지 제거)
  • 테스트에서 누수 여부를 쉽게 검증 가능

트랜잭션, 롤백, 커밋까지 자동화하기

DB는 단순히 close 만으로 끝나지 않습니다. 트랜잭션의 커밋/롤백 규칙을 강제해야 “논리적 누수(락/트랜잭션 미종료)”를 막습니다.

아래는 “성공 시 커밋, 예외 시 롤백, 마지막에 close”를 보장하는 컨텍스트 매니저입니다.

from contextlib import contextmanager
from typing import Iterator

class TxConn:
    def __init__(self):
        self.closed = False
        self.in_tx = False

    def begin(self):
        self.in_tx = True

    def commit(self):
        self.in_tx = False

    def rollback(self):
        self.in_tx = False

    def close(self):
        self.closed = True


@contextmanager
def transactional_conn() -> Iterator[TxConn]:
    c = TxConn()
    c.begin()
    try:
        yield c
        c.commit()
    except Exception:
        c.rollback()
        raise
    finally:
        c.close()

이제 위의 inject_resource 와 결합하면, “트랜잭션 미종료”로 인한 장애를 구조적으로 차단합니다.

락, 세마포어, 파일 핸들: 누수 유형별 체크리스트

리소스 누수는 형태가 다릅니다. 컨텍스트 매니저에 아래 규칙을 녹이면 안전성이 크게 올라갑니다.

1) 락 누수(Deadlock 유발)

  • 획득 후 예외로 빠지면 release 가 누락
  • 타임아웃 없이 무한 대기
import threading
from contextlib import contextmanager
from typing import Iterator

@contextmanager
def acquired(lock: threading.Lock, *, timeout: float | None = None) -> Iterator[None]:
    ok = lock.acquire(timeout=timeout) if timeout is not None else lock.acquire()
    if not ok:
        raise TimeoutError("lock acquire timeout")
    try:
        yield
    finally:
        lock.release()

이 컨텍스트를 데코레이터로 감싸면 “락을 잡고 안 놓는” 버그를 거의 제거할 수 있습니다.

2) 파일 디스크립터 누수

  • open 후 예외로 빠지면 close 누락
  • 반복 처리에서 누적

파일은 표준 with open(...) 만 지켜도 안전하지만, “호출자가 with 를 빼먹는” 상황을 없애려면 데코레이터 주입이 유효합니다.

3) 스레드풀/프로세스풀 누수

  • executor.shutdown 누락
  • 작업 중 예외로 종료 경로가 꼬임

풀은 특히 “생성 스코프”를 명확히 해야 합니다. 함수 단위로 생성/파괴하면 오버헤드가 크고, 전역으로 두면 종료가 애매합니다. 이때는 “요청 스코프(예: 배치 1회 실행, API 요청 1회 처리)” 단위로 컨텍스트를 두는 게 좋습니다.

실패해도 정리는 한다: 예외, 재시도, 타임아웃 조합

실무에서는 재시도 로직이 누수를 악화시키는 경우가 많습니다.

  • 재시도마다 커넥션을 열고 닫지 않으면 커넥션이 쌓임
  • 예외를 삼키면 롤백이 안 된 상태로 진행

권장 구조는 다음입니다.

  • “재시도 루프” 바깥에서 리소스를 잡지 말 것
  • 재시도 1회당 리소스를 새로 잡고, 실패하면 즉시 정리
import time
from typing import Callable, TypeVar

R = TypeVar("R")


def retry(times: int, *, delay: float) -> Callable[[Callable[[], R]], Callable[[], R]]:
    def decorator(fn: Callable[[], R]) -> Callable[[], R]:
        def wrapper() -> R:
            last_exc: Exception | None = None
            for _ in range(times):
                try:
                    return fn()
                except Exception as e:
                    last_exc = e
                    time.sleep(delay)
            assert last_exc is not None
            raise last_exc
        return wrapper
    return decorator


@retry(3, delay=0.2)
def do_tx_job() -> str:
    # 재시도 1회마다 transactional_conn 컨텍스트가 새로 열리고 닫힘
    with transactional_conn() as conn:
        # 작업 수행
        return f"done:{conn.closed}"  # 함수 반환 시점에는 아직 close 전

핵심은 “재시도 단위”와 “리소스 스코프”를 일치시키는 것입니다.

관측 가능성: 누수는 결국 운영 이슈다

리소스 누수는 단순 버그를 넘어 운영 장애로 연결됩니다. 예를 들어 커넥션 풀이 고갈되면 애플리케이션이 멈추고, 컨테이너 환경에서는 헬스체크 실패로 재시작 루프에 들어갈 수 있습니다. 이런 증상은 CrashLoopBackOff 나 OOM 같은 형태로 나타나기도 합니다. 운영에서 재시작 루프를 빨리 좁히는 방법은 Kubernetes CrashLoopBackOff 원인별 로그·해결 9가지 같은 체크리스트가 도움이 됩니다.

코드 레벨에서는 컨텍스트 매니저에 “획득/해제 로그”를 넣어두면 누수 추적이 쉬워집니다.

import logging
from contextlib import contextmanager
from typing import Iterator

logger = logging.getLogger(__name__)

@contextmanager
def logged_resource(name: str) -> Iterator[None]:
    logger.info("resource acquire: %s", name)
    try:
        yield
    finally:
        logger.info("resource release: %s", name)

이 패턴을 커넥션/락/임시파일 컨텍스트에 적용하면, “획득은 있는데 해제가 없는” 케이스를 로그로 잡아낼 수 있습니다.

실전 조합: ExitStack + 주입 데코레이터로 다중 리소스 원샷 관리

요청 한 번 처리에 커넥션, 락, 임시 디렉터리, 트레이싱 스팬 등 여러 리소스가 필요할 수 있습니다. 이때는 “하나의 컨텍스트 매니저가 여러 리소스를 관리”하도록 만들고, 데코레이터로 함수에 주입하는 방식이 가장 견고합니다.

from __future__ import annotations

import tempfile
import threading
from contextlib import ExitStack, contextmanager
from dataclasses import dataclass
from typing import Iterator

@dataclass
class Scope:
    tmpdir: str
    lock: threading.Lock
    conn: TxConn


@contextmanager
def scope_cm() -> Iterator[Scope]:
    lock = threading.Lock()

    with ExitStack() as stack:
        # tmpdir
        tmp = stack.enter_context(tempfile.TemporaryDirectory())

        # lock acquired
        stack.enter_context(acquired(lock, timeout=1.0))

        # transactional conn
        conn = stack.enter_context(transactional_conn())

        yield Scope(tmpdir=tmp.name, lock=lock, conn=conn)


def inject_scope(param: str = "scope"):
    def decorator(fn):
        from functools import wraps

        @wraps(fn)
        def wrapper(*args, **kwargs):
            if param in kwargs:
                raise TypeError(f"'{param}' is managed by decorator")
            with scope_cm() as scope:
                kwargs[param] = scope
                return fn(*args, **kwargs)
        return wrapper
    return decorator


@inject_scope("scope")
def run_job(job_id: str, *, scope: Scope) -> str:
    # scope.tmpdir, scope.conn 등을 안전하게 사용
    return f"job:{job_id}:tmp={scope.tmpdir}"

이 구조의 장점은 다음과 같습니다.

  • 리소스 획득/해제가 한 곳(scope_cm)에만 존재
  • 정리 순서가 ExitStack 으로 자동 관리
  • 호출자는 run_job 만 호출하면 됨
  • 테스트에서 scope_cm 만 집중 검증하면 전체 누수 방지 효과 확보

주의점: 데코레이터가 리소스 수명을 “숨기는” 부작용

데코레이터 주입은 강력하지만, 과하면 디버깅이 어려워질 수 있습니다. 아래 가이드를 지키면 균형이 맞습니다.

  1. 함수 시그니처에 주입되는 파라미터를 keyword-only 로 둔다
    • 예: def f(x, *, conn): ...
  2. 데코레이터가 관리하는 파라미터는 외부에서 덮어쓰지 못하게 막는다
    • 위 예제처럼 param in kwargsTypeError
  3. 컨텍스트 매니저 이름을 역할 중심으로 짓는다
    • 예: transactional_conn, request_scope, batch_scope
  4. 관측(로그/메트릭)을 컨텍스트 매니저에 넣는다

데코레이터 자체의 구현 실수(시그니처/메타데이터 손실)는 다른 버그를 낳을 수 있으니, wrapsParamSpec 활용은 사실상 필수입니다. 자세한 배경은 앞서 언급한 내부 글(Python 데코레이터 args/kwargs 깨짐 완벽 복구)을 참고하세요.

마무리: “리소스는 스코프에 귀속”시키면 누수는 사라진다

리소스 누수를 0에 가깝게 만드는 핵심은 간단합니다.

  • 리소스 획득/해제 규약을 컨텍스트 매니저로 캡슐화
  • 그 컨텍스트를 데코레이터로 함수 호출에 강제 적용
  • 여러 리소스는 ExitStack 으로 정리 순서까지 포함해 일원화

이 조합을 표준 패턴으로 팀에 정착시키면, “예외 경로에서만 새는” 고질적인 누수를 크게 줄일 수 있습니다. 그리고 누수는 결국 운영 장애로 이어지므로, 로그/메트릭까지 컨텍스트에 함께 묶어두면 문제를 훨씬 빨리 발견하고 복구할 수 있습니다.