Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 806 – 혼합 동기/비동기 컨텍스트 관리자에 대한 정밀한 비동기 표시

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

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 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()를 허용하며, 단일 코드 스타일의 강제를 일관된 구문 설계보다 우선하지 않습니다. 이러한 사항은 스타일 가이드, 린터, 포매터 등이 처리할 것으로 예상합니다. 자세한 논의는 here를 참조하십시오.

실제 세계에 미치는 영향

이러한 개선 사항은 Python 개발자가 매일 마주하는 애로 사항을 해결합니다. 업계 코드베이스를 조사한 결과, 하나 이상의 비동기 컨텍스트 관리자를 포함하는 함수가 만 개를 넘었습니다. 이 중 19%에는 동기 컨텍스트 관리자도 포함되어 있었습니다. 참고로 비동기 함수에는 비동기 컨텍스트 관리자가 포함되는 경우에 비해 약 3분의 2 정도의 빈도로 동기 컨텍스트 관리자가 포함됩니다.

with문과 async with문을 모두 포함하는 함수의 39%는 제안된 구문으로 즉시 전환할 수 있지만, 이는 동기 컨텍스트 관리자를 피하고 Rejected Ideas 아래에 나열된 우회 방법을 사용하기 때문에 느슨한 하한입니다. 함수의 무작위 표본을 조사한 결과를 바탕으로, 컨텍스트 관리자를 하나라도 포함하는 비동기 함수 중 20%에서 50%가 이 PEP가 채택되면 with async를 사용할 것으로 추정합니다.

더 넓은 생태계 전체에서는 그 비율이 더 낮아 5%에서 20% 범위일 것으로 예상합니다. 조사한 코드베이스는 Trio를 사용한 구조적 동시성을 활용하며, PEP 533PEP 789에서 논의된 문제를 완화하기 위해 컨텍스트 관리자를 광범위하게 사용하기도 합니다.

근거

혼합 동기/비동기 컨텍스트 관리자는 비동기 데이터베이스 연결이나 API 클라이언트와 동기식 파일 작업 등 현대 Python 애플리케이션에서 흔히 사용됩니다. 현재 구문은 개발자가 매우 깊게 중첩된 코드와 AsyncExitStack과 같은 오류가 발생하기 쉬운 우회 방법 중 하나를 선택하도록 강제합니다.

이 PEP는 기존 패턴을 기반으로 하는 최소한의 구문 변경으로 이 문제를 해결합니다. 개별 컨텍스트 관리자를 async로 표시할 수 있도록 함으로써, 불필요한 중첩을 제거하면서도 비동기 코드에 대한 Python의 명시적인 접근 방식을 유지합니다.

구문 설탕으로 구현하면 새 구문이 개발자가 현재 작성하는 것과 동일한 중첩된 with문과 async with문으로 구문적으로 확장되므로 런타임 오버헤드가 전혀 발생하지 않습니다. 이 접근 방식에는 새로운 프로토콜, 기존 컨텍스트 관리자의 변경 사항 또는 이해해야 할 새로운 런타임 동작이 필요하지 않습니다.

사양

with (..., async ...):구문은 현재의 다중 컨텍스트 with문과 동일한 방식으로 컨텍스트 관리자 시퀀스로 구문적으로 확장되지만, async키워드가 앞에 붙은 컨텍스트 관리자는 __aenter__ / __aexit__ 프로토콜을 사용합니다.

with 문만 수정되며, async with async ctx():는 구문 오류입니다.

새로운 is_async정수 속성이 ast.withitem 노드에 추가되며, ast.comprehension에 있는 기존 is_async속성에 이어집니다. async with 문 항목에서 이 속성은 항상 1입니다. 일반 with 문의 항목에서는 async 키워드가 있으면 이 속성이 1이고, 그렇지 않으면 0입니다. 이를 통해 기존 AST 처리 도구와의 하위 호환성을 유지하면서 어떤 컨텍스트 관리자가 비동기 프로토콜을 사용해야 하는지를 AST에 정확히 나타낼 수 있습니다.

하위 호환성

이 변경 사항은 완전히 하위 호환됩니다. 관찰 가능한 유일한 차이는 이전에 SyntaxError를 발생시켰던 특정 구문이 이제 성공적으로 실행된다는 점입니다.

컨텍스트 관리자를 구현하는 라이브러리(표준 라이브러리 및 서드 파티)는 수정 없이 새로운 구문에서 작동합니다. 소스 코드에서 직접 작동하는 라이브러리와 도구는 새로운 구문이 추가될 때마다 그렇듯이 약간의 업데이트가 필요합니다.

이것을 가르치는 방법

async with와 함께 또는 바로 다음에 “혼합 컨텍스트 관리자”를 소개하는 것을 권장합니다. 예를 들어, 튜토리얼에서는 다음을 다룰 수 있습니다.

  1. 기본 컨텍스트 관리자: 단일 with 문부터 시작합니다.
  2. 여러 컨텍스트 관리자: 현재의 쉼표 구문을 보여 줍니다.
  3. 비동기 컨텍스트 관리자: async with를 소개합니다.
  4. 혼합 컨텍스트: “각 비동기 컨텍스트 관리자에 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은 상당한 복잡성과 오류 발생 가능성을 초래합니다. 구문을 사용한 컨텍스트 관리라면 보장할 속성, 예를 들어 ‘last-in, first-out’ 순서를 쉽게 위반할 수 있습니다.

해결 방법: AsyncExitStack 기반 도우미

직접 AsyncExitStack을 사용하는 데 따른 단점 일부를 피하는 multicontext() 래퍼도 구현할 수 있습니다.

async with multicontext(
    acquire_lock(),
    open('file'),
) as (f, _):
    ...

그러나 이 도우미는 as절의 국소성을 깨뜨리므로, 코드 샘플에서처럼 반환된 변수를 실수로 잘못 할당하기 쉽습니다. 또한 태그된 유니언과 같은 방법을 사용해 동기 컨텍스트 관리자와 비동기 컨텍스트 관리자를 구분해야 합니다. 예를 들어 연산자를 오버로드하여 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 및 직장에서 논의한 많은 다른 분들께도 감사드립니다.