모던 파이썬 기초 #6 에러와 예외 처리
#5 함수 — 인자 패턴에서 함수의 표현력을 잡았습니다. 이번 글은 그 함수가 실패할 때 어떻게 다루는가 — 예외 처리입니다.
파이썬은 **예외(exception)**로 오류를 표현하는 언어입니다. 다른 언어처럼 에러 코드를 반환하는 스타일이 아니라, “비정상 상황은 던지고, 받을 사람은 잡는다” 가 기본입니다. EAFP (Easier to Ask for Forgiveness than Permission)라는 표현으로 자주 부릅니다.
가장 간단한 try / except #
def divide(a: int, b: int) -> float:
try:
return a / b
except ZeroDivisionError:
return float("inf")
print(divide(10, 2)) # 5.0
print(divide(10, 0)) # inf문법:
try:블록 안에서 예외가 발생할 수 있는 코드를 실행except 예외타입:으로 잡음- 잡지 못한 예외는 위로 전파 (caller로 올라감)
LBYL vs EAFP #
다른 언어는 보통 “쓰기 전에 확인” (LBYL — Look Before You Leap) 스타일입니다.
if b != 0:
result = a / b
else:
result = float("inf")파이썬은 EAFP가 더 관용적입니다.
try:
result = a / b
except ZeroDivisionError:
result = float("inf")이유: 파이썬은 동적 타입이라 “조건을 정확히 미리 확인” 하기 어려운 경우가 많고, 예외 처리 비용도 (예외가 자주 일어나는 경우가 아니면) 낮습니다.
여러 예외 잡기 #
def parse_int(value: str) -> int | None:
try:
return int(value)
except (ValueError, TypeError):
return None
print(parse_int("42")) # 42
print(parse_int("abc")) # None
print(parse_int(None)) # None(예외1, 예외2) tuple로 묶어서 잡습니다.
다른 처리를 하고 싶으면 분리 #
try:
do_work()
except ValueError as e:
print(f"잘못된 값: {e}")
except KeyError as e:
print(f"키 없음: {e}")
except Exception as e:
print(f"그 외: {e}")
raise # 다시 던짐as e로 예외 객체를 받습니다. 메시지(str(e))와 원인(e.__cause__) 같은 정보를 꺼낼 수 있습니다.
else와 finally
#
try에는 두 개의 추가 절이 있습니다.
def read_file(path: str) -> str:
f = None
try:
f = open(path)
data = f.read()
except FileNotFoundError:
return ""
else:
# 예외 없이 try가 끝났을 때만 실행
print(f"읽기 성공: {len(data)} bytes")
return data
finally:
# 예외 발생 여부와 무관하게 항상 실행
if f is not None:
f.close()else—try가 예외 없이 끝났을 때만. “성공 시에만 할 일"이 있는 경우finally— 예외 여부와 무관하게 항상. 자원 정리(파일 close, 락 해제)에 씀
위 코드는 사실 with 문으로 더 깔끔하게 쓸 수 있습니다.
def read_file(path: str) -> str:
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
return ""with가 finally의 close 책임을 가져갑니다. 다음 시리즈(파이썬 중급)에서 컨텍스트 매니저로 자세히 다룰 주제입니다.
raise — 직접 던지기
#
def withdraw(balance: int, amount: int) -> int:
if amount <= 0:
raise ValueError("출금액은 양수여야 합니다")
if amount > balance:
raise ValueError(f"잔액 부족: {balance} < {amount}")
return balance - amountraise 다음에 예외 인스턴스 (또는 클래스)를 적습니다. raise ValueError (클래스만)도 가능하지만, 메시지를 같이 적는 raise ValueError("...")가 일반적입니다.
다시 던지기 #
except 안에서 처리는 했지만 호출자에게 알리고 싶을 때.
try:
do_work()
except KeyError:
log("키 없음 발생")
raise # 인자 없이 raise — 잡은 예외 그대로 다시 던짐raise만 적으면 현재 잡은 예외 그대로 위로 던집니다. trace가 보존됩니다.
raise ... from ... — 원인 연결
#
원래 예외를 다른 예외로 감싸고 싶을 때.
def load_config(path: str) -> dict:
try:
with open(path) as f:
return parse(f.read())
except (OSError, ValueError) as e:
raise ConfigError(f"설정 로드 실패: {path}") from efrom e가 있으면 traceback에 “이 예외가 일어난 직접 원인은 e” 라는 연결이 표시됩니다. 디버깅할 때 정말 유용합니다.
예외 계층 #
파이썬 예외는 클래스 계층입니다. 부모를 잡으면 자식도 다 잡힙니다.
BaseException
├─ SystemExit ← sys.exit()
├─ KeyboardInterrupt ← Ctrl+C
└─ Exception ← 보통 여기 아래만 잡음
├─ ValueError
│ └─ UnicodeError
├─ TypeError
├─ LookupError
│ ├─ KeyError
│ └─ IndexError
├─ ArithmeticError
│ └─ ZeroDivisionError
├─ OSError
│ └─ FileNotFoundError
└─ ...중요한 룰: 일반적인 코드에서는 except Exception 까지만 잡으세요. except BaseException 또는 모든 except:는 KeyboardInterrupt 같은 시스템 신호까지 삼켜서 Ctrl+C가 안 먹히는 사고를 만듭니다.
try:
risky()
except: # bare except — KeyboardInterrupt도 잡힘
pass
# 또는
except BaseException: # 같은 문제
passtry:
risky()
except Exception as e:
log(e)사용자 정의 예외 #
라이브러리/모듈을 만들 때, 자기만의 예외 클래스를 정의해 두는 게 좋습니다. 호출자가 정확히 무엇을 잡을지 명시할 수 있습니다.
class AppError(Exception):
"""이 앱의 모든 예외의 베이스."""
class ConfigError(AppError):
"""설정 관련 오류."""
class DatabaseError(AppError):
"""DB 관련 오류."""
class RowNotFoundError(DatabaseError):
"""필요한 행이 없음."""
def __init__(self, table: str, key: str):
super().__init__(f"{table}에서 {key} 못 찾음")
self.table = table
self.key = key호출 측은:
try:
user = repo.get_user("u1")
except RowNotFoundError as e:
return Response(status=404)
except DatabaseError as e:
log_critical(e)
return Response(status=500)각 호출 측에서 딱 잡고 싶은 만큼만 잡습니다.
finally의 함정 — return 우선순위
#
finally에서 return 하면 try의 return을 덮어씁니다. 거의 안티패턴입니다.
def f() -> int:
try:
return 1
finally:
return 2 # 이게 이김
print(f()) # 2finally는 부수 효과 정리만 하고, return이나 raise는 가능하면 안 하는 게 좋습니다.
Exception Group — except* (3.11+)
#
여러 작업이 동시에 실패할 수 있는 상황(asyncio의 TaskGroup, 병렬 작업)에서, 한 번에 여러 예외를 묶어서 던질 수 있는 새 메커니즘이 들어왔습니다. 이게 ExceptionGroup 이고, 그걸 잡는 문법이 except* 입니다.
errors = ExceptionGroup(
"여러 작업이 실패",
[ValueError("a"), TypeError("b"), ValueError("c")],
)
raise errorsexcept*는 그룹 안에서 해당 타입의 예외만 골라잡고, 나머지는 다시 던집니다.
try:
raise ExceptionGroup(
"여러 실패",
[ValueError("a"), TypeError("b"), ValueError("c")],
)
except* ValueError as eg:
print("값 오류들:", [str(e) for e in eg.exceptions])
except* TypeError as eg:
print("타입 오류들:", [str(e) for e in eg.exceptions])언제 만나나? **asyncio.TaskGroup**으로 동시에 여러 task를 돌릴 때, 그 중 둘 이상이 실패하면 자동으로 ExceptionGroup이 만들어집니다. 비동기는 다음 시리즈에서 다루지만, “여러 예외가 한꺼번에 올 수 있는 상황이 있다” 는 사실만 기억해 두세요.
assert — 개발 중 가설 점검
#
def calc_average(items: list[int]) -> float:
assert len(items) > 0, "빈 리스트는 평균을 못 구함"
return sum(items) / len(items)assert 조건, 메시지 — 조건이 False면 AssertionError가 납니다.
주의: python -O (optimization) 모드에서는 모든 assert가 제거됩니다. 그래서:
- ✅ 개발 중 가설 점검 (“이 시점에선 절대 None일 리 없다” 같은 것)
- ❌ 사용자 입력 검증 — 이건 항상 일반
if+raise로 해야 함
def serve(user: User | None):
# ✅ 시스템 가설: 인증 미들웨어가 None을 막았어야 함
assert user is not None, "auth middleware 누락?"
# ❌ 사용자 입력은 assert로 막으면 안 됨
# assert age >= 0, "음수 나이 불가" ← -O에서 사라짐
if age < 0:
raise ValueError("나이는 0 이상")좋은 예외 처리 감각 #
마지막으로 코드 리뷰에서 자주 등장하는 패턴 정리.
1) 너무 넓게 잡지 말 것 #
try:
process(item)
except Exception:
pass # 모든 예외를 무시 — 버그를 숨김try:
process(item)
except ValueError as e:
log(f"잘못된 항목: {e}")2) try 블록은 짧게 #
try:
a = compute()
b = transform(a)
c = validate(b)
d = save(c)
return d
except SomeError:
...SomeError가 어느 줄에서 났는지 모릅니다. 의심 가는 한 줄만 감싸세요.
3) pass만 적는 except는 의심하라
#
빈 except: pass는 거의 항상 버그의 시작입니다. 정말 무시해도 되는 경우라면 이유를 주석으로 남기거나, 적어도 로깅은 해주세요.
4) 일반 흐름에 예외 쓰지 않기 #
예외는 비정상 상황용입니다. 일반 분기까지 try/except로 풀면 코드를 읽는 사람이 함정에 빠집니다. (단, 파이썬은 EAFP가 관용적이라 어떤 경우는 그게 정상 — int("abc") 같은 변환 실패 처리 등.)
정리 #
이번 글에서 잡은 것:
- EAFP — 파이썬에서는 시도하고 잡는 게 더 자연스러운 경우가 많습니다
try/except/else/finally의 역할- 자원 정리는
finally보다 **with**가 보통 더 깔끔합니다 raise와raise ... from ...(원인 연결)except Exception까지만 —BaseException이나 bareexcept:는 안 됩니다- 사용자 정의 예외는 베이스 클래스를 두고 계층으로 만듭니다
assert는 가설 점검용, 입력 검증은 절대if + raiseExceptionGroup+except*(3.11+) — 여러 예외 동시 처리 (asyncio TaskGroup 등)
다음 글(#7 모듈/패키지와 pyproject.toml)에서는 여러 파일로 코드를 쪼개는 법 — import 시스템, 모듈과 패키지의 차이, __init__.py, __main__, 그리고 pyproject.toml까지 한곳에 정리합니다.