실전 팁 — 예외 로깅, 예외 체이닝, 방어적 프로그래밍
예외 처리는 단순히 오류를 잡는 것을 넘어, 프로덕션 환경에서 문제를 빠르게 진단하고 복구할 수 있도록 설계해야 합니다. 이 장에서는 실전에서 자주 쓰이는 고급 패턴을 다룹니다.
예외 로깅 — logging 모듈 + exc_info=True
logging 모듈의 exc_info=True 파라미터를 사용하면 예외의 전체 트레이스백을 로그에 포함할 수 있습니다.
import logging
# 로거 설정
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
def process_data(data: dict) -> dict:
try:
result = transform(data)
return result
except KeyError as e:
# exc_info=True: 트레이스백 전체 포함
logger.error("필수 키 누락: %s", e, exc_info=True)
return {}
except Exception:
# logger.exception: exc_info=True를 자동으로 포함하는 단축 메서드
logger.exception("예기치 않은 오류 발생")
raise
logger.exception() 은 logger.error(..., exc_info=True)와 동일하게 동작합니다. 예외 핸들러 안에서만 사용합니다.
구조화 로깅 (structlog 패턴)
import logging
logger = logging.getLogger(__name__)
def process_order(order_id: int, user_id: int) -> None:
try:
_execute_order(order_id)
except ValueError as e:
logger.error(
"주문 처리 실패",
extra={
"order_id": order_id,
"user_id": user_id,
"error": str(e),
},
exc_info=True,
)
raise
예외 체이닝 — raise X from Y
raise X from Y 구문은 새 예외를 발생시키면서 원인 예외(Y)를 명시적으로 연결합니다. 트레이스백에 두 예외가 모두 표시됩니다.
class DatabaseError(Exception):
pass
def load_user(user_id: int) -> dict:
try:
return db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
except sqlite3.OperationalError as e:
# 저수준 예외를 도메인 예외로 래핑
raise DatabaseError(f"사용자 {user_id} 조회 실패") from e
트레이스백 출력:
sqlite3.OperationalError: no such table: users
The above exception was the direct cause of the following exception:
DatabaseError: 사용자 42 조회 실패
from e가 없으면 "During handling of the above exception, another exception occurred:" 메시지가 표시됩니다 (암묵적 체이닝).
예외 억제 — raise X from None
원인 예외를 숨기고 새 예외만 표시하려면 from None을 사용합니다. 민감한 내부 정보(DB 쿼리, 연결 정보 등)를 외부에 노출하지 않을 때 유용합니다.
def get_config(key: str) -> str:
try:
return _internal_config[key]
except KeyError:
# from None: 내부 KeyError 트레이스백 숨김
raise KeyError(f"설정 키 '{key}'를 찾을 수 없습니다") from None
KeyError: "설정 키 'DATABASE_URL'를 찾을 수 없습니다"
# KeyError: 'DATABASE_URL' 같은 내부 정보가 노출되지 않음
EAFP vs LBYL 철학
Python 커뮤니티에는 두 가지 방어적 프로그래밍 철학이 있습니다.
EAFP (Easier to Ask Forgiveness than Permission)
"허락을 구하는 것보다 용서를 구하는 것이 쉽다" — 먼저 시도하고 실패하면 처리합니다.
# EAFP 스타일 — Python다운 방식
def get_user_age(user: dict) -> int:
try:
return int(user["age"])
except (KeyError, ValueError):
return 0
LBYL (Look Before You Leap)
"도약하기 전에 살펴보라" — 먼저 조건을 검사하고 실행합니다.
# LBYL 스타일 — C/Java에서 흔한 방식
def get_user_age(user: dict) -> int:
if "age" in user and isinstance(user["age"], (int, str)):
try:
return int(user["age"])
except ValueError:
return 0
return 0
Python은 EAFP를 선호합니다. 동시성 환경(멀티스레드)에서도 EAFP가 TOCTOU(Time of Check to Time of Use) 경쟁 조건을 방지합니다.
# LBYL — 멀티스레드에서 경쟁 조건 발생 가능
if os.path.exists(path): # 검사
os.remove(path) # 다른 스레드가 이미 삭제했을 수 있음
# EAFP — 안전
try:
os.remove(path)
except FileNotFoundError:
pass
방어적 프로그래밍 패턴
패턴 1 — 전제 조건 검사 (Guard Clause)
def process_payment(amount: float, user_id: int) -> None:
# 전제 조건을 함수 시작에서 검사
if amount <= 0:
raise ValueError(f"결제 금액은 양수여야 합니다: {amount}")
if user_id <= 0:
raise ValueError(f"유효하지 않은 사용자 ID: {user_id}")
# 검증 통과 후 핵심 로직
_charge_payment(amount, user_id)
패턴 2 — Null Object 패턴으로 None 방어
from typing import Protocol
class UserRepository(Protocol):
def find(self, user_id: int) -> dict | None: ...
class NullUser:
"""사용자를 찾지 못했을 때 반환하는 Null Object"""
name = "익명"
email = ""
is_authenticated = False
def __bool__(self) -> bool:
return False
def get_user(repo: UserRepository, user_id: int):
user = repo.find(user_id)
return user if user is not None else NullUser()
# None 체크 없이 안전하게 사용
user = get_user(repo, 999)
print(user.name) # "익명" — AttributeError 없음
패턴 3 — 결과 타입 (Result Pattern)
from dataclasses import dataclass
from typing import Generic, TypeVar
T = TypeVar("T")
E = TypeVar("E", bound=Exception)
@dataclass
class Ok(Generic[T]):
value: T
ok: bool = True
@dataclass
class Err(Generic[E]):
error: E
ok: bool = False
Result = Ok[T] | Err[E]
def parse_int(value: str) -> Result:
try:
return Ok(int(value))
except ValueError as e:
return Err(e)
# 예외 없이 오류를 값으로 처리
result = parse_int("abc")
if result.ok:
print(f"파싱 성공: {result.value}")
else:
print(f"파싱 실패: {result.error}")
실전 — 프로덕션 에러 처리 패턴
패턴 1 — 글로벌 예외 핸들러
import logging
import sys
import traceback
logger = logging.getLogger("global")
def global_exception_handler(exc_type, exc_value, exc_traceback):
"""처리되지 않은 예외를 잡아 로깅합니다."""
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logger.critical(
"처리되지 않은 예외",
exc_info=(exc_type, exc_value, exc_traceback)
)
sys.excepthook = global_exception_handler
패턴 2 — 데코레이터 기반 예외 처리
import functools
import logging
from typing import Callable, TypeVar
F = TypeVar("F", bound=Callable)
def handle_errors(
*exception_types: type[Exception],
default=None,
log_level: int = logging.ERROR,
):
"""함수 예외를 자동으로 처리하는 데코레이터"""
def decorator(func: F) -> F:
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except exception_types as e:
logging.log(
log_level,
"%s 오류: %s",
func.__qualname__,
e,
exc_info=True,
)
return default
return wrapper # type: ignore
return decorator
# 사용
@handle_errors(FileNotFoundError, PermissionError, default=[])
def load_items(path: str) -> list:
with open(path) as f:
import json
return json.load(f)
패턴 3 — 회로 차단기 (Circuit Breaker)
import time
from enum import Enum, auto
class CircuitState(Enum):
CLOSED = auto() # 정상
OPEN = auto() # 차단
HALF_OPEN = auto() # 복구 시도 중
class CircuitBreaker:
"""
외부 서비스 호출 실패가 연속될 때 요청을 차단합니다.
"""
def __init__(
self,
failure_threshold: int = 5,
recovery_timeout: float = 30.0,
) -> None:
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.failure_count = 0
self.last_failure_time: float | None = None
self.state = CircuitState.CLOSED
def call(self, func, *args, **kwargs):
if self.state == CircuitState.OPEN:
if self._should_attempt_reset():
self.state = CircuitState.HALF_OPEN
else:
raise RuntimeError("서킷 브레이커 OPEN — 요청 차단")
try:
result = func(*args, **kwargs)
self._on_success()
return result
except Exception as e:
self._on_failure()
raise
def _on_success(self):
self.failure_count = 0
self.state = CircuitState.CLOSED
def _on_failure(self):
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
def _should_attempt_reset(self) -> bool:
return (
self.last_failure_time is not None
and time.time() - self.last_failure_time > self.recovery_timeout
)
고수 팁
팁 1 — 예외 트레이스백을 문자열로 캡처
import traceback
try:
1 / 0
except ZeroDivisionError:
tb_str = traceback.format_exc()
# 나중에 로그에 저장하거나 알림 발송에 활용
send_alert(tb_str)
팁 2 — __cause__ 와 __context__ 속성
try:
raise ValueError("원인")
except ValueError as e:
exc = RuntimeError("결과")
exc.__cause__ = e # raise X from Y 와 동일한 효과
raise exc
# 예외 객체에서 원인 접근
try:
...
except RuntimeError as e:
print(e.__cause__) # 명시적 체이닝
print(e.__context__) # 암묵적 체이닝
팁 3 — warnings 모듈로 소프트 경고
예외를 발생시키기엔 부드럽지만 사용자에게 알려야 할 때 warnings.warn()을 사용합니다.
import warnings
def deprecated_function():
warnings.warn(
"deprecated_function은 v2.0에서 제거됩니다. new_function()을 사용하세요.",
DeprecationWarning,
stacklevel=2,
)
return new_function()
팁 4 — 예외를 로깅하고 재발생시키는 패턴
# 로깅하되 예외를 유지
try:
risky()
except Exception:
logger.exception("risky() 실패")
raise # 원본 예외 그대로 재발생 (스택 정보 유지)
# 절대 아래처럼 하지 말 것
try:
risky()
except Exception as e:
logger.error(str(e))
raise e # ← 스택 트레이스가 이 줄로 재설정됨!