본문으로 건너뛰기
Advertisement

사용자 정의 예외 — 커스텀 Exception 클래스 설계 패턴

내장 예외만으로는 도메인 특유의 오류 상황을 명확하게 표현하기 어렵습니다. 커스텀 예외 클래스를 설계하면 오류의 의미를 명확히 전달하고, 호출자가 정교하게 처리할 수 있습니다.


가장 간단한 커스텀 예외

class AppError(Exception):
"""애플리케이션 기반 예외"""
pass

class UserNotFoundError(AppError):
"""사용자를 찾을 수 없을 때"""
pass

# 사용
raise UserNotFoundError("사용자 ID 42를 찾을 수 없습니다.")

단순히 pass만 있어도 충분합니다. 예외 이름 자체가 오류의 의미를 전달합니다.


추가 속성과 메서드 포함

예외에 구조화된 정보를 담으면 호출자가 오류를 더 정밀하게 처리할 수 있습니다.

class ValidationError(Exception):
"""입력값 검증 실패"""

def __init__(
self,
field: str,
value: object,
message: str
) -> None:
self.field = field
self.value = value
self.message = message
# 부모 클래스 초기화
super().__init__(f"[{field}] {message} (받은 값: {value!r})")

def to_dict(self) -> dict:
return {
"field": self.field,
"value": self.value,
"message": self.message,
}
try:
raise ValidationError("age", -5, "나이는 0 이상이어야 합니다")
except ValidationError as e:
print(e) # [age] 나이는 0 이상이어야 합니다 (받은 값: -5)
print(e.field) # age
print(e.to_dict()) # {'field': 'age', 'value': -5, ...}

__str____repr__ 오버라이드

class HttpError(Exception):
"""HTTP 오류 응답"""

def __init__(self, status_code: int, detail: str) -> None:
self.status_code = status_code
self.detail = detail
super().__init__(detail)

def __str__(self) -> str:
return f"HTTP {self.status_code}: {self.detail}"

def __repr__(self) -> str:
return (
f"{type(self).__name__}("
f"status_code={self.status_code!r}, "
f"detail={self.detail!r})"
)

err = HttpError(404, "리소스를 찾을 수 없습니다")
print(str(err)) # HTTP 404: 리소스를 찾을 수 없습니다
print(repr(err)) # HttpError(status_code=404, detail='리소스를 찾을 수 없습니다')

Exception 상속 계층 설계

좋은 예외 계층은 도메인 경계오류 심각도를 반영합니다.

# ── 기반 예외 ──────────────────────────────────────────────
class AppBaseError(Exception):
"""모든 애플리케이션 예외의 루트"""
pass

# ── 도메인별 분류 ─────────────────────────────────────────
class DatabaseError(AppBaseError):
"""데이터베이스 관련 오류"""
pass

class NetworkError(AppBaseError):
"""네트워크 관련 오류"""
pass

class AuthError(AppBaseError):
"""인증/인가 관련 오류"""
pass

class ValidationError(AppBaseError):
"""데이터 검증 오류"""
pass

# ── 세부 예외 ──────────────────────────────────────────────
class ConnectionError(DatabaseError):
"""DB 연결 실패"""
pass

class QueryError(DatabaseError):
"""SQL 쿼리 오류"""
def __init__(self, query: str, reason: str) -> None:
self.query = query
self.reason = reason
super().__init__(f"쿼리 실패: {reason}\n쿼리: {query}")

class TimeoutError(NetworkError):
"""요청 시간 초과"""
def __init__(self, url: str, timeout: float) -> None:
self.url = url
self.timeout = timeout
super().__init__(f"타임아웃 ({timeout}s): {url}")

class UnauthorizedError(AuthError):
"""인증 실패"""
pass

class ForbiddenError(AuthError):
"""권한 부족"""
def __init__(self, resource: str, required_role: str) -> None:
self.resource = resource
self.required_role = required_role
super().__init__(
f"'{resource}' 접근에는 '{required_role}' 역할 필요"
)

계층을 설계하면 호출자가 세부 예외와 범용 예외 중 선택해서 처리할 수 있습니다.

def process():
raise QueryError("SELECT * FROM users", "테이블 없음")

try:
process()
except QueryError as e:
print(f"SQL 오류 처리: {e.query}") # 세부 처리
except DatabaseError:
print("일반 DB 오류 처리") # 범용 처리
except AppBaseError:
print("앱 오류 처리") # 최상위 처리

실전 예제 — API 에러 클래스 계층 구조

실제 REST API 서버에서 활용할 수 있는 예외 체계입니다.

from http import HTTPStatus
from typing import Any

class APIError(Exception):
"""API 오류의 기반 클래스"""

status_code: int = 500
error_code: str = "INTERNAL_ERROR"

def __init__(
self,
message: str,
detail: Any = None,
headers: dict[str, str] | None = None,
) -> None:
self.message = message
self.detail = detail
self.headers = headers or {}
super().__init__(message)

def to_response(self) -> dict[str, Any]:
"""API 오류 응답 딕셔너리 반환"""
response: dict[str, Any] = {
"error": self.error_code,
"message": self.message,
}
if self.detail is not None:
response["detail"] = self.detail
return response

def __repr__(self) -> str:
return (
f"{type(self).__name__}("
f"status={self.status_code}, "
f"code={self.error_code!r}, "
f"message={self.message!r})"
)


class BadRequestError(APIError):
"""400 Bad Request"""
status_code = 400
error_code = "BAD_REQUEST"


class ValidationAPIError(BadRequestError):
"""400 — 입력값 검증 실패"""
error_code = "VALIDATION_ERROR"

def __init__(self, errors: list[dict[str, str]]) -> None:
self.errors = errors
super().__init__(
message="입력값 검증에 실패했습니다.",
detail=errors,
)

def to_response(self) -> dict[str, Any]:
resp = super().to_response()
resp["errors"] = self.errors
return resp


class NotFoundError(APIError):
"""404 Not Found"""
status_code = 404
error_code = "NOT_FOUND"

def __init__(self, resource: str, identifier: Any) -> None:
self.resource = resource
self.identifier = identifier
super().__init__(
message=f"{resource} '{identifier}'을(를) 찾을 수 없습니다.",
detail={"resource": resource, "id": identifier},
)


class ConflictError(APIError):
"""409 Conflict"""
status_code = 409
error_code = "CONFLICT"


class RateLimitError(APIError):
"""429 Too Many Requests"""
status_code = 429
error_code = "RATE_LIMIT_EXCEEDED"

def __init__(self, retry_after: int) -> None:
self.retry_after = retry_after
super().__init__(
message="요청 한도를 초과했습니다.",
headers={"Retry-After": str(retry_after)},
)


# FastAPI / Flask 스타일 예외 핸들러
def handle_api_error(exc: APIError) -> dict[str, Any]:
return {
"status": exc.status_code,
"body": exc.to_response(),
"headers": exc.headers,
}
# 사용 예시
def get_user(user_id: int) -> dict:
users = {1: {"name": "Alice"}}
if user_id not in users:
raise NotFoundError("User", user_id)
return users[user_id]

def create_user(data: dict) -> dict:
errors = []
if not data.get("name"):
errors.append({"field": "name", "message": "필수 항목"})
if not data.get("email"):
errors.append({"field": "email", "message": "필수 항목"})
if errors:
raise ValidationAPIError(errors)
return {"id": 42, **data}

# 처리
try:
user = get_user(99)
except NotFoundError as e:
response = handle_api_error(e)
print(response)
# {'status': 404, 'body': {'error': 'NOT_FOUND', ...}, 'headers': {}}

dataclass 기반 예외 (Python 3.10+)

from dataclasses import dataclass, field

@dataclass
class BusinessError(Exception):
"""비즈니스 로직 오류"""
code: str
message: str
context: dict = field(default_factory=dict)

def __post_init__(self):
# Exception 초기화 (str(exception) 지원)
super().__init__(self.message)

def __str__(self) -> str:
return f"[{self.code}] {self.message}"

# 사용
raise BusinessError(
code="INSUFFICIENT_BALANCE",
message="잔액이 부족합니다.",
context={"required": 10000, "available": 3000}
)

고수 팁

팁 1 — 예외 클래스에 from __future__ import annotations 불필요

예외 클래스 자체는 런타임에 평가되어야 하므로 PEP 563 지연 평가가 필요 없습니다.

팁 2 — 라이브러리를 만든다면 항상 기반 예외를 노출하라

# mylib/exceptions.py
class MyLibError(Exception):
"""mylib 패키지의 모든 예외의 기반 클래스"""
pass

# 사용자가 mylib의 모든 예외를 한 번에 잡을 수 있음
try:
mylib.do_something()
except mylib.MyLibError:
pass

팁 3 — 예외 메시지는 개발자용으로 작성하라

# 나쁜 예: 모호한 메시지
raise ValueError("오류")

# 좋은 예: 원인, 값, 해결 힌트 포함
raise ValueError(
f"나이 값 {age!r}은 유효하지 않습니다. "
f"0 이상 150 이하의 정수를 입력하세요."
)

팁 4 — __init_subclass__로 예외 등록 자동화

class RegisteredError(Exception):
_registry: dict[str, type] = {}

def __init_subclass__(cls, code: str = "", **kwargs):
super().__init_subclass__(**kwargs)
if code:
RegisteredError._registry[code] = cls

class NotFoundError(RegisteredError, code="NOT_FOUND"):
pass

class AuthError(RegisteredError, code="UNAUTHORIZED"):
pass

# 코드로 예외 클래스 조회
exc_class = RegisteredError._registry["NOT_FOUND"]
raise exc_class("찾을 수 없음")
Advertisement