트레이스백 읽는 방법

디버깅을 위해 Python 트레이스백을 읽는 방법을 개괄적으로 설명해요.


프레임 객체

함수가 호출되면, 함수에 전달된 지역 변수와 인자를 담기 위해 프레임 객체가 만들어져요. 함수가 반환되면 프레임 객체는 사라져요. 함수 A 안에서 함수 B가 호출되면, 함수 B의 값들이 프레임 객체에 담기고, 그 객체는 호출 스택에서 함수 A의 프레임 객체 위에 올려져요.

호출 스택

호출 스택은 현재 활성화된 함수들의 프레임 객체를 모아 놓은 것이에요. 함수 A가 함수 B를 호출하고 함수 B가 함수 C를 호출했다면, 세 함수의 프레임 객체가 모두 호출 스택에 있어요. 함수 C가 반환되면 그 프레임 객체는 스택에서 빠져나가고, 함수 A와 B의 프레임 객체만 호출 스택에 남아요.

트레이스백

트레이스백은 특정 시점에 스택에 있는 모든 프레임 객체를 보고한 것이에요. Python 프로그램이 처리되지 않은 예외를 만나면, 예외 메시지와 트레이스백을 출력해요. 트레이스백은 예외가 어디에서 발생했는지, 그리고 거기까지 어떤 함수들이 호출되었는지 보여줘요.

트레이스백 읽는 방법

ValueError는 흔한 예외예요.

다음은 오른쪽의 값 하나로 왼쪽의 변수 두 개에 할당하려고 할 때 발생하는 ValueError의 예예요:

>>> first, second = [1]
Traceback (most recent call last):
File <stdin>, line 1, in <module>
    first, second = [1]
ValueError: not enough values to unpack (expected 2, got 1)

트레이스백은 가장 최근의 호출이 마지막에 오도록 구성되어 있어요. 그래서 트레이스백을 읽기 시작할 곳은 맨 아래의 예외예요. 거기에서부터 위로 읽어 올라가면 그 문에 어떻게 도달했는지 알 수 있어요. 문제가 된 줄을 함수 안에 넣고 그 함수를 호출하면 더 긴 트레이스를 볼 수 있어요:

>>> def my_func():
...     first, second = [1]
...
>>> my_func()
Traceback (most recent call last):
  File <stdin>, line 5, in <module>
    my_func()
  File <stdin>, line 2, in my_func
    first, second = [1]
ValueError: not enough values to unpack (expected 2, got 1)

맨 아래에서부터 거꾸로 따라가 보면, 예외가 발생한 호출은 my_func의 2번째 줄이라는 것을 알 수 있어요. 그곳에는 5번째 줄에서 my_func를 호출함으로써 도달했어요.

흔한 예외

Python은 60개가 넘는 내장 예외 클래스를 정의하고 있어요. 여기에서는 좀 더 흔한 예외 몇 가지와 그것이 무엇을 나타내는지 간략히 살펴봐요.

SyntaxError

Python은 유효하지 않은 문법 때문에 코드를 이해할 수 없을 때 SyntaxError를 발생시켜요. 예를 들어, 여는 괄호는 있는데 짝이 맞는 닫는 괄호가 없을 수 있어요.

코드 예제를 보려면 여기를 클릭해요.

이 코드를 실행하면:

def distance(strand_a, strand_b):
    if len(strand_a) != len(strand_b):
        raise ValueError("Strands must be of equal length."  # This is missing the closing parenthesis

다음과 비슷한 스택 트레이스가 나와요 (마지막 줄의 메시지에 주목해요):

.usr.local.lib.python3.10.site-packages._pytest.python.py:608: in _importtestmodule
    mod = import_path(self.path, mode=importmode, root=self.config.rootpath)
.usr.local.lib.python3.10.site-packages._pytest.pathlib.py:533: in import_path
    importlib.import_module(module_name)
.usr.local.lib.python3.10.importlib.__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
<frozen importlib._bootstrap>:1050: in _gcd_import ???
<frozen importlib._bootstrap>:1027: in _find_and_load ???
<frozen importlib._bootstrap>:1006: in _find_and_load_unlocked ???
<frozen importlib._bootstrap>:688: in _load_unlocked ???
.usr.local.lib.python3.10.site-packages._pytest.assertion.rewrite.py:168: in exec_module
    exec(co, module.__dict__)
.mnt.exercism-iteration.hamming_test.py:3: in <module>
    from hamming import (  
E     File ".mnt.exercism-iteration.hamming.py", line 10
E       raise ValueError("Strands must be of equal length."
E                       ^
E   SyntaxError: '(' was never closed

AssertionError

Python은 assert 문(아래 참고)이 실패할 때 AssertionError를 발생시켜요.

코드 예제를 보려면 여기를 클릭해요.

이 코드를 실행하면:

def distance(strand_a, strand_b):
    assert len(strand_a) == len(strand_b)


distance("ab", "abc")

다음과 비슷한 스택 트레이스가 나와요 (마지막 줄의 메시지에 주목해요):

hamming_test.py:3: in <module>
    from hamming import (
hamming.py:5: in <module>
    distance("ab", "abc")
hamming.py:2: in distance
    assert len(strand_a) == len(strand_b)
E   AssertionError

AttributeError

AttributeError는 코드(또는 단위 테스트!)가 객체의 속성에 접근하려고 하는데 그 객체에 그런 속성이 없을 때 발생해요. 예를 들어, 단위 테스트는 Robot 객체에 direction 속성이 있기를 기대하는데, robot.direction에 접근하려고 하면 그것이 존재하지 않는 경우예요.

오타를 나타낼 수도 있어요. 예를 들어 올바른 문법이 "Hello".lower()인데 "Hello".lowercase()를 사용한 경우예요. "Hello".lowercase()는 AttributeError: 'str' object has no attribute 'lowercase'를 발생시켜요.

Robot 클래스 예제를 보려면 여기를 클릭해요.

이 코드를 실행하면:

class Robot:
    def __init__():
        #note that there is no self.direction listed here
        self.position = (0, 0)
        self.orientation = 'SW'

    def forward():
        pass
        

robby = Robot
robby.direction

다음과 비슷한 스택 트레이스가 나와요 (마지막 줄의 메시지에 주목해요):

robot_simulator_test.py:3: in <module>
    from robot_simulator import (
robot_simulator.py:12: in <module>
    robby.direction
E   AttributeError: type object 'Robot' has no attribute 'direction'
문자열 예제를 보려면 여기를 클릭해요.

이 코드를 실행하면:

def distance(strand_a, strand_b):
    if strand_a.lowercase() == strand_b:
        return 0


distance("ab", "abc")

다음과 비슷한 스택 트레이스가 나와요 (마지막 줄의 메시지에 주목해요):

    def distance(strand_a, strand_b):
>       if strand_a.lowercase() == strand_b:
E       AttributeError: 'str' object has no attribute 'lowercase'

ImportError

ImportError는 코드가 무언가를 임포트하려고 하는데 Python이 그것을 할 수 없을 때 발생해요. 예를 들어, Guidos Gorgeous Lasagna의 단위 테스트는 from lasagna import bake_time_remaining를 실행하는데, lasgana.py 풀이 파일에 bake_time_remaining이 정의되어 있지 않을 수 있어요.

코드 예제를 보려면 여기를 클릭해요

정의되지 않은 함수가 있는 상태에서 lasgana.py 파일을 실행하면 다음과 같은 오류가 나와요:

We received the following error when we ran your code:

  ImportError while importing test module '.mnt.exercism-iteration.lasagna_test.py'.
Hint: make sure your test modules.packages have valid Python names.

Traceback:
.mnt.exercism-iteration.lasagna_test.py:6: in <module>
    from lasagna import (EXPECTED_BAKE_TIME,
E   ImportError: cannot import name 'bake_time_remaining' from 'lasagna' (.mnt.exercism-iteration.lasagna.py)

During handling of the above exception, another exception occurred:
.usr.local.lib.python3.10.importlib.__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
.mnt.exercism-iteration.lasagna_test.py:23: in <module>
    raise ImportError("In your 'lasagna.py' file, we can not find or import the"

E   ImportError: In your 'lasagna.py' file, we can not find or import the function named 'bake_time_remaining()'. Did you mis-name or forget to define it?

### **IndexError**

Python raises an `IndexError` when an invalid index is used to look up a value in a sequence.
This often indicates the index is not computed properly and is often an off-by-one error.


<details>
<summary>Click here for code example</summary>


Consider the following code.

```python
def distance(strand_a, strand_b):
    same = 0
    for i in range(len(strand_a)):
        if strand_a[i] == strand_b[i]:
            same += 1
    return same


distance("abc", "ab")  # Note the first strand is longer than the second strand.

이 코드를 실행하면 다음과 비슷한 오류가 나와요. (마지막 줄에 주목해요.)

hamming_test.py:3: in <module>
    from hamming import (
hamming.py:9: in <module>
    distance("abc", "ab")  # Note the first strand is longer than the second strand.
hamming.py:4: in distance
    if strand_a[i] == strand_b[i]:
E   IndexError: string index out of range

KeyError

IndexError와 비슷하게, 이 예외는 키를 사용해 딕셔너리 값을 찾으려고 하는데 그 키가 딕셔너리에 설정되어 있지 않을 때 발생해요.

코드 예제를 보려면 여기를 클릭해요

다음 코드를 살펴봐요.

def to_rna(dna_letter):
    translation = {"G": "C", "C": "G", "A": "U", "T": "A"}
    return translation[dna_letter]


print(to_rna("Q"))  # Note, "Q" is not in the translation.

이 코드를 실행하면 다음과 비슷한 오류가 나와요. (마지막 줄에 주목해요.)

rna_transcription_test.py:3: in <module>
    from rna_transcription import to_rna
rna_transcription.py:6: in <module>
    print(to_rna("Q"))
rna_transcription.py:3: in to_rna
    return translation[dna_letter]
E   KeyError: 'Q'

TypeError

일반적으로 TypeError는 잘못된 타입의 데이터가 함수에 전달되거나 연산에 사용될 때 발생해요.

코드 예제를 보려면 여기를 클릭해요

다음 코드를 살펴봐요.

def hello(name):  # This function expects a string.
    return 'Hello, ' + name + '!'


print(hello(100))  # 100 is not a string.

이 코드를 실행하면 다음과 비슷한 오류가 나와요. (마지막 줄에 주목해요.)

hello_world_test.py:3: in <module>
    import hello_world
hello_world.py:5: in <module>
    print(hello(100))
hello_world.py:2: in hello
    return 'Hello, ' + name + '!'
E   TypeError: can only concatenate str (not "int") to str

ValueError

ValueError는 보통 유효하지 않은 값이 함수에 전달될 때 발생해요.

코드 예제를 보려면 여기를 클릭해요

참고로, 제곱근이 실수로 존재하는 것은 양수뿐이에요. math.sqrt(-1)을 호출하면 -1이 제곱근에 유효한 값이 아니기 때문에 ValueError: math domain error를 발생시켜요. (수학적) 전문 용어로 말하면, -1은 제곱근의 정의역에 속하지 않아요.

import math

math.sqrt(-1)

이 코드를 실행하면 다음과 비슷한 오류가 나와요. (마지막 줄에 주목해요.)

square_root_test.py:3: in <module>
    from square_root import (
square_root.py:3: in <module>
    math.sqrt(-1)
E   ValueError: math domain error

print 함수 사용하기

때로는 오류가 발생하지 않는데 값이 예상과 다를 수 있어요. 값이 여러 계산을 거친 결과라면 특히 더 당황스러울 수 있어요. 그런 상황에서는 각 단계의 값을 살펴보면서 어느 단계가 예상대로 동작하지 않는지 확인하는 것이 도움이 돼요. print 함수를 사용하면 값을 콘솔에 출력할 수 있어요. 다음은 예상한 값을 반환하지 않는 함수의 예예요:

# the intent is to pass an integer to this function and get an integer back
def halve_and_quadruple(num):
    return (num / 2) * 4

함수에 5를 전달하면 기대하는 값은 8이지만, 실제로는 10.0을 반환해요. 문제를 해결하려면, 각 단계마다 값을 확인할 수 있도록 계산을 나눠요.

# the intent is to pass an integer to this function and get an integer back
def halve_and_quadruple(num):
    # verify the number in is what is expected
    # prints 5
    print(num)
    # we want the int divided by an integer to be an integer
    # but this prints 2.5! We've found our mistake.
    print(num / 2)
    # this makes sense, since 2.5 x 4 = 10.0
    print((num / 2) * 4)
    return (num / 2) * 4

What the `print` calls revealed is that we used `/` when we should have used `//`, the [floor division operator][floor division operator].

## Logging

[Logging][logging] can be used similarly to `print`, but it is more powerful.
What is logged can be configured by the logging severity (e.g., 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'.)
A call to the `logging.error` function can pass `True` to the `exc_info` parameter, which will additionally log the stack trace.
By configuring multiple handlers, logging can write to more than one place with the same logging function.

Following is an example of logging printed to the console:

```python

>>> import logging
>>>
>>> # configures minimum logging level as INFO
>>> logging.basicConfig(level=logging.INFO)
>>>
>>> def halve_and_quadruple(num):
...     # prints INFO:root: num == 5
...     logging.info(f" num == {num}")
...     return (num // 2) * 4
...
>>> print(halve_and_quadruple(5))

기본 레벨이 WARNING이기 때문에 레벨을 INFO로 설정했어요. 로그를 계속 남기려면, 로거가 파일에 기록하도록 다음과 같이 설정할 수 있어요:

>>> import logging
...
>>> # configures the output file name to example.log, and the minimum logging level as INFO
>>> logging.basicConfig(filename='example.log', level=logging.INFO)
...
... def halve_and_quadruple(num):
...     # prints INFO:root: num == 5 to the example.log file
...     logging.info(f" num == {num}")
...     return (num // 2) * 4
...
>>> print(halve_and_quadruple(5))

assert

assert는 프로그램에 버그가 없는 한 항상 True로 평가되어야 하는 문이에요. assert가 False로 평가되면 AssertionError를 발생시켜요. AssertionError의 트레이스백에는 assert 문의 일부인 선택적 메시지가 포함될 수 있어요. 메시지는 선택 사항이지만, assert 정의에 항상 메시지를 포함하는 것이 좋은 관행이에요.

다음은 assert를 사용하는 예예요:

>>> def int_division(dividend, divisor):
...     assert divisor != 0, "divisor must not be 0"
...     return dividend // divisor
...
>>> print(int_division(2, 1))
2
>>> print(int_division(2, 0))
Traceback (most recent call last):
  File <stdin>, line 7, in <module>
    print(int_division(2, 0))
          ^^^^^^^^^^^^^^^^^^
  File <stdin>, line 2, in int_division
    assert divisor != 0, "divisor must not be 0"
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: divisor must not be 0

트레이스백을 맨 아래에서부터 읽기 시작하면(그렇게 해야 해요), 문제는 0이 divisor로 전달되어서는 안 된다는 것을 금방 알 수 있어요.

assert는 값이 기대하는 타입인지 확인하는 데에도 사용할 수 있어요:

>>> import numbers
...
...
... def int_division(dividend, divisor):
...     assert divisor != 0, "divisor must not be 0"
...     assert isinstance(divisor, numbers.Number), "divisor must be a number"
...     return dividend // divisor
...
>>> print(int_division(2, 1))
2
>>> print(int_division(2, '0'))
Traceback (most recent call last):
  File <stdin>, line 11, in <module>
    print(int_division(2, '0'))
          ^^^^^^^^^^^^^^^^^^^^
  File <stdin>, line 6, in int_division
    assert isinstance(divisor, numbers.Number), "divisor must be a number"
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: divisor must be a number

버그를 찾았다면, assert를 오류 처리로 바꾸는 것을 고려해 봐요. 왜냐하면 모든 assert 문은 Python을 -O 또는 -OO 옵션으로 실행하거나, PYTHONOPTIMIZE 환경 변수를 1 또는 2로 설정하면 비활성화될 수 있기 때문이에요. PYTHONOPTIMIZE를 1로 설정하는 것은 assert 문을 비활성화하는 -O 옵션으로 Python을 실행하는 것과 같아요. PYTHONOPTIMIZE를 2로 설정하는 것은 assert 문을 비활성화하고 바이트코드에서 독스트링을 제거하는 -OO 옵션으로 Python을 실행하는 것과 같아요. 바이트코드를 줄이는 것은 코드를 더 빠르게 실행하는 방법 중 하나예요.

Python 디버거

Python에는 내장 디버거인 pdb가 있어요. 코드를 한 단계씩 실행하며 변수를 살펴보는 데 사용할 수 있어요. 중단점을 설정할 수도 있어요. 시작하려면 먼저 import pdb를 하고, 디버깅을 시작하고 싶은 곳에서 pdb.set_trace()를 호출해야 해요:

import pdb

def add(num1, num2):
    return num1 + num2

pdb.set_trace()
sum = add(1,5)
print(sum)

이 코드를 실행하면 명령을 입력할 수 있는 pdb 프롬프트가 나타나요. 명령 목록을 보려면 help를 입력해요. 가장 흔한 것은 그 줄에서 호출된 함수 안으로 들어가는 step이에요. next는 함수 호출을 건너뛰고 다음 줄로 이동해요. where는 현재 어느 줄에 있는지 알려줘요. 다른 유용한 명령으로는 변수의 타입을 알려주는 whatis <variable>과 변수의 값을 출력하는 print(<variable>)가 있어요. 변수의 값을 출력하려면 그냥 <variable>을 사용해도 돼요. 또 다른 명령은 특정 줄 번호로 이동하는 jump <line number>예요.

다음은 앞의 코드를 바탕으로 디버거를 사용하는 작은 예예요. 이 예제와 다음 예제에서 MacOS나 Linux에서는 파일 경로가 슬래시를 사용한다는 점에 유의해요:

>>> python pdb.py
... > c:\pdb.py(7)<module>()
... -> sum = add(1,5)
... (Pdb)
>>> step
... > c:\pdb.py(3)add()
... -> def add(num1, num2):
... (Pdb)
>>> whatis num1
... <class 'int'>
>>> print(num2)
... 5
>>> next
... > c:\pdb.py(4)add()
... -> return num1 + num2
... (Pdb)
>>> jump 3
... > c:\pdb.py(3)add()
... -> def add(num1, num2):
... (Pdb)

중단점은 break <filename>:<line number> <condition>로 설정하는데, 여기서 조건은 중단점이 걸리기 위해 참이어야 하는 선택적 조건이에요. 그냥 break를 입력하면 설정한 중단점 목록을 볼 수 있어요. 중단점을 비활성화하려면 disable <breakpoint number>를 입력해요. 중단점을 활성화하려면 enable <breakpoint number>를 입력해요. 중단점을 삭제하려면 clear <breakpoint number>를 입력해요. 실행을 계속하려면 continue 또는 c를 입력해요. 디버거를 종료하려면 quit 또는 q를 입력해요.

다음은 앞의 코드를 바탕으로 위의 디버거 명령을 사용하는 예예요:

>>> python pdb.py
... > c:\pdb.py(7)<module>()
... -> sum = add(1,5)
... (Pdb)
>>> break
...
>>> break pdb:4
... Breakpoint 1 at c:\pdb.py:4
>>> break
... Num Type         Disp Enb   Where
... 2   breakpoint   keep yes   at c:\pdb.py:4
>>> c # continue
... > c:\pdn.py(4)add()
... -> return num1 + num2
>>> disable break 1
... Disabled breakpoint 1 at c:\pdb.py:4
>>> break
... Num Type         Disp Enb   Where
... 1   breakpoint   keep no    at c:\pdb.py:4
...         breakpoint already hit 1 time
>>> clear break 1
... Deleted breakpoint 1 at c:\pdb.py:4
>>> break
...

Python 3.7 이상에서는 중단점을 만드는 더 쉬운 방법이 있어요. 필요한 곳에 breakpoint()를 작성하기만 하면 중단점이 만들어져요.

def add(num1, num2):
    breakpoint()
    return num1 + num2

breakpoint()
sum = add(1,5)
print(sum)
>>> python pdb.py
... > c:\pdb.py(7)<module>()
... -> sum = add(1,5)
... (Pdb)
>>> c # continue
... > c:\pdb.py(5)add()
... -> return num1 + num2