파이썬은 다양한 오류 상황이 예외 클래스로 범주화되어 있다. 예외가 발생하면 예외 상황에 대한 정보를 담은 예외 인스턴스가 만들어진다. 여러분이 새로운 예외의 종류(클래스)를 직접 정의하거나, 예외 객체를 직접 생성할 수도 있다.

이 절에서는 파이썬에 정의되어 있는 예외의 분류를 간단히 살펴보고, 필요한 예외 유형을 직접 정의하는 방법과 상황에 맞는 예외를 일으키는 방법을 알아본다.

9.4.1 예외의 분류

파이썬의 예외 클래스는 계층적으로 범주화되어 있다. 모든 예외 클래스는 BaseException 클래스의 하위 클래스이며, 대부분의 예외 클래스는 Exception 클래스의 하위 클래스다.

다음은 중요한 예외와 접하기 쉬운 예외를 꼽아 본 것이다. 외울 필요는 없으니 살짝 훑어만 보자. 언제 어떤 오류가 발생하는지는 프로그래밍 경험을 쌓다 보면 점차 알게 된다.

그림 9-4 주요 예외 클래스의 범주와 의미(발생 원인)

그림 9-4 주요 예외 클래스의 범주와 의미(발생 원인)

9.4.2 여러 예외를 동일하게 처리하기

여러 종류의 예외가 동일한 방식으로 처리되어야 할 때도 있다. 튜플과 사전에서, 존재하지 않는 인덱스 또는 키를 인덱싱하는 경우를 생각해 보자.

코드 9-31 튜플과 사전에 존재하지 않는 인덱스 또는 키를 인덱싱하는 예외

def get(key, dataset):
    """데이터 집합(dataset)에서 인덱스(키)에 해당하는 값을 반환한다.
    데이터 집합에 해당하는 인덱스(키)가 존재하지 않는 경우,
    None을 반환한다.
    """
    try:
        value = dataset[key]
    except IndexError:  # 인덱스가 잘못된 예외
        return None
    except KeyError:    # 키가 잘못된 예외
        return None
    else:
        return value

print(get(3, (1, 2, 3)))               # 범위를 벗어난 인덱스를 인덱싱
print(get('age', {'name': '박연오'}))  # 사전에 없는 키 인덱싱

실행 결과:

None
None

위 코드처럼 여러 종류의 예외를 동일한 방식으로 처리해야 할 때는 except 절에 처리할 예외를 괄호로 감싸고 콤마로 구별해 나열하면 된다. (여기서는 괄호를 생략하면 안 된다.) 예외를 괄호로 묶으면 다음과 같이 중복을 줄일 수 있다.

코드 9-32 여러 예외 동일하게 처리하기

def get(key, dataset):
    """데이터 집합(dataset)에서 인덱스(키)에 해당하는 값을 반환한다.
    데이터 집합에 해당하는 인덱스(키)가 존재하지 않는 경우,
    None을 반환한다.
    """
    try:
        value = dataset[key]
    except (IndexError, KeyError):  # 두 예외를 함께 처리
        return None
    else:
        return value

print(get(3, (1, 2, 3)))               # 범위를 벗어난 인덱스를 인덱싱
print(get('age', {'name': '박연오'}))  # 사전에 없는 키 인덱싱

실행 결과:

None
None

상위 범주 예외 처리하기

except 절에서 상위 범주의 예외를 처리하면 그 범주에 속하는 하위 범주의 예외도 함께 처리된다. IndexError 예외와 KeyError 예외는 상위 범주 예외 LookupError에 속한다. 따라서 코드 9-32은 다음과 같이 LookupError로 처리하도록 수정하는 것도 좋다.

코드 9-33 상위 범주 예외 처리하기

def get(key, dataset):
    """데이터 집합(dataset)에서 인덱스(키)에 해당하는 값을 반환한다.
    데이터 집합에 해당하는 인덱스(키)가 존재하지 않는 경우,
    None을 반환한다.
    """
    try:
        value = dataset[key]
    except LookupError:  # 상위 범주 예외 처리
        return None
    else:
        return value

print(get(3, (1, 2, 3)))               # 범위를 벗어난 인덱스를 인덱싱
print(get('age', {'name': '박연오'}))  # 사전에 없는 키 인덱싱

실행 결과:

None
None

모든 예외를 처리해버리면 어떨까?

최상위 예외 클래스인 BaseException이나 그에 준하는 Exception 등을 except 절에서 처리하면 발생하는 모든 예외를 처리할 수 있다.

코드 9-34 상위 범주 예외 처리하기

try:
    print(1 / 0)
except BaseException:
    print('종류는 모르겠지만 하여튼 예외가 발생했다.')

하지만 이런 식으로 모든 예외를 똑같이 처리해버리는 것은 분명한 이유가 없다면 피해야 한다. 대부분의 경우에는, 예외의 종류마다 처리해야 할 방법이 다르다. 예외를 제각각 구별하여 처리하기가 귀찮다고 모든 예외를 똑같이 다뤄 버리면, 예상치 못한 실행시간 오류가 발생했을 때 대처할 수 없게 된다.

9.4.3 예외 객체 살펴보기

except 절 안에서는 예외 객체에 담긴 오류 정보를 이용할 수 있다. except 절에서 예외 객체를 이용하려면 except 예외클래스 as 변수이름:과 같이, 처리할 예외 클래스 오른쪽에 as 변수이름을 추가하면 된다. ‘as’는 ‘~로서’라는 뜻이며, 전달받은 예외를 지정한 변수 이름으로 부르겠다는 뜻이다. 예외를 가리키는 이름으로는 ‘e’, ‘error’, ‘exception’ 등을 많이 사용한다.

대화식 셸에서 예외 객체를 전달받아 그 내용을 확인해 보자. try 문에서 예외를 발생시키고 except 절에서 발생한 예외를 전달받아, 전역변수에 대입한다.

코드 9-35 예외 객체 살펴보기

>>> try: 
...     1 / 0 
... except ZeroDivisionError as e:  # 예외 객체를 변수 e에 대입
...     exception = e    # 예외 객체를 전역변수 exception에 대입
... 

>>> type(exception)  # 예외 객체의 유형 확인
<class 'ZeroDivisionError'>

>>> isinstance(exception, ZeroDivisionError)  # ZeroDivisionError의 인스턴스인가?
True

>>> isinstance(exception, BaseException)  # BaseException의 인스턴스인가?
True

>>> str(exception)  # 문자열로 변환하면 오류의 발생원인을 나타내는 문자열이 된다
'division by zero'

실험에서 확인할 수 있듯이 예외 객체는 해당되는 예외 클래스의 인스턴스이며, 오류의 발생원인을 알려주는 문자열도 포함하고 있다.

9.4.4 예외 유형 정의하기

여러분의 프로그램에서만 발생할 예외가 파이썬이 제공하는 예외 유형에 들어맞지 않는 경우가 있을 수 있다. 그럴 때는 여러분이 예외 유형을 직접 정의하면 된다. 예외의 유형은 클래스이므로 class 문으로 정의할 수 있다. Exception 클래스를 상속해 정의하면 된다.

문을 자동으로 여닫는 프로그램을 만든다고 생각해보자. 문이 열린 상태에서 문을 열거나, 문이 닫힌 상태에서 문을 닫는 것은 예외 상황이다. 이를 나타내는 예외 유형을 다음과 같이 정의하기로 하자.

그림 9-5 문 관련 예외의 범주

그림 9-5 문 관련 예외의 범주

코드 9-36 문 관련 예외 정의하기

class DoorException(Exception):
    """문과 관련된 예외"""
    pass

class DoorOpenedException(DoorException):
    """문 열림 예외"""
    pass

class DoorClosedException(DoorException):
    """문 닫힘 예외"""
    pass

문의 조작과 관련된 예외(DoorException)를 상위 범주로 정의하고, 그 하위 범주로 문 열림 예외(DoorOpenedException)와 문 닫힘 예외(DoorClosedException)를 정의했다. 상위 범주는 Exception을 상속했고, 하위 범주는 상위 범주 클래스를 상속해 정의했다. 예외 클래스의 본문에는 일반적으로 특별히 정의할 내용이 없으므로 pass 문으로 생략해도 된다.

새로 정의한 예외 유형은 except DoorException:과 같이 except 절에서 처리할 수 있다. 그런데 이 예외는 언제 발생할까? 문이 열리고 닫히는 것을 파이썬이 자동으로 확인하여 예외를 발생시켜줄까? 이에 관해서는 이어지는 내용에서 소개된다.

개념 정리

  • 예외는 객체이고, 예외 유형은 클래스다.
  • class 문으로 새로운 예외 유형을 정의할 수 있다.

연습문제

연습문제 9-4 은행 계좌 관련 예외 1

은행 계좌와 관련된 예외를 다음과 같이 정의해 보아라.

BaseException
└── Exception
    └── AccountException (계좌 관련 예외)
        ├── AccountBalanceException (계좌 잔고 예외)
        ├── FrozenAccountException (동결 계좌 예외)
        └── InvalidTransactionException (잘못된 입출금 예외)

9.4.5 raise 문: 예외 발생시키기

파이썬에 기본으로 정의되어 있는 예외는 인터프리터가 적절한 상황에서 자동으로 발생시켜 준다. 하지만 프로그래머가 오류 상황을 직접 정의하여 예외를 발생시켜야 할 때도 있다. 예외를 직접 발생시키는 방법으로는 raise 문을 이용하는 방법과 assert 문을 이용하는 방법이 있다.

raise 문부터 알아보자. ‘raise’는 ‘일으키다’라는 뜻이며, raise 문은 지정한 예외를 일으킨다.. raise 예약어 오른쪽에 발생시킬 예외 객체를 다음과 같이 표기하면 된다.

raise 예외클래스(메시지)

예외 클래스를 인스턴스화할 때는 오류의 원인을 나타내는 문자열을 첫 번째 매개변수로 지정하는 것이 관례다. 대화식 셸에서 직접 오류를 발생시켜보자.

코드 9-37 raise 문으로 예외 발생시키기

>>> raise ZeroDivisionError('0으로 나눌 수 없음')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: 0으로 나눌 수 없음

오류 원인이 “division by zero” 대신 “0으로 나눌 수는 없음”으로 바뀐 점을 제외하면, 위 코드에서 출력된 오류 메시지는 1 / 0을 연산했을 때 발생하는 오류 메시지와 완전히 같다. 우리가 1 / 0을 연산하도록 할 때, 인터프리터가 raise ZeroDivisionError('division by zero')을 실행해주는 셈이다.

raise 문으로 발생시키는 예외는 파이썬 인터프리터가 자동으로 발생시키는 예외와 똑같다. try 문으로 처리할 수도 있다.

코드 9-38 raise 문으로 발생시킨 예외 처리하기

>>> try:
...     raise ZeroDivisionError('0으로 나눌 수 없음')
... except ZeroDivisionError:
...     print('0으로 나누는 예외가 발생했습니다.')
... 
0으로 나누는 예외가 발생했습니다.

발생시키는 예외 객체에 추가 정보를 담을 수도 있다. 예외 객체를 먼저 만든 후 추가로 포함할 속성을 대입하여 raise 문으로 발생시키면 된다.

코드 9-39 예외 객체에 추가 정보를 속성으로 대입하기

>>> try:
...     exception = ZeroDivisionError('0으로 나눌 수 없음')
...     exception.user = '박연오'
...     raise exception
... except ZeroDivisionError as e:
...     print('오류 원인:', str(e))
...     print('오류를 일으킨 사용자:', e.user)
... 
오류 원인: 0으로 나눌 수 없음
오류를 일으킨 사용자: 박연오

raise 문으로 논리 오류 방지하기

raise 문을 활용하면 조용히 발생하는 논리 오류를 명시적인 실행시간 오류로 완화할 수 있다. 논리 오류가 발생할 가능성이 있을 때, 상황을 확인해서 예외를 발생시키면 된다. 문을 나타내는 클래스를 정의한 다음 코드를 보자.

코드 9-40 문 관련 예외를 적절히 처리하는 문 클래스

class Door:
    """문을 나타내는 클래스"""
    def __init__(self):
        self.is_opened = True  # 문이 열려있는지를 나타내는 상태

    def open(self):
        # ❶ 문이 이미 열린 경우, 예외를 일으킨다
        if self.is_opened:
            raise DoorOpenedException('문이 이미 열려 있음')
        # 그렇지 않다면, 문을 연다
        else:
            print('문을 엽니다.')
            self.is_opened = True

    def close(self):
        # 문이 이미 닫힌 경우, 예외를 일으킨다
        if not self.is_opened:
            raise DoorClosedException('문이 이미 닫혀 있음')
        # 그렇지 않다면, 문을 닫는다
        else:
            print('문을 닫습니다.')
            self.is_opened = False

❶ 문이 이미 열려있는 상태에서 다시 열지 않도록, 코드 9-36에서 정의한 문 관련 예외를 발생시키도록 했다. if 문으로 논리 오류를 검사한 후, raise 문으로 예외를 발생시키면 된다. 오류가 제대로 처리되는지 인스턴스를 생성하여 확인해 보자.

코드 9-41 문 클래스의 예외 발생 시험하기

door = Door()  # 문 인스턴스 생성
door.close()   # 문 닫기
door.open()    # 문 열기
door.open()    # 문 열린 상태에서 문 열기

실행 결과:

문을 닫습니다.
문을 엽니다.
Traceback (most recent call last):
  File "example_9_38.py", line 39, in <module>
    door.open()    # 문 열린 상태에서 문 열기
  File "example_9_40.py", line 21, in open
    raise DoorOpenedException('문이 이미 열려 있음')
__main__.DoorOpenedException: 문이 이미 열려 있음

문이 열린 상태에서 닫거나, 문이 닫힌 상태에서 열 때는 정상 동작하고, 문이 열린 상태에서 문을 열자 DoorOpenedException이 발생하는 것을 확인할 수 있다.

개념 정리

  • raise 문으로 예외를 발생시킬 수 있다.
  • 프로그램이 정상적으로 실행될 수 없는 상황일 때, 예외를 발생시켜 이 사실을 바깥으로 알려야 한다.

연습문제

연습문제 9-5 은행 계좌 관련 예외 2

은행 계좌를 관리하는 클래스를 다음과 같이 정의하였다.

class Account():
    """은행 계좌"""
    def __init__(self, balance, is_frozen):
        """인스턴스를 초기화한다."""
        self.balance = balance      # 계좌 잔액
        self.is_frozen = is_frozen  # 계좌 동결 여부
    
    def check(self):
        """계좌의 잔고를 조회한다."""
        print('계좌 잔액은', self.balance, '원 입니다.')
    
    def deposit(self, amount):
        """계좌에 amount 만큼의 금액을 입금한다."""
        self.balance += amount
    
    def withdraw(self, amount):
        """계좌에서 amount 만큼의 금액을 인출한다."""
        self.balance -= amount

그런데 이 클래스를 시험하는 중 다음과 같은 논리 오류가 발견되었다.

  • 계좌의 잔액을 초과하는 액수가 출금되어서는 안 된다.
  • 동결된 계좌에서 출금되어서는 안 된다.
  • 0 이하의 액수는 입금 또는 출금할 수 없다.

연습문제 9-4에서 정의한 예외 클래스를 활용해, 위의 논리 오류 상황에서 적절한 예외가 발생되도록 deposit() 메서드와 withdraw() 메서드를 수정해라.

9.4.6 assert 문: 단언하기

논리 오류를 해결하는 또다른 방법으로 assert 문이 있다. assert 문은 예외를 일으킨다는 점에서 raise 문과 비슷한 명령이다. 하지만 언제·어떤 예외를 발생시키는지가 raise 문과 다르다.

비교 raise 문 assert 문
용도 예외의 발생 상태의 검증
언제 예외를 일으키는가? 항상 검증식이 거짓일 때만
어떤 예외를 일으키는가? 지정한 예외 AssertionError

표 9-3 raise 문과 assert 문의 비교

raise 문은 오류를 이미 발견한 상황에서 예외를 발생시키기 위한 명령이다. 따라서 무조건 예외를 발생시키며, 지정한 예외를 발생시킨다. 반면, assert 문은 상태를 검증하기 위한 명령이다. 지정한 검증식을 계산하여 결과가 참일 때는 아무 것도 하지 않고, 결과가 거짓일 때만 AssertionError 예외를 발생시킨다.

assert 문을 작성하는 양식은 다음과 같다.

assert 검증식, 오류메시지

‘assert’는 ‘단언하다’라는 뜻이며, assert 문은 “이 식은 올바르다!”라고 강하게 주장하는 것과 같다. 입력된 식이 거짓이라면 크게 부끄러울 것이므로 예외가 발생한다. 오류 메시지는 선택사항이므로 검증식만 입력해도 된다. 한 번 확인해 보자.

코드 9-42 assert 문으로 식 검사하기

>>> assert True        # 식이 올바르면 문제 없다
>>> assert 1 + 1 == 2  # 식이 올바르면 문제 없다
>>> assert 1 - 1 == 2  # 식이 거짓이면 AssertionError가 발생한다
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError

>>> assert False, '뭔가 잘못됐군요'  # 오류 메시지 지정하기
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError: 뭔가 잘못됐군요

assert 문으로 논리 오류 검사하기

assert 문을 활용하면 논리 오류를 간단히 검사할 수 있다. 계산된 결과가 올바른지 검산할 때, 어떤 중요한 명령을 실행하기 전에 준비가 잘 갖춰졌는지 확인할 때 등 무언가 ‘확인이 필요한’ 상황에서 assert 문을 사용하면 좋다.

assert 문을 사용하면 코드 9-40의 문 클래스를 더 간결하게 정의할 수 있다.

코드 9-43 assert 문으로 클래스의 상태 검사하기

class Door:
    """문을 나타내는 클래스"""
    def __init__(self):
        self.is_opened = True  # 문이 열려있는지를 나타내는 상태

    def open(self):
        # 문이 닫혀 있어야 한다
        assert not self.is_opened

        # 그렇지 않다면, 문을 연다
        print('문을 엽니다.')
        self.is_opened = True

    def close(self):
        # 문이 열려 있어야 한다
        assert self.is_opened

        # 그렇지 않다면, 문을 닫는다
        print('문을 닫습니다.')
        self.is_opened = False

실행 결과:

문을 닫습니다.
문을 엽니다.
Traceback (most recent call last):
  File "example_9_40.py", line 25, in <module>
    door.open()    # 문 열린 상태에서 문 열기
  File "example_9_40.py", line 8, in open
    assert not self.is_opened
AssertionError

assert 문은 사용하기 간편하지만, 발생시킬 예외를 직접 지정할 수 없다는 점이 단점이다. DoorException 같은 예외 클래스가 정의되어 있어도 assert 문에서는 활용하지 못한다. 간단한 오류 검사에는 assert 문을 활용하고, 오류를 체계적으로 관리해야 할 때는 예외 클래스를 적절히 정의하고 raise 문을 활용하도록 하자.

개념 정리

  • assert 문으로 상태를 검증할 수 있다.
  • 정확한 오류 정보를 전달하기 위해서는 assert 문보다 raise 문을 이용하는 것이 좋다.

이 절에서 예외를 분류하는 방법과 예외를 발생시키는 방법을 알아보았다. 예외를 일부러 일으킨다는 것이 이상하게 생각될 수 있다. 예외를 일으키는 것은 오류를 만들어내는 게 아니라, 오류가 있을 때 이를 외부에 알리는 것이다. 심각한 병일수록 조용히 자라나는 법이다. 적극적으로 검사하고 드러내야 곪기 전에 치료할 수 있다.

연습문제

연습문제 9-6 은행 계좌 관련 예외 3

연습문제 9-5에서는 if 문과 raise 문을 이용해 논리 오류를 처리했다. 이를 assert 문을 이용해 처리하는 방식으로 수정해 보아라.