PEP 806 – 정밀한 async 표시를 통한 동기/비동기 컨텍스트 관리자 혼용
- Author:
- Zac Hatfield-Dodds <zac at zhd.dev>
- Sponsor:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Rejected
- Type:
- Standards Track
- Created:
- 05-Sep-2025
- Python-Version:
- 3.15
- Post-History:
- 22-May-2025, 25-Sep-2025
- Resolution:
- 23-Apr-2026
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
Python은 with 문과 async with 문이 하나의 문에서 여러 컨텍스트 관리자를 다룰 수 있도록 허용하지만, 이는 컨텍스트 관리자가 모두 동기이거나 모두 비동기인 경우에 한합니다. 동기 컨텍스트 관리자와 비동기 컨텍스트 관리자를 혼용할 때, 개발자는 깊게 중첩된 문을 사용하거나 AsyncExitStack의 남용과 같은 위험한 우회 방법을 사용해야 합니다.
따라서 우리는 개별 비동기 컨텍스트 관리자 앞에 async 키워드를 붙임으로써 with 문이 하나의 문에서 동기 컨텍스트 관리자와 비동기 컨텍스트 관리자를 모두 받아들일 수 있도록 허용할 것을 제안합니다.
이 변경은 비동기 코드의 명시성을 조금도 떨어뜨리지 않으면서 불필요한 중첩을 없애고, 코드 가독성을 높이며, 사용 편의성을 개선합니다.
동기
현대의 Python 애플리케이션은 동기 컨텍스트 관리자와 비동기 컨텍스트 관리자를 섞어 사용하여 여러 자원을 획득해야 하는 경우가 많습니다. 모두 동기이거나 모두 비동기인 경우에는 여러 컨텍스트 관리자를 하나의 문으로 작성할 수 있지만, 둘을 혼용하면 “파멸의 계단”이 만들어집니다:
async def process_data():
async with acquire_lock() as lock:
with temp_directory() as tmpdir:
async with connect_to_db(cache=tmpdir) as db:
with open('config.json', encoding='utf-8') as f:
# We're now 16 spaces deep before any actual logic
config = json.load(f)
await db.execute(config['query'])
# ... more processing
이러한 과도한 들여쓰기는 컨텍스트 관리자가 바람직한 의미 체계를 가지고 있음에도 불구하고 그 사용을 꺼리게 만듭니다. 현재의 우회 방법과 그 단점에 대한 논평은 Rejected Ideas 절을 참조하십시오.
이 PEP를 적용하면, 해당 함수를 대신 다음과 같이 작성할 수 있습니다:
async def process_data():
with (
async acquire_lock() as lock,
temp_directory() as tmpdir,
async connect_to_db(cache=tmpdir) as db,
open('config.json', encoding='utf-8') as f,
):
config = json.load(f)
await db.execute(config['query'])
# ... more processing
이 간결한 대안은 동기 컨텍스트 관리자와 비동기 컨텍스트 관리자 사이를 전환할 때마다 새로운 들여쓰기 수준을 강요하지 않습니다. 동시에, 이 대안은 기존 키워드만을 사용하며, async 키워드로 비동기 코드를 현재 문법보다도 더 정밀하게 구분합니다.
우리는 async with 문이 폐기되어야 한다고 제안하지 않으며, 오히려 비동기 컨텍스트 관리자를 여는 각 줄에서 “async”가 공백이 아닌 첫 번째 토큰이 되도록 한 줄짜리 문에서는 이를 계속 사용할 것을 권장합니다.
그럼에도 우리의 제안은 with async some_ctx()를 허용하는데, 이는 단일 코드 스타일의 강제보다 일관된 문법 설계를 더 중시하기 때문이며, 코드 스타일 문제는 스타일 가이드, 린터, 포매터 등이 처리할 것으로 예상합니다. 자세한 논의는 여기를 참조하십시오.
실제 영향
이러한 개선 사항은 Python 개발자가 매일 겪는 불편함을 해결합니다. 우리는 한 산업계 코드베이스를 조사하여, 비동기 컨텍스트 관리자를 하나 이상 포함하는 함수를 1만 개 넘게 찾아냈습니다. 이 중 19%는 동기 컨텍스트 관리자도 포함하고 있었습니다. 참고로, 비동기 함수가 동기 컨텍스트 관리자를 포함하는 빈도는 비동기 컨텍스트 관리자를 포함하는 빈도의 약 3분의 2입니다.
with 문과 async with 문을 모두 포함하는 함수의 39%는 제안된 문법으로 즉시 전환할 수 있지만, 동기 컨텍스트 관리자 사용을 기피하거나 Rejected Ideas에 나열된 우회 방법을 사용한 경우가 있기 때문에 이는 느슨한 하한값입니다. 무작위로 추출한 함수 표본을 검토한 결과, 이 PEP가 채택되면 컨텍스트 관리자를 하나라도 포함하는 비동기 함수의 20%에서 50% 사이가 with async를 사용할 것으로 추정합니다.
생태계 전반으로 넓혀 보면 이보다 낮은 비율, 아마도 5%에서 20% 범위를 예상합니다: 조사한 코드베이스는 Trio를 통한 구조적 동시성을 사용하며, PEP 533과 PEP 789에서 논의된 문제를 완화하기 위해 컨텍스트 관리자를 광범위하게 사용하기 때문입니다.
근거
비동기 데이터베이스 연결이나 API 클라이언트와 동기 파일 작업을 함께 사용하는 경우처럼, 동기/비동기 컨텍스트 관리자를 혼용하는 일은 현대의 Python 애플리케이션에서 흔합니다. 현재 문법은 개발자에게 깊게 중첩된 코드와 AsyncExitStack 같은 오류가 발생하기 쉬운 우회 방법 중 하나를 선택하도록 강요합니다.
이 PEP는 기존 패턴을 기반으로 하는 최소한의 문법 변경으로 이 문제를 해결합니다. 개별 컨텍스트 관리자를 async로 표시할 수 있게 함으로써, 불필요한 중첩을 없애면서도 비동기 코드에 대한 Python의 명시적 접근 방식을 유지합니다.
문법적 설탕으로 구현하므로 런타임 오버헤드가 전혀 없습니다 – 새 문법은 오늘날 개발자가 작성하는 것과 동일한 중첩된 with 문과 async with 문으로 디슈거링됩니다. 이 접근 방식은 새로운 프로토콜도, 기존 컨텍스트 관리자의 변경도, 새로 이해해야 할 런타임 동작도 필요로 하지 않습니다.
명세
with (..., async ...): 문법은 현재의 다중 컨텍스트 with 문과 같은 방식으로 컨텍스트 관리자의 시퀀스로 디슈가링됩니다. 다만 async 키워드가 앞에 붙은 것은 __aenter__ / __aexit__ 프로토콜을 사용합니다.
with 문만 수정되며, async with async ctx():는 문법 오류입니다.
ast.withitem 노드에는 ast.comprehension에 있는 기존 is_async 속성을 따라 새로운 is_async 정수 속성이 추가됩니다. async with 문의 항목에서 이 속성은 항상 1입니다. 일반 with 문의 항목에서 이 속성은 async 키워드가 있으면 1이고, 그렇지 않으면 0입니다. 이를 통해 AST는 기존 AST 처리 도구와의 하위 호환성을 유지하면서도 어떤 컨텍스트 관리자가 비동기 프로토콜을 사용해야 하는지를 정확하게 표현할 수 있습니다.
하위 호환성
이 변경은 완전한 하위 호환성을 유지합니다. 관찰 가능한 유일한 차이는 이전에 SyntaxError를 발생시키던 특정 문법이 이제 성공적으로 실행된다는 점입니다.
컨텍스트 관리자를 구현하는 라이브러리(표준 라이브러리와 서드파티)는 수정 없이 새 문법과 함께 동작합니다. 소스 코드를 직접 다루는 라이브러리와 도구는 다른 모든 새 문법과 마찬가지로 약간의 업데이트가 필요합니다.
교육 방법
“혼합 컨텍스트 관리자”는 async with와 함께, 또는 그 직후에 소개할 것을 권장합니다. 예를 들어, 튜토리얼에서는 다음을 다룰 수 있습니다:
- 기본 컨텍스트 관리자: 단일
with문으로 시작합니다 - 여러 컨텍스트 관리자: 현재의 쉼표 문법을 보여 줍니다
- 비동기 컨텍스트 관리자:
async with를 소개합니다 - 혼합 컨텍스트: “각 비동기 컨텍스트 관리자를
async로 표시하십시오”
기각된 아이디어
우회 방법: as_acm() 래퍼
동기 컨텍스트 관리자를 비동기 컨텍스트 관리자로 감싸는 헬퍼 함수는 쉽게 구현할 수 있습니다. 예를 들면 다음과 같습니다:
@contextmanager
async def as_acm(sync_cm):
with sync_cm as result:
await sleep(0)
yield result
async with (
acquire_lock(),
as_acm(open('file')) as f,
):
...
이것은 거의 모든 코드에 대해 저희가 권장하는 우회 방법입니다.
하지만 취소를 허용하기 위해 비동기 런타임으로 다시 호출하는 것(즉, await sleep(0)을 실행하는 것)이 바람직하지 않은 경우도 있습니다. 반면에 await sleep(0)을 생략하면, 구문상의 await / async for / async with가 항상 비동기 런타임으로 다시 호출한다(또는 예외를 일으킨다)는 추이적 속성이 깨지게 됩니다. 오늘날 이 속성을 강제하는 코드베이스는 거의 없지만, 저희는 이 속성이 교착 상태를 방지하는 데 필수적이라는 것을 확인했으며, 따라서 생태계를 위해 더 깔끔한 기반을 선호합니다.
우회 방법: AsyncExitStack 사용
AsyncExitStack은 동기 및/또는 비동기 컨텍스트 관리자에 명시적으로 진입할 수 있게 해 주는 강력한 저수준 인터페이스를 제공합니다.
async with contextlib.AsyncExitStack() as stack:
await stack.enter_async_context(acquire_lock())
f = stack.enter_context(open('file', encoding='utf-8'))
...
하지만 AsyncExitStack은 상당한 복잡성과 오류 가능성을 초래합니다. 컨텍스트 관리자를 구문으로 사용할 때 보장되는 ‘후입선출’ 순서와 같은 속성을 위반하기 쉽습니다.
우회 방법: AsyncExitStack 기반 헬퍼
AsyncExitStack을 직접 사용할 때의 단점 일부를 피하는 multicontext() 래퍼를 구현할 수도 있습니다:
async with multicontext(
acquire_lock(),
open('file'),
) as (f, _):
...
하지만 이 헬퍼는 as 절의 지역성을 깨뜨리므로, (코드 예제에서처럼) yield된 변수를 실수로 잘못 할당하기 쉽습니다. 또한 태그된 유니온 같은 것을 사용해 동기 컨텍스트 관리자와 비동기 컨텍스트 관리자를 구별하거나(예를 들어 async_ @ acquire_lock()이 동작하도록 연산자를 오버로딩하는 방식), 아니면 동기와 비동기 컨텍스트 관리자 프로토콜을 모두 구현하는 객체를 어떻게 처리할지 추측해야 합니다. 마지막으로, 이 헬퍼는 예외 처리와 관련하여 오류가 발생하기 쉬운 의미 체계를 가지고 있는데, 바로 이 의미 체계 때문에 contextlib.nested()가 폐기되고 여러 인자를 받는 with 문이 대신 쓰이게 되었습니다.
문법: async with sync_cm, async_cm: 허용
이 제안의 초기 초안에서는 컨텍스트 관리자를 섞어 쓸 때 비동기 컨텍스트 관리자가 하나 이상 있는 경우, 문 전체에 async with를 사용했습니다:
# Rejected approach
async with (
acquire_lock(),
open('config.json') as f, # actually sync, surprise!
):
...
비동기 컨텍스트 관리자를 요구하면 문법과 스케줄러 사이의 연결은 유지되지만, 향후 코드 변경에 보이지 않는 제약을 두는 대가를 치르게 됩니다. 여러 컨텍스트 관리자 중 하나를 제거했는데 그것이 마침 마지막 비동기 컨텍스트 관리자였다면 런타임 오류가 발생할 수 있습니다!
명시적인 것이 암시적인 것보다 낫습니다.
문법: 한 줄짜리 with async ... 금지
제안한 문법을 제한할 수도 있습니다. 예를 들어 괄호로 묶은 다중 컨텍스트 with 문에서 async를 각 줄의 첫 번째 토큰으로만 둘 수 있게 하는 식입니다. 실제로 저희가 권장하는 사용법도 이것이며, 대부분의 사용 사례가 이 패턴을 따를 것으로 예상합니다.
async with ctx():와 with async ctx(): 중 어느 쪽으로든 쓸 수 있는 선택지는 모호함 때문에 약간의 혼란을 일으킬 수 있지만, 문법을 통해 선호하는 스타일을 강제하면 Python을 배우기가 더 혼란스러워질 것이라고 생각합니다. 따라서 단순한 문법 규칙과 그 사용 방법에 관한 커뮤니티 관례를 함께 두는 쪽을 선호합니다.
예를 들어, 다음 코드 예제들 중 어느 지점에서(그런 지점이 있다면) 이 문법이 허용되지 않아야 하는지는 분명하지 않다고 생각합니다:
with (
sync_context() as foo,
async a_context() as bar,
): ...
with (
sync_context() as foo,
async a_context()
): ...
with (
# sync_context() as foo,
async a_context()
): ...
with (async a_context()): ...
with async a_context(): ...
감사의 글
with async를 제안해 주신 Rob Rolls 님께 감사드립니다. PyCon 2025 스프린트, Discourse, 그리고 직장에서 이 문제와 가능한 해결책을 함께 논의한 다른 많은 분께도 감사드립니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.