Published on

Python async context manager 누수 잡는 법

Authors

서버를 asyncio 기반으로 돌리다 보면 async with를 “분명히 썼는데”도 파일 디스크립터가 늘고, DB 커넥션 풀이 고갈되고, Task was destroyed but it is pending! 경고가 쌓이는 상황을 만납니다. 이런 문제는 대개 async context manager 자체가 나쁜 게 아니라, 종료 경로가 실행되지 않거나(취소/예외), 종료가 비동기적으로 지연되거나, 컨텍스트 밖으로 리소스가 새어나가는 구조에서 발생합니다.

이 글은 Python의 async context manager 누수를 재현 가능한 형태로 분류하고, 관측(로그/트레이싱/리소스 카운트), 탐지(테스트/린팅/런타임 가드), **차단(구조 개선)**까지 한 번에 정리합니다.

운영에서 증상이 CrashLoopBackOff나 OOM으로 이어질 때도 많으니, 컨테이너 환경에서 징후를 같이 보는 관점도 도움이 됩니다. 필요하면 K8s CrashLoopBackOff 진단 - OOMKilled·Probe도 함께 참고하세요.

async context manager에서 “누수”가 생기는 대표 원인

1) __aexit__가 호출되지 않는 구조(컨텍스트 탈출 경로 누락)

가장 흔한 건 async with를 쓰지 않고 __aenter__만 호출하거나, 컨텍스트를 생성해두고 실제로 감싸지 않는 패턴입니다.

# 나쁜 예: aenter만 호출하고 aexit가 보장되지 않음
cm = sessionmaker()  # 예시
session = await cm.__aenter__()
# ...
# 예외/리턴/취소 시 __aexit__ 누락

반드시 async with로 감싸서 종료를 구조적으로 강제해야 합니다.

async with sessionmaker() as session:
    ...

2) 컨텍스트 안에서 만든 리소스를 밖으로 “반출”

컨텍스트는 종료되는데, 내부에서 만든 객체(스트림/응답/커서)를 외부에서 계속 쓰면 정리가 꼬입니다.

# 나쁜 예: response를 반환해 컨텍스트 밖에서 사용
import aiohttp

async def fetch_raw(url: str):
    async with aiohttp.ClientSession() as s:
        async with s.get(url) as resp:
            return resp  # resp는 컨텍스트 종료 후 닫혀야 함

대신 컨텍스트 안에서 필요한 데이터를 모두 읽고 반환하세요.

import aiohttp

async def fetch_text(url: str) -> str:
    async with aiohttp.ClientSession() as s:
        async with s.get(url) as resp:
            return await resp.text()

3) CancelledError 취소 전파와 종료 경쟁(race)

async 서버는 타임아웃/요청 취소로 코루틴이 자주 취소됩니다. 이때 __aexit__가 실행되더라도 내부 정리 로직이 취소에 의해 중단되면 실제 close가 끝나지 않을 수 있습니다.

예를 들어 __aexit__에서 네트워크 close를 await하는데 취소가 들어오면, close가 중간에 끊깁니다. 이 경우 asyncio.shield로 “정리 구간”만큼은 취소로부터 보호하는 전략이 필요합니다.

import asyncio

class SafeCloser:
    def __init__(self, resource):
        self.resource = resource

    async def __aenter__(self):
        return self.resource

    async def __aexit__(self, exc_type, exc, tb):
        # 정리 작업은 취소로부터 보호
        await asyncio.shield(self.resource.aclose())
        return False

주의: shield는 남용하면 취소가 늦게 반영되어 지연이 커질 수 있습니다. “정리 작업”에만 최소 범위로 적용하세요.

4) 백그라운드 태스크가 컨텍스트를 붙잡는 경우

컨텍스트 내부에서 asyncio.create_task로 만든 태스크가 컨텍스트 종료 이후에도 살아있으면, 내부 리소스(세션/커넥션/락)를 계속 참조해 누수처럼 보입니다.

import asyncio

async def worker(session):
    # session을 계속 사용
    ...

async def handler(session_factory):
    async with session_factory() as session:
        asyncio.create_task(worker(session))
        return "ok"  # 컨텍스트는 종료되지만 task는 session을 잡고 있음

해결은 두 가지입니다.

  • 태스크 생명주기를 컨텍스트에 포함시키기(TaskGroup 사용)
  • 태스크가 컨텍스트 리소스를 참조하지 않도록 설계(큐로 데이터만 전달)

Python 3.11+라면 asyncio.TaskGroup이 가장 깔끔합니다.

import asyncio

async def worker(session):
    ...

async def handler(session_factory):
    async with session_factory() as session:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(worker(session))
        return "ok"  # TaskGroup 종료 시 태스크 정리 보장

5) 커넥션 풀/세마포어 반환 누락(획득은 했는데 release가 안 됨)

DB/HTTP 클라이언트는 내부적으로 풀을 씁니다. 풀은 “close가 조금 늦어도” 버틸 수 있지만, release가 누락되면 곧바로 고갈됩니다.

  • 커넥션/세션을 전역으로 만들고 종료를 안 함
  • 트랜잭션 컨텍스트를 중첩하면서 일부 경로에서 rollback/commit이 누락
  • 스트리밍 응답을 끝까지 읽지 않아 커넥션이 재사용되지 않음

이 유형은 운영에서 “응답은 느려지고, 동시성만 올리면 갑자기 타임아웃”으로 나타납니다.

누수를 “보이게” 만드는 관측 방법

1) asyncio 디버그 모드와 느린 콜백 로깅

테스트/스테이징에서 디버그 모드를 켜면, 종료되지 않은 태스크/핸들러 단서를 빨리 잡을 수 있습니다.

import asyncio

def enable_asyncio_debug():
    loop = asyncio.get_event_loop()
    loop.set_debug(True)
    loop.slow_callback_duration = 0.05

# 또는 실행 시 환경변수로:
# PYTHONASYNCIODEBUG=1

디버그 모드에서는 “pending task” 경고가 더 잘 드러나고, 콜스택 정보가 풍부해집니다.

2) 열린 파일 디스크립터 수로 빠르게 감지(Linux)

HTTP 커넥션/소켓 누수는 FD 증가로 나타납니다. 컨테이너에서도 /proc/self/fd는 유효합니다.

from pathlib import Path

def count_open_fds() -> int:
    return len(list(Path("/proc/self/fd").iterdir()))

async def sample():
    before = count_open_fds()
    # ... 요청/작업 수행 ...
    after = count_open_fds()
    print({"fds_before": before, "fds_after": after})

FD가 톱니처럼 계속 우상향하면, 컨텍스트 종료가 누락되었거나 스트리밍 응답/소켓이 반환되지 않는 상태일 가능성이 큽니다.

3) tracemalloc로 “어떤 객체가 남는지” 추적

메모리 누수처럼 보이지만 실제로는 “세션/응답 객체가 GC 되지 않는” 참조 누수일 수 있습니다.

import tracemalloc

tracemalloc.start()

# ... 부하를 준 뒤
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:10]:
    print(stat)

이 결과를 통해 특정 모듈/라인에서 세션/커넥션 객체가 계속 생성되는지 확인합니다.

재현 가능한 테스트로 누수 잡기(가장 효과적)

운영에서만 재현되는 누수는 고치기 어렵습니다. “같은 요청을 N번 반복했을 때 리소스가 원복되는가”를 자동화하면, 회귀를 막을 수 있습니다.

1) aiohttp 세션 누수 재현/검증 예시

아래는 일부러 잘못된 코드를 만들고, 반복 호출 시 FD가 증가하는지 확인하는 형태입니다.

import asyncio
from pathlib import Path
import aiohttp


def count_open_fds() -> int:
    return len(list(Path("/proc/self/fd").iterdir()))


async def leaky_call(url: str) -> None:
    # 나쁜 예: ClientSession을 매번 만들고 close를 안 함
    s = aiohttp.ClientSession()
    async with s.get(url) as resp:
        await resp.text()
    # s.close() 누락


async def main():
    url = "https://example.com"
    before = count_open_fds()

    for _ in range(50):
        await leaky_call(url)

    after = count_open_fds()
    print({"fds_before": before, "fds_after": after})


if __name__ == "__main__":
    asyncio.run(main())

해결은 세션 생명주기를 애플리케이션 단위로 묶거나, 함수 내부라면 반드시 async with로 감싸는 것입니다.

import asyncio
import aiohttp


async def safe_call(session: aiohttp.ClientSession, url: str) -> str:
    async with session.get(url) as resp:
        return await resp.text()


async def main():
    async with aiohttp.ClientSession() as session:
        for _ in range(50):
            await safe_call(session, "https://example.com")


if __name__ == "__main__":
    asyncio.run(main())

2) “종료가 항상 일어나는지”를 강제하는 가드 컨텍스트

누수는 종종 “닫힘이 호출되었는지”를 놓쳐서 생깁니다. 테스트에서만이라도 __del__에 의존하지 말고, 명시적으로 검증하는 래퍼를 둘 수 있습니다.

class MustClose:
    def __init__(self, resource, name: str = "resource"):
        self._resource = resource
        self._closed = False
        self._name = name

    async def __aenter__(self):
        return self

    async def __aexit__(self, exc_type, exc, tb):
        await self.aclose()
        return False

    async def aclose(self):
        if not self._closed:
            self._closed = True
            await self._resource.aclose()

    def assert_closed(self):
        assert self._closed, f"{self._name} was not closed"

테스트에서:

async def test_something():
    async with MustClose(real_resource, name="db-session") as r:
        ...
    r.assert_closed()

이 방식은 “종료는 되었겠지”라는 추측을 제거해줍니다.

실무에서 자주 터지는 패턴별 해결책

패턴 A: FastAPI/Starlette 의존성에서 세션 누수

의존성 주입으로 세션을 제공할 때, yield 기반 의존성을 잘못 작성하면 종료가 누락됩니다. 원칙은 “획득은 try 앞, 반환은 finally에서”입니다.

# 개념 예시: yield 의존성 패턴
from contextlib import asynccontextmanager

@asynccontextmanager
async def get_session(session_factory):
    session = await session_factory().__aenter__()
    try:
        yield session
    finally:
        await session_factory().__aexit__(None, None, None)

실제로는 asynccontextmanager로 감싸기보다는, 애초에 session_factory가 올바른 async context manager를 제공하도록 하고 async with로 단순화하는 편이 안전합니다.

패턴 B: 스트리밍 응답을 끝까지 소비하지 않아 커넥션이 반환되지 않음

HTTP 클라이언트는 응답 바디를 끝까지 읽거나 명시적으로 close해야 커넥션을 풀에 반환합니다. “헤더만 보고 리턴”하는 코드가 누수의 원인이 됩니다.

# 나쁜 예: 상태 코드만 확인하고 바디를 안 읽음
async with session.get(url) as resp:
    if resp.status != 200:
        return None
    return "ok"  # 바디 미소비로 커넥션 반환 지연 가능

해결:

async with session.get(url) as resp:
    _ = await resp.read()  # 또는 resp.release()에 준하는 동작
    if resp.status != 200:
        return None
    return "ok"

패턴 C: async with lock 안에서 await로 오래 걸리는 작업

락/세마포어는 리소스 누수는 아니지만, “풀 고갈”과 동일한 증상을 만듭니다. 락을 잡은 채로 네트워크 I/O를 기다리면 동시성 전체가 막힙니다.

# 나쁜 예: 락 범위가 과도하게 큼
async with lock:
    data = await fetch_remote()
    cache[key] = data

락 범위를 최소화:

data = await fetch_remote()
async with lock:
    cache[key] = data

운영에서 누수 징후를 빠르게 찾는 체크리스트

  • FD 수가 지속 증가하는가(/proc/self/fd)
  • aiohttp 경고 로그에 Unclosed client session 또는 Unclosed connector가 보이는가
  • DB 풀 메트릭에서 in-use가 계속 높고 반환이 안 되는가
  • Task was destroyed but it is pending!가 배포 후 증가하는가
  • 타임아웃/취소가 많은 구간에서만 발생하는가(취소 안전성 의심)

컨테이너 환경에서는 누수가 OOMKilled나 재시작으로 보일 수 있습니다. 이때는 앱 로그 외에도 K8s 이벤트/리소스 제한을 함께 봐야 합니다. 빌드/배포 파이프라인이 느려져 디버깅이 지연된다면 Docker BuildKit 캐시 폭발로 CI 느림 해결법처럼 주변 문제를 먼저 정리하는 것도 실무적으로 효과가 큽니다.

권장 구조: “리소스는 앱 단위로 만들고, 요청 단위로 빌려 쓰기”

누수 방지의 가장 강력한 처방은 리소스 생명주기를 계층화하는 것입니다.

  • 애플리케이션 시작 시: ClientSession, DB Engine 같은 무거운 리소스 생성
  • 요청 처리 시: 커넥션/세션/트랜잭션을 async with로 짧게 빌림
  • 백그라운드 작업: TaskGroup 또는 명시적 shutdown 훅으로 종료 보장

이 구조를 따르면 “요청 단위 누수”가 있더라도 영향 반경이 줄고, shutdown 시점에 강제 정리/검증도 쉬워집니다.

마무리: 누수는 대부분 “종료가 보장되지 않는 경로”에서 나온다

async context manager 누수는 신기한 버그라기보다, 대개 아래 중 하나로 환원됩니다.

  • 컨텍스트 밖으로 리소스를 반출했다
  • 취소/예외에서 정리 코드가 중단되었다
  • 백그라운드 태스크가 리소스 참조를 붙잡고 있다
  • 스트리밍/풀 반환 조건을 충족하지 못했다

가장 추천하는 접근은 (1) 누수를 재현하는 반복 테스트를 만들고, (2) FD/풀 메트릭/asyncio 디버그로 관측하며, (3) TaskGroup과 최소 범위 asyncio.shield로 종료를 구조적으로 보장하는 것입니다.

분산 환경에서 이런 “자원 반환 누락”은 데이터 일관성 문제로도 이어질 수 있습니다. 트랜잭션/보상 설계가 꼬이는 케이스는 MSA에서 Saga 보상 트랜잭션이 꼬일 때처럼 별도의 관점으로도 점검해보면 좋습니다.