Python에서 예외를 올바르게 처리하는 방법
1. 개요
Python은 EAFP 스타일을 권장합니다: 일단 시도하고, 에러가 나면 처리합니다. 따라서 예외는 Python 코드 곳곳에서 나타납니다. 잘못 사용하면 에러가 무시되고, traceback이 읽기 어렵고, 로그가 중복될 수 있습니다.
이 글에서는 핵심 기법들을 다룹니다: try/except/else/finally, 올바른 예외 타입 잡기, 사용자 정의 예외 시스템, raise ... from으로 예외 연결하기, 로깅, ExceptionGroup, pytest로 테스트하기. 이를 읽은 후에는 프로젝트에 바로 적용할 수 있는 명확한 규칙들이 생길 것입니다. 예제는 Python 3.11 이상을 사용합니다.
2. try/except/else/finally 구조
완전한 예외 처리 블록에는 네 부분이 있습니다:
def parse_quantity(raw: str) -> int:
try:
quantity = int(raw)
except ValueError:
print(f"수량이 유효하지 않음: {raw!r}")
return 0
else:
print("파싱 성공")
return quantity
finally:
print("파싱 끝")
print(parse_quantity("3"))
print(parse_quantity("ba"))
각 부분의 역할:
try: 예외를 발생시킬 수 있는 코드만 포함합니다. 여기서는int(raw).except ValueError:int()가 문자열을 파싱하지 못할 때 실행됩니다.else:try가 예외를 발생시키지 않았을 때 실행됩니다. “정상 경로” 코드는 여기에 넣는 것이 낫습니다.try에 집어넣는 것보다요.finally: 항상 실행됩니다.return이나 예외가 있어도요. 리소스 정리하기 좋습니다.
결과:
파싱 성공
파싱 끝
3
수량이 유효하지 않음: 'ba'
파싱 끝
0
finally가 반환 값이 출력되기 전에 실행되는 것을 주목하세요. Python은 함수를 떠나기 직전에 finally를 실행합니다.
2.1. else를 써야 하는 이유
다음 두 가지를 비교해봅시다:
# 나쁨: try가 너무 넓음
try:
quantity = int(raw)
total = quantity * unit_price # unit_price 타입이 잘못되면?
except ValueError:
total = 0
unit_price가 문자열이면 곱셈이 ValueError를 발생시키지 않습니다. 그런데 다른 많은 연산들이 예상치 못한 곳에서 ValueError를 발생시킬 수 있고, 이것이 “수량이 잘못됨”으로 잘못 해석될 것입니다. try를 가능한 한 좁게 유지하고, 나머지는 else에 넣으세요.
3. 일반적인 예외보다 구체적인 예외를 잡으세요
가장 흔한 실수는 except: 또는 except Exception:을 무분별하게 사용하는 것입니다:
import time
def fetch_order_status(order_id: str) -> str:
try:
time.sleep(10) # API 호출이 느린 것을 시뮬레이션
return "PAID"
except: # 매우 나쁨
return "UNKNOWN"
except: 절은 KeyboardInterrupt와 SystemExit까지 잡습니다. Ctrl+C를 누르면 프로그램이 종료되지 않고 "UNKNOWN"을 반환합니다. Python의 예외 계층은 다음과 같습니다:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ValueError
├── KeyError
├── OSError
└── ...
규칙:
- 당신이 실제로 처리할 수 있는 가장 구체적인 예외를 잡으세요. 예:
KeyError,TimeoutError. except Exception은 오직 애플리케이션의 “경계”에만 사용하세요: worker 루프, 최상위 HTTP handler 등. 사용할 때는 항상 로깅하세요.except BaseException이나except:은 절대 사용하지 마세요. 즉시 다시 발생시키는 경우는 제외합니다.
tuple으로 여러 타입을 한 번에 잡을 수 있습니다:
def read_price(prices: dict[str, str], sku: str) -> float:
try:
return float(prices[sku])
except (KeyError, ValueError) as exc:
print(f"{sku}의 가격을 읽을 수 없음: {type(exc).__name__}")
return 0.0
print(read_price({"A1": "12.5", "B2": "abc"}, "B2"))
print(read_price({"A1": "12.5"}, "Z9"))
B2의 가격을 읽을 수 없음: ValueError
0.0
Z9의 가격을 읽을 수 없음: KeyError
0.0
4. 도메인을 위한 사용자 정의 예외
비즈니스 로직이 있다면, 모듈의 기본 예외를 만들고 그것을 상속해야 합니다:
class PaymentError(Exception):
"""결제 모듈의 기본 예외."""
class InsufficientFundsError(PaymentError):
def __init__(self, order_id: str, balance: float, amount: float) -> None:
super().__init__(
f"주문 {order_id}: 잔액 {balance}가 {amount}을(를) 지불하기에 부족합니다"
)
self.order_id = order_id
self.balance = balance
self.amount = amount
class CardDeclinedError(PaymentError):
pass
class PaymentService:
def __init__(self, balance: float) -> None:
self.balance = balance
def charge(self, order_id: str, amount: float) -> None:
if amount > self.balance:
raise InsufficientFundsError(order_id, self.balance, amount)
self.balance -= amount
사용 방법:
service = PaymentService(balance=100)
try:
service.charge("ORD-42", 250)
except InsufficientFundsError as exc:
print(exc)
print("부족한 금액:", exc.amount - exc.balance)
except PaymentError:
print("다른 결제 오류")
주문 ORD-42: 잔액 100이 250을(를) 지불하기에 부족합니다
부족한 금액: 150
이 조직 방식에는 세 가지 이점이 있습니다:
- 호출자는
PaymentError를 잡아서 모든 결제 오류를 처리하거나,InsufficientFundsError만 잡아서 특정 경우만 처리할 수 있습니다. - 예외가 구조화된 데이터(
order_id,balance,amount)를 담고 있어서, 처리 코드가 메시지를 파싱할 필요가 없습니다. - 사용자 정의 예외는
ValueError같은 시스템 예외와 분리되므로, 실수로 잡히지 않습니다.
주의:
Exception을 상속받으세요.BaseException은 아닙니다. 클래스 이름은 PEP 8 관례에 따라Error로 끝내세요.
5. raise ... from으로 예외 연결하기
저수준 에러를 도메인 에러로 변환할 때, 원래 원인을 남겨두세요:
import json
class ConfigError(Exception):
pass
def load_config(text: str) -> dict:
try:
return json.loads(text)
except json.JSONDecodeError as exc:
raise ConfigError("설정 파일이 유효하지 않습니다") from exc
load_config("{port: 8080}")
Traceback이 두 에러를 모두 보여줍니다:
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
...
ConfigError: 설정 파일이 유효하지 않습니다
from exc는 원래 예외를 __cause__ 속성에 할당합니다. 디버거는 JSON 에러가 정확히 어느 열에서 발생했는지 바로 알 수 있습니다.
from exc를 빼면 Python은 여전히 __context__를 통해 예외를 연결하지만, 메시지는 *“During handling of the above exception, another exception occurred”*입니다. 이것은 handler가 실패했다는 의미인데, 사실이 아닙니다. 명시적으로 from을 항상 쓰세요.
원인이 내부 구현 세부사항일 때는 from None을 사용해 숨기세요:
def get_customer_tier(tiers: dict[str, str], customer_id: str) -> str:
try:
return tiers[customer_id]
except KeyError:
raise LookupError(f"고객 {customer_id}을(를) 찾을 수 없습니다") from None
6. Context manager로 리소스 정리하기
파일, 연결, 잠금을 다룰 때 with은 손으로 쓴 try/finally보다 안전합니다:
from pathlib import Path
def count_orders(path: Path) -> int:
with path.open(encoding="utf-8") as f:
return sum(1 for line in f if line.strip())
파일은 루프가 예외를 발생시키더라도 항상 닫힙니다.
특정 에러를 정말로 무시해야 할 때는 contextlib.suppress이 손으로 쓴 try/except: pass보다 의도를 명확히 합니다:
from contextlib import suppress
from pathlib import Path
with suppress(FileNotFoundError):
Path("cache/orders.tmp").unlink()
이 줄을 읽으면 사람들은 바로 “파일이 있으면 삭제하고, 없으면 그냥 진행”이라는 뜻을 알 수 있습니다.
7. 로깅과 예외 다시 발생시키기
except 블록에서 logger.exception()을 사용해 전체 traceback을 기록하세요:
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("orders")
def process_order(service: PaymentService, order_id: str, amount: float) -> bool:
try:
service.charge(order_id, amount)
except InsufficientFundsError:
logger.warning("주문 %s이(가) 자금 부족으로 거절됨", order_id)
return False
except PaymentError:
logger.exception("주문 %s에 대한 알 수 없는 결제 오류", order_id)
raise
return True
주의할 점:
logger.exception(...)은logger.error(..., exc_info=True)와 같습니다. 이 함수는except블록 안에서만 호출해야 합니다.- 독립형
raise는 현재 예외를 다시 발생시키고 traceback을 유지합니다.raise exc라고 쓰지 마세요. Traceback에 여기가 추가되니까요. - 예상되는 에러(
InsufficientFundsError)는warning만으로 충분하고, traceback이 필요 없습니다.
주의: “로깅한 후 다시 발생”은 모든 계층에서 하지 마세요. 한 에러가 로그에 다섯 번 나타나 잡음이 됩니다. 실제로 처리하는 곳이나 애플리케이션의 최외곽 경계에서만 로깅하세요.
8. ExceptionGroup으로 여러 에러 동시에 처리하기
Python 3.11부터, 여러 예외를 동시에 발생시킬 수 있습니다. 데이터 검증이나 병렬 작업에 유용합니다:
def validate_order(order: dict) -> None:
errors: list[Exception] = []
if order.get("quantity", 0) <= 0:
errors.append(ValueError("quantity는 0보다 커야 합니다"))
if not order.get("customer"):
errors.append(KeyError("customer"))
if errors:
raise ExceptionGroup("주문이 유효하지 않습니다", errors)
try:
validate_order({"quantity": 0})
except* ValueError as group:
for e in group.exceptions:
print("잘못된 값:", e)
except* KeyError as group:
for e in group.exceptions:
print("누락된 필드:", e)
잘못된 값: quantity는 0보다 커야 합니다
누락된 필드: 'customer'
일반 except와 다르게, 여러 except* 절이 동시에 실행될 수 있습니다. 각 절이 타입과 일치하는 예외만 포함한 ExceptionGroup 서브셋을 받습니다. 어떤 절도 처리하지 않은 예외는 자동으로 다시 발생합니다.
asyncio.TaskGroup도 이 메커니즘을 사용합니다. 여러 task가 실패하면 ExceptionGroup을 받게 됩니다.
9. pytest로 예외 동작 테스트하기
pytest를 설치하세요:
pip install pytest
PaymentService와 load_config를 테스트하세요:
# test_payment.py
import pytest
from payment import (
ConfigError,
InsufficientFundsError,
PaymentService,
load_config,
)
def test_charge_raises_when_balance_is_not_enough():
service = PaymentService(balance=100)
with pytest.raises(InsufficientFundsError, match="ORD-42") as exc_info:
service.charge("ORD-42", 250)
assert exc_info.value.amount == 250
assert service.balance == 100 # 잔액은 차감되지 않음
def test_load_config_keeps_original_cause():
with pytest.raises(ConfigError) as exc_info:
load_config("{port: 8080}")
assert exc_info.value.__cause__ is not None
assert type(exc_info.value.__cause__).__name__ == "JSONDecodeError"
설명:
pytest.raises(...)는 예외가 발생하지 않거나 타입이 다르면 테스트를 실패시킵니다.match=는 정규식을 받고str(exception)과 매칭합니다.exc_info.value는 실제 예외 객체입니다. 그 덕분에amount,__cause__같은 속성을 검사할 수 있습니다.- 첫 번째 테스트는 에러 이후의 상태도 확인합니다: 잔액은 그대로여야 합니다. 많은 사람이 이 부분을 빠뜨립니다.
테스트를 실행하세요:
pytest -q
.. [100%]
2 passed in 0.02s
10. 흔한 실수들
예외를 조용히 무시하기.
try:
service.charge(order_id, amount)
except Exception:
pass # 주문이 "성공"했는데 돈을 안 빼짐
이런 실수는 디버깅이 거의 불가능합니다. 정말로 무시해야 한다면 구체적인 예외 타입과 함께 suppress를 사용하고 이유를 주석으로 남기세요.
finally에서 return하기.
def risky() -> str:
try:
raise RuntimeError("데이터 손실")
finally:
return "ok" # 예외가 사라짐!
finally 안의 return, break, continue는 진행 중인 예외를 취소합니다. Python 3.14부터 인터프리터는 이 경우 SyntaxWarning을 발생시킵니다. 절대로 이렇게 쓰지 마세요.
일반적인 Exception 발생시키기. raise Exception("오류")는 호출자가 Exception을 잡아야 하므로 다른 모든 것도 잡게 됩니다. 적절한 내장 예외(ValueError, TypeError)를 사용하거나 도메인 예외를 사용하세요.
자주 실패하는 루프에서 예외를 제어 흐름으로 사용하기. EAFP는 Pythonic합니다. 하지만 “오류” 경우가 대부분의 시간 발생한다면, dict.get()이나 if 체크가 더 빠르고 명확합니다:
# key가 자주 없을 때 더 나음
discount = discounts.get(customer_id, 0)
입력 검증에 assert 사용하기. python -O로 실행하면 assert가 비활성화됩니다. 외부에서 들어오는 데이터에는 raise ValueError(...)를 사용하세요.
11. 결론
Python에서 예외를 올바르게 처리하는 것은 몇 가지 간단한 원칙을 중심으로 합니다:
try를 좁게 유지하고, 다음 코드는else에, 정리는finally나with을 사용하세요.- 가장 구체적인 예외를 잡으세요. 오직 애플리케이션 경계에서만
Exception을 잡고, 항상 로깅하세요. - 도메인을 위한 예외 시스템을 만들고, 구조화된 데이터를 담으세요.
raise ... from exc로 원인을 유지하고, 독립형raise로 다시 발생시키세요.- 동시에 여러 에러가 있을 때
ExceptionGroup과except*를 사용하세요. pytest.raises로 예외 타입과 사후 상태를 모두 테스트하세요.
이 규칙들을 적용하면 traceback이 더 읽기 쉬워지고, 로그가 깔끔해지고, 에러가 조용히 무시되지 않을 것입니다.