파이썬(Python) 공부 9편
예외 처리 try/except 완전 정복
코드를 작성하다 보면 예상치 못한 오류가 반드시 생깁니다. 없는 파일을 열거나, 0으로 나누거나, 잘못된 타입의 값이 들어올 때 — 이런 상황에서 프로그램이 갑자기 멈추는 대신 우아하게 대응하게 만드는 것이 예외 처리입니다. try/except 기본부터 else·finally·raise·사용자 정의 예외까지 한 번에 정리합니다.
⚠ 예외(Exception)란 무엇인가
파이썬에서 프로그램 실행 중에 발생하는 오류를 예외(Exception)라고 합니다. 예외가 발생하면 파이썬은 즉시 실행을 멈추고 오류 메시지를 출력합니다. 예외 처리를 하지 않으면 프로그램이 강제 종료되지만, try/except 블록으로 감싸면 예외를 잡아서 처리하고 프로그램을 계속 실행할 수 있습니다.
# 예외 처리 없음 → 프로그램 강제 종료
result = 10 / 0
print(result)
# ZeroDivisionError: division by zero ← 여기서 프로그램 종료!
# 아래 코드는 절대 실행 안 됨
# 예외 처리 있음 → 오류 잡고 계속 실행
try:
result = 10 / 0
except ZeroDivisionError:
print("0으로 나눌 수 없습니다")
print("프로그램이 계속 실행됩니다")
# 0으로 나눌 수 없습니다
# 프로그램이 계속 실행됩니다
📋 try/except/else/finally — 전체 구조 한눈에
def divide(a, b):
try:
result = a / b # 예외 가능 코드
except ZeroDivisionError:
print("0으로 나눌 수 없음")
return None
else:
print(f"성공: {a} ÷ {b} = {result}") # 예외 없을 때만
return result
finally:
print("계산 시도 완료") # 항상 실행
divide(10, 2)
# 성공: 10 ÷ 2 = 5.0
# 계산 시도 완료
divide(10, 0)
# 0으로 나눌 수 없음
# 계산 시도 완료 ← finally는 여기서도 실행됨
🔍 자주 만나는 내장 예외 종류
파이썬에는 수십 가지 내장 예외가 있습니다. 처음에는 자주 마주치는 것들만 알아도 충분합니다.
| 예외 클래스 | 발생 상황 | 예시 코드 |
|---|---|---|
| ValueError | 값의 타입은 맞지만 값 자체가 잘못됨 | int("abc") |
| TypeError | 잘못된 타입으로 연산 시도 | "2" + 2 |
| ZeroDivisionError | 0으로 나누기 | 10 / 0 |
| IndexError | 리스트 인덱스 범위 초과 | lst[100] |
| KeyError | 딕셔너리에 없는 키 접근 | d["없는키"] |
| AttributeError | 없는 속성·메서드 접근 | None.upper() |
| FileNotFoundError | 존재하지 않는 파일 열기 | open("없는파일") |
| NameError | 정의되지 않은 변수 사용 | print(미정의변수) |
| ImportError | 모듈 import 실패 | import 없는모듈 |
| StopIteration | 반복자가 더 이상 값 없음 | next(빈이터레이터) |
| RecursionError | 재귀 깊이 한도 초과 | 종료 조건 없는 재귀 함수 |
| MemoryError | 메모리 부족 | 매우 큰 데이터 생성 시 |
- 모든 예외의 최상위 클래스는 BaseException
- 일반적인 프로그램 오류는 Exception을 상속함
- except Exception은 대부분의 일반 예외를 잡을 수 있음
- except: (예외 없이) 또는 except BaseException은 KeyboardInterrupt·SystemExit까지 잡아버려 권장하지 않음
🔄 except 심화 — 여러 예외 처리하기
# ① 여러 except — 예외 종류별로 다르게 처리
def safe_convert(value, divisor):
try:
num = int(value) # ValueError 가능
return num / divisor # ZeroDivisionError 가능
except ValueError:
print(f"'{value}'은 숫자로 변환 불가")
except ZeroDivisionError:
print("divisor가 0입니다")
safe_convert("abc", 2) # 'abc'은 숫자로 변환 불가
safe_convert("10", 0) # divisor가 0입니다
# ② 한 except에서 여러 예외 동시 처리 (튜플)
try:
x = int("bad")
except (ValueError, TypeError) as e:
print(f"변환 오류: {e}")
# ③ as e — 예외 객체를 변수로 받아 상세 정보 확인
try:
lst = [1, 2, 3]
print(lst[10])
except IndexError as e:
print(f"예외 타입: {type(e).__name__}") # IndexError
print(f"메시지: {e}") # list index out of range
# ④ 가장 넓은 예외 처리 — 마지막에 배치
try:
result = risky_operation()
except FileNotFoundError:
print("파일 없음")
except ValueError:
print("값 오류")
except Exception as e:
print(f"예상치 못한 오류: {e}") # 위에서 못 잡은 나머지
- except: pass — 모든 예외를 조용히 무시. 버그가 숨어도 알 방법이 없어 실무에서 최악의 패턴으로 꼽힌다
- except Exception: pass — 위와 동일한 문제. 예외를 처리하는 척하면서 실제론 아무것도 안 함
- 올바른 방향: 최소한 logging.exception(e)나 print(e)라도 남겨서 어디서 무슨 오류가 났는지 추적 가능하게 할 것
✅ else와 finally — 언제 실행되나
else는 try 블록에서 예외가 발생하지 않았을 때만 실행됩니다. "성공했을 때 추가 작업"을 try 안에 몰아넣지 않고 else로 분리하면 코드 의도가 명확해집니다.
- try에 몰아넣으면: 성공 시 작업에서 예외가 나도 except가 잡아버림 → 의도치 않은 동작
- else로 분리하면: try는 "예외가 날 수 있는 부분"만 담당, else는 "성공 후 작업" 담당 → 역할 분리 명확
finally는 예외가 나든 안 나든, return을 만나든 안 만나든 반드시 실행됩니다. 파일 닫기, DB 연결 해제, 네트워크 소켓 닫기처럼 반드시 정리해야 하는 작업에 사용합니다.
- try 안에서
return을 만나도 finally는 실행됨 - except에서 예외를 다시 raise해도 finally는 실행됨
- 파일·DB 작업에서
with문이 finally와 같은 역할을 자동으로 처리해줌
import json
def load_config(path: str):
try:
with open(path, encoding="utf-8") as f:
data = json.load(f) # FileNotFoundError / JSONDecodeError 가능
except FileNotFoundError:
print(f"설정 파일 없음: {path}")
return {}
except json.JSONDecodeError as e:
print(f"JSON 파싱 오류: {e}")
return {}
else:
print(f"설정 로드 성공: {len(data)}개 항목") # 성공 시만
return data
finally:
print("load_config 완료") # 항상 실행
# 흐름 정리:
# 파일 있고 JSON 정상 → try 실행 → else 실행 → finally 실행
# 파일 없음 → try → FileNotFoundError → except 실행 → finally 실행
# JSON 파싱 오류 → try → JSONDecodeError → except 실행 → finally 실행
🚀 raise — 예외를 직접 발생시키기
raise는 코드에서 직접 예외를 발생시킬 때 사용합니다. 함수에 잘못된 인수가 전달됐을 때, 비즈니스 규칙을 위반했을 때 등 호출자에게 "이 상황은 허용되지 않는다"라고 알릴 때 씁니다.
# ① raise — 예외 직접 발생
def set_age(age: int):
if not isinstance(age, int):
raise TypeError(f"age는 int여야 합니다. 받은 타입: {type(age).__name__}")
if age < 0 or age > 150:
raise ValueError(f"나이는 0~150 사이여야 합니다: {age}")
print(f"나이 설정: {age}")
try:
set_age(-5)
except ValueError as e:
print(f"입력 오류: {e}")
# 입력 오류: 나이는 0~150 사이여야 합니다: -5
# ② raise ... from — 예외 연쇄 (원인 예외 명시)
def load_user(user_id: int):
try:
data = fetch_from_db(user_id)
except ConnectionError as e:
raise RuntimeError("유저 로드 실패") from e # 원인 예외 연결
# ③ raise (인수 없이) — 잡은 예외 다시 던지기
try:
result = 10 / 0
except ZeroDivisionError:
print("로그 기록 후 재발생")
raise # 같은 예외를 다시 발생시킴
🔧 사용자 정의 예외 — 나만의 예외 클래스 만들기
내장 예외만으로는 표현하기 어려운 비즈니스 로직 오류가 있을 때 사용자 정의 예외 클래스를 만들 수 있습니다. Exception을 상속하는 것이 기본 원칙이며, 예외 이름은 관례상 Error로 끝내는 것이 일반적입니다.
# ① 기본 사용자 정의 예외
class InsufficientBalanceError(Exception):
"""잔액 부족 예외"""
pass
# ② 속성을 추가한 예외
class ValidationError(Exception):
"""유효성 검사 실패 예외"""
def __init__(self, field: str, message: str):
self.field = field
self.message = message
super().__init__(f"[{field}] {message}")
# ③ 예외 계층 구성
class AppError(Exception):
"""앱 기본 예외"""
pass
class DatabaseError(AppError): # AppError 상속
pass
class NetworkError(AppError): # AppError 상속
pass
# ④ 활용 예시
def withdraw(balance: int, amount: int):
if amount <= 0:
raise ValidationError("amount", "출금액은 0보다 커야 합니다")
if amount > balance:
raise InsufficientBalanceError(
f"잔액 부족: 잔액 {balance}원, 출금 시도 {amount}원"
)
return balance - amount
try:
new_balance = withdraw(10000, 15000)
except ValidationError as e:
print(f"유효성 오류 — 필드: {e.field}, 사유: {e.message}")
except InsufficientBalanceError as e:
print(f"출금 실패: {e}")
# 출금 실패: 잔액 부족: 잔액 10000원, 출금 시도 15000원
💻 실전 패턴 — 예외 처리를 잘 쓰는 방법
import logging
# 패턴 1: 기본값 반환 (EAFP — 허락보다 용서를 구하라)
def get_value(d: dict, key: str, default=None):
try:
return d[key]
except KeyError:
return default
# 패턴 2: 재시도 (retry)
import time
def fetch_with_retry(url: str, retries: int = 3):
for attempt in range(retries):
try:
return http_get(url)
except ConnectionError as e:
print(f"시도 {attempt+1}/{retries} 실패: {e}")
if attempt == retries - 1:
raise # 마지막 시도면 예외 재발생
time.sleep(2 ** attempt) # 지수 백오프
# 패턴 3: 컨텍스트 매니저에서의 예외 처리
def process_file(path: str):
try:
with open(path, encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
logging.warning(f"파일 없음: {path}")
return ""
except PermissionError:
logging.error(f"읽기 권한 없음: {path}")
return ""
# 패턴 4: 예외 무시 (suppress) — contextlib 활용
from contextlib import suppress
with suppress(FileNotFoundError):
open("없어도_괜찮은_파일.txt").close()
# FileNotFoundError 발생해도 조용히 무시 (suppress 사용이 pass보다 의도 명확)
📝 9편 실습 문제
- 사용자 입력 문자열을 받아 정수로 변환하는 함수 safe_int(value, default=0) 작성
- 변환 불가 시 default 반환, 성공 시 변환된 정수 반환
- 음수 입력 시 ValueError를 raise로 발생시키기
- AgeError 사용자 정의 예외 클래스 만들기 (min_age, max_age 속성 포함)
- 나이 범위 검증 함수에 적용하고, 예외를 잡아서 사용자 친화적 메시지 출력
# 실습 1 — safe_int
def safe_int(value: str, default: int = 0) -> int:
try:
result = int(value)
except (ValueError, TypeError):
return default
else:
if result < 0:
raise ValueError(f"음수는 허용되지 않습니다: {result}")
return result
print(safe_int("42")) # 42
print(safe_int("abc")) # 0 (default)
print(safe_int("3.14")) # 0 (float 문자열은 int 변환 불가)
try:
safe_int("-5")
except ValueError as e:
print(e) # 음수는 허용되지 않습니다: -5
# 실습 2 — AgeError 사용자 정의 예외
class AgeError(ValueError):
def __init__(self, age: int, min_age: int, max_age: int):
self.age = age
self.min_age = min_age
self.max_age = max_age
super().__init__(f"나이 {age}는 허용 범위 {min_age}~{max_age}를 벗어남")
def check_age(age: int, min_age: int = 0, max_age: int = 120):
if not (min_age <= age <= max_age):
raise AgeError(age, min_age, max_age)
return age
try:
check_age(200)
except AgeError as e:
print(f"오류: {e}")
print(f"입력값 {e.age}은 최대 {e.max_age}세를 초과합니다")
# 오류: 나이 200는 허용 범위 0~120를 벗어남
# 입력값 200은 최대 120세를 초과합니다
- try/except 기본: try에 예외 가능 코드, except에 처리 코드 — 예외 발생 시 프로그램 종료 대신 우아하게 대응
- 여러 except: 예외 종류별로 다른 블록 / 튜플로 묶어서 동시 처리 / as e로 예외 객체 받기
- else 블록: try가 예외 없이 완료됐을 때만 실행 — 성공 시 작업과 예외 처리 명확히 분리
- finally 블록: 예외·return 여부 무관하게 항상 실행 — 파일·DB·네트워크 연결 정리에 사용
- 안티패턴: except: pass는 버그를 숨김 — 최소한 로그나 print라도 남길 것
- raise: 직접 예외 발생 / raise ... from e로 원인 연결 / 인수 없는 raise로 재발생
- 사용자 정의 예외: Exception 상속 / __init__에서 속성 추가 / 계층 구조로 구성 가능
- contextlib.suppress: 특정 예외를 조용히 무시할 때 pass보다 의도가 명확
- EAFP 원칙: 파이썬은 "미리 검사(LBYL)"보다 "시도 후 예외 처리(EAFP)"를 더 파이썬스럽게 봄
class 정의 / __init__ / 인스턴스·클래스 변수 / 상속 / 캡슐화 / 매직 메서드