Published on

Python 3.11+ asynccontextmanager로 리소스 누수 막기

Authors

운영에서 가장 성가신 장애 중 하나는 “느리게 죽는” 유형입니다. 처음엔 정상인데 시간이 지날수록 지연이 커지고, 어느 순간 Too many open files 같은 에러가 터지거나 커넥션 풀이 고갈되어 요청이 줄줄이 타임아웃됩니다. 이런 문제의 상당수는 리소스 정리 누락(누수)에서 시작합니다.

Python 비동기 코드에서는 try/finally 로 정리를 강제해야 하지만, 여러 단계의 리소스(예: HTTP 세션 + DB 트랜잭션 + 분산락)가 얽히면 코드가 쉽게 복잡해집니다. 이때 contextlib.asynccontextmanager 를 사용하면 “획득-사용-정리”를 하나의 추상화로 묶어 실수를 줄이고, 리뷰 포인트도 명확해집니다.

이 글에서는 Python 3.11+ 기준으로 asynccontextmanager 를 활용해 리소스 누수를 잡는 방법을 패턴 중심으로 설명합니다.

왜 비동기에서 누수가 더 자주 터질까

비동기 환경에서는 다음 특성이 누수를 더 “잘 숨깁니다”.

  1. 취소(CancelledError)가 흔함: 타임아웃, 상위 요청 취소, 셧다운에서 태스크가 중간에 끊깁니다.
  2. 동시성으로 누수 속도가 빨라짐: 초당 수백 개 태스크가 누수하면 몇 분 만에 한계치 도달.
  3. 정리 코드가 여러 레이어로 흩어짐: 함수가 await 를 거치며 분기되고 예외가 섞이면 finally 를 놓치기 쉽습니다.

결론은 단순합니다. “정리 로직을 사람이 기억해서 넣는” 방식은 확률적으로 실패합니다. 그래서 컨텍스트 매니저로 강제하는 편이 안전합니다.

asynccontextmanager 핵심: yield 앞뒤가 생명주기

contextlib.asynccontextmanager 는 비동기 제너레이터 기반으로 동작합니다.

  • yield 이전: 리소스 획득(열기, 연결, 락 획득)
  • yield 이후: 정리(닫기, 반환, 락 해제)
  • async with 블록에서 예외가 나도 yield 이후 구간이 실행됩니다

기본 형태는 아래와 같습니다.

from contextlib import asynccontextmanager

@asynccontextmanager
async def managed_resource():
    # acquire
    resource = object()
    try:
        yield resource
    finally:
        # cleanup
        pass

여기서 중요한 포인트는 try/finally반드시 두는 것입니다. yield 이후 정리 코드에서 또 예외가 나면 원래 예외를 가릴 수 있으니, 정리 단계는 최대한 “실패해도 안전”하게 작성하는 것이 좋습니다.

패턴 1: HTTP 클라이언트 세션 누수 방지

aiohttphttpx 같은 클라이언트는 세션을 닫지 않으면 소켓이 남아 커넥션이 고갈됩니다. 아래는 httpx.AsyncClient 를 안전하게 여닫는 패턴입니다.

from contextlib import asynccontextmanager
import httpx

@asynccontextmanager
async def http_client(timeout_s: float = 10.0):
    client = httpx.AsyncClient(timeout=timeout_s)
    try:
        yield client
    finally:
        await client.aclose()

async def fetch_json(url: str) -> dict:
    async with http_client() as client:
        r = await client.get(url)
        r.raise_for_status()
        return r.json()

실전 팁

  • “요청마다 클라이언트 생성”은 비용이 큽니다. 하지만 정리가 확실한 코드가 먼저입니다.
  • 성능이 필요하면 “앱 수명주기 단위”로 클라이언트를 만들되, 그때도 컨텍스트 매니저로 수명주기를 강제하세요(예: FastAPI lifespan).

패턴 2: DB 트랜잭션을 commit/rollback 실수 없이

DB는 누수가 더 치명적입니다. 커넥션이 반환되지 않으면 풀 고갈로 서비스가 멈춥니다. SQLAlchemy 2.x 비동기 엔진을 예로 들면 다음과 같이 트랜잭션 스코프를 만들 수 있습니다.

from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import sessionmaker

@asynccontextmanager
async def session_scope(engine: AsyncEngine):
    async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
    session: AsyncSession = async_session()

    try:
        yield session
        await session.commit()
    except Exception:
        await session.rollback()
        raise
    finally:
        await session.close()

이 패턴의 장점은 명확합니다.

  • 성공 시 commit
  • 예외 시 rollback
  • 어떤 경우든 close

“커밋을 빼먹었다”, “예외인데 롤백이 안 됐다”, “세션을 닫지 않았다” 같은 실수가 구조적으로 줄어듭니다.

패턴 3: 임시 파일/디렉터리와 비동기 작업

비동기 작업에서 임시 파일은 특히 위험합니다. 예외나 취소가 발생하면 파일이 남고, 디스크를 천천히 잠식합니다. 또한 “삭제했는데 용량이 안 줄어드는” 경우는 파일 핸들이 닫히지 않아 발생하기도 합니다. 관련 진단은 리눅스 디스크 100%인데 삭제해도 용량이 안 줄 때(lsof) 를 참고하세요.

아래 예시는 tempfile.TemporaryDirectory 를 비동기 컨텍스트로 감싸는 방식입니다.

from contextlib import asynccontextmanager
from tempfile import TemporaryDirectory
from pathlib import Path

@asynccontextmanager
async def temp_dir(prefix: str = "job-"):
    td = TemporaryDirectory(prefix=prefix)
    try:
        yield Path(td.name)
    finally:
        td.cleanup()

async def build_artifact():
    async with temp_dir() as d:
        p = d / "out.txt"
        p.write_text("hello")
        return p.read_text()

TemporaryDirectory 자체는 동기 객체지만, 정리 시점 보장을 위해 비동기 컨텍스트로 래핑하는 것은 충분히 유효합니다.

패턴 4: asyncio.Lock/세마포어를 “반드시 반환”하게 만들기

락은 누수라기보다 “영구 대기”를 만들 수 있습니다. 복잡한 함수에서 락 해제를 누락하면 데드락에 준하는 현상이 생깁니다.

from contextlib import asynccontextmanager
import asyncio

@asynccontextmanager
async def acquire(lock: asyncio.Lock):
    await lock.acquire()
    try:
        yield
    finally:
        lock.release()

lock = asyncio.Lock()

async def critical_section():
    async with acquire(lock):
        # do something
        await asyncio.sleep(0.1)

이 패턴은 특히 “락을 잡은 뒤 여러 await 를 수행”하는 코드에서 유용합니다.

Python 3.11+에서 특히 신경 쓸 것: 취소와 ExceptionGroup

Python 3.11은 TaskGroupExceptionGroup 이 도입되면서 동시성 코드가 더 깔끔해졌습니다. 하지만 예외가 “묶여서” 올라오는 만큼, 정리 로직이 더 중요해졌습니다.

TaskGroup 에서 리소스 스코프를 명확히

리소스를 태스크들이 공유한다면, 리소스 컨텍스트가 TaskGroup 을 감싸도록 두는 편이 안전합니다.

import asyncio
from contextlib import asynccontextmanager
import httpx

@asynccontextmanager
async def shared_client():
    client = httpx.AsyncClient(timeout=5.0)
    try:
        yield client
    finally:
        await client.aclose()

async def worker(client: httpx.AsyncClient, url: str):
    r = await client.get(url)
    return r.status_code

async def main(urls: list[str]):
    async with shared_client() as client:
        async with asyncio.TaskGroup() as tg:
            for u in urls:
                tg.create_task(worker(client, u))

이 구조에서는 어떤 태스크가 실패해 TaskGroup 이 나머지를 취소하더라도, async with shared_client()finally 가 실행되며 소켓이 정리됩니다.

정리 코드에서 CancelledError 를 삼키지 말기

정리 단계에서 CancelledError 를 무심코 처리하면 셧다운이 지연되거나, 상위 취소가 무시될 수 있습니다. 원칙은 다음입니다.

  • 정리 단계는 finally 에서 수행
  • 필요하면 정리 중 발생한 예외만 로깅
  • 취소 자체는 되도록 전파

예를 들어, 정리에서 네트워크 호출이 필요하면 타임아웃을 짧게 두고, 실패해도 프로세스가 멈추지 않게 설계합니다.

누수는 결국 OS 한계로 드러난다: Too many open files

리소스 누수의 대표적 결과가 파일 디스크립터 고갈입니다. 소켓도 파일 디스크립터를 사용하므로, HTTP 세션 누수와 동일한 증상으로 나타날 수 있습니다.

  • 현상: 간헐적 연결 실패, 새 파일/소켓 생성 실패
  • 로그: OSError: [Errno 24] Too many open files

즉시 진단과 해결은 리눅스 Too many open files 즉시 진단·해결 를 참고하고, 애플리케이션 레벨에서는 “닫힘 보장”을 구조로 강제해야 합니다.

테스트로 누수 잡기: “닫혔는지”를 검증하라

컨텍스트 매니저는 구조를 개선하지만, 회귀는 언제든 생깁니다. 아래는 pytest 에서 “정리 함수가 호출됐는지”를 검증하는 간단한 예입니다.

import pytest
from contextlib import asynccontextmanager

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

    async def aclose(self):
        self.closed = True

@asynccontextmanager
async def dummy_resource():
    d = Dummy()
    try:
        yield d
    finally:
        await d.aclose()

@pytest.mark.asyncio
async def test_cleanup_on_exception():
    with pytest.raises(RuntimeError):
        async with dummy_resource() as d:
            raise RuntimeError("boom")

    assert d.closed is True

이런 테스트는 “예외 경로에서도 닫히는가”를 강제합니다. 운영에서 터지기 쉬운 경로가 바로 예외 경로이기 때문입니다.

체크리스트: asynccontextmanager 를 도입할 때 흔한 실수

  1. yield 뒤 정리 코드에 await 를 빼먹음
  2. 정리 단계에서 예외가 나면 원래 예외가 묻힘
    • 필요하면 정리 예외는 로깅하고 삼키되, 원래 예외는 유지
  3. 리소스 획득 단계에서 예외가 나면 정리 코드가 실행되지 않음
    • 획득 단계에서 부분적으로 열린 리소스가 있다면, 획득 직후부터 try 로 감싸기
  4. 공유 리소스를 태스크별로 만들었다가 폭증
    • 스코프를 “요청 단위” 또는 “앱 수명주기 단위”로 재설계

마무리: “정리 코드를 쓰는 것”이 아니라 “정리가 강제되는 구조”로

asynccontextmanager 는 문법 설탕이 아니라 운영 안정성을 올리는 구조적 장치입니다. 특히 Python 3.11+에서 TaskGroup 기반 동시성이 늘어날수록, 취소와 예외 경로가 다양해지고 리소스 누수의 파급도 커집니다.

정리 누락이 의심된다면 다음 순서로 접근해 보세요.

  1. 누수 후보 리소스(세션, 커넥션, 파일, 락)를 식별
  2. 각 리소스를 asynccontextmanager 로 감싸 “획득-정리”를 한 곳에 모으기
  3. 예외 경로 테스트 추가
  4. OS 레벨 증상(Too many open files, 디스크 공간 미반환)을 함께 관찰

이렇게만 해도 “가끔 죽는 서비스”에서 “예측 가능한 서비스”로 체감이 크게 바뀝니다.