예외 연결: __cause__와 __context__
Python 예외 연결의 내부 메커니즘을 설명합니다. __cause__와 __context__의 차이, raise from의 동작 원리, suppress_context로 원인 숨기기, 예외 체인 순회까지 완전히 다룹니다.
지난 글에서 커스텀 예외 클래스를 설계하는 방법을 배웠다. 이번에는 예외 연결(exception chaining)의 내부 동작을 깊이 파고든다. raise ... from을 사용하면 어떤 일이 일어나는지, __cause__와 __context__가 어떻게 다른지를 코드로 직접 확인해 보자.
예외 연결이 필요한 이유
def connect_db():
raise OSError("연결 거부됨")
def get_user(user_id):
try:
conn = connect_db()
except OSError as e:
raise ValueError(f"사용자 {user_id} 조회 실패") from e
ValueError가 발생했을 때 트레이스백에 OSError도 같이 보인다:
OSError: 연결 거부됨
The above exception was the direct cause of the following exception:
ValueError: 사용자 42 조회 실패
이렇게 하면 “왜 ValueError가 발생했나” → “OSError 때문에” 라는 인과 관계가 명확해진다.
cause: 명시적 연결
raise B() from A를 쓰면:
B.__cause__ = A설정 (명시적 원인)B.__suppress_context__ = True설정 (암묵적 연결 숨김)
try:
int("abc")
except ValueError as e:
new_exc = RuntimeError("파싱 실패")
raise new_exc from e
# 이후 except 블록에서
# new_exc.__cause__ is e → True
# new_exc.__suppress_context__ → True
__cause__가 있으면 트레이스백에 “The above exception was the direct cause of…”가 출력된다. 이 표현이 “프로그래머가 의도적으로 원인을 지정했다”는 신호다.
context: 암묵적 연결
except 블록 안에서 from 없이 새 예외를 발생시키면 Python이 자동으로 __context__를 설정한다.
try:
int("abc")
except ValueError as e:
raise RuntimeError("파싱 실패")
# RuntimeError.__context__ = ValueError 자동 설정
트레이스백:
ValueError: invalid literal...
During handling of the above exception, another exception occurred:
RuntimeError: 파싱 실패
__context__는 “이 예외를 처리하다가 또 다른 예외가 났다”는 의미다. 의도적 연결(__cause__)과 달리, 이건 처리 중 우연히 발생한 연결이다.
두 속성의 차이점 정리
try:
raise ValueError("원본 오류")
except ValueError as e:
new = RuntimeError("새 오류")
print(new.__context__) # ValueError 인스턴스
print(new.__cause__) # None (아직 from 없음)
print(new.__suppress_context__) # False
new2 = RuntimeError("또 다른 오류")
new2.__cause__ = e # 수동으로 설정 가능
print(new2.__suppress_context__) # False (수동 설정 시 True 안 됨)
raise new2 from e # → suppress=True, cause=e
| 속성 | 설정 방법 | 트레이스백 메시지 |
|---|---|---|
__cause__ | raise B from A | ”direct cause of…” |
__context__ | 자동 (except 내 raise) | “During handling of…” |
__suppress_context__ | from None | 원인 숨김 |
raise … from None
class DBError(Exception):
pass
def find_user(uid):
try:
return db.execute(f"SELECT * FROM users WHERE id={uid}")
except psycopg2.OperationalError as e:
# 내부 구현 세부사항(psycopg2)을 노출하지 않음
raise DBError("사용자 조회 실패") from None
from None은 다음 두 가지를 동시에 한다:
__suppress_context__ = True(암묵적 context 숨김)__cause__ = None(명시적 원인도 없음)
결과적으로 트레이스백에 원본 예외가 전혀 보이지 않는다.
DBError: 사용자 조회 실패
이렇게 하면 호출자에게 깔끔한 에러 메시지를 제공할 수 있다. 단, 내부 로그에는 원본 예외를 별도로 남겨야 한다.
import logging
def find_user(uid):
try:
return db.execute(...)
except psycopg2.OperationalError as e:
logging.exception("DB 오류 발생 (내부)") # 로그에는 원본 트레이스백
raise DBError("사용자 조회 실패") from None
예외 체인 순회
긴 예외 체인을 프로그래밍적으로 탐색해야 할 때는 __cause__와 __context__를 따라간다.
def get_root_cause(exc):
"""예외 체인에서 최초 원인을 찾는다"""
seen = set()
current = exc
while current is not None:
exc_id = id(current)
if exc_id in seen:
break
seen.add(exc_id)
next_exc = current.__cause__ or (
current.__context__ if not current.__suppress_context__ else None
)
if next_exc is None:
return current
current = next_exc
return exc
try:
risky()
except Exception as e:
root = get_root_cause(e)
print(f"최초 원인: {type(root).__name__}: {root}")
traceback 모듈로 체인 출력
import traceback
try:
try:
int("abc")
except ValueError as e:
raise RuntimeError("파싱 실패") from e
except RuntimeError:
# 전체 체인 출력
traceback.print_exc()
# 문자열로 얻기
chain_str = traceback.format_exc()
print(chain_str)
실전 패턴: 레이어드 아키텍처
# Repository 레이어
class UserRepository:
def find(self, user_id):
try:
return self.db.query(f"WHERE id={user_id}")
except DatabaseConnectionError as e:
raise RepositoryError(f"사용자 조회 실패: {user_id}") from e
# Service 레이어
class UserService:
def get_profile(self, user_id):
try:
user = self.repo.find(user_id)
except RepositoryError as e:
raise ServiceError(f"프로필 로드 실패: {user_id}") from e
return build_profile(user)
# Controller 레이어
@app.get("/users/{user_id}")
def get_user_endpoint(user_id: int):
try:
return service.get_profile(user_id)
except ServiceError as e:
# 체인 맨 끝: 클라이언트에게 깔끔한 메시지
return {"error": str(e)}, 500
각 레이어에서 from e로 원인을 연결하면, 로그에서 전체 체인을 볼 수 있으면서도 각 레이어는 자신의 추상화 수준에 맞는 예외 타입만 노출한다.
예외 연결은 복잡해 보이지만 실제로는 단순한 원칙이다: 경계를 넘어 예외를 변환할 때는 항상 raise NewError() from original. 다음 글에서는 Python 3.11에서 추가된 ExceptionGroup과 except* 문법을 살펴본다.
지난 글: 커스텀 예외 클래스 설계하기
다음 글: 예외 그룹과 except*: Python 3.11의 새 문법
읽어주셔서 감사합니다. 😊