- Published on
async with·yield로 안전한 비동기 리소스 관리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
비동기 코드에서 가장 흔한 장애 원인 중 하나는 리소스 정리 실패입니다. 데이터베이스 커넥션, HTTP 세션, 파일 핸들, 분산 락, 임시 디렉터리 같은 리소스는 "획득"만큼 "반납"이 중요합니다. 동기 코드에서는 with 블록이 이를 단순하게 만들어주지만, 비동기에서는 await가 끼어들면서 제어 흐름이 복잡해지고, 특히 취소(cancellation) 가 발생하면 정리 로직이 누락되기 쉽습니다.
이 글에서는 async with와 yield를 중심으로, 예외·타임아웃·취소 상황에서도 안전하게 리소스를 회수하는 패턴을 정리합니다. FastAPI 같은 프레임워크 의존성을 만들 때도 동일한 원리가 적용됩니다.
관련해서 이벤트 루프 종료 타이밍과 정리의 상호작용이 문제를 키우기도 하니, 필요하면 Python asyncio RuntimeError - Event loop is closed 해결도 함께 참고하면 좋습니다.
왜 비동기 리소스 관리는 더 어렵나
비동기 환경에서는 다음 이유로 리소스 누수가 더 자주 발생합니다.
await사이에 태스크가 중단되고 다른 태스크가 실행되며, 개발자가 생각한 순서대로 실행되지 않는다.asyncio.CancelledError로 태스크가 취소될 수 있고, 이 예외는 일반 예외 처리와 다르게 전파/재발생 규칙을 잘못 다루면 정리 코드가 건너뛰어진다.- 타임아웃(
asyncio.timeout,asyncio.wait_for)은 내부적으로 취소를 사용하므로, 타임아웃 처리도 결국 취소 안전성과 직결된다. - 커넥션 풀/세션 같은 리소스는 "반납"이 지연되면 곧바로 장애(고갈)로 이어진다.
핵심 목표는 하나입니다.
- 획득한 리소스는 어떤 경로로 빠져나가든 반드시 반납한다.
이를 가장 깔끔하게 보장하는 도구가 async with이고, yield는 이를 더 재사용 가능하게 만드는 구성 요소입니다.
async with로 보장되는 것
async with는 비동기 컨텍스트 매니저의 __aenter__와 __aexit__를 호출합니다.
__aenter__: 리소스 획득__aexit__: 블록 종료 시 정리(예외 여부와 무관)
즉, 아래 코드는 예외가 나든, 정상 종료하든, close()가 호출될 기회를 갖습니다.
class AsyncResource:
async def __aenter__(self):
print("acquire")
return self
async def __aexit__(self, exc_type, exc, tb):
print("release")
# 예외를 삼키지 않으려면 False 반환(또는 None)
return False
async def main():
async with AsyncResource() as r:
raise RuntimeError("boom")
async with가 특히 유용한 지점은 취소입니다. 태스크가 취소되면 CancelledError가 발생하면서 스택을 풀어가는데, 이때도 컨텍스트 매니저의 __aexit__는 실행됩니다. 따라서 async with는 취소 안전한 정리의 1차 방어선입니다.
다만, __aexit__ 내부에서 또 다른 await를 수행하다가 추가 취소를 맞으면 정리가 중단될 수 있습니다. 이 부분은 뒤에서 asyncio.shield와 함께 다룹니다.
yield로 만드는 “획득/반납” 템플릿
Python에는 "컨텍스트 매니저를 제너레이터로 쉽게 만들기" 위한 표준 도구가 있습니다.
- 동기:
contextlib.contextmanager - 비동기:
contextlib.asynccontextmanager
비동기 버전은 async def 함수 안에서 yield를 한 번 사용해, yield 앞은 획득, yield 뒤는 정리로 구성합니다.
from contextlib import asynccontextmanager
@asynccontextmanager
async def managed_resource():
# acquire
res = object()
try:
yield res
finally:
# release
# 여기서 예외/취소가 발생해도 finally는 실행된다
pass
async def main():
async with managed_resource() as res:
...
이 패턴의 장점은 다음과 같습니다.
- 클래스(
__aenter__,__aexit__) 없이도 컨텍스트 매니저를 만들 수 있다. try/finally구조가 강제되어 "반납"을 빼먹기 어렵다.- 함수로 추상화하기 쉬워, 서비스 전반에서 일관된 리소스 관리가 가능하다.
예제 1: aiohttp.ClientSession을 안전하게 다루기
HTTP 클라이언트 세션은 커넥션 풀을 내부에 갖고 있어, 닫지 않으면 소켓이 누적됩니다. 가장 안전한 기본은 세션 자체를 컨텍스트로 감싸는 것입니다.
import aiohttp
from contextlib import asynccontextmanager
@asynccontextmanager
async def http_session(timeout_s: float = 10.0):
timeout = aiohttp.ClientTimeout(total=timeout_s)
session = aiohttp.ClientSession(timeout=timeout)
try:
yield session
finally:
await session.close()
async def fetch_json(url: str) -> dict:
async with http_session() as session:
async with session.get(url) as resp:
resp.raise_for_status()
return await resp.json()
여기서 중요한 포인트는 session.get()도 async with로 감싸고 있다는 점입니다. 응답 객체도 스트림/커넥션을 쥐고 있으므로, 바디를 다 읽지 못한 예외 상황에서도 커넥션이 풀로 반환되게 해야 합니다.
예제 2: DB 커넥션/트랜잭션을 yield로 묶기
DB 드라이버에 따라 다르지만, 일반적으로는 아래 2단계를 분리해야 합니다.
- 커넥션을 풀에서 빌린다
- 트랜잭션을 시작하고 커밋/롤백한다
yield 기반 컨텍스트 매니저로 이를 강제하면 실수를 크게 줄일 수 있습니다.
from contextlib import asynccontextmanager
@asynccontextmanager
async def db_transaction(pool):
conn = await pool.acquire()
try:
tx = await conn.begin()
try:
yield conn
except Exception:
await tx.rollback()
raise
else:
await tx.commit()
finally:
await pool.release(conn)
이 구조의 핵심은 다음입니다.
- 사용자 코드 블록에서 예외가 나면
rollback()후 예외를 다시 올린다. - 예외가 없으면
commit(). - 어떤 경우든 커넥션은
finally에서 풀에 반환한다.
이 패턴은 커넥션 풀 고갈을 막는 데 직접적으로 효과가 있습니다. (동기 환경에서 커넥션 고갈이 장애로 이어지는 것처럼, 비동기에서도 동일합니다.)
취소(cancellation)와 타임아웃에서 진짜로 안전해지기
타임아웃은 곧 취소다
asyncio.wait_for()나 asyncio.timeout()은 내부적으로 실행 중인 태스크를 취소하는 방식으로 타임아웃을 구현합니다. 따라서 타임아웃이 자주 걸리는 경로라면, 정리 로직이 취소에 의해 중단되지 않는지 확인해야 합니다.
__aexit__/finally 안의 await도 취소될 수 있다
finally가 실행된다고 해서, 그 안의 await까지 끝난다는 보장은 없습니다. 정리 단계에서 또 취소를 맞으면 정리 작업이 중단될 수 있습니다.
이때 사용할 수 있는 도구가 asyncio.shield()입니다. shield()는 "이 await는 취소로부터 보호"하되, 바깥 태스크의 취소 상태는 유지합니다.
import asyncio
from contextlib import asynccontextmanager
@asynccontextmanager
async def safe_close(resource):
try:
yield resource
finally:
# close가 오래 걸리거나 네트워크를 타도, 취소로 중단되지 않게 보호
await asyncio.shield(resource.aclose())
주의할 점도 있습니다.
shield()는 정리를 "무조건 완료"로 만들어주지만, 그만큼 종료 지연을 만들 수 있습니다.- 리소스 정리 자체가 영원히 걸릴 수 있는 함수라면, 정리에도 별도의 타임아웃을 두는 편이 안전합니다.
import asyncio
async def close_with_timeout(resource, timeout_s: float = 2.0):
try:
async with asyncio.timeout(timeout_s):
await asyncio.shield(resource.aclose())
except TimeoutError:
# 최후 수단: 로그/메트릭 남기고 누수 추적
pass
yield 컨텍스트 매니저 작성 시 흔한 실수
1) yield 뒤에 예외 처리를 빼먹기
트랜잭션처럼 "예외가 나면 롤백"이 필요한 리소스는 yield 뒤에 except/else를 명시적으로 두는 편이 좋습니다.
finally만 쓰면 커밋/롤백 분기 로직을 놓치기 쉽습니다.
2) 예외를 삼켜버리기
@asynccontextmanager 내부에서 예외를 잡고 raise를 하지 않으면, 호출자는 실패를 성공으로 오인합니다. 정리 로직에서 로깅을 하더라도, 원래 예외는 가능한 한 전파하는 것이 기본입니다.
3) 리소스 획득 단계에서 예외가 나면 반납 코드가 실행되지 않는 문제
획득 도중 예외가 나면 아직 "완전히 획득된 리소스"가 없을 수 있습니다. 하지만 부분적으로 만들어진 객체가 있다면 정리가 필요합니다. 획득 단계도 try로 감싸고, 생성된 것만 정리하도록 작성하세요.
from contextlib import asynccontextmanager
@asynccontextmanager
async def acquire_two(a_factory, b_factory):
a = None
b = None
try:
a = await a_factory()
b = await b_factory()
yield (a, b)
finally:
if b is not None:
await b.aclose()
if a is not None:
await a.aclose()
FastAPI 의존성에서 yield가 특히 강력한 이유
FastAPI는 의존성 함수에서 yield를 사용하면, yield 전후를 각각 "요청 시작"과 "요청 종료" 훅처럼 실행합니다. 즉, 요청 단위로 리소스를 만들고 확실히 정리하는 데 적합합니다.
from contextlib import asynccontextmanager
import aiohttp
@asynccontextmanager
async def get_session():
session = aiohttp.ClientSession()
try:
yield session
finally:
await session.close()
이런 구조는 외부 API 호출이 많은 서비스에서 소켓 누수, 커넥션 풀 고갈을 줄여줍니다. 또한 재시도 로직을 얹을 때도 "요청 단위로 상태를 격리"하기 쉬워집니다. 재시도와 결제/중복 요청 방지 같은 주제는 LangChain 리트라이 중복청구 막는 idempotency_key 실전처럼 멱등성 관점과 함께 설계하는 것이 안전합니다.
동시성 제한도 리소스 관리의 일부: 세마포어를 async with로
리소스는 "객체"만이 아니라 "동시 실행 슬롯"도 포함합니다. 외부 API나 DB가 버티는 한도를 넘기지 않도록 제한해야 하는데, 이때 asyncio.Semaphore는 async with로 다룰 수 있습니다.
import asyncio
sem = asyncio.Semaphore(10)
async def limited_call(fn, *args, **kwargs):
async with sem:
return await fn(*args, **kwargs)
이 패턴은 지연/타임아웃을 줄이는 데도 도움됩니다. 인프라 레벨에서 타임아웃이 연쇄적으로 터지는 현상을 다룰 때는 AWS ALB 504 Gateway Timeout 원인·해결 12가지처럼 네트워크/로드밸런서 관점도 함께 점검해야 합니다.
운영에서 체크해야 할 “안전한 정리” 체크리스트
- 리소스 획득은
async with또는@asynccontextmanager로 감싸져 있는가 - 응답 스트림/커서처럼 "2차 리소스"도
async with로 닫고 있는가 - 트랜잭션은
commit/rollback분기가 명확한가 - 정리 단계의
await가 취소로 중단되면 치명적인가- 치명적이라면
asyncio.shield()및 정리 타임아웃을 고려했는가
- 치명적이라면
- 타임아웃을 어디에 걸었는가
- 작업 본문뿐 아니라 정리에도 타임아웃이 필요한가
- 누수가 발생했을 때 추적 가능한가
finally에서 로깅/메트릭(정리 실패 횟수, close 타임아웃)을 남기는가
마무리
async with는 비동기 코드에서 "반드시 정리"라는 규칙을 문법으로 강제하는 가장 강력한 도구입니다. 그리고 yield 기반의 @asynccontextmanager는 이를 함수형으로 재사용 가능하게 만들어, 팀 전체 코드베이스의 리소스 관리 품질을 끌어올립니다.
정리하자면 다음 순서로 적용하면 안전합니다.
- 기본은 항상
async with - 반복되는 획득/반납은
@asynccontextmanager+yield로 템플릿화 - 취소/타임아웃이 빈번하면 정리 단계에
asyncio.shield()와 정리 타임아웃을 검토
이 3가지만 지켜도, 비동기 서비스에서 흔한 커넥션 누수·소켓 누수·락 미해제 같은 문제를 눈에 띄게 줄일 수 있습니다.