호출 위치에서 오류 찾기
75분 안팎
학습 목표
예외가 호출자를 거쳐 전파되는 순서를 설명합니다.
개념
왜 마지막 줄부터 읽는가
호출이 여러 함수로 나뉘면 화면을 만든 함수가 오류 위치처럼 보일 수 있습니다. traceback은 그 순간 실패한 호출 경로를 보여 줍니다. 마지막 줄은 예외 종류와 메시지이고 위쪽에는 파일·줄 번호·함수가 나옵니다. 보통 바깥 호출부터 안쪽 호출로 내려가므로 마지막 프레임에서 실제 실패한 연산을 찾고 위로 올라가 전달된 값을 조사합니다. 이번에는 submit에서 calculate를 부르고 calculate에서 parse_cost를 부르는 세 함수로 원인을 좁힙니다.
발생 지점과 수정 지점을 구분
parse_cost의 int(raw)에서 ValueError가 발생해도 항상 그 줄을 바꾸는 것은 아닙니다. 숫자 문자열만 받기로 한 내부 함수에 호출자가 제목을 넘겼다면 인자를 넘기는 호출부가 수정 대상입니다. 반대로 사용자 원문을 받는 입력 경계라면 abc는 예상한 실패이므로 파서가 명시적인 실패 결과를 줘야 합니다. 어떤 계약인지 읽은 뒤 수정 지점을 결정합니다. 예외가 난 함수, 그 함수를 부른 줄, 실제 입력값을 각각 적으면 추측과 관찰을 나눌 수 있습니다.
전파 중 실행되지 않는 코드
parse_cost가 값을 반환하지 못하면 calculate의 덧셈도 끝나지 않습니다. submit의 성공 출력도 실행되지 않습니다. 예외는 처리할 except를 찾으며 호출 경로를 되돌아갑니다. 호출한 모든 함수가 각각 새 오류를 낸 것은 아닙니다. 가장 바깥에서 잡으면 프로그램이 이어질 수 있지만 이미 변경된 목록을 되돌려 주지는 않습니다. 따라서 예외를 잡는 위치와 목록을 바꾸는 위치를 별도로 검토합니다. 잡고 pass로 넘어가면 왜 실패했는지 근거가 사라집니다.
재현 가능한 관찰 방법
전체 traceback에는 실행 경로와 줄 번호가 들어가 환경별로 달라질 수 있습니다. 아래 실험은 traceback.extract_tb로 실제 발생한 프레임을 읽어 함수 이름만 출력합니다. except ValueError 안에서 예외를 관찰하며 다른 오류는 숨기지 않습니다. names에서 맨 바깥의 모듈 실행 프레임을 제외해 세 함수만 보입니다. 현장에서 보고서를 작성할 때는 실제 파일과 줄 번호도 원문으로 보존하되 개인 홈 경로나 민감한 입력은 공유 전에 검토합니다.
흔한 메시지의 의미
invalid literal for int()는 정수로 해석할 수 없는 원문을 전달했다는 신호입니다. NameError는 찾을 이름이 없고 KeyError는 요청한 딕셔너리 키가 없다는 신호입니다. TypeError는 연산이나 호출에 맞지 않는 종류의 값일 가능성을 조사합니다. 같은 메시지가 매번 나온다면 버전을 올리거나 반복 실행하기 전에 가장 작은 재현 입력으로 확인합니다. 마지막 줄만 복사하면 누가 호출했는지 잃고 맨 위만 보면 실제 연산을 놓칩니다. 두 정보를 함께 사용합니다.
호출 경로를 종이에 펼치기
submit(raw)는 calculate(raw)를 호출하고 calculate는 parse_cost(raw)의 결과에 100을 더합니다. raw가 abc라면 먼저 submit의 인자, calculate가 넘긴 인자, parse_cost가 받은 인자를 차례로 기록합니다. 세 함수에서 같은 문자열을 받았다면 중간에 값이 바뀌었다는 가설은 근거가 없습니다. 마지막 함수의 int가 실패했으므로 정상 반환값도 없습니다. 반대로 중간 함수가 다른 필드를 전달했다면 그 줄이 조사 대상입니다. 함수 이름을 나열하는 데서 끝내지 않고 각 호출에서 어떤 값이 전달되었는지 연결합니다.
프레임과 예외 메시지의 역할
프레임은 실행 위치를 알려 주지만 모든 지역 변수의 값을 자동으로 설명하지는 않습니다. traceback.extract_tb가 반환하는 FrameSummary에서 이름과 줄 번호를 얻을 수 있어도 입력은 별도로 확인해야 합니다. 예외 메시지의 abc는 이번 int 변환에 사용된 원문에 대한 단서입니다. 코드에 같은 int 호출이 여러 개 있다면 마지막 프레임의 줄과 실제 실행한 파일을 대조합니다. 편집기에서 열어 둔 파일과 실행한 파일이 다르면 수정이 반영되지 않은 것처럼 보일 수 있으므로 경로도 함께 읽습니다.
관찰 출력의 순서를 설명하기
따라하기에서는 전체 traceback 대신 예외 종류와 함수 이름을 표준 출력에 기록합니다. 함수 이름은 바깥에서 안쪽으로 이어지므로 submit, calculate, parse_cost 순서를 예상합니다. 실패 뒤 성공 메시지가 없는 이유는 출력이 고장 나서가 아니라 반환을 기다리던 연산이 중단되었기 때문입니다. 일반적인 처리되지 않은 예외의 traceback은 표준 오류로 나오므로 터미널에서 일반 출력과 섞여 보일 수 있습니다. 두 스트림의 화면 순서만으로 실행 순서를 확정하지 말고 각 함수의 호출과 반환을 기준으로 설명합니다.
처리 위치를 선택하는 기준
내부 계산 함수가 잘못된 자료형을 받았다면 호출 계약을 바로잡습니다. 사용자 입력을 받는 경계가 예상한 실패를 처리한다면 호출자는 성공 결과를 받은 뒤 계산을 이어갑니다. 예외를 가장 바깥에서 잡는다는 이유만으로 내부 데이터가 정상이라는 보장은 없습니다. 이미 append한 기록이 있다면 except를 지나도 그대로 남습니다. try 이전, try 안에서 실패 전, except 안에서 실행된 줄을 나누어 표시하면 상태 변경의 범위를 찾을 수 있습니다. 자동 복구를 기대하지 말고 코드에 실제 되돌림이 있는지 확인합니다.
작은 재현으로 가설 검증하기
복잡한 메뉴 전체를 실행하기 전에 세 함수와 abc 입력만 남긴 실험을 만듭니다. 정상 입력 12로 같은 경로를 실행하면 calculate가 112를 반환하는지 확인할 수 있습니다. 실패 입력과 정상 입력에서 달라지는 최초 연산을 찾으면 관련 없는 화면 코드를 수정할 필요가 줄어듭니다. 조사 중 출력한 자료는 함수 이름과 예제 입력처럼 필요한 값으로 제한합니다. 재현 코드에서 성공했다면 원래 실행 경로의 인자나 실행 파일이 다른지 다시 대조하고, 재현되지 않은 상태에서 원인이 해결되었다고 결론 내리지 않습니다.
브라우저 과제에서 지켜야 할 관찰 계약
과제의 출력은 제공된 예외 관찰 형식에 맞춥니다. 임의의 전체 traceback을 출력하면 절대 경로와 줄 번호가 달라져 같은 현상도 다른 텍스트가 됩니다. 예외 종류와 추출한 함수 이름을 출력하는 방식은 이번 호출 경로를 비교하려는 목적에 맞습니다. 모듈 실행 프레임을 제외하는 이유와 남기는 함수 수를 코드에서 확인합니다. 모든 예외를 같은 문자열로 바꾸면 관찰 대상이 사라지므로 지정된 예외만 처리합니다. 호출 경로가 짧아졌다고 해결된 것은 아니며 정상 반환과 실패 전파를 각각 확인해야 합니다.
따라하기
실제 호출 경로
다음 코드를 별도 파일 demo.py에 저장하고 python3 demo.py로 실행합니다.
def parse_cost(raw):
return int(raw)
def calculate(raw):
return parse_cost(raw) + 100
def submit(raw):
return calculate(raw)
import traceback
try:
submit("abc")
except ValueError as error:
print(type(error).__name__)
frames = traceback.extract_tb(error.__traceback__)
print(" -> ".join(frame.name for frame in frames[1:]))
실행 결과
ValueError submit -> calculate -> parse_cost
호출 인자 수정
다음 코드를 별도 파일 demo.py에 저장하고 python3 demo.py로 실행합니다.
def parse_cost(raw):
return int(raw)
def calculate(raw):
return parse_cost(raw) + 100
def submit(raw):
return calculate(raw)
print(submit("12000"))
실행 결과
12100
입력 경계만 처리
다음 코드를 별도 파일 demo.py에 저장하고 python3 demo.py로 실행합니다.
import re
def parse_number(raw):
text = raw.strip()
if not text:
return None, "EMPTY"
if not re.fullmatch(r"[+-]?[0-9]+", text):
return None, "NOT_INTEGER"
try:
value = int(text)
except ValueError:
return None, "NOT_INTEGER"
if value < 0:
return None, "NEGATIVE"
return value, None
def calculate(raw):
value, error = parse_number(raw)
if error is not None:
return error
return f"OK|{value + 100}"
print(calculate("abc"))
print(calculate("0"))
실행 결과
NOT_INTEGER OK|100
확인 문제
실습
submit → calculate → parse_cost 순서로 금액을 처리합니다. 금액 한 줄을 읽고 성공이면 100원을 더해 OK|합계를 출력합니다. 실패는 ERR_COST_EMPTY, ERR_COST_NOT_INTEGER, ERR_COST_NEGATIVE입니다. parse_cost에서만 문자열을 검증해 (값, 오류)를 반환하고 calculate는 오류일 때 덧셈하지 않습니다. submit은 결과를 전달합니다. 다른 함수 전체를 except로 숨기지 않습니다.
모범 답안
import re
def parse_number(raw):
text = raw.strip()
if not text:
return None, "EMPTY"
if not re.fullmatch(r"[+-]?[0-9]+", text):
return None, "NOT_INTEGER"
try:
value = int(text)
except ValueError:
return None, "NOT_INTEGER"
if value < 0:
return None, "NEGATIVE"
return value, None
def parse_cost(raw):
return parse_number(raw)
def calculate(raw):
value, error = parse_cost(raw)
if error is not None:
return f"ERR_COST_{error}"
return f"OK|{value + 100}"
def submit(raw):
return calculate(raw)
print(submit(input()))
더 읽기
면접 질문
- 지출 합계 프로그램의 잘못된 입력 처리 방식을 설명합니다.