Published on

async contextmanager로 누수·취소 버그 잡기

Authors

서버 사이드 Python 비동기 코드에서 가장 흔한 장애는 성능 문제가 아니라 자원 정리 실패로 인한 누수취소(Cancellation) 전파로 인한 반쯤 열린 상태입니다. 특히 asyncio 기반 서비스에서 요청 타임아웃, 배포 중 연결 종료, 상위 태스크 취소가 섞이면 “어느 시점에 무엇을 반드시 닫아야 하는가”가 흐려지고, 그 결과로 커넥션/세마포어/락/임시 파일/트레이스 스팬 등이 조용히 쌓입니다.

이 글은 contextlib.asynccontextmanager를 이용해 획득(acquire)과 해제(release)를 하나의 구조로 묶고, 예외·취소·타임아웃에서도 cleanup이 일관되게 실행되도록 만드는 실전 패턴을 다룹니다.

관련해서 장애 대응 관점의 재시도/폴백/서킷브레이커 패턴은 별도 글인 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커도 참고하면 좋습니다.

왜 누수·취소 버그가 생기나

비동기 코드에서 누수와 취소 버그는 보통 아래 3가지 조합에서 터집니다.

  1. 획득은 여러 곳에서, 해제는 한 곳에서
    • 중간 단계에서 예외가 나면 해제 코드까지 도달하지 못합니다.
  2. CancelledError가 “예외”이면서도 “제어 신호”
    • 취소는 에러처럼 보이지만, 대부분은 정상적인 제어 흐름입니다(타임아웃, 셧다운 등).
    • 취소를 무심코 삼키면 상위 태스크가 “취소되었다고 믿는 상태”와 실제 리소스 상태가 엇갈립니다.
  3. cleanup이 또 다른 await를 포함
    • 해제 과정에서 await가 있으면, cleanup 자체가 취소될 수 있습니다.

이 3가지를 동시에 만족하면 “가끔만 터지는” 고질병이 됩니다.

asynccontextmanager로 얻는 것

asynccontextmanagerasync with 블록을 기준으로

  • 블록 진입 전에 획득
  • 블록 종료 시 항상 해제(finally)
  • 예외/취소가 발생해도 해제 경로를 강제

하는 구조적 보장을 제공합니다. 핵심은 “정리 코드를 호출하는 것을 개발자가 기억하는 방식”에서 “구문 구조가 보장하는 방식”으로 바꾸는 것입니다.

안티패턴: try/finally를 흩뿌린 코드

아래 코드는 얼핏 안전해 보이지만, 실제로는 취소가 끼면 정리가 불완전해지기 쉽습니다(특히 finally 안에서 await가 있는 경우).

import asyncio

class Pool:
    async def acquire(self):
        ...
    async def release(self, conn):
        ...

async def handler(pool: Pool):
    conn = await pool.acquire()
    try:
        # 중간에 여러 await가 섞임
        await asyncio.sleep(0.1)
        return "ok"
    finally:
        # release도 await라서, 여기서 취소되면 누수 가능
        await pool.release(conn)

문제는 finally가 실행된다는 사실이 아니라, finally 내부의 await가 취소될 수 있다는 점입니다.

기본 패턴: 풀/락/세마포어를 컨텍스트로 감싸기

가장 먼저 할 일은 “획득/해제를 하나의 컨텍스트로 묶는 것”입니다.

import asyncio
from contextlib import asynccontextmanager

class Pool:
    async def acquire(self):
        ...
    async def release(self, conn):
        ...

@asynccontextmanager
async def acquired(pool: Pool):
    conn = await pool.acquire()
    try:
        yield conn
    finally:
        await pool.release(conn)

async def handler(pool: Pool):
    async with acquired(pool) as conn:
        await asyncio.sleep(0.1)
        return "ok"

여기까지는 “코드가 더 예뻐진 정도”로 보일 수 있습니다. 하지만 이 구조가 진짜 강해지는 지점은 취소 안전한 cleanup을 추가할 때입니다.

핵심: cleanup은 CancelledError에 지지 않게 만들기

취소가 걸린 상태에서 await pool.release(conn)를 수행하면, release 도중 다시 취소가 들어와 중단될 수 있습니다. 즉, “finally는 실행됐는데 release가 끝나지 않은 상태”가 됩니다.

이를 막는 대표적인 방법은 cleanup 구간을 취소로부터 보호(shield) 하는 것입니다.

import asyncio
from contextlib import asynccontextmanager

@asynccontextmanager
async def acquired(pool):
    conn = await pool.acquire()
    try:
        yield conn
    finally:
        # cleanup은 끝까지 가도록 보호
        try:
            await asyncio.shield(pool.release(conn))
        except Exception:
            # release 실패는 로깅/메트릭 후 삼킬지 재전파할지 정책화
            # 여기서는 예시로 재전파하지 않고 누수만 막는 쪽을 택함
            # (실제로는 pool 구현에 따라 다름)
            pass

shield를 쓸 때 주의점

  • asyncio.shield(x)현재 태스크의 취소가 x에 전파되지 않게 합니다.
  • 하지만 “취소 자체가 사라지는 것”은 아닙니다. cleanup이 끝난 뒤에는 원래 취소가 다시 관측될 수 있습니다.
  • 따라서 cleanup에서 CancelledError를 무작정 삼키는 것과는 다릅니다. 삼키면 상위 호출자가 취소를 감지하지 못해 더 큰 논리 버그가 생깁니다.

타임아웃과 취소를 섞어도 안전한 패턴

현실에서는 asyncio.timeout(...) 또는 asyncio.wait_for(...)로 타임아웃을 걸고, 그 결과로 취소가 발생합니다. 이때도 cleanup은 반드시 끝나야 합니다.

import asyncio
from contextlib import asynccontextmanager

@asynccontextmanager
async def acquired(pool):
    conn = await pool.acquire()
    try:
        yield conn
    finally:
        await asyncio.shield(pool.release(conn))

async def call_with_timeout(pool):
    try:
        async with asyncio.timeout(0.2):
            async with acquired(pool) as conn:
                await asyncio.sleep(1.0)
                return "done"
    except TimeoutError:
        return "timeout"

이 구조의 장점은 “타임아웃으로 취소가 발생해도 release는 끝까지 실행된다”로 요약됩니다.

누수의 또 다른 원인: 부분 초기화(half-initialized) 객체

획득 이후 초기화 단계에서 예외가 나면, 객체가 “획득은 됐는데 초기화는 실패” 상태가 됩니다. 예를 들어 DB 트랜잭션 시작, 임시 테이블 생성, 파일 생성 후 헤더 쓰기 등이 여기에 해당합니다.

이럴 때 asynccontextmanager획득-초기화-사용-정리를 한 덩어리로 만들 수 있습니다.

from contextlib import asynccontextmanager
import asyncio

class Tx:
    async def begin(self):
        ...
    async def commit(self):
        ...
    async def rollback(self):
        ...

@asynccontextmanager
async def transaction(tx: Tx):
    await tx.begin()
    committed = False
    try:
        yield tx
        await tx.commit()
        committed = True
    finally:
        if not committed:
            # rollback도 취소로부터 보호
            await asyncio.shield(tx.rollback())

async def service(tx: Tx):
    async with transaction(tx):
        await asyncio.sleep(0.1)
        # 여기서 예외나 취소가 나면 rollback이 보장됨

이 패턴은 Saga/보상 트랜잭션 설계에도 연결됩니다. 분산 환경에서 중복 보상 실행을 방지하는 관점은 Saga 패턴 보상 트랜잭션 중복 실행 방지법과 같이 보면 더 입체적으로 이해됩니다.

“정리 중 예외”를 어떻게 다룰 것인가

cleanup에서 예외가 발생하면 선택지가 3개입니다.

  1. 원래 예외를 유지하고 cleanup 예외는 로깅만
    • 운영에서는 가장 흔합니다. 원인 분석은 로그/메트릭으로.
  2. cleanup 예외를 재전파
    • 자원 무결성이 더 중요할 때.
  3. 둘 다 보존(예: ExceptionGroup)
    • Python 3.11+에서 병렬 태스크 정리 시 유용.

중요한 건 “어떤 정책인지 코드에 드러나게” 하는 것입니다. asynccontextmanager는 이 정책을 한 곳에 모아두게 해줍니다.

병렬 작업에서의 누수: TaskGroup과 컨텍스트의 결합

여러 태스크를 동시에 실행할 때, 하나가 실패하면 나머지를 취소시키는 게 일반적입니다. 이때 각 태스크가 잡고 있던 자원이 제대로 풀리지 않으면 누수가 폭발합니다.

Python 3.11+의 asyncio.TaskGroupasynccontextmanager를 결합하면 “각 태스크의 자원은 태스크가 책임지고, 그룹은 취소/예외를 책임지는” 형태로 깔끔해집니다.

import asyncio
from contextlib import asynccontextmanager

@asynccontextmanager
async def acquired(pool):
    conn = await pool.acquire()
    try:
        yield conn
    finally:
        await asyncio.shield(pool.release(conn))

async def worker(pool, n: int):
    async with acquired(pool) as conn:
        await asyncio.sleep(0.05)
        return n

async def run(pool):
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(worker(pool, i)) for i in range(10)]
    return [t.result() for t in tasks]

포인트는 취소가 발생해도 각 worker의 컨텍스트가 cleanup을 수행한다는 점입니다.

실전 체크리스트: 누수·취소 버그를 줄이는 규칙

  1. acquire/release, open/close, begin/end는 항상 컨텍스트로 묶기
  2. cleanup에서 await가 있다면 취소 안전성을 검토하기
    • 필요하면 asyncio.shield(...)
  3. CancelledError를 무심코 잡아서 삼키지 않기
    • 취소는 “정상 제어 흐름”이지만, 상위로 전파되어야 합니다
  4. 부분 초기화가 있다면 “초기화 완료 플래그”로 rollback/cleanup 분기하기
  5. 태스크 병렬화 시 각 태스크가 자원을 책임지도록 만들기

운영 환경에서 이런 누수는 결국 OOM, 커넥션 고갈, 파일 디스크립터 고갈로 이어져 장애를 만듭니다. 장애 진단 관점에서는 K8s CrashLoopBackOff - OOMKilled·Probe·Exit 137 진단처럼 “증상(메모리/재시작)에서 원인(누수)을 역추적”하는 글도 같이 보면 도움이 됩니다.

마무리

asynccontextmanager는 단순히 문법 설탕이 아니라, 비동기 시스템에서 가장 다루기 어려운 예외·취소·정리 경로를 구조적으로 고정하는 도구입니다. 특히 cleanup에 await가 들어가는 순간부터는 “finally가 있으니 안전하다”가 아니라 “취소가 cleanup 자체를 끊지 못하게 했는가”로 관점을 바꿔야 합니다.

다음에 비동기 코드에서 커넥션이 가끔씩 모자라거나, 락이 풀리지 않거나, 타임아웃 이후 상태가 꼬이는 증상이 보이면, 먼저 async with로 경계를 만들고 cleanup을 취소 안전하게 만드는 것부터 적용해 보세요.