[파이썬 100강] 86강. traceback으로 예외 원인을 빠르게 추적하고 운영 로그 품질 높이기

[파이썬 100강] 86강. traceback으로 예외 원인을 빠르게 추적하고 운영 로그 품질 높이기

에러가 났을 때 except Exception as e: print(e)만 찍으면, "무슨 에러인지는 알겠는데 어디서 왜 터졌는지"를 놓치기 쉽습니다. 이번 강의에서는 traceback 모듈로 호출 흐름을 텍스트로 남기고, 운영 환경에서 재현 가능한 디버깅 단서를 확보하는 방법을 바로 예제로 정리합니다.


핵심 개념

  • traceback은 예외 발생 시점의 호출 스택(call stack) 정보를 문자열/리스트 형태로 다루게 해주는 표준 라이브러리입니다.
  • traceback.format_exc()는 현재 처리 중인 예외의 전체 트레이스백을 문자열로 반환해 로그 저장에 유리합니다.
  • traceback.format_exception()/extract_tb()를 쓰면 출력 포맷을 세밀하게 제어할 수 있어, "사용자에게는 짧게, 내부 로그에는 자세히" 같은 이중 전략이 가능합니다.

디버깅 시간을 줄이는 팀은 보통 "에러가 났다"보다 "어떤 경로로 여기까지 왔는지"를 더 중요하게 봅니다. 예외 메시지 한 줄은 증상이고, 트레이스백은 맥락입니다. 특히 함수가 여러 단계를 거치거나 외부 API/파일 입출력이 섞이면, 실제 원인은 에러가 보이는 줄보다 위쪽 호출 단계에 숨어 있는 경우가 많습니다. traceback을 쓰면 이 호출 체인을 그대로 복원해 텍스트로 남길 수 있습니다. 또한 운영 환경에서는 개인정보나 토큰이 로그에 섞일 수 있으므로, 트레이스백을 남기되 사용자 노출 메시지와 내부 보관 로그를 분리하는 습관이 중요합니다. 오늘 목표는 "원인 파악 속도"와 "운영 안전성"을 동시에 확보하는 것입니다.

기본 사용

예제 1) format_exc()로 전체 오류 흐름 남기기

>>> import traceback
>>>
>>> def divide(a, b):
...     return a / b
...
>>> try:
...     divide(10, 0)
... except Exception:
...     err_text = traceback.format_exc()
...     print("=== captured traceback ===")
...     print(err_text.splitlines()[-1])
...
=== captured traceback ===
ZeroDivisionError: division by zero

해설:

  • format_exc()는 현재 except 블록의 예외를 문자열로 반환합니다.
  • 실제 운영에서는 print 대신 파일 로그/중앙 로그 시스템으로 전달하면 됩니다.
  • 마지막 줄만 뽑으면 에러 타입 요약(ZeroDivisionError)을 빠르게 확인할 수 있습니다.

예제 2) format_exception()으로 구조화된 로그 만들기

>>> import traceback
>>>
>>> def parse_user_age(text):
...     return int(text)
...
>>> try:
...     parse_user_age("twenty")
... except Exception as exc:
...     lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
...     print(lines[0].strip())
...     print(lines[-1].strip())
...
Traceback (most recent call last):
ValueError: invalid literal for int() with base 10: 'twenty'

해설:

  • format_exception()은 라인 리스트를 반환해 후처리(필터링/마스킹/요약)에 유리합니다.
  • 예를 들어 첫 줄은 고정 헤더, 마지막 줄은 에러 요약으로 분리 저장할 수 있습니다.

예제 3) extract_tb()로 프레임 단위 분석하기

>>> import traceback
>>>
>>> def layer3():
...     return 1 / 0
...
>>> def layer2():
...     return layer3()
...
>>> def layer1():
...     return layer2()
...
>>> try:
...     layer1()
... except Exception as exc:
...     frames = traceback.extract_tb(exc.__traceback__)
...     print(len(frames) >= 3)
...     print(frames[-1].name, frames[-1].lineno)
...
True
layer3 4

해설:

  • 프레임 리스트를 얻으면 "어느 함수에서 최종 실패했는지"를 코드로 판별할 수 있습니다.
  • 장애 통계 시 frames[-1].name 기반으로 실패 지점을 집계하면 반복 이슈 파악이 쉬워집니다.

자주 하는 실수

실수 1) except 밖에서 format_exc() 호출하기

>>> import traceback
>>> traceback.format_exc()
'NoneType: None\n'

원인:

  • 처리 중인 활성 예외가 없으면 format_exc()는 의미 있는 트레이스백을 만들 수 없습니다.
  • "나중에 필요할 때 찍어야지" 하고 예외 컨텍스트를 벗어나면 정보가 사라집니다.

해결:

>>> import traceback
>>> try:
...     int("x")
... except Exception:
...     saved_tb = traceback.format_exc()
...
>>> "ValueError" in saved_tb
True

핵심은 예외를 잡는 즉시 저장하는 것입니다.

실수 2) 사용자에게 내부 트레이스백을 그대로 노출하기

  • 증상: API 응답/웹 화면에 내부 파일 경로, 함수명, 구현 세부가 그대로 출력됩니다.
  • 원인: 디버깅 편의 때문에 traceback.format_exc() 문자열을 사용자 응답에 재사용합니다.
  • 해결: 사용자 응답은 짧고 안전한 메시지로 고정하고, 상세 트레이스백은 내부 로그에만 남깁니다.
>>> def public_error_message():
...     return "요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
...
>>> public_error_message()
'요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'

실수 3) 로그를 남겼지만 요청 맥락(request_id)을 함께 저장하지 않기

  • 증상: 트레이스백은 많은데, 어떤 사용자 요청과 연결되는지 찾기 어렵습니다.
  • 원인: 예외 문자열만 저장하고 요청 ID/입력 요약/시간대를 함께 기록하지 않았습니다.
  • 해결: 트레이스백 저장 시 메타데이터를 같은 레코드로 묶어 저장합니다.
>>> log_record = {
...     "request_id": "req-20260217-1405",
...     "endpoint": "/users/create",
...     "error_type": "ValueError",
... }
>>> sorted(log_record.keys())
['endpoint', 'error_type', 'request_id']

실무 패턴

  • 입력 검증 규칙: 예외를 "막연히 catch"하기 전에 경계값 검증(빈 문자열, 형식 오류, 범위 초과)을 먼저 수행해 예상 가능한 실패를 줄입니다.
  • 로그/예외 처리 규칙: 사용자 메시지와 내부 로그를 분리합니다. 사용자에게는 안전한 일반 문구, 내부에는 traceback 전체 + request_id + 주요 파라미터 스냅샷을 남깁니다.
  • 재사용 함수/구조화 팁: capture_exception(exc, context) 같은 공용 함수 하나로 트레이스백 포맷을 통일합니다. 팀마다 출력 형식이 제각각이면 검색/집계가 어려워집니다.
  • 성능/메모리 체크 포인트: 트레이스백 문자열은 길 수 있으므로 대량 장애 시 로그 폭주가 발생할 수 있습니다. 동일 에러는 샘플링하거나, 동일 스택 해시 단위로 집계 저장하는 전략이 유효합니다.

운영 경험상 중요한 점은 "많이 남기는 것"보다 "다시 찾을 수 있게 남기는 것"입니다. 예를 들어 traceback 전체를 남겨도 request_id가 없으면 재현이 느려집니다. 반대로 request_id만 있어도 스택 정보가 없으면 원인 지점 특정이 지연됩니다. 결국 에러 타입 + 스택 + 맥락 메타데이터 3종 세트를 같이 저장하는 것이 가장 실용적입니다. 또한 보안 관점에서 쿼리 문자열, 토큰, 비밀번호 같은 민감 값이 스택/로컬 변수 덤프에 섞일 수 있으니 마스킹 정책을 반드시 함께 설계하세요.

오늘의 결론

한 줄 요약: traceback은 "에러 메시지 한 줄"을 "재현 가능한 원인 기록"으로 바꾸는 가장 기본적인 도구입니다.

기억할 것:

  • format_exc()except 안에서 즉시 저장해야 의미가 있습니다.
  • 사용자 노출 메시지와 내부 디버깅 로그는 반드시 분리하세요.
  • request_id 같은 맥락 정보와 함께 저장해야 운영 대응 속도가 빨라집니다.

연습문제

  1. safe_execute(func, *args) 함수를 만들어 예외 발생 시 traceback.format_exc() 결과를 문자열로 반환하고, 정상 시 결과값을 반환해 보세요.
  2. traceback.extract_tb()를 사용해 마지막 프레임의 함수명과 라인 번호만 뽑아 {"func": ..., "line": ...} 딕셔너리로 만드는 코드를 작성해 보세요.
  3. "사용자 응답 메시지"와 "내부 로그 레코드"를 분리하는 에러 처리 템플릿을 함수 형태로 설계해 보세요. 내부 로그에는 request_id를 포함해야 합니다.

이전 강의 정답

  1. reprlib.Repr() 정책 적용 후 중첩 딕셔너리 출력
>>> import reprlib
>>> r = reprlib.Repr()
>>> r.maxstring = 30
>>> r.maxlist = 4
>>> r.maxlevel = 2
>>> data = {"team": ["backend", "frontend", "data", "infra", "qa"], "meta": {"owner": "geonwoo", "env": ["dev", "staging", "prod"]}}
>>> print(r.repr(data))
{'meta': {'env': [...], 'owner': 'geonwoo'}, 'team': ['backend', 'frontend', 'data', 'infra', ...]}

설명: 리스트는 4개까지만 보이고 나머지는 ...로 축약됩니다. 중첩 레벨도 제한되어 깊은 구조가 과도하게 펼쳐지지 않습니다.

  1. 민감 키 마스킹 후 축약 문자열 반환하는 safe_repr(obj)
>>> import reprlib
>>> def safe_repr(obj):
...     r = reprlib.Repr()
...     r.maxstring = 40
...     r.maxlist = 5
...     if isinstance(obj, dict):
...         masked = {k: ("***" if k in {"token", "password"} else v) for k, v in obj.items()}
...         return r.repr(masked)
...     return r.repr(obj)
...
>>> safe_repr({"user": "geonwoo", "token": "abcd-very-secret-token", "password": "p@ss", "roles": ["admin", "editor", "viewer", "ops", "audit", "guest"]})
"{'password': '***', 'roles': ['admin', 'editor', 'viewer', 'ops', 'audit', ...], 'token': '***', 'user': 'geonwoo'}"

설명: 노출되면 안 되는 키를 먼저 치환하고, 그다음 축약을 적용하면 보안성과 가독성을 동시에 확보할 수 있습니다.

  1. pformat(data) vs reprlib.repr(data) 사용 상황
>>> decision = {
...     "pformat": "데이터 구조를 자세히 읽어야 하는 개발/분석 단계",
...     "reprlib": "운영 로그에서 길이 폭주를 막아야 하는 단계"
... }
>>> decision["pformat"]
'데이터 구조를 자세히 읽어야 하는 개발/분석 단계'
>>> decision["reprlib"]
'운영 로그에서 길이 폭주를 막아야 하는 단계'

설명: pformat은 가독성 중심(상세 확인), reprlib은 안정성 중심(길이 제한)입니다. 운영에서는 둘을 상황별로 병행하는 것이 가장 현실적입니다.

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 예제 검증 방식: Python REPL(pycon) 기준으로 동작 확인