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
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
PEP 654 ExceptionGroup이 Python 커뮤니티 전반에서 널리 쓰이게 되면서, 흔하지만 어색한 패턴이 몇 가지 나타났습니다. 따라서 예외 객체에 두 가지 새로운 메서드를 추가할 것을 제안합니다:
BaseExceptionGroup.leaf_exceptions()는 ‘리프’ 예외를 리스트로 반환하며, 각 트레이스백에는 중간 그룹들의 트레이스백이 합성됩니다.BaseException.preserve_context()는self의self.__context__속성을 저장했다가 복원하여, 다른 핸들러 안에서 예외를 다시 발생시켜도 기존 컨텍스트를 덮어쓰지 않도록 하는 컨텍스트 관리자입니다.
이를 통해 중간 정도로 복잡한 많은 경우에 오류 처리 로직을 더 간결하게 표현할 수 있을 것으로 기대합니다. 이 메서드들이 없으면 예외 그룹 핸들러는 계속해서 중간 트레이스백을 버리고 __context__ 예외를 잘못 처리할 것이며, 이는 비동기 코드를 디버깅하는 모든 사람에게 해가 됩니다.
동기
예외 그룹이 널리 쓰이게 되면서, 라이브러리 작성자와 최종 사용자는 개별 리프 예외를 처리하거나 그에 대응하는 코드를 자주 작성합니다. 예를 들어 웹 프레임워크에서 미들웨어, 오류 로깅, 응답 핸들러를 구현할 때가 그렇습니다.
GitHub 검색에서는 처음 60개의 결과 중 leaf_exceptions()를 다양한 이름으로 구현한 사례를 네 개 찾았는데, 그중 트레이스백을 처리하는 것은 하나도 없었습니다.[1] 같은 검색에서 .leaf_exceptions()를 사용할 수 있었을 사례도 열세 개 찾았습니다. 따라서 트레이스백을 올바르게 보존하는 메서드를 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() 메서드는 이러한 경우에 쉽게 찾을 수 있고, 읽기 쉬우며, 사용하기 쉬운 해결책이 될 것입니다.
명세
BaseExceptionGroup에 새로운 메서드 leaf_exceptions()가 다음 시그니처로 추가됩니다:
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.
"""
BaseException에 새로운 메서드 preserve_context()가 다음 시그니처로 추가됩니다:
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 튜토리얼을 모두 갱신하여 새 메서드를 시연하고 설명해야 합니다. 튜토리얼에는 .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()의 동기가 되었습니다).
모든 예외에 .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() 추가
컨텍스트 관리자 형태를 선호하는 이유는, 사용자가 __cause__를 설정(또는 재설정)하고자 할 때 raise ... from ...을 사용할 수 있고, 전반적으로 마법 같은 동작이 다소 덜하며 적절하지 않은 경우에 사용하고 싶은 유혹도 덜하기 때문입니다. 다만 다른 분들이 이 형태를 선호한다면 저희가 설득될 여지는 있습니다.
추가 속성 보존
__cause__와 __suppress_context__ 속성은 보존하지 않기로 했습니다. 이 속성들은 예외를 다시 발생시켜도 바뀌지 않으며, with exc.preserve_context():와 함께 raise exc from None 또는 raise exc from cause_exc를 지원하는 편을 선호하기 때문입니다.
마찬가지로 __traceback__ 속성을 보존하는 방안도 검토했지만, 추가된 raise ... 문이 어떤 오류를 이해하는 데 중요한 단서가 될 수 있으므로 채택하지 않기로 했습니다. 최종 사용자가 트레이스백에서 프레임을 제거하고 싶다면 별도의 컨텍스트 관리자를 사용하여 그렇게 할 수 있습니다.
각주
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.