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

Python 개선 제안 한국어 번역

PEP 657 – 트레이스백에 세밀한 오류 위치 포함하기

Author:
Pablo Galindo Salgado <pablogsal at python.org>, Batuhan Taskaya <batuhan at python.org>, Ammar Askar <ammar at ammaraskar.com>
Discussions-To:
Discourse thread
Status:
Final
Type:
Standards Track
Created:
08-May-2021
Python-Version:
3.11
Post-History:


Table of Contents

번역·라이선스 안내

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

초록

이 PEP는 각 바이트코드 명령어를 해당 명령어를 생성한 줄의 시작 및 끝 열 오프셋과 끝 줄 번호에 매핑하는 기능을 추가할 것을 제안합니다. 이 데이터는 디버깅 경험을 개선하기 위해 CPython 인터프리터가 표시하는 트레이스백을 개선하는 데 사용됩니다. 또한 이 PEP는 다른 도구(예: 커버리지 분석 도구, 프로파일러, 트레이서, 디버거)가 코드 객체에서 이 정보를 사용할 수 있도록 하는 API를 추가할 것을 제안합니다.

동기

이 PEP의 주된 동기는 디버깅을 지원하기 위해 오류 위치에 관해 제공되는 피드백을 개선하는 것입니다.

Python은 현재 컴파일 과정에서 바이트코드를 줄 번호에 매핑하여 보관합니다. 인터프리터는 이 매핑을 사용하여 오류와 연관된 소스 줄을 가리킵니다. 명령어에 대한 이러한 줄 수준 세분성은 유용하지만, Python 코드 한 줄이 수십 개의 바이트코드 연산으로 컴파일될 수 있으므로 해당 줄의 어느 부분에서 오류가 발생했는지 추적하기 어렵습니다.

다음 Python 코드 줄을 살펴보십시오.:

x['a']['b']['c']['d'] = 1

딕셔너리의 값 중 하나라도 None이면 표시되는 오류는 다음과 같습니다.:

Traceback (most recent call last):
  File "test.py", line 2, in <module>
    x['a']['b']['c']['d'] = 1
TypeError: 'NoneType' object is not subscriptable

트레이스백만으로는 오류를 일으킨 None요소가 어느 딕셔너리에 있었는지 확인할 수 없습니다. 사용자는 문제를 추적하기 위해 디버거를 연결하거나 식을 분할해야 하는 경우가 많습니다.

그러나 인터프리터가 줄 번호뿐만 아니라 바이트코드를 열 오프셋에도 매핑했다면 다음과 같이 유용하게 표시할 수 있습니다.:

Traceback (most recent call last):
  File "test.py", line 2, in <module>
    x['a']['b']['c']['d'] = 1
    ~~~~~~~~~~~^^^^^
TypeError: 'NoneType' object is not subscriptable

이는 사용자에게 객체 x['a']['b']None이어야 함을 알려 줍니다. 이러한 강조 표시는 트레이스백의 모든 프레임에서 발생합니다. 예를 들어 유사한 오류가 복잡한 함수 호출 체인의 일부인 경우, 트레이스백은 각 프레임에서 현재 명령어와 연관된 코드를 표시합니다.:

Traceback (most recent call last):
  File "test.py", line 14, in <module>
    lel3(x)
    ^^^^^^^
  File "test.py", line 12, in lel3
    return lel2(x) / 23
           ^^^^^^^
  File "test.py", line 9, in lel2
    return 25 + lel(x) + lel(x)
                ^^^^^^
  File "test.py", line 6, in lel
    return 1 + foo(a,b,c=x['z']['x']['y']['z']['y'], d=e)
                         ~~~~~~~~~~~~~~~~^^^^^
TypeError: 'NoneType' object is not subscriptable

이 문제는 다음과 같은 상황에서 발생합니다.

  • 여러 객체를 함수 호출에 전달하면서 해당 객체에서 동일한 속성에 접근하는 경우입니다. 예를 들어 다음 오류가 있습니다.:
    Traceback (most recent call last):
      File "test.py", line 19, in <module>
        foo(a.name, b.name, c.name)
    AttributeError: 'NoneType' object has no attribute 'name'
    

    이 PEP의 개선 사항을 적용하면 다음과 같이 표시됩니다.:

    Traceback (most recent call last):
      File "test.py", line 17, in <module>
        foo(a.name, b.name, c.name)
                    ^^^^^^
    AttributeError: 'NoneType' object has no attribute 'name'
    
  • 복잡한 수학 표현식이 포함된 줄을 처리하는 경우이며, 특히 인자에 따라 산술 연산이 실패할 수 있는 numpy와 같은 라이브러리를 사용할 때 그렇습니다. 예를 들면 다음과 같습니다.
    Traceback (most recent call last):
      File "test.py", line 1, in <module>
        x = (a + b) @ (c + d)
    ValueError: operands could not be broadcast together with shapes (1,2) (2,3)
    

    어느 연산이 실패했는지 명확히 알 수 없습니다. 왼쪽의 덧셈인지, 오른쪽의 덧셈인지, 아니면 가운데의 행렬 곱셈인지 알 수 없습니다. 이 PEP를 적용하면 새로운 오류 메시지는 다음과 같이 표시됩니다.:

    Traceback (most recent call last):
      File "test.py", line 1, in <module>
        x = (a + b) @ (c + d)
                       ~~^~~
    ValueError: operands could not be broadcast together with shapes (1,2) (2,3)
    

    훨씬 더 명확하고 디버깅하기 쉬운 오류 메시지를 제공합니다.

디버깅 외에도 이 추가 정보는 코드 커버리지 도구에 유용하며, 줄 수준 커버리지만 측정하는 대신 식 수준 커버리지를 측정할 수 있게 합니다. 예를 들어 다음 줄이 주어졌을 때입니다.

x = foo() if bar() else baz()

coverage, profile 또는 상태 분석 도구는 두 분기에서 전체 줄을 강조 표시하므로 어느 분기가 실행되었는지 구분할 수 없습니다. 이는 pycoverage_에서 알려진 문제입니다.

이 PEP와 유사한 노력은 JEP358_의 형태로 Java와 같은 다른 언어에서도 이루어졌습니다. Java의 NullPointerExceptions도 복잡한 표현식이 포함된 줄에서는 마찬가지로 모호했습니다. NullPointerException은 오류의 근본 원인을 찾는 데 거의 도움이 되지 않았습니다. JEP358의 구현은 상당히 복잡하며, 제어 흐름 그래프 분석기와 디컴파일 기법을 사용하여 바이트코드를 역추적하고 null 포인터로 이어진 소스 코드를 복원해야 합니다. 이 해결책의 복잡성은 높고 Java 바이트코드가 변경될 때마다 디컴파일러를 유지 관리해야 하지만, 단 하나의 예외 유형에 제공되는 추가 정보 때문에 이 개선은 가치가 있다고 판단되었습니다.

근거

예외가 발생했을 때 실행 중인 소스 코드의 범위를 식별하려면, 이 제안에서는 모든 바이트코드 명령어에 새로운 데이터를 추가해야 합니다. 이는 디스크에 저장되는 pyc 파일의 크기와 메모리에 저장되는 코드 객체의 크기에 영향을 줍니다. 이 제안의 작성자들은 이러한 영향을 최소화하도록 노력하는 방식으로 데이터 유형을 선택했습니다. 제안된 오버헤드는 각 바이트코드 명령어에 대해 두 개의 uint8_t(시작 오프셋용 하나와 끝 오프셋용 하나)와 끝 줄 정보를 저장하는 것입니다(현재 시작 줄을 저장하는 것과 동일한 인코딩 방식).

이 변경의 영향을 가늠하기 위한 예시로, 시작 오프셋과 끝 오프셋을 포함하면 표준 라이브러리의 pyc 파일 크기가 28.4MB에서 34.7MB로 22%(6MB) 증가한다는 계산 결과를 얻었습니다. 메모리 사용량의 오버헤드도 동일합니다(전체 표준 라이브러리가 동일한 프로그램에 로드된다고 가정하면). 특히 현대 컴퓨터의 저장 공간과 메모리 용량을 고려하면 오버헤드의 규모가 매우 작으므로, 이 수치는 매우 수용할 만한 수준이라고 생각합니다. 또한 일반적으로 Python 프로그램의 메모리 크기는 코드 객체가 지배하지 않습니다. 이 가정을 확인하기 위해 여러 인기 있는 PyPI 프로젝트(NumPy, pytest, Django, Cython 포함)의 테스트 모음과 여러 애플리케이션(Black, pylint, mypy 자체 또는 표준 라이브러리에서 실행한 mypy)을 실행했으며, 코드 객체가 프로그램의 평균 메모리 크기에서 일반적으로 3~6%를 차지한다는 사실을 확인했습니다.

일부 사용자에게는 이 정보로 인한 추가 비용이 허용되지 않을 수 있음을 이해하므로, 생성된 코드 객체에 추가 정보가 포함되지 않도록 하면서 pyc 파일에도 추가 정보가 포함되지 않게 하는 옵트아웃 메커니즘을 제안합니다.

사양

오류가 발생한 주어진 줄 내의 위치를 정확하게 확인하는 데 충분한 정보를 얻으려면, 바이트코드 명령어를 열 오프셋(시작 및 끝 오프셋) 및 끝 줄 번호에 연결하는 매핑이 필요합니다. 이는 현재 줄 번호를 바이트코드 명령어에 연결하는 방식과 유사합니다.

이 PEP의 구현 과정에서 다음과 같은 변경이 수행됩니다.

  • 오프셋 정보는 co_positions라는 코드 객체 클래스의 새 속성을 통해 Python에 노출되며, 이 속성은 모든 명령어의 전체 위치(시작 줄, 끝 줄, 시작 열 오프셋 및 끝 열 오프셋 포함)를 담은 네 요소 튜플의 시퀀스를 반환하거나, 오프셋 정보 없이 코드 객체가 생성된 경우 None을 반환합니다.
  • 새로운 C-API 함수 하나:
    int PyCode_Addr2Location(
        PyCodeObject *co, int addrq,
        int *start_line, int *start_column,
        int *end_line, int *end_column)
    

    바이트코드 명령어의 인덱스를 지정하여 끝 줄, 시작 열 오프셋 및 끝 열 오프셋을 얻을 수 있도록 추가됩니다. 이 함수는 정보를 사용할 수 없는 경우 값을 0으로 설정합니다.

정보의 내부 저장, 압축 및 인코딩은 구현 세부 사항으로 남겨 두며, 공개 API가 변경되지 않는 한 언제든지 변경할 수 있습니다.

오프셋 의미론

이러한 오프셋은 현재 모든 AST 노드에 저장된 오프셋으로부터 컴파일러에 의해 전달됩니다. 이러한 속성을 다루는 공개 API(co_positionsPyCode_Addr2Location)의 출력은 AST 노드와 마찬가지로 0부터 인덱싱된 오프셋을 사용하지만, 기반 구현은 실제 데이터를 가장 효율적이라고 선택한 어떤 형식으로든 표현할 수 있습니다. 정보를 사용할 수 없음을 나타내는 오류 코드는 co_positions() API에서는 None이며, PyCode_Addr2Location API에서는 -1입니다. 정보의 사용 가능성은 오프셋이 범위 내에 들어오는지 여부와 인터프리터 구성의 런타임 플래그에 크게 좌우됩니다.

AST 노드는 이러한 값을 저장하는 데 int 유형을 사용합니다. 그러나 현재 구현은 저장 공간에 미치는 영향을 최소화하기 위한 구현 세부 사항으로 uint8_t 유형을 사용합니다. 이 결정에 따라 오프셋은 0부터 255까지 사용할 수 있으며, 이보다 큰 오프셋은 누락된 것으로 처리됩니다(PyCode_Addr2Location에서는 -1을 반환하고 co_positions() API에서는 None을 반환합니다).

앞서 명시한 바와 같이, 오프셋의 기반 저장 방식은 구현 세부 사항으로 간주해야 합니다. 값을 가져오는 공개 API는 C int 형식 또는 Python int 객체를 반환하므로, 향후 더 큰 범위를 지원해야 하는 경우 더 나은 압축/인코딩을 구현할 수 있습니다. 이 PEP는 이보다 간단한 버전으로 시작하고 개선 사항은 향후 작업으로 미룰 것을 제안합니다.

트레이스백 표시

트레이스백을 표시할 때 기본 예외 훅은 코드 객체에서 이 정보를 조회하도록 수정되며, 정보가 제공되는 경우 트레이스백에 표시되는 각 줄에 캐럿 시퀀스를 표시하는 데 이 정보를 사용합니다. 예를 들어:

  File "test.py", line 6, in lel
    return 1 + foo(a,b,c=x['z']['x']['y']['z']['y'], d=e)
                         ~~~~~~~~~~~~~~~~^^^^^
TypeError: 'NoneType' object is not subscriptable

트레이스백을 표시할 때 명령어 오프셋은 트레이스백 객체에서 가져옵니다. 따라서 새 정보를 스택에 저장할 필요 없이 다시 발생한 예외를 자연스럽게 강조 표시할 수 있습니다. 예를 들어 다음 코드의 경우:

def foo(x):
    1 + 1/0 + 2

def bar(x):
    try:
        1 + foo(x) + foo(x)
    except Exception as e:
        raise ValueError("oh no!") from e

bar(bar(bar(2)))

출력되는 트레이스백은 다음과 같이 표시됩니다.:

Traceback (most recent call last):
  File "test.py", line 6, in bar
    1 + foo(x) + foo(x)
        ^^^^^^
  File "test.py", line 2, in foo
    1 + 1/0 + 2
        ~^~
ZeroDivisionError: division by zero

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "test.py", line 10, in <module>
    bar(bar(bar(2)))
            ^^^^^^
  File "test.py", line 8, in bar
    raise ValueError("oh no!") from e
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: oh no

반면 다음 코드는:

def foo(x):
    1 + 1/0 + 2
def bar(x):
    try:
        1 + foo(x) + foo(x)
    except Exception:
        raise
bar(bar(bar(2)))

다음과 같이 표시됩니다.:

Traceback (most recent call last):
  File "test.py", line 10, in <module>
    bar(bar(bar(2)))
            ^^^^^^
  File "test.py", line 6, in bar
    1 + foo(x) + foo(x)
        ^^^^^^
  File "test.py", line 2, in foo
    1 + 1/0 + 2
        ~^~
ZeroDivisionError: division by zero

현재 동작을 유지하면서 트레이스백에는 한 줄만 표시됩니다. 여러 줄에 걸쳐 있는 명령어의 경우(끝 오프셋과 시작 오프셋이 서로 다른 줄에 속하는 경우), 끝 오프셋이 시작 오프셋과 같은 줄에 적용되는지 확인하려면 끝 줄 번호를 검사해야 합니다.

옵트아웃 메커니즘

저장 공간과 메모리 오버헤드를 중요하게 여기며 현재 트레이스백을 구문 분석하는 서드파티 도구 및 기타 프로그램이 대응할 수 있도록 하려는 사용자를 위해, 이 기능을 비활성화하는 다음 방법이 제공됩니다.

  • 새 환경 변수: PYTHONNODEBUGRANGES.
  • 개발 모드를 위한 새 명령줄 옵션: python -Xno_debug_ranges.

이러한 방법 중 어느 것이라도 사용하면 Python 컴파일러는 새 정보로 코드 객체를 채우지 않으며 (대신 None이 사용됩니다), 추가 정보를 포함하는 마셜 해제된 코드 객체에서는 해당 정보가 제거되고 None으로 대체됩니다). 또한 정보가 존재하더라도 트레이스백 처리 기능은 확장된 위치 정보를 표시하지 않습니다. 이 방법을 사용하면 다음을 수행할 수 있습니다.

  • 해당 파일을 생성할 때 두 방법 중 하나를 사용하여 더 작은 pyc 파일을 생성합니다.
  • 처음부터 추가 정보와 함께 생성된 pyc 파일에서 추가 정보를 로드하지 않습니다.
  • 트레이스백을 표시할 때 오류 위치를 나타내는 캐럿 문자를 비롯한 추가 정보를 비활성화합니다.

이렇게 해도 성능 저하는 매우 작습니다. 코드 객체가 생성될 때 구성을 조회하려면 인터프리터 상태를 가져와야 하기 때문입니다. 코드 객체 생성은 성능에 민감한 작업이 아니므로 이는 문제가 되지 않습니다.

하위 호환성

이 변경 사항은 완전히 하위 호환됩니다.

참조 구현

참조 구현은 implementation 포크에서 확인할 수 있습니다.

거부된 아이디어

범위 대신 단일 캐럿 사용

기능을 단순화하기 위한 방법으로 오류를 보고할 때 전체 범위를 강조 표시하는 대신 단일 캐럿을 사용하자는 제안이 있었습니다. 다음과 같은 이유로 이 방식을 택하지 않기로 했습니다.

  • 현재 AST의 배치로는 캐럿의 위치를 도출하는 일이 간단하지 않습니다. AST 노드는 시작 및 끝 줄 번호와 시작 및 끝 열 오프셋만 기록하기 때문입니다. AST 노드는 (의도적으로) 원래 토큰을 보존하지 않으므로, 일부 토큰의 정확한 위치를 도출하려면 추가로 다시 구문 분석해야 하며, 그렇지 않으면 불가능합니다. 예를 들어 현재 이항 연산자에는 피연산자에 대한 노드가 있지만 연산자의 유형은 열거형에 저장되므로 해당 위치를 노드에서 도출할 수 없습니다(이는 이 문제가 나타나는 방식의 한 예일 뿐이며 유일한 예는 아닙니다).
  • AST 노드에서 범위를 도출하면 구현이 크게 단순해지고 유지 관리 비용과 오류가 발생할 가능성이 많이 줄어듭니다. 범위를 사용하면 모든 AST 노드에 대해 항상 일반적인 방식으로 처리할 수 있지만, 다른 사용자 지정 정보는 서로 다른 노드 유형마다 서로 다른 방식으로 추출해야 하기 때문입니다. AST를 생성할 때 위치를 수동으로 가져오는 작업이 수동 프로세스였던 시절에 얼마나 오류가 발생하기 쉬웠는지를 고려하면, 일반적인 해결책은 우리가 추구해야 할 매우 중요한 특성이라고 생각합니다.
  • 단일 캐럿을 강조 표시하는 정보를 저장하는 것은 커버리지 도구와 프로파일러뿐 아니라 이 새로운 기능을 활용하려는 IPython 및 IDE와 같은 도구에도 매우 큰 제약이 됩니다. “friendly-traceback”의 저자가 작성한 이 메시지에서 언급하듯이, 그 이유는 전체 범위(끝 줄 포함)가 없으면 이러한 도구가 관련 소스 코드를 올바르게 강조하기가 매우 어렵기 때문입니다. 예를 들어 다음 코드의 경우입니다.:
    something = foo(a,b,c) if bar(a,b,c) else other(b,c,d)
    

    도구(예: 커버리지 보고 도구)는 단일 문자만이 아니라 실행된 바이트코드가 포함하는 호출 전체(가령 foo(a,b,c))를 강조 표시할 수 있어야 합니다. 소스 코드를 다시 구문 분석하고 다시 토큰화하여 정보를 재구성하는 것이 기술적으로 가능하더라도, 이를 안정적으로 수행할 수 없으며 사용자 경험이 훨씬 나빠집니다.

  • 많은 사용자가 단일 캐럿은 전체 범위보다 읽기가 훨씬 어렵다고 보고했으며, 이로 인해 구문 오류를 강조 표시할 때 범위를 사용하게 되었고 이는 매우 좋은 반응을 얻었습니다. 또한 시각에 문제가 있는 사용자는 단일 캐럿 문자보다 범위를 훨씬 쉽게 식별할 수 있다는 점이 지적되었으며, 이는 범위 사용의 큰 장점이라고 생각합니다.

선택 해제를 위한 configure 플래그를 추가합니다.

Python을 비최적화 모드로 실행할 때에도 오버헤드를 선택적으로 해제할 수 있는 configure 플래그를 두는 것이 바람직해 보일 수 있지만, 플래그가 활성화된 상태로 컴파일되지 않은 인터프리터 버전으로 생성된 pyc 파일을 읽을 때 문제가 발생할 수 있습니다. 이로 인해 일반 사용자가 디버깅하기 매우 어려운 충돌이 발생할 수 있으며, 서로 다른 pyc 파일이 서로 호환되지 않게 됩니다. 이러한 pyc 파일은 원본 소스 없이 라이브러리나 애플리케이션의 일부로 제공될 수 있으므로, 해당 pyc 파일을 강제로 다시 컴파일하는 것이 항상 가능한 것도 아닙니다. 이러한 이유로 이 동작을 선택 해제하려면 -O 플래그를 사용하기로 했습니다.

열 정보의 지연 로딩

이 기능의 메모리 사용량을 줄이는 한 가지 방법은 코드를 가져올 때 pyc 파일에서 열 정보를 로드하지 않는 것입니다. 처리되지 않은 예외가 전파되거나 C-API 함수 호출이 이루어진 경우에만 pyc 파일에서 열 정보를 로드합니다. 이는 예외가 전파될 때 트레이스백에 표시할 소스 줄만 읽는 방식과 유사합니다. 이렇게 하면 실제로 메모리 사용량은 줄어들지만, 코드 객체의 일부를 선택적으로 무시하도록 가져오기 메커니즘을 변경해야 하므로 구현이 훨씬 더 복잡해집니다. 이를 흥미로운 탐구 방향이라고 생각하지만, 궁극적으로는 이 특정 PEP의 범위를 벗어난다고 생각합니다. 또한 사용자가 pyc 파일을 사용하지 않거나 런타임에 동적으로 생성된 코드 객체인 경우에는 열 정보를 사용할 수 없다는 의미이기도 합니다.

압축을 구현합니다.

pyc 파일과 코드 객체의 새로운 데이터에 어떤 형태로든 압축을 구현하는 것은 가능하겠지만, pyc 파일의 경우 영향이 더 크고 코드 객체의 새로운 데이터의 경우 열 오프셋에 패턴이 부족하여 압축이 잘 되지 않을 것으로 예상되므로, 이는 이 제안의 범위를 벗어난다고 생각합니다.

감사의 말

이 아이디어의 초기 프로토타입을 Pypy 인터프리터에 보여 주고 유익한 논의를 해 주신 Carl Friedrich Bolz-Tereick에게 감사드립니다.

참조 자료