PYTHON · 심층 가이드
Python 예외처리 완전 정리
예외 계층 설계와 raise from 체이닝, ExceptionGroup과 except*, 로깅·재시도·그레이스풀 종료까지 실패를 다루는 코드의 기본기를 실무 기준으로 다룹니다.
Python은 예외를 예외적인 사건이 아니라 일상적인 제어 흐름의 일부로 씁니다. 반복문의 종료도, 딕셔너리 조회 실패도 예외로 표현됩니다. 그래서 미리 확인하고 진입하는 방식보다 일단 해보고 실패를 잡는 EAFP 스타일이 관용적입니다. 문제는 이 편의가 except Exception: pass라는 최악의 습관으로 이어지기 쉽다는 데 있습니다. 잡을 예외를 좁게 지정하고, 잡았으면 반드시 무언가를 하거나 다시 올리는 두 가지 규칙만 지켜도 코드 품질은 확 달라집니다.
예외 처리 & 커스텀 예외에서 계층의 기본형을 잡고 커스텀 예외 계층으로 프로젝트 규모의 설계를 세웁니다. 원인 추적은 예외 체이닝과 traceback 분석이 한 쌍이고, 동시 실행에서 여러 실패가 한꺼번에 오는 상황은 ExceptionGroup (3.11+)이 담당합니다. 여기서 Python 비동기의 TaskGroup과 자연스럽게 만납니다. 운영 측면은 로깅 (logging), 재시도 패턴 (tenacity), 그레이스풀 종료가 이어받아 실패를 관측 가능하고 회복 가능한 것으로 바꿉니다.
구체적인 함정 셋을 기억해 두면 좋습니다. 첫째, except Exception은 KeyboardInterrupt와 asyncio.CancelledError를 잡지 않습니다. 둘 다 BaseException에서 갈라져 있고, 특히 취소 신호를 삼키면 종료가 걸립니다. 둘째, finally 블록에서 return하면 전파 중이던 예외가 조용히 사라집니다. 셋째, assert는 -O 옵션으로 실행하면 통째로 제거되므로 입력 검증에 쓰면 프로덕션에서 검증이 없어집니다. 검증은 if와 명시적 raise로 적어야 합니다.
01예외 처리 & 커스텀 예외
Python의 예외 계층과 올바른 예외 처리 패턴.
Python code
# 커스텀 예외 — 계층 구조 설계
class AppError(Exception):
"""애플리케이션 기반 예외"""
def __init__(self, message: str, code: int = 0):
super().__init__(message)
self.code = code
class ValidationError(AppError):
def __init__(self, field: str, message: str):
super().__init__(f"[{field}] {message}", code=400)
self.field = field
class NotFoundError(AppError):
def __init__(self, resource: str, id: int):
super().__init__(f"{resource} #{id} 없음", code=404)
# try / except / else / finally
def process(data: dict) -> str:
try:
value = data["key"] # KeyError 가능
result = int(value) # ValueError 가능
if result < 0:
raise ValidationError("key", "양수여야 함")
return f"결과: {result}"
except KeyError as e:
raise ValidationError("key", f"{e} 키 없음") from e
except ValueError as e:
raise ValidationError("key", "정수 변환 불가") from e
except ValidationError:
raise # 재발생
else:
# 예외 없을 때만 실행
print("성공")
finally:
# 항상 실행
print("정리")
# ExceptionGroup (Python 3.11+)
try:
raise ExceptionGroup("다중 오류", [
ValueError("값 오류"),
TypeError("타입 오류"),
])
except* ValueError as eg:
print(f"ValueError: {eg.exceptions}")
except* TypeError as eg:
print(f"TypeError: {eg.exceptions}")raise X from Y는 예외 체이닝입니다. 원인 예외가 __cause__에 저장되어 디버깅 시 원인 추적이 용이합니다.
except Exception:은 SystemExit, KeyboardInterrupt를 잡지 않지만 except BaseException:은 잡습니다. 대부분의 경우 Exception으로 충분합니다.
02Python 3.12 f-string 개선
중첩 f-string, 이스케이프 문자 허용 등 개선 사항
Python code
<span class="cm">// Python 3.12 f-string 개선 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Python 3.12 f-string 개선") }PYTHON 공식 문서를 함께 참고하세요.
자주 발생하는 실수에 주의하세요.
03예외 체이닝
raise ... from으로 예외를 체이닝하여 원인과 결과를 명확히 연결합니다.
Python code
# 명시적 예외 체이닝
class DatabaseError(Exception):
pass
class UserNotFoundError(Exception):
pass
def get_user(user_id: int) -> dict:
try:
# 데이터베이스 조회 시뮬레이션
if user_id < 0:
raise ValueError(f"잘못된 ID: {user_id}")
if user_id > 100:
raise ConnectionError("DB 연결 실패")
return {"id": user_id, "name": f"User-{user_id}"}
except ValueError as e:
raise UserNotFoundError(f"사용자 {user_id} 없음") from e
except ConnectionError as e:
raise DatabaseError("데이터베이스 오류") from e
# 체이닝된 예외 확인
try:
get_user(-1)
except UserNotFoundError as e:
print(f"에러: {e}")
print(f"원인: {e.__cause__}")
# 체이닝 억제: from None
def safe_parse(text: str) -> int:
try:
return int(text)
except ValueError:
raise ValueError(f"'{text}'는 숫자가 아닙니다") from None
try:
safe_parse("abc")
except ValueError as e:
print(f"에러: {e}")
print(f"원인: {e.__cause__}") # Nonefrom None을 사용하면 원래 예외를 숨겨 에러 메시지를 깔끔하게 만들 수 있습니다.
raise만 사용하면 암시적 체이닝(__context__)이 됩니다. 의도적 체이닝은 from을 명시하세요.
04ExceptionGroup (3.11+)
ExceptionGroup으로 여러 예외를 동시에 처리합니다. except* 구문과 함께 사용됩니다.
Python code
# ExceptionGroup 생성
def validate(data: dict) -> None:
errors = []
if not data.get("name"):
errors.append(ValueError("이름은 필수입니다"))
if not isinstance(data.get("age", ""), int):
errors.append(TypeError("나이는 정수여야 합니다"))
if data.get("age", 0) < 0:
errors.append(ValueError("나이는 양수여야 합니다"))
if errors:
raise ExceptionGroup("검증 실패", errors)
# except* 로 선택적 처리
try:
validate({"name": "", "age": "스물"})
except* ValueError as eg:
print(f"ValueError ({len(eg.exceptions)}개):")
for e in eg.exceptions:
print(f" - {e}")
except* TypeError as eg:
print(f"TypeError ({len(eg.exceptions)}개):")
for e in eg.exceptions:
print(f" - {e}")
# ExceptionGroup 조합
try:
raise ExceptionGroup("다중 오류", [
ValueError("값 오류"),
ExceptionGroup("네트워크 오류", [
ConnectionError("연결 실패"),
TimeoutError("타임아웃"),
]),
])
except* (ConnectionError, TimeoutError) as eg:
print(f"네트워크 문제: {len(eg.exceptions)}개")
except* ValueError as eg:
print(f"값 문제: {len(eg.exceptions)}개")except*는 여러 절이 동시에 매칭될 수 있어 각 예외 유형을 독립적으로 처리합니다.
except*와 except를 같은 try 블록에 혼합할 수 없습니다.
05커스텀 예외 계층
프로젝트별 예외 계층을 설계하여 체계적인 에러 처리를 구현합니다.
Python code
class AppError(Exception):
"""애플리케이션 기본 예외"""
def __init__(self, message: str, code: str = "UNKNOWN"):
super().__init__(message)
self.code = code
class ValidationError(AppError):
def __init__(self, field: str, message: str):
super().__init__(f"{field}: {message}", code="VALIDATION")
self.field = field
class NotFoundError(AppError):
def __init__(self, resource: str, resource_id):
super().__init__(
f"{resource}({resource_id})을 찾을 수 없음",
code="NOT_FOUND"
)
self.resource = resource
self.resource_id = resource_id
class AuthError(AppError):
def __init__(self, message="인증 실패"):
super().__init__(message, code="AUTH")
# 사용 예시
def get_user(user_id: int):
if user_id <= 0:
raise ValidationError("user_id", "양수여야 합니다")
if user_id > 100:
raise NotFoundError("User", user_id)
return {"id": user_id}
# 계층적 에러 처리
for uid in [0, 200, 50]:
try:
print(get_user(uid))
except NotFoundError as e:
print(f"[{e.code}] {e.resource} 없음: {e.resource_id}")
except ValidationError as e:
print(f"[{e.code}] {e.field} 검증 실패")
except AppError as e:
print(f"[{e.code}] 앱 에러: {e}")예외에 code 필드를 추가하면 API 응답이나 로그에서 에러를 프로그래밍적으로 구분할 수 있습니다.
너무 세분화된 예외 계층은 관리가 어렵습니다. 3-4단계 이내로 유지하세요.
06컨텍스트 매니저 에러 처리
컨텍스트 매니저에서 예외를 잡고, 변환하고, 억제하는 패턴입니다.
Python code
from contextlib import contextmanager, suppress
# 예외를 변환하는 컨텍스트 매니저
@contextmanager
def error_handler(error_map: dict):
try:
yield
except tuple(error_map.keys()) as e:
new_error_cls = error_map[type(e)]
raise new_error_cls(str(e)) from e
# 사용
class APIError(Exception): pass
try:
with error_handler({ConnectionError: APIError, TimeoutError: APIError}):
raise ConnectionError("서버 다운")
except APIError as e:
print(f"API 에러로 변환됨: {e}")
# suppress: 특정 예외 무시
import os
with suppress(FileNotFoundError):
os.remove("없는파일.txt")
# FileNotFoundError가 발생해도 무시됨
print("파일 삭제 시도 완료")
# 리소스 정리 보장
@contextmanager
def managed_resource(name):
print(f" {name} 획득")
try:
yield name
except Exception as e:
print(f" {name} 에러 처리: {e}")
raise # 예외 재발생
finally:
print(f" {name} 해제")
try:
with managed_resource("DB") as r:
raise RuntimeError("쿼리 실패")
except RuntimeError:
print("외부에서 처리")suppress는 try/except: pass보다 의도가 명확하고 Pythonic합니다.
컨텍스트 매니저의 __exit__에서 True를 반환하면 예외가 억제됩니다. 의도하지 않은 억제에 주의하세요.
07warnings 모듈
warnings로 경고를 발생시키고 관리합니다. 에러와 달리 프로그램을 중단하지 않습니다.
Python code
import warnings
# 경고 발생
def deprecated_func():
warnings.warn(
"이 함수는 v2.0에서 제거됩니다. new_func()를 사용하세요.",
DeprecationWarning,
stacklevel=2
)
return "레거시 결과"
# 커스텀 경고 클래스
class PerformanceWarning(UserWarning):
pass
def slow_query(query: str):
warnings.warn(
f"느린 쿼리 감지: {query[:50]}",
PerformanceWarning,
stacklevel=2
)
return []
# 경고 필터 제어
warnings.filterwarnings("always") # 모든 경고 표시
result = deprecated_func()
print(f"결과: {result}")
warnings.filterwarnings("error", category=DeprecationWarning)
try:
deprecated_func() # 경고가 에러로 변환됨
except DeprecationWarning as e:
print(f"경고→에러: {e}")
# 특정 경고 무시
warnings.filterwarnings("ignore", category=PerformanceWarning)
slow_query("SELECT * FROM huge_table")
print("경고 무시됨")stacklevel=2를 설정하면 경고가 호출한 코드 위치를 가리킵니다.
warnings.filterwarnings("error")를 설정한 상태에서 경고를 발생시키면 예외가 발생하므로 주의하세요.
08어설션 활용
assert문으로 프로그램의 내부 불변조건(invariant)을 검증합니다.
Python code
# 전제 조건 검증
def divide(a: float, b: float) -> float:
assert b != 0, "제수는 0이 될 수 없습니다"
return a / b
print(divide(10, 3)) # 3.333...
# 후위 조건 검증
def sort_list(data: list) -> list:
result = sorted(data)
assert all(result[i] <= result[i+1]
for i in range(len(result)-1)), "정렬 결과가 올바르지 않습니다"
return result
print(sort_list([3, 1, 4, 1, 5]))
# 클래스 불변조건
class BankAccount:
def __init__(self, balance: float):
self.balance = balance
self._check_invariant()
def _check_invariant(self):
assert self.balance >= 0, f"잔액이 음수: {self.balance}"
def withdraw(self, amount: float):
assert amount > 0, "출금액은 양수여야 합니다"
self.balance -= amount
self._check_invariant()
return self.balance
account = BankAccount(1000)
print(account.withdraw(300)) # 700
# assert vs raise
# assert: 내부 버그 검출 (개발 시)
# raise: 외부 입력 검증 (항상)
def public_api(user_input: str):
if not user_input: # raise 사용 (외부 입력)
raise ValueError("입력이 비어있습니다")
assert isinstance(user_input, str) # 내부 확인assert는 -O 플래그로 비활성화되므로, 사용자 입력 검증에는 사용하지 마세요.
python -O로 실행하면 모든 assert가 제거됩니다. 보안이나 외부 입력 검증에는 raise를 사용하세요.
09로깅 (logging)
logging 모듈로 구조화된 로그를 출력하여 디버깅과 모니터링을 수행합니다.
Python code
import logging
# 기본 설정
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S"
)
# 모듈별 로거
logger = logging.getLogger(__name__)
# 로그 레벨
logger.debug("디버그 정보")
logger.info("일반 정보")
logger.warning("경고!")
logger.error("에러 발생")
logger.critical("치명적 에러")
# 예외 정보 포함
try:
result = 1 / 0
except ZeroDivisionError:
logger.exception("계산 오류") # 스택 트레이스 포함
# 구조화된 로깅
def process_order(order_id: int, amount: float):
logger.info(
"주문 처리: order_id=%d, amount=%.2f",
order_id, amount
)
# f-string 대신 % 포맷 사용 (지연 평가)
return {"status": "완료"}
process_order(123, 45000.0)
# 로거 계층 구조
child_logger = logging.getLogger(__name__ + ".sub")
child_logger.info("자식 로거 메시지")로그 메시지에 f-string 대신 % 포맷을 사용하면, 로그 레벨이 낮아 출력되지 않을 때 문자열 포맷팅 비용을 절약합니다.
print() 대신 logging을 사용하세요. 로깅은 레벨 제어, 파일 출력, 포맷 설정 등 운영에 필수적인 기능을 제공합니다.
10traceback 분석
traceback 모듈로 예외의 스택 트레이스를 프로그래밍적으로 분석하고 포맷합니다.
Python code
import traceback
import sys
def function_c():
raise RuntimeError("깊은 곳에서 발생한 에러")
def function_b():
function_c()
def function_a():
function_b()
# 스택 트레이스 캡처
try:
function_a()
except RuntimeError:
# 문자열로 포맷
tb_str = traceback.format_exc()
print("=== 전체 트레이스 ===")
print(tb_str[:300])
# 프로그래밍적 분석
tb = traceback.extract_tb(sys.exc_info()[2])
print("=== 프레임 분석 ===")
for frame in tb:
print(f" {frame.filename}:{frame.lineno} in {frame.name}")
print(f" 코드: {frame.line}")
# 경량 트레이스 (로깅용)
def format_short_trace(e: Exception) -> str:
tb = traceback.extract_tb(e.__traceback__)
last = tb[-1]
return f"{type(e).__name__}: {e} at {last.name}:{last.lineno}"
try:
function_a()
except RuntimeError as e:
print(f"요약: {format_short_trace(e)}")traceback.extract_tb()로 각 프레임의 파일명, 줄번호, 함수명, 코드를 구조적으로 접근할 수 있습니다.
traceback 정보를 로그에 포함할 때 민감한 변수 값이 노출되지 않도록 주의하세요.
11재시도 패턴 (tenacity)
실패할 수 있는 작업에 재시도 로직을 적용하는 패턴입니다. 지수 백오프와 조건부 재시도를 다룹니다.
Python code
import time
import random
from functools import wraps
def retry(max_attempts=3, delay=1.0, backoff=2.0, exceptions=(Exception,)):
"""재시도 데코레이터 (지수 백오프)"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
current_delay = delay
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
if attempt == max_attempts:
raise
print(f" 시도 {attempt} 실패: {e}, "
f"{current_delay:.1f}초 후 재시도")
time.sleep(current_delay * 0.01) # 데모용 축소
current_delay *= backoff
return wrapper
return decorator
@retry(max_attempts=5, delay=0.5, backoff=2.0,
exceptions=(ConnectionError,))
def unreliable_api():
if random.random() < 0.6:
raise ConnectionError("서버 응답 없음")
return {"status": "success", "data": [1, 2, 3]}
# 실행
try:
result = unreliable_api()
print(f"성공: {result}")
except ConnectionError:
print("최종 실패")
# tenacity 라이브러리 패턴
# from tenacity import retry, stop_after_attempt, wait_exponential
# @retry(stop=stop_after_attempt(3),
# wait=wait_exponential(multiplier=1, max=10))
# def call_api(): ...실제 프로젝트에서는 pip install tenacity를 사용하세요. 조건부 재시도, 콜백, 통계 등 풍부한 기능을 제공합니다.
모든 예외에 재시도하면 프로그래밍 오류(TypeError, ValueError)까지 재시도합니다. 네트워크 에러 등 일시적 오류만 지정하세요.
12그레이스풀 종료
시그널 핸들링으로 프로그램을 안전하게 종료하고 리소스를 정리합니다.
Python code
import signal
import sys
import atexit
# atexit: 프로그램 종료 시 실행
def cleanup():
print(" 정리 작업 수행 중...")
print(" 리소스 해제 완료")
atexit.register(cleanup)
# 시그널 핸들러
class GracefulShutdown:
def __init__(self):
self.should_stop = False
signal.signal(signal.SIGTERM, self._handler)
signal.signal(signal.SIGINT, self._handler)
def _handler(self, signum, frame):
sig_name = signal.Signals(signum).name
print(f"\n{sig_name} 수신, 종료 중...")
self.should_stop = True
# 사용 예시
shutdown = GracefulShutdown()
# 워커 루프 시뮬레이션
import time
for i in range(3):
if shutdown.should_stop:
print("안전하게 종료합니다")
break
print(f"작업 {i+1} 처리 중...")
time.sleep(0.1)
# try/finally 패턴
class Resource:
def __init__(self, name):
self.name = name
print(f" {name} 획득")
def close(self):
print(f" {self.name} 해제")
r = Resource("DB 연결")
try:
print(" 작업 수행")
finally:
r.close()
print("프로그램 정상 종료")장기 실행 서비스에서는 SIGTERM 핸들러를 등록하여 진행 중인 작업을 완료한 후 종료하세요.
SIGKILL(kill -9)은 핸들링할 수 없습니다. 반드시 SIGTERM을 먼저 보내고 시간을 줘야 합니다.
정리하며
- 예외는 좁게 잡고, 잡았으면 처리하거나
raise로 다시 올립니다. - 원인 보존은
raise NewError(...) from err로 하고logging.exception에 남깁니다. finally에서return하지 않습니다. 전파 중인 예외가 사라집니다.- 입력 검증에
assert를 쓰지 않습니다.-O실행 시 통째로 제거됩니다.
더 깊이 들어가고 싶다면 Python 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.