Python 예외 처리: try-except, raise, 커스텀 예외

이 글의 핵심

except Exception 한 줄로 모든 에러를 삼키면 당장은 프로그램이 멈추지 않지만 진짜 버그까지 숨겨 버립니다. 잡을 예외를 좁게 고르는 기준, 잔액 부족 같은 도메인 상황을 커스텀 예외로 표현하는 방법, 재시도 로직에서 예외를 다시 올려야 하는 시점을 예제로 확인합니다.

들어가며

예외 처리는 프로그램의 안정성을 높이는 핵심 기술입니다. 다만 “에러가 나도 멈추지 않게 하는 것”과 “안정적인 것”은 다릅니다. 실패를 조용히 삼킨 프로그램은 멈추지는 않지만 잘못된 결과를 계속 만들고, 문제가 드러났을 때는 원인이 어디서 시작됐는지 알 수 없게 됩니다.

Python은 “허락보다 용서를 구하는 편이 쉽다(EAFP)“는 스타일을 권장하는 언어라서, 파일이 있는지 먼저 확인하기보다 일단 열어 보고 FileNotFoundError를 처리하는 코드가 자연스럽습니다. 확인과 사용 사이에 다른 프로세스가 파일을 지울 수도 있으니 이쪽이 더 정확하기도 합니다. 이 글에서는 예외를 잡는 문법과 함께 무엇을, 어디서 잡아야 하는지의 기준을 정리합니다.


try-except 기본

try-except

try 블록은 문제가 날 수 있는 줄을 안전 구역 안에 두는 것이고, except는 비상구로 빠져 나와 사용자에게 알맞은 메시지를 보여 주는 곳입니다. 0으로 나누기·잘못된 입력·없는 파일처럼 예상할 수 있는 실패마다 다른 except 절을 두면 원인 파악이 쉬워집니다.

# 기본 형태
try:
    result = 10 / 0
except ZeroDivisionError:
    print("0으로 나눌 수 없습니다")
    result = None
# 여러 예외 처리
try:
    number = int(input("숫자 입력: "))
    result = 10 / number
except ValueError:
    print("숫자를 입력하세요")
except ZeroDivisionError:
    print("0이 아닌 숫자를 입력하세요")
# 예외 객체 받기
try:
    file = open('없는파일.txt', 'r')
except FileNotFoundError as e:
    print(f"에러: {e}")

except 절은 위에서부터 순서대로 검사되고, 발생한 예외가 그 클래스이거나 하위 클래스이면 걸립니다. 그래서 넓은 예외를 위에 두면 아래쪽의 구체적인 절은 절대 실행되지 않습니다. 예를 들어 except Exception:을 except ValueError: 위에 두면 ValueError도 첫 절에서 잡힙니다. 같은 처리를 할 예외 여러 개는 except (ValueError, ZeroDivisionError):처럼 튜플로 묶을 수 있습니다.

두 번째 예제에서 try 블록에 int() 변환과 나눗셈을 함께 넣은 것은 두 줄 모두 사용자 입력 때문에 실패할 수 있어서입니다. 반대로 try 안에 관련 없는 코드까지 많이 넣으면, 예상하지 못한 줄에서 난 ValueError도 “숫자를 입력하세요”로 처리되어 버그가 가려집니다. try 블록은 실패를 예상하는 줄만 감쌀 만큼 좁게 유지하는 것이 원칙입니다.

as e로 받은 예외 객체를 문자열로 만들면 [Errno 2] No such file or directory: '없는파일.txt'처럼 원인이 담긴 메시지가 나옵니다. e 변수는 except 블록이 끝나면 자동으로 삭제되므로 블록 밖에서 쓰려면 다른 변수에 담아 둬야 합니다.


else와 finally까지 포함한 전체 구조

전체 구조

try:
    # 시도할 코드
    file = open('data.txt', 'r')
    content = file.read()
except FileNotFoundError:
    # 예외 발생 시
    print("파일이 없습니다")
else:
    # 예외 없을 때만 실행
    print(f"파일 읽기 성공: {len(content)}자")
finally:
    # 항상 실행 (파일 닫기 등)
    if 'file' in locals():
        file.close()
    print("작업 완료")

네 절의 실행 규칙은 이렇습니다. 예외가 없으면 try → else → finally, 예외가 잡히면 try(중간까지) → except → finally, 잡히지 않는 예외가 나면 try → finally 후 예외가 위로 전파됩니다. finally는 try나 except 안에서 return을 해도 반환 직전에 실행됩니다.

else가 따로 있는 이유는 성공했을 때만 할 일을 try 밖으로 빼기 위해서입니다. 위 예제에서 print(...)를 try 안에 넣어도 동작은 같아 보이지만, 그 줄에서 예상하지 못한 예외가 나면 except FileNotFoundError에 잡힐 위험이 생깁니다. else로 옮기면 try에는 실패를 예상하는 코드만 남습니다.

if 'file' in locals():는 open() 자체가 실패해 file 변수가 만들어지지 않은 경우를 피하려는 코드입니다. 이렇게 하지 않고 finally에서 바로 file.close()를 부르면 원래의 FileNotFoundError 대신 NameError가 나서 오류 메시지가 엉뚱해집니다. 이런 번거로움 때문에 파일, 락, DB 연결 같은 자원은 with open('data.txt') as file:처럼 컨텍스트 매니저로 다루는 것이 관례입니다. with 블록은 예외가 나든 안 나든 빠져나올 때 자원을 정리해 주므로 finally를 직접 쓸 일이 크게 줄어듭니다. 또 finally 안에서 return을 쓰면 진행 중이던 예외가 조용히 사라지므로 피해야 합니다.


raise로 예외 발생시키고 다시 던지기

기본 raise

def divide(a, b):
    if b == 0:
        raise ValueError("b는 0이 될 수 없습니다")
    return a / b
try:
    result = divide(10, 0)
except ValueError as e:
    print(f"에러: {e}")

raise는 함수가 자기 책임 범위에서 처리할 수 없는 상황을 호출한 쪽에 알리는 방법입니다. divide가 0으로 나누기를 만났을 때 None이나 -1을 반환하면, 호출한 쪽이 반환값 검사를 빠뜨리는 순간 잘못된 값이 계속 전달됩니다. 예외는 검사를 빠뜨리면 프로그램이 멈추므로 실패를 무시하기 어렵게 만듭니다.

예외 클래스는 의미에 맞게 고릅니다. 인자의 타입은 맞지만 값이 허용 범위 밖이면 ValueError, 타입 자체가 틀리면 TypeError, 객체 상태상 지금은 할 수 없는 작업이면 RuntimeError나 커스텀 예외가 일반적입니다. 이 예제는 설명을 위해 ValueError를 직접 던졌지만, 실제로 a / 0은 Python이 알아서 ZeroDivisionError를 내므로 검사를 생략해도 됩니다. 직접 검사하는 의미는 더 이해하기 쉬운 메시지를 주는 데 있습니다.

예외 재발생

def process_data(data):
    try:
        result = int(data)
    except ValueError:
        print("데이터 변환 실패")
        raise  # 예외 재발생
try:
    process_data("abc")
except ValueError:
    print("상위에서 처리")

인자 없는 raise는 지금 처리 중인 예외를 원래 트레이스백 그대로 다시 던집니다. 이 패턴은 “로그는 여기서 남기되 처리는 상위에 맡긴다”는 경우에 씁니다. raise e로 써도 비슷하게 동작하지만 트레이스백에 재발생 위치가 추가되므로, 원래 예외를 그대로 올릴 때는 인자 없는 raise가 정석입니다.

저수준 예외를 의미 있는 예외로 바꿔서 올릴 때는 raise ConfigError("설정 값이 숫자가 아닙니다") from e처럼 from을 붙입니다. 그러면 트레이스백에 “The above exception was the direct cause of the following exception”과 함께 원래 ValueError가 남아, 바뀐 예외만 보고도 근본 원인을 추적할 수 있습니다. from 없이 except 블록 안에서 다른 예외를 던지면 “During handling of the above exception, another exception occurred”라는 메시지가 붙는데, 이는 “처리하다가 또 실패했다”는 뜻이라 의도한 변환인지 버그인지 구분이 어렵습니다.

한 가지 주의할 점은 예제처럼 로그를 남기고 다시 올리는 코드가 여러 계층에 있으면, 예외 하나에 같은 로그가 층마다 반복해서 찍힌다는 것입니다. 처음 예외 처리 규칙을 정할 때 흔히 겪는 문제로, 로그는 실제로 처리하는 곳 한 군데서만 남기는 편이 로그를 읽기 쉽습니다.


커스텀 예외 클래스

사용자 정의 예외

class InsufficientBalanceError(Exception):
    """잔액 부족 예외"""
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f"잔액 부족: {balance}원 (필요: {amount}원)")
class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
    
    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientBalanceError(self.balance, amount)
        self.balance -= amount
        return self.balance
# 사용
account = BankAccount("철수", 10000)
try:
    account.withdraw(15000)
except InsufficientBalanceError as e:
    print(e)  # 잔액 부족: 10000원 (필요: 15000원)
    print(f"현재 잔액: {e.balance}원")

커스텀 예외를 만드는 이유는 호출한 쪽이 상황별로 다르게 대응할 수 있게 하기 위해서입니다. 잔액 부족을 ValueError("잔액 부족")으로 던지면 호출한 쪽은 메시지 문자열을 비교해야 원인을 구분할 수 있고, 메시지가 바뀌면 코드가 깨집니다. 전용 클래스가 있으면 except InsufficientBalanceError:로 정확히 잡고, e.balance, e.amount처럼 구조화된 정보로 “5000원이 부족합니다” 같은 안내를 만들 수 있습니다.

super().__init__(메시지)를 호출하는 것도 중요합니다. 이 호출을 빠뜨리면 print(e)나 로그에 메시지가 비어 나오고, e.args도 채워지지 않습니다. 커스텀 예외는 BaseException이 아니라 Exception을 상속해야 except Exception으로 한 번에 처리하는 상위 코드와 잘 어울립니다.

예외가 여러 개로 늘어나면 class BankError(Exception)처럼 기반 클래스를 두고 InsufficientBalanceError(BankError), AccountLockedError(BankError)로 계층을 만드는 방식이 흔합니다. 호출하는 쪽은 개별 예외를 골라 잡을 수도, except BankError:로 이 모듈의 예외를 한꺼번에 잡을 수도 있습니다. 다만 모든 조건마다 클래스를 만들 필요는 없고, 호출하는 쪽이 다르게 처리할 필요가 있는 상황만 별도 클래스로 두면 충분합니다.


자주 만나는 내장 예외

자주 쓰는 예외

# ValueError: 값이 잘못됨
try:
    int("abc")
except ValueError:
    print("숫자 변환 실패")
# TypeError: 타입이 잘못됨
try:
    "hello" + 5
except TypeError:
    print("타입 불일치")
# KeyError: 딕셔너리 키 없음
try:
    data = {'name': '철수'}
    print(data['age'])
except KeyError:
    print("키가 없습니다")
# IndexError: 인덱스 범위 초과
try:
    arr = [1, 2, 3]
    print(arr[10])
except IndexError:
    print("인덱스 초과")
# FileNotFoundError: 파일 없음
try:
    open('없는파일.txt', 'r')
except FileNotFoundError:
    print("파일이 없습니다")

이 예외들은 대부분 계층 관계로 묶여 있어서, 알아 두면 잡는 범위를 조절하기 쉽습니다. KeyError와 IndexError는 모두 LookupError의 하위 클래스이고, FileNotFoundError, PermissionError, IsADirectoryError는 OSError의 하위 클래스입니다. 파일 작업에서 “없음”만 따로 처리하고 나머지 입출력 오류는 한꺼번에 다루고 싶다면 except FileNotFoundError: 다음에 except OSError:를 두면 됩니다.

TypeError, KeyError, IndexError는 사용자 입력보다 코드의 버그를 뜻하는 경우가 많다는 점도 기억해 둘 만합니다. 딕셔너리에 키가 없을 수 있다면 data.get('age')나 'age' in data로 처리하는 편이 명확하고, KeyError를 잡는 것은 “키가 있어야 정상인데 없다”는 상황을 알리고 싶을 때가 더 어울립니다.


안전한 파일 처리와 재시도 예제

안전한 파일 처리

import json
def safe_read_json(filename):
    """JSON 파일 안전하게 읽기"""
    try:
        with open(filename, 'r', encoding='utf-8') as f:
            return json.load(f)
    except FileNotFoundError:
        print(f"{filename} 파일이 없습니다")
        return {}
    except json.JSONDecodeError as e:
        print(f"JSON 파싱 에러: {e}")
        return {}
    except Exception as e:
        print(f"예상치 못한 에러: {e}")
        return {}

with 문으로 파일을 열었기 때문에 json.load()에서 파싱 오류가 나도 파일은 자동으로 닫힙니다. 파일이 없을 때와 내용이 깨졌을 때를 나눠 잡은 이유는 대응이 다르기 때문입니다. 설정 파일이 없으면 기본값으로 시작하는 것이 자연스럽지만, 있는데 깨져 있다면 사용자가 고친 설정이 무시되는 셈이라 알려 줘야 합니다. json.JSONDecodeError 메시지에는 Expecting ',' delimiter: line 3 column 5 (char 27)처럼 위치가 들어 있어 원인을 찾기 쉽습니다.

마지막 except Exception은 신중하게 써야 하는 부분입니다. 모든 실패에서 빈 딕셔너리를 돌려주면, 호출한 쪽은 “파일이 비어 있음”과 “권한이 없어 못 읽음”을 구분할 수 없고 빈 설정으로 계속 진행합니다. 이 함수가 선택적 캐시 파일을 읽는 용도라면 괜찮지만, 필수 설정을 읽는 용도라면 예상하지 못한 예외는 잡지 말고 전파시키는 편이 낫습니다. 또 print 대신 logging.exception()을 쓰면 트레이스백까지 함께 기록됩니다.

재시도 로직

import time
def retry_operation(func, max_attempts=3):
    """실패 시 재시도"""
    for attempt in range(max_attempts):
        try:
            return func()
        except Exception as e:
            print(f"시도 {attempt + 1} 실패: {e}")
            if attempt < max_attempts - 1:
                time.sleep(1)
            else:
                raise
# 사용
def unstable_operation():
    import random
    if random.random() < 0.7:
        raise ConnectionError("연결 실패")
    return "성공"
try:
    result = retry_operation(unstable_operation)
    print(result)
except Exception as e:
    print(f"최종 실패: {e}")

재시도 함수는 앞의 두 원칙을 합친 예입니다. 중간 실패는 기록하고 삼키되, 마지막 시도에서도 실패하면 인자 없는 raise로 마지막 예외를 그대로 올려 보냅니다. 여기서 raise를 빼고 None을 반환하게 만들면 호출한 쪽은 result가 None인 채로 다음 작업을 진행하다가 전혀 다른 곳에서 AttributeError: 'NoneType' object has no attribute ...를 만나게 됩니다. 원인과 증상이 멀리 떨어지는 전형적인 경우입니다.

except Exception으로 모든 예외를 재시도하는 것은 이 예제의 약점입니다. 연결 실패처럼 다시 하면 성공할 수 있는 예외와, 잘못된 인자로 생긴 TypeError처럼 몇 번을 해도 실패할 예외를 구분하지 않기 때문입니다. retry_operation(func, retry_on=(ConnectionError, TimeoutError))처럼 재시도할 예외 타입을 받도록 바꾸는 것이 안전하고, 같은 로직을 여러 함수에 붙이려면 데코레이터로 만드는 방법도 있습니다.


예외를 골라 잡는 습관 (안전망)

try/except는 문제가 터졌을 때 프로그램 전체가 멈추지 않게 받쳐 주는 안전망입니다. Exception만 포괄적으로 잡으면 원인 추적이 어려우므로, 기대할 수 있는 예외 이름을 골라 처리하는 편이 유지보수에 유리합니다.

# ✅ 구체적인 예외 처리
try:
    value = int(user_input)
except ValueError:
    print("숫자를 입력하세요")
# ❌ 너무 광범위한 예외 처리
try:
    value = int(user_input)
except Exception:  # 모든 예외를 잡음 (디버깅 어려움)
    print("에러 발생")
# ✅ 예외 메시지 활용
try:
    file = open('data.txt', 'r')
except FileNotFoundError as e:
    print(f"파일 에러: {e}")
# ✅ 리소스 정리는 finally (open은 try 밖에서)
file = open('data.txt', 'r')
try:
    pass  # 작업
finally:
    file.close()  # 항상 실행

마지막 예제에서 open()을 try 밖에 둔 이유는, 여는 것부터 실패하면 닫을 파일이 없기 때문입니다. open()을 try 안에 두면 파일이 없을 때 finally의 file.close()가 NameError를 일으켜 원래 오류를 덮어 버립니다. “획득은 try 앞에서, 해제는 finally에서”가 수동 정리의 기본 형태이고, 앞에서 말했듯 대부분의 경우 with 문이 이 패턴을 대신해 줍니다.

except Exception이 except:(bare except)보다는 낫다는 점도 알아 두세요. 아무 클래스도 적지 않은 except:는 BaseException까지 잡아서 Ctrl+C(KeyboardInterrupt)나 sys.exit()(SystemExit)까지 삼킵니다. 반복문 안에 bare except를 넣은 스크립트가 Ctrl+C로도 멈추지 않는 것이 이 때문입니다. 반대로 프로그램 최상단에서 “어떤 예외든 로그를 남기고 종료”하는 용도라면 except Exception을 쓰는 것이 합리적인 드문 경우입니다.


예외 처리 요약

  1. try-except: 예외 처리 기본
  2. finally: 항상 실행 (리소스 정리)
  3. raise: 예외 발생
  4. 커스텀 예외: Exception 상속
  5. 베스트 프랙티스: 구체적 예외, with 문

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. except Exception으로 모든 예외를 한 번에 잡으면 안 되나요?

A. 동작은 하지만 오타로 생긴 NameError나 예상하지 못한 버그까지 모두 삼켜서 원인 추적이 어려워집니다. int(user_input)에는 ValueError, 파일 열기에는 FileNotFoundError처럼 해당 코드에서 실제로 일어날 수 있는 예외를 골라 잡는 것이 좋습니다. 부득이하게 넓게 잡아야 한다면 except Exception as e:로 메시지를 로그에 남기고, 파일 닫기 같은 정리 작업은 finally나 with 문으로 처리합니다.