결론: 실패 지점마다 구체적인 예외를 잡고 도메인 오류는 직접 정의한다
예외 처리는 오류가 났다는 사실을 숨기는 도구가 아니라, 어떤 실패는 프로그램이 계속 진행해도 되고 어떤 실패는 즉시 멈춰야 하는지를 코드로 명시하는 도구다. try/except로 예상되는 실패만 좁게 잡고, else로 성공했을 때만 할 일을, finally로 성공·실패와 무관하게 항상 할 일을 나눈다. 파이프라인에 의미 있는 실패는 Exception을 상속한 사용자 정의 예외로 이름을 붙여, 호출하는 쪽이 원인을 정확히 구분해 대응할 수 있게 한다.
오류와 예외의 차이
Python에서 흔히 말하는 오류는 크게 두 종류다. 문법 오류(SyntaxError)는 코드를 실행하기도 전에 해석 단계에서 걸리며, 괄호를 안 닫거나 콜론을 빠뜨리는 등 코드 자체가 잘못됐을 때 난다. 예외는 문법은 올바르지만 실행 중에 더 진행할 수 없는 상황을 만났을 때 발생한다.
print(10 / 0)
Traceback (most recent call last):
...
ZeroDivisionError: division by zero
이 코드는 문법상 문제가 없어 정상적으로 해석되지만, 0으로 나누는 연산을 실제로 수행하려는 순간 ZeroDivisionError 예외가 발생하고 프로그램이 그 지점에서 멈춘다. try/except는 이렇게 실행 중에 발생하는 예외를 다루는 문법이며, 문법 오류 자체를 고쳐 주지는 않는다.
try-except로 예외 잡기
가장 단순한 형태는 실패할 수 있는 코드를 try 블록에 두고, 예상하는 예외 타입을 except로 잡는 것이다.
def divide(a, b):
try:
return a / b
except ZeroDivisionError:
print("0으로 나눌 수 없습니다.")
return None
print(divide(10, 0))
print(divide(10, 2))
0으로 나눌 수 없습니다.
None
5.0
실패할 수 있는 지점이 여러 개면 except 절을 여러 번 두어 각 예외를 서로 다르게 처리한다. 첫 번째로 일치하는 except만 실행되므로, 더 구체적인 예외를 먼저 두고 넓은 예외를 나중에 둔다.
def read_amount():
try:
raw = input("금액을 입력하세요: ")
return 10 / int(raw)
except ValueError:
print("숫자만 입력해주세요!")
except ZeroDivisionError:
print("0으로 나눌 수 없습니다!")
except Exception as exc:
# 예상하지 못한 나머지 예외. 원인 파악을 위해 메시지를 남긴다
print("예상하지 못한 오류:", exc)
except Exception은 대부분의 오류를 잡지만KeyboardInterrupt·SystemExit처럼 프로그램 종료 신호에 쓰이는 예외는BaseException만 상속하고Exception은 상속하지 않아 잡히지 않는다. 이는 우연이 아니라 Ctrl+C 같은 종료 신호를 일반 오류 처리 코드가 삼켜버리지 않게 하려는 의도된 설계다. 예외 없이 모든 것을 잡는except:(bare except)는 이 구분마저 없애므로 쓰지 않는다.
else와 finally: 성공했을 때와 항상 실행할 때
else 블록은 try 블록에서 예외가 전혀 발생하지 않았을 때만 실행된다. finally 블록은 예외 발생 여부, return 실행 여부와 무관하게 항상 실행되며 파일 닫기·연결 해제처럼 반드시 정리해야 하는 자원에 쓴다.
def read_config(path):
try:
f = open(path)
except FileNotFoundError:
print("설정 파일이 없습니다.")
return None
else:
print("파일을 정상적으로 열었습니다.")
content = f.read()
f.close()
return content
finally:
print("read_config 종료")
read_config("no_such_file.txt")
설정 파일이 없습니다.
read_config 종료
try 블록에는 실패할 가능성이 있는 최소한의 코드만 두고, 성공했을 때만 할 후속 작업은 else로 분리하면 어떤 코드가 예외를 유발할 수 있는지 한눈에 구분된다. 파일을 with 문으로 열면 finally 없이도 자동으로 닫히므로, 파일·소켓처럼 컨텍스트 매니저를 지원하는 자원은 with를 우선한다.
raise로 예외를 직접 일으키고 전달하기
raise는 예외를 직접 발생시킨다. 함수 안에서 조건을 검증하다 규칙을 어긴 값을 만나면 그 자리에서 예외를 일으켜 잘못된 값이 뒤로 흘러가지 않게 막는다.
def check_age(age):
if age < 0:
raise ValueError("나이는 0 이상이어야 합니다.")
return age
try:
check_age(-5)
except ValueError as exc:
print("오류:", exc)
오류: 나이는 0 이상이어야 합니다.
낮은 수준의 예외를 잡아 더 의미 있는 예외로 바꿔 다시 던질 때는 raise 새예외 from 원본예외 형태로 원인을 함께 남긴다. 그냥 raise 새예외만 쓰면 원래 무엇 때문에 실패했는지 흔적이 사라진다.
def load_row(raw):
try:
return int(raw)
except ValueError as exc:
raise DataValidationError("id", raw) from exc
try:
load_row("abc")
except DataValidationError as exc:
print("id 오류:", exc)
print("원인:", exc.__cause__)
id 오류: id='abc' 검증 실패
원인: invalid literal for int() with base 10: 'abc'
DataValidationError는 다음 절에서 정의한다. from exc로 연결하면 예외의 __cause__ 속성과 트레이스백에 원본 예외가 함께 남아, 로그만 보고도 근본 원인을 추적할 수 있다.
사용자 정의 예외 작성
내장 예외로 표현하기 어려운 도메인 규칙은 Exception(또는 그 하위 클래스)을 상속해 이름 있는 예외로 만든다. 예외 이름 자체가 무엇이 잘못됐는지 설명하는 문서 역할을 한다.
class DataValidationError(ValueError):
"""분석 파이프라인에서 데이터 검증에 실패했을 때 발생시키는 예외."""
def __init__(self, column, value):
self.column = column
self.value = value
super().__init__(f"{column}={value!r} 검증 실패")
def validate_amount(row):
if row["amount"] < 0:
raise DataValidationError("amount", row["amount"])
return row
try:
validate_amount({"amount": -100})
except DataValidationError as exc:
print("검증 오류:", exc)
print("문제 열:", exc.column)
검증 오류: amount=-100 검증 실패
문제 열: amount
Exception을 직접 상속하는 대신 ValueError처럼 의미가 가까운 내장 예외를 상속하면, 아직 이 사용자 정의 예외를 모르는 호출 코드도 except ValueError로 넓게 잡을 수 있어 호환성이 좋아진다. column·value처럼 원인 파악에 필요한 정보를 속성으로 남겨 두면, 예외 메시지를 문자열로 파싱하지 않고도 호출하는 쪽에서 그 값을 바로 활용할 수 있다.
실습: 파일 로딩 함수에 실패 경계 만들기
여러 종류의 실패가 있는 함수를 감쌀 때는 각 실패에 맞는 except를 따로 두고, 실패 여부와 무관하게 남겨야 하는 기록은 finally에 둔다. 자세한 로그 기록 방법은 다음 글에서 다루므로 여기서는 print로 표시한다.
import pandas as pd
class DataValidationError(ValueError):
def __init__(self, column, value):
self.column = column
self.value = value
super().__init__(f"{column}={value!r} 검증 실패")
def safe_load(path):
try:
df = pd.read_parquet(path)
if df.empty:
raise DataValidationError("rows", 0)
return df
except FileNotFoundError:
print(f"[오류] 파일 없음: {path}")
return None
except DataValidationError as exc:
print(f"[경고] {exc}")
return None
finally:
print(f"[정보] 로딩 시도 종료: {path}")
print(safe_load("data/sales.parquet"))
print("---")
print(safe_load("data/missing.parquet"))
[정보] 로딩 시도 종료: data/sales.parquet
id product amount region
0 1 노트북 1250000 서울
1 2 키보드 89000 부산
2 3 모니터 310000 서울
---
[오류] 파일 없음: data/missing.parquet
[정보] 로딩 시도 종료: data/missing.parquet
None
FileNotFoundError(파일이 아예 없음)와 DataValidationError(파일은 있지만 내용이 비었음)는 서로 원인이 다르므로 따로 잡아 각각 다르게 대응한다. finally는 성공·실패와 무관하게 항상 마지막에 실행되어, 로딩을 시도했다는 사실 자체를 빠짐없이 남긴다.
try·except·else·finally 정리
절 | 실행 시점 | 쓰임 |
|---|---|---|
try | 실패할 수 있는 코드를 감싼다 | 실패 가능 범위를 최소한으로 좁힌다 |
except | 지정한 예외(의 하위 클래스)가 발생했을 때 | 예상한 실패를 원인별로 다르게 처리한다 |
else | try 블록이 예외 없이 끝났을 때만 | 성공했을 때만 필요한 후속 작업을 분리한다 |
finally | 성공·실패·return과 무관하게 항상 | 파일 닫기 등 반드시 해야 하는 정리를 보장한다 |
자주 발생하는 문제
except:처럼 예외 타입 없이 전부 잡는 bare except는KeyboardInterrupt까지 삼켜 프로그램을 강제 종료하기 어렵게 만든다. 항상 구체적인 타입이나 최소한except Exception을 쓴다.예외를 잡아 놓고
pass만 하면 실패가 조용히 사라져 원인을 나중에 찾기 어렵다. 잡은 예외는 로그로 남기거나 다시 던진다.부모 예외 클래스(예:
ValueError)를 자식 예외(예:DataValidationError)보다 먼저except로 두면 자식용 절이 절대 실행되지 않는다. 구체적인 예외를 먼저 쓴다.raise로 새 예외를 던질 때from을 빼먹으면 원래 예외의 트레이스백이 끊겨 근본 원인을 찾기 어렵다.finally안에서 다시 예외가 발생하면 원래 예외를 덮어써 버릴 수 있다.finally블록은 실패 가능성이 없는 정리 코드로만 채운다.
다음 글: print 대신 logging을 사용해야 하는 이유
이번 글의 safe_load 예제는 print로 실패를 표시했다. 다음 글에서는 로그 레벨·핸들러·포매터로 print보다 추적 가능한 기록을 남기는 logging 모듈을 다루고, 여기서 만든 except 절들을 실제 로그 기록으로 바꾼다.
참고 자료
Python 공식 튜토리얼: Errors and Exceptions — try/except/else/finally, raise, 사용자 정의 예외 (2026-08-10 확인)
Python 공식 문서: 내장 예외 계층 — BaseException과 Exception의 상속 구조 (2026-08-10 확인)
Python 언어 레퍼런스: raise 문 — raise ... from을 통한 예외 체이닝 (2026-08-10 확인)
댓글 0
댓글을 불러오는 중…