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()

커버리지, 프로파일 또는 상태 분석 도구는 두 브랜치 모두에서 줄 전체를 강조하므로 어느 브랜치가 실행되었는지 구별할 수 없습니다. 이는 pycoverage의 알려진 문제입니다.

이 PEP와 비슷한 시도는 Java 같은 다른 언어에서도 JEP358 형태로 이루어졌습니다. Java의 NullPointerExceptions도 복잡한 표현식이 있는 줄에서는 마찬가지로 모호했습니다. NullPointerException은 오류의 근본 원인을 찾는 데 거의 도움이 되지 않았습니다. JEP358의 구현은 상당히 복잡합니다. 널 포인터로 이어진 소스 코드를 복원하려면 제어 흐름 그래프 분석기와 디컴파일 기법으로 바이트코드를 거슬러 올라가야 합니다. 이 해법은 복잡도가 높고 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에 노출됩니다. 이 속성은 모든 명령어의 전체 위치(시작 줄, 끝 줄, 시작 열 오프셋, 끝 열 오프셋 포함)를 담은 4-요소 튜플의 시퀀스를 반환하며, 코드 객체가 오프셋 정보 없이 생성된 경우에는 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_positions와 PyCode_Addr2Location)의 출력은 (AST 노드와 마찬가지로) 0부터 시작하는 오프셋을 사용하지만, 내부 구현은 실제 데이터를 가장 효율적이라고 판단하는 어떤 형태로든 자유롭게 표현할 수 있습니다. 정보를 사용할 수 없을 때의 오류 코드는 co_positions() API에서는 None이고, PyCode_Addr2Location API에서는 -1입니다. 정보를 사용할 수 있는지는 오프셋이 표현 범위 안에 드는지와 인터프리터 구성의 런타임 플래그에 크게 좌우됩니다.

AST 노드는 이 값들을 저장하는 데 int 타입을 사용합니다. 하지만 현재 구현은 저장 공간에 미치는 영향을 최소화하기 위해 구현 세부 사항으로서 uint8_t 타입을 사용합니다. 이 결정에 따라 오프셋은 0부터 255까지의 값을 가질 수 있습니다. 이보다 큰 오프셋은 누락된 것으로 처리됩니다(PyCode_Addr2Location API에서는 -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에게 감사드립니다.

참고 문헌