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

Python 개선 제안 한국어 번역

PEP 785 – ExceptionGroup을 더 쉽게 처리하기 위한 새로운 메서드

Author:
Zac Hatfield-Dodds <zac at zhd.dev>
Sponsor:
Gregory P. Smith <greg at krypto.org>
Discussions-To:
Discourse thread
Status:
Draft
Type:
Standards Track
Created:
08-Apr-2025
Python-Version:
3.14
Post-History:
13-Apr-2025

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

Python 커뮤니티 전반에서 PEP 654ExceptionGroup이 널리 사용되면서, 일반적이지만 다소 어색한 패턴이 일부 나타났습니다. 따라서 예외 객체에 두 가지 새로운 메서드를 추가할 것을 제안합니다.

  • BaseExceptionGroup.leaf_exceptions()는 ‘리프’ 예외를 목록으로 반환하며, 각 트레이스백은 모든 중간 그룹에서 합성됩니다.
  • BaseException.preserve_context()selfself.__context__ 속성을 저장하고 복원하는 컨텍스트 관리자이므로, 다른 핸들러 내에서 예외를 다시 발생시켜도 기존 컨텍스트를 덮어쓰지 않습니다.

이를 통해 중간 정도의 복잡성을 가진 많은 경우에 오류 처리 로직을 더 간결하게 표현할 수 있을 것으로 기대합니다. 이러한 메서드가 없으면 예외 그룹 핸들러는 계속해서 중간 트레이스백을 버리고 __context__ 예외를 잘못 처리하게 되며, 그 결과 비동기 코드를 디버깅하는 사람들이 불이익을 받게 됩니다.

동기

예외 그룹이 널리 사용되면서, 라이브러리 작성자와 최종 사용자는 개별 리프 예외를 처리하거나 이에 응답하는 코드를 자주 작성합니다. 예를 들어 웹 프레임워크에서 미들웨어, 오류 로깅 또는 응답 핸들러를 구현할 때 그러합니다.

GitHub 검색에서첫 60개 결과 중 다양한 이름으로 구현된 leaf_exceptions()를 네 가지 찾았으며, 그중 어느 것도 트레이스백을 처리하지 않았습니다.[1] 동일한 검색에서 .leaf_exceptions()를 사용할 수 있는 사례도 13개 발견되었습니다. 따라서 적절한 트레이스백 보존 기능을 갖춘 메서드를 BaseException 타입에 제공하면 생태계 전반의 오류 처리 및 디버깅 경험이 향상될 것이라고 생각합니다.

예외 그룹이 등장하면서 이전 핸들러에서 잡힌 예외를 다시 발생시키는 일이 훨씬 흔해졌습니다. 예를 들어 웹 서버 미들웨어는 그룹의 유일한 리프가 HTTPException인경우 이를 언래핑할 수 있습니다.

except* HTTPException as group:
    first, *rest = group.leaf_exceptions()  # get the whole traceback :-)
    if not rest:
        raise first
    raise

그러나 무해해 보이는 이 코드에는 문제가 있습니다. raise first는 부수 효과로 first.__context__ = group을수행합니다. 이로 인해 오류의 원래 컨텍스트가 사라지며, 여기에는 예외가 발생한 이유를 이해하는 데 중요한 정보가 포함되어 있을 수 있습니다. 또한 많은 프로덕션 앱에서 트레이스백이 수백 줄에서 수만 줄, 심지어 수십만 줄에이르도록 부풀어 오르게 하며, 이러한 규모 때문에 오류를 이해하기가 원래보다 훨씬 어려워집니다.

새로운 BaseException.preserve_context() 메서드는 이러한 경우에 쉽게 찾을 수 있고, 읽기 쉬우며, 사용하기 쉬운 해결책이 될 것입니다.

명세

새로운 leaf_exceptions()메서드가 BaseExceptionGroup에다음 시그니처로 추가됩니다.

def leaf_exceptions(self, *, fix_tracebacks=True) -> list[BaseException]:
    """
    Return a flat list of all 'leaf' exceptions in the group.

    If fix_tracebacks is True, each leaf will have the traceback replaced
    with a composite so that frames attached to intermediate groups are
    still visible when debugging. Pass fix_tracebacks=False to disable
    this modification, e.g. if you expect to raise the group unchanged.
    """

새로운 preserve_context()메서드가 BaseException에다음 시그니처로 추가됩니다.

def preserve_context(self) -> contextlib.AbstractContextManager[Self]:
    """
    Context manager that preserves the exception's __context__ attribute.

    When entering the context, the current values of __context__ is saved.
    When exiting, the saved value is restored, which allows raising an
    exception inside an except block without changing its context chain.
    """

사용 예:

# We're an async web framework, where user code can raise an HTTPException
# to return a particular HTTP error code to the client. However, it may
# (or may not) be raised inside a TaskGroup, so we need to use `except*`;
# and if there are *multiple* such exceptions we'll treat that as a bug.
try:
    user_code_here()
except* HTTPException as group:
    first, *rest = group.leaf_exceptions()
    if rest:
        raise  # handled by internal-server-error middleware
    ... # logging, cache updates, etc.
    with first.preserve_context():
        raise first

.preserve_context()가 없으면 이 코드는 다음 중 하나를 해야 합니다.

  • except\ * 블록 이후에 예외가 발생하도록 조치해야 하므로, 복잡한 경우 코드 흐름을 따라가기 어렵게 만들거나
  • first의 기존 __context__를버리고 이를 단순한 구현 세부 사항에 불과한 ExceptionGroup으로 대체하거나
  • except* 대신 try/except를사용하여 그룹에 HTTPException이 전혀 포함되지 않을 가능성을 처리하거나,[2] 또는
  • .preserve_context()의의미를 인라인으로 구현해야 합니다. 이는 문자 그대로 전례가 없는 것은 아니지만, 여전히 매우 드뭅니다.

하위 호환성

내장 클래스에 새로운 메서드를 추가하면 상당한 영향을 미칠 수 있으며, 특히 BaseException처럼널리 사용되는 클래스의 경우 더욱 그렇습니다. 그러나 GitHub 검색 결과 이러한 메서드 이름과 충돌하는 항목은 없습니다(검색 결과 0건[3]서로 관련 없는 결과 3건 각각). 비공개 코드에 이러한 이름의 사용자 정의 메서드가 존재한다면 PEP에서 제안하는 메서드를 가리게 되지만, 런타임 동작은 변경되지 않습니다.

이것을 가르치는 방법

예외 그룹을 다루는 것은 중급에서 고급 수준의 주제이며, 초급 프로그래머에게는 발생할 가능성이 낮습니다. 따라서 문서와 정적 분석 도구의 필요 시점 피드백을 통해 이 주제를 가르칠 것을 제안합니다. 중급 수업에서는 .leaf_exceptions().split().subgroup() 메서드와 함께 가르치고, 특정 문제점을 해결하기 위한 고급 옵션으로 .preserve_context()를 언급할 것을 권장합니다.

API 참조와 기존의 ExceptionGroup tutorial을 모두 업데이트하여 새로운 메서드를 시연하고 설명해야 합니다. 튜토리얼에는 .leaf_exceptions().preserve_context()를 사용하여 오류 처리 로직을 단순화할 수 있는 일반적인 패턴의 예제가 포함되어야 합니다. 예외 그룹을 자주 사용하는 하위 라이브러리에도 유사한 문서를 포함할 수 있습니다.

또한 flake8-async에 포함할 린트 규칙을 설계했으며, group.exceptions을 순회하거나 리프 예외를 다시 발생시킬 때 .leaf_exceptions()를 사용하도록 제안하고, except* 블록 내부에서 리프 예외를 다시 발생시키면 기존 컨텍스트를 덮어쓰게 되는 경우 .preserve_context()를 사용하도록 제안합니다.

참조 구현

이 PEP가 승인되면 내장 예외의 메서드는 C로 구현되지만, 다음 Python 구현이 이전 버전의 Python에서 유용하게 사용되고 의도한 의미를 보여 줄 수 있기를 바랍니다.

대규모 프로덕션 코드베이스에서 ExceptionGroup을 다룰 때 이러한 도우미 함수가 매우 유용하다는 것을 확인했습니다.

leaf_exceptions() 도우미 함수

import copy
import types
from types import TracebackType


def leaf_exceptions(
    self: BaseExceptionGroup, *, fix_traceback: bool = True
) -> list[BaseException]:
    """
    Return a flat list of all 'leaf' exceptions.

    If fix_tracebacks is True, each leaf will have the traceback replaced
    with a composite so that frames attached to intermediate groups are
    still visible when debugging. Pass fix_tracebacks=False to disable
    this modification, e.g. if you expect to raise the group unchanged.
    """

    def _flatten(group: BaseExceptionGroup, parent_tb: TracebackType | None = None):
        group_tb = group.__traceback__
        combined_tb = _combine_tracebacks(parent_tb, group_tb)
        result = []
        for exc in group.exceptions:
            if isinstance(exc, BaseExceptionGroup):
                result.extend(_flatten(exc, combined_tb))
            elif fix_tracebacks:
                tb = _combine_tracebacks(combined_tb, exc.__traceback__)
                result.append(exc.with_traceback(tb))
            else:
                result.append(exc)
        return result

    return _flatten(self)


def _combine_tracebacks(
    tb1: TracebackType | None,
    tb2: TracebackType | None,
) -> TracebackType | None:
    """
    Combine two tracebacks, putting tb1 frames before tb2 frames.

    If either is None, return the other.
    """
    if tb1 is None:
        return tb2
    if tb2 is None:
        return tb1

    # Convert tb1 to a list of frames
    frames = []
    current = tb1
    while current is not None:
        frames.append((current.tb_frame, current.tb_lasti, current.tb_lineno))
        current = current.tb_next

    # Create a new traceback starting with tb2
    new_tb = tb2

    # Add frames from tb1 to the beginning (in reverse order)
    for frame, lasti, lineno in reversed(frames):
        new_tb = types.TracebackType(
            tb_next=new_tb, tb_frame=frame, tb_lasti=lasti, tb_lineno=lineno
        )

    return new_tb

preserve_context() 컨텍스트 관리자

class preserve_context:
    def __init__(self, exc: BaseException):
        self.__exc = exc
        self.__context = exc.__context__

    def __enter__(self):
        return self.__exc

    def __exit__(self, exc_type, exc_value, traceback):
        assert exc_value is self.__exc, f"did not raise the expected exception {self.__exc!r}"
        exc_value.__context__ = self.__context
        del self.__context  # break gc cycle

거부된 아이디어

메서드 대신 유틸리티 함수 추가

예외에 메서드를 추가하는 대신 위의 참조 구현과 같은 유틸리티 함수를 제공할 수 있습니다. 그러나 메서드를 선호할 몇 가지 이유가 있습니다. 도우미 함수가 있어야 할 명확한 위치가 없고, 도우미 함수는 BaseException의 인스턴스여야 하는 인자 하나만 정확히 받으며, 메서드가 더 편리하고 발견하기도 쉽습니다.

BaseException.as_group() 추가(또는 그룹 메서드 추가)

ExceptionGroup 관련 오류 처리 코드를 조사하면서 단독 예외와 그룹 내부의 동일한 종류의 예외를 모두 처리하기 위한 중복 로직이 많은 경우에 발견되었으며, 이는 종종 잘못 구현되어 .leaf_exceptions()를 제안하게 된 계기입니다.

우리는 잠시 proposed하여 모든 예외에 .split(...).subgroup(...) 메서드도 추가하려 했지만, .leaf_exceptions()를 고려한 뒤 이것이 지나치게 번거롭다고 판단했습니다. 더 깔끔한 대안으로 .as_group() 메서드의 개요를 작성했습니다.

def as_group(self):
    if not isinstance(self, BaseExceptionGroup):
        return BaseExceptionGroup("", [self])
    return self

그러나 기존 코드를 리팩터링하기 위해 이 메서드를 적용해도 사소한 인라인 버전을 작성하는 것보다 개선 효과가 거의 없었습니다. 또한 이전 Python 버전이 수명 종료에 도달함에 따라 이러한 메서드의 현재 사용 사례 중 상당수가 except*로 해결되기를 바랍니다.

BaseException에 그룹 관련 메서드를 추가하는 대신, 중복 제거된 오류 처리를 위한 “그룹으로 변환” 레시피를 문서화할 것을 권장합니다.

컨텍스트 관리자 대신 e.raise_with_preserved_context() 추가

사용자가 원할 경우 raise ... from ...을 사용하여 __cause__를 설정하거나 재설정할 수 있으므로 컨텍스트 관리자 형식을 선호하며, 전반적으로도 덜 마법적이고 적절하지 않은 경우에 사용하고 싶은 유혹이 적습니다. 다만 다른 사람들이 이 형식을 선호한다면 이에 대해 논의해 볼 수 있습니다.

추가 속성 보존

__cause____suppress_context__속성은 예외를 다시 발생시켜도 변경되지 않으므로 이를 보존하지 않기로 결정했으며, 대신 with exc.preserve_context():와 함께 raise exc from None 또는 raise exc from cause_exc를 지원하는 것을 선호합니다.

마찬가지로 __traceback__ 특성을 보존하는 방안을 고려했지만, 추가적인 raise ... 문이 일부 오류를 이해하는 데 중요한 단서일 수 있기 때문에 보존하지 않기로 결정했습니다. 최종 사용자가 트레이스백에서 프레임을 제거하려는 경우, 별도의 컨텍스트 관리자를 사용하여 그렇게 할 수 있습니다.

각주