포스트

[Python 100일 챌린지] Day 46 - 예외 처리 고급

[Python 100일 챌린지] Day 46 - 예외 처리 고급

class PaymentError(Exception)raise PaymentError("잔액 부족") → 명확한 에러! 😊

“FileNotFoundError” 대신 “회원정보없음Error”, “결제실패Error” 같은 나만의 에러 만들기! 대형 프로젝트처럼 전문적인 예외 처리를 배웁니다!

(35-45분 완독 ⭐⭐⭐)

🎯 오늘의 학습 목표

📚 사전 지식


🎯 학습 목표 1: 사용자 정의 예외 만들기

1.1 기본 커스텀 예외

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ValidationError(Exception):
    """데이터 검증 오류"""
    pass

def validate_age(age):
    if age < 0:
        raise ValidationError("나이는 음수일 수 없습니다")
    if age > 150:
        raise ValidationError("나이가 너무 큽니다")
    return True

# 사용
try:
    validate_age(-5)
except ValidationError as e:
    print(f"❌ 검증 오류: {e}")

1.2 속성을 가진 예외

예외 클래스에 추가 정보를 저장할 수 있습니다.

💡 super().__init__(message) 설명

super()는 부모 클래스를 의미합니다. 여기서 APIError의 부모는 Exception이죠. super().__init__(message)는 부모 클래스의 생성자를 호출해서 에러 메시지를 전달합니다. 이렇게 해야 str(e)print(e)로 에러 메시지를 출력할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class APIError(Exception):
    """API 오류"""
    def __init__(self, message, status_code, response=None):
        # 부모 클래스(Exception)에 메시지 전달
        super().__init__(message)
        # 추가 정보를 속성으로 저장
        self.status_code = status_code
        self.response = response

    def __str__(self):
        return f"[{self.status_code}] {super().__str__()}"

# 사용
try:
    raise APIError("서버 오류", status_code=500, response={"error": "..."})
except APIError as e:
    print(e)  # [500] 서버 오류
    print(f"상태 코드: {e.status_code}")

1.3 예외 계층 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class AppError(Exception):
    """앱 기본 예외"""
    pass

class DatabaseError(AppError):
    """데이터베이스 오류"""
    pass

class NetworkError(AppError):
    """네트워크 오류"""
    pass

class AuthenticationError(AppError):
    """인증 오류"""
    pass

# 사용
try:
    raise DatabaseError("연결 실패")
except AppError as e:  # 모든 앱 예외 처리
    print(f"앱 오류: {e}")

🎯 학습 목표 2: 예외 발생시키기와 체이닝

2.1 raise 기본 사용법

raise 키워드로 의도적으로 예외를 발생시킬 수 있습니다:

1
2
3
4
5
6
7
8
9
10
def validate_age(age):
    if age < 0:
        raise ValueError("나이는 음수일 수 없습니다")
    return age

# 사용
try:
    validate_age(-5)
except ValueError as e:
    print(f"❌ 오류: {e}")

2.2 예외 체이닝 (from 키워드)

다른 예외를 처리하다가 새 예외를 발생시킬 때, from을 사용해 원인을 연결합니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def process_data(data):
    try:
        result = int(data) * 2
        return result
    except ValueError as e:
        # 원인 예외를 포함하여 새 예외 발생
        raise TypeError("데이터 처리 실패") from e

# 사용
try:
    process_data("abc")
except TypeError as e:
    print(f"오류: {e}")
    print(f"원인: {e.__cause__}")  # 원래 ValueError 확인 가능

출력 결과:

1
2
오류: 데이터 처리 실패
원인: invalid literal for int() with base 10: 'abc'

2.3 암시적 예외 컨텍스트

from을 사용하지 않아도, except 블록에서 새 예외를 발생시키면 자동으로 컨텍스트가 연결됩니다:

1
2
3
4
5
6
7
8
9
try:
    try:
        result = 10 / 0
    except ZeroDivisionError:
        raise ValueError("잘못된 계산")

except ValueError as e:
    print(f"예외: {e}")
    print(f"컨텍스트: {e.__context__}")  # 원래 ZeroDivisionError

💡 __cause__ vs __context__

  • __cause__: raise ... from e로 명시적으로 연결된 원인
  • __context__: except 블록에서 자동으로 연결된 이전 예외

🎯 학습 목표 3: 컨텍스트 매니저와 예외

💡 컨텍스트 매니저란?

with 문과 함께 사용되는 객체입니다. 파일, 데이터베이스 연결 등 자원을 안전하게 관리합니다. __enter__(시작할 때)와 __exit__(끝날 때) 메서드를 구현합니다.

1
2
3
4
# 가장 흔한 예: 파일 처리
with open('file.txt') as f:  # __enter__ 호출
    content = f.read()
# __exit__ 호출 → 자동으로 파일 닫힘

3.1 __exit__에서 예외 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class DatabaseConnection:
    def __enter__(self):
        print("DB 연결")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        """예외 정보를 받음"""
        print("DB 연결 해제")

        if exc_type is not None:
            print(f"예외 발생: {exc_type.__name__}: {exc_val}")

        # False 반환: 예외를 다시 발생
        # True 반환: 예외를 억제
        return False

# 사용
try:
    with DatabaseConnection() as db:
        raise ValueError("데이터 오류")
except ValueError:
    print("외부에서 처리")

3.2 예외 억제

1
2
3
4
5
6
7
8
from contextlib import suppress

# 예외 무시하고 계속 진행
with suppress(FileNotFoundError):
    with open('없는파일.txt') as f:
        content = f.read()

print("계속 실행")

🎯 학습 목표 4: 예외 처리 베스트 프랙티스

4.1 구체적인 예외 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
# ❌ 나쁜 예
try:
    # ...
except:
    pass

# ✅ 좋은 예
try:
    # ...
except ValueError as e:
    logging.error(f"값 오류: {e}")
except TypeError as e:
    logging.error(f"타입 오류: {e}")

4.2 예외를 무시하지 말기

1
2
3
4
5
6
7
8
9
10
11
12
# ❌ 나쁜 예
try:
    # ...
except Exception:
    pass  # 완전히 무시!

# ✅ 좋은 예
try:
    # ...
except Exception as e:
    logging.exception("예외 발생")
    # 또는 기본값 반환

4.3 EAFP vs LBYL

Python에서 조건 검사를 하는 두 가지 스타일이 있습니다:

💡 LBYL: “Look Before You Leap” - “뛰기 전에 살펴보라”

  • 먼저 조건을 확인하고 작업 실행
  • 다른 언어에서 흔한 방식

EAFP: “Easier to Ask for Forgiveness than Permission” - “허락보다 용서가 쉽다”

  • 일단 실행하고, 문제가 생기면 예외 처리
  • Python에서 권장하는 방식!
1
2
3
4
5
6
7
8
9
10
11
# LBYL - 먼저 확인하고 실행 (조건문 사용)
if key in dictionary:
    value = dictionary[key]
else:
    value = default

# EAFP - 일단 실행하고 예외 처리 (Python스러움! ✅)
try:
    value = dictionary[key]
except KeyError:
    value = default

왜 EAFP가 더 좋을까요?

  • 코드가 더 깔끔함
  • 경쟁 조건(race condition) 방지
  • 파일 존재 확인 후 열기 vs 바로 열기 (파일이 그 사이 삭제될 수 있음!)

4.4 예외 로깅

1
2
3
4
5
6
7
8
import logging

logging.basicConfig(level=logging.INFO)

try:
    result = 10 / 0
except ZeroDivisionError:
    logging.exception("0으로 나누기 시도")  # 스택 트레이스 포함

4.5 실전 패턴

패턴 1: Retry (재시도)

💡 데코레이터(Decorator)란?

함수를 꾸며주는 함수입니다. @데코레이터 형태로 함수 위에 붙입니다. 함수 실행 전/후에 추가 기능을 넣을 수 있습니다.

1
2
3
4
5
6
@retry  # ← 이게 데코레이터!
def my_function():
    pass

# 위 코드는 아래와 같습니다:
# my_function = retry(my_function)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import time
from functools import wraps

def retry(max_attempts=3, delay=1, exceptions=(Exception,)):
    """
    재시도 데코레이터
    - max_attempts: 최대 시도 횟수
    - delay: 재시도 간격 (초)
    - exceptions: 재시도할 예외 종류
    """
    def decorator(func):
        @wraps(func)  # 원래 함수의 이름, 문서 등을 유지
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    if attempt == max_attempts - 1:
                        raise  # 마지막 시도에서 실패하면 예외 발생
                    print(f"재시도 {attempt + 1}/{max_attempts}...")
                    time.sleep(delay)
        return wrapper
    return decorator

# 사용 - @retry를 붙이면 자동으로 재시도 기능이 추가됨!
@retry(max_attempts=3, delay=0.5, exceptions=(ConnectionError,))
def fetch_data(url):
    # 네트워크 요청 (실패하면 자동 재시도)
    pass

패턴 2: Fallback 체인

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def get_config_value(key):
    """설정 값 가져오기 (여러 소스 시도)"""
    # 1. 환경 변수
    try:
        import os
        return os.environ[key]
    except KeyError:
        pass

    # 2. 설정 파일
    try:
        import json
        with open('config.json') as f:
            config = json.load(f)
            return config[key]
    except (FileNotFoundError, KeyError):
        pass

    # 3. 기본값
    defaults = {'timeout': 30, 'debug': False}
    try:
        return defaults[key]
    except KeyError:
        raise ValueError(f"설정 없음: {key}")

패턴 3: 예외 변환

내부 예외를 외부용 예외로 변환하는 패턴입니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 예외 클래스 정의
class ServiceError(Exception):
    """서비스 오류 (외부용)"""
    pass

class DatabaseError(Exception):
    """데이터베이스 오류 (내부용)"""
    pass

# 예외 변환 예제
def get_user_data(user_id):
    """사용자 데이터 가져오기"""
    try:
        # 파일에서 데이터 읽기 (DB 대신 간단한 예제)
        with open(f"users/{user_id}.json") as f:
            return f.read()

    except FileNotFoundError as e:
        # 내부 예외 → 외부용 예외로 변환
        raise ServiceError(f"사용자 {user_id}를 찾을 수 없습니다") from e

    except PermissionError as e:
        raise ServiceError("데이터 접근 권한이 없습니다") from e

# 사용
try:
    data = get_user_data(123)
except ServiceError as e:
    print(f"❌ 서비스 오류: {e}")
    print(f"원인: {e.__cause__}")  # 원본 예외 확인 가능

4.6 종합 예제

예제 1: 견고한 파일 처리기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import logging

class FileProcessor:
    """예외 처리가 강화된 파일 처리기"""
    def __init__(self, filename, mode='r', encoding='utf-8'):
        self.filename = filename
        self.mode = mode
        self.encoding = encoding
        self.file = None

    def __enter__(self):
        try:
            self.file = open(self.filename, self.mode, encoding=self.encoding)
            return self.file

        except FileNotFoundError:
            raise FileProcessingError(
                f"파일 없음: {self.filename}"
            )

        except PermissionError:
            raise FileProcessingError(
                f"권한 없음: {self.filename}"
            )

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.file:
            try:
                self.file.close()
            except Exception as e:
                logging.warning(f"파일 닫기 실패: {e}")

        # 예외를 다시 발생시킴
        return False

class FileProcessingError(Exception):
    """파일 처리 오류"""
    pass

# 사용
try:
    with FileProcessor('data.txt') as f:
        content = f.read()
        print(content)
except FileProcessingError as e:
    print(f"{e}")

예제 2: 재시도 로직이 있는 API 클라이언트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import time
import random

# 예외 클래스 정의
class APIError(Exception):
    """API 오류"""
    pass

class NetworkError(Exception):
    """네트워크 오류"""
    pass

class APIClient:
    """예외 처리가 강화된 API 클라이언트"""

    def request(self, url, max_retries=3):
        """API 요청 (재시도 로직 포함)"""
        for attempt in range(max_retries):
            try:
                response = self._do_request(url)
                return response

            except NetworkError as e:
                # 마지막 시도면 예외 발생
                if attempt == max_retries - 1:
                    raise APIError(f"네트워크 오류: {e}") from e

                # 재시도 (지수 백오프: 1초, 2초, 4초...)
                wait_time = 2 ** attempt
                print(f"⚠️ 재시도 {attempt + 1}/{max_retries} ({wait_time}초 후)")
                time.sleep(wait_time)

    def _do_request(self, url):
        """실제 요청 (시뮬레이션)"""
        # 30% 확률로 실패
        if random.random() < 0.3:
            raise NetworkError("연결 실패")
        return {"status": "ok", "data": "..."}

# 사용 예제
client = APIClient()

try:
    result = client.request("https://api.example.com/data")
    print(f"✅ 성공: {result}")
except APIError as e:
    print(f"❌ API 오류: {e}")

💡 실전 팁 & 주의사항

✅ 커스텀 예외 설계 팁

  1. 명확한 이름
    1
    2
    3
    4
    5
    6
    7
    
    # ✅ 좋은 예
    class PaymentProcessingError(Exception):
        pass
    
    # ❌ 나쁜 예
    class Error1(Exception):
        pass
    
  2. 예외 계층 구조
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
    class AppError(Exception):
        """기본 예외"""
        pass
    
    class DatabaseError(AppError):
        """DB 예외"""
        pass
    
    class NetworkError(AppError):
        """네트워크 예외"""
        pass
    
  3. 유용한 정보 포함
    1
    2
    3
    4
    
    class ValidationError(Exception):
        def __init__(self, field, message):
            self.field = field
            super().__init__(f"{field}: {message}")
    

⚠️ 주의사항

  • 예외 클래스는 Exception을 상속받으세요
  • 너무 많은 커스텀 예외는 복잡도를 높입니다
  • 예외 체이닝으로 원인을 명확히 하세요

🧪 연습 문제

문제 1: 커스텀 예외 클래스

목표: 은행 계좌 시스템을 위한 커스텀 예외를 작성하세요.

요구사항:

  • InsufficientFundsError: 잔액 부족 (부족한 금액 정보 포함)
  • InvalidAmountError: 잘못된 금액 (음수 등)
  • AccountLockedError: 계좌 잠김
해답 보기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class InsufficientFundsError(Exception):
    """잔액 부족 예외"""
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        shortage = amount - balance
        super().__init__(
            f"잔액 부족: {balance}원 (부족액: {shortage}원)"
        )

class InvalidAmountError(Exception):
    """잘못된 금액 예외"""
    pass

class AccountLockedError(Exception):
    """계좌 잠김 예외"""
    pass

class BankAccount:
    def __init__(self, balance=0):
        self.balance = balance
        self.locked = False

    def withdraw(self, amount):
        if self.locked:
            raise AccountLockedError("계좌가 잠겨있습니다")

        if amount <= 0:
            raise InvalidAmountError("금액은 양수여야 합니다")

        if amount > self.balance:
            raise InsufficientFundsError(self.balance, amount)

        self.balance -= amount
        return self.balance

# 테스트
account = BankAccount(10000)

try:
    account.withdraw(15000)
except InsufficientFundsError as e:
    print(f"{e}")
    print(f"현재 잔액: {e.balance}")

문제 2: Retry 데코레이터

목표: 실패 시 자동으로 재시도하는 데코레이터를 작성하세요.

요구사항:

  • 최대 3회 재시도
  • 재시도 간격 1초
  • 특정 예외만 재시도
해답 보기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import time
from functools import wraps

def retry(max_attempts=3, delay=1, exceptions=(Exception,)):
    """재시도 데코레이터"""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    if attempt == max_attempts - 1:
                        raise
                    print(f"재시도 {attempt + 1}/{max_attempts}... ({e})")
                    time.sleep(delay)
        return wrapper
    return decorator

# 사용 예
@retry(max_attempts=3, delay=0.5)
def unstable_operation():
    import random
    if random.random() < 0.7:
        raise ConnectionError("연결 실패")
    return "성공!"

# 테스트
try:
    result = unstable_operation()
    print(f"{result}")
except ConnectionError:
    print("❌ 최종 실패")

📝 오늘 배운 내용 정리

개념 설명 예시
커스텀 예외 사용자 정의 예외 클래스 class MyError(Exception)
super().__init__() 부모 클래스 생성자 호출 에러 메시지 전달용
예외 체이닝 원인 예외 연결 raise NewError() from e
컨텍스트 매니저 with문과 함께 자원 관리 __enter__, __exit__ 메서드
EAFP 일단 실행, 문제시 예외 처리 Python 권장 스타일
LBYL 먼저 확인 후 실행 다른 언어에서 흔함
데코레이터 함수를 꾸며주는 함수 @retry 형태로 사용

핵심 코드 패턴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 커스텀 예외
class ValidationError(Exception):
    def __init__(self, field, message):
        self.field = field
        super().__init__(f"{field}: {message}")

# 예외 체이닝
try:
    result = process()
except ValueError as e:
    raise ProcessingError("처리 실패") from e

# Retry 패턴
for attempt in range(max_retries):
    try:
        return operation()
    except TempError:
        if attempt == max_retries - 1:
            raise
        time.sleep(delay)

🔗 관련 자료


📚 이전 학습

Day 45: 예외 처리 기초 ⭐⭐⭐

어제는 try-except 기본 구조, 예외 타입, finally 절 등 예외 처리의 기초를 배웠습니다!

📚 다음 학습

Day 47: 로깅 시스템 ⭐⭐⭐

내일은 logging 모듈로 프로그램의 실행 기록을 파일로 남기고 관리하는 방법을 배웁니다!


“늦었다고 생각할 때가 가장 빠른 시기입니다!” 🚀

Day 46/100 Phase 5: 파일 처리와 예외 처리 #100DaysOfPython

이제와서 시작하는 Python 마스터하기 - Day 46 완료! 🎉

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.