Python의 예외 처리 메커니즘은 예외가 발생하는 순간 전체 호출 스택을 캡슐화하는 traceback 객체를 생성합니다. 각 traceback 노드는 실행 프레임을 참조하는 tb_frame 속성을 포함하고 있으며, 이 프레임은 f_locals를 통해 모든 지역 변수에 대한 참조를 보유합니다. 이 설계는 디버깅 목적으로 실행 컨텍스트를 유지하며, 예외가 포착된 후에도 변수 상태를 검사할 수 있게 합니다. 그러나 프레임이 f_back을 통해 호출 프레임을 참조하고, 지역 변수가 예외 객체 자체를 참조할 수 있기 때문에, 장기 지속 객체에 traceback을 저장하면 가비지 컬렉션을 방지하는 참조 사이클이 생성됩니다.
이 동작의 역사는 CPython이 pdb와 같은 모듈을 통해 사후 디버깅을 지원해야 할 필요성에서 유래되었습니다. 이 모듈은 전체 실행 상태에 대한 접근을 요구합니다. 예외가 발생하면 인터프리터는 tb_next 속성을 통해 traceback 객체의 연결 리스트를 구축하며, 각 노드는 프레임 객체를 가리킵니다. 이 traceback이 클로저 또는 인스턴스 변수에 저장될 때 문제가 발생하는데, 프레임이 f_locals에 예외 객체를 할당하면, 예외는 __traceback__를 통해 traceback을 참조하여 순환 참조를 생성합니다. 해결책은 traceback.clear_frames()를 사용하여 이러한 참조를 명시적으로 끊거나, 원시 traceback 객체를 저장하는 대신 관련 데이터를 즉시 추출하는 것입니다.
import sys import traceback def risky_function(): local_data = "x" * 10**6 # 큰 객체 raise ValueError("Something failed") def handle_error(): try: risky_function() except ValueError: exc_type, exc_val, exc_tb = sys.exc_info() # exc_tb 저장은 참조 사이클을 생성합니다. return exc_tb # 프로덕션에서는 이러면 안 됩니다. # 메모리 누수 상황 saved_tb = handle_error() # saved_tb.tb_frame.f_locals는 여전히 큰 문자열을 참조합니다. # 함수가 반환되더라도 메모리가 해제되지 않습니다.
데이터 처리 파이프라인이 배치 작업 중 심각한 메모리 고갈을 겪었고, 단지 1MB 청크를 순차적으로 처리하면서 몇 시간 만에 8GB의 RAM을 소모했습니다. 조사 결과 오류 처리 미들웨어가 비동기 로그 작성을 위해 전역 deque에 전체 traceback 객체를 캡처하고 있음을 확인했습니다. 각 traceback은 대량의 pandas DataFrame과 numpy 배열을 포함하는 전체 스택 프레임을 참조하여 처리 함수가 반환된 이후에도 가비지 컬렉션을 방지하고 있었습니다.
고려된 해결책 중 하나는 traceback.format_exc()를 사용하여 traceback을 문자열로 즉시 변환하는 것이었습니다. 이 접근 방식은 객체 참조를 완전히 끊어 메모리를 안전한 수준으로 줄였지만, 디버깅 중 프레임 변수를 구조적으로 분석할 수 있는 능력을 희생하게 됩니다. 또 다른 옵션은 추출 후 exc_tb = None을 사용하여 traceback을 수동으로 nullify하는 것이었으나, 이는 다양한 코드 경로에서 취약하고 오류가 발생하기 쉬웠습니다. 팀은 결국 필요한 디버그 정보를 추출한 후 traceback.clear_frames(saved_tb)를 구현하였으며, 이는 traceback 체인의 모든 프레임에서 지역 변수를 명시적으로 비워주고 줄 번호 및 코드 객체 참조는 유지합니다.
이 솔루션은 메모리 사용량을 99% 줄이면서 충분한 디버깅 컨텍스트를 유지하는 데 성공했습니다. 이제 파이프라인은 메모리 증가 없이 테라바이트의 데이터를 처리하며, 로깅 시스템은 라이브 객체 대신 정리된 traceback 요약을 저장합니다. 개발자들은 traceback을 지속적인 데이터 구조가 아니라 임시 리소스로 취급하는 법을 배웠습니다.
sys.exc_info()가 except 블록을 벗어난 후에도 활성 traceback 정보를 계속 반환하는 이유는 무엇인가요?
Python에서 인터프리터는 명시적으로 지우거나 새로운 예외가 발생하기 전까지 스레드 로컬 저장소에 예외 상태를 유지합니다. except 블록을 벗어나면 예외 정보가 여전히 sys.exc_info()를 통해 접근 가능하게 되는데, 이는 인터프리터가 당신이 다른 곳에 traceback에 대한 참조를 저장했는지 알 수 없기 때문입니다. 이 설계는 중첩된 예외 처리 및 디버깅 후크를 지원하지만, 단순히 except 범위를 벗어난다고 해서 프레임이 해제되지는 않습니다. 이러한 상태를 제대로 지우려면 sys.exc_info()를 호출하고 반환된 세 가지 값을 모두 삭제해야 하며, Python 2에서는 sys.exc_clear()를 사용할 수 있습니다(단, Python 3에서는 deprecated).
예외의 __traceback__ 속성을 클로저에 저장하는 것이 왜 순환 참조를 생성하여 순환 가비지 수집기를 무력화하나요?
exc.__traceback__를 클로저나 객체 속성에 저장하면 사이클이 생성됩니다: traceback은 tb_frame을 통해 프레임을 참조하고, 프레임은 f_locals를 통해 지역 변수를 참조하며, 지역 변수 중 어느 것이 예외를 참조한다면(직접적으로 또는 간접적으로) 예외는 __traceback__를 통해 traceback을 참조합니다. Python의 순환 가비지 수집기는 순수한 Python 객체를 처리하지만, 프레임 객체는 C-수준 포인터를 포함하고 있어 수집이 지연되거나 특정 세대에서 요구될 수 있습니다. 또한, 프레임에 __del__ 메서드나 외부 리소스를 보유한 C 확장이 포함된 경우, 사이클은 수집 불가능하게 됩니다. 사이클을 끊으려면 traceback.clear_frames()를 호출하거나 예외의 __traceback__ 속성을 삭제해야 합니다.
예외 전파의 맥락에서 traceback 객체의 tb_next 속성과 프레임 객체의 f_back 속성을 구분하는 점은 무엇인가요?
후보자들은 종종 이 두 체인을 혼동합니다. tb_next 속성은 예외가 풀릴 때의 순서에 따라 traceback 객체를 연결하는데, 이는 발생 지점에서 캐치 포인트까지의 스택 추적을 나타냅니다. 반면, f_back은 현재 호출 스택 내의 실행 프레임을 연결하며, 프로그램이 계속 실행됨에 따라 변경됩니다. 예외가 잡히면 traceback은 tb_frame을 통해 프레임의 스냅샷을 캡처하지만, 이러한 프레임 내의 f_back은 제대로 분리되지 않은 경우 여전히 활성 프레임을 가리킬 수 있습니다. tb_next를 수정하면 오직 예외 이력 체인에만 영향을 미치고, f_back은 동적 호출 스택을 반영하므로, traceback이 역사적 상태를 보존하는 반면 프레임은 현재 실행 상태를 나타낸다는 것을 이해하는 것이 중요합니다.