에러와 예외 처리
try/except/else/finally의 역할, raise와 사용자 정의 예외, 그리고 3.11이 가져온 ExceptionGroup과 except*까지 정리합니다.
5장 함수 — 인자 패턴에서 함수의 표현력을 잡았습니다. 본 챕터는 그 함수가 실패할 때 어떻게 다루는가 — 예외 처리입니다.
본 챕터의 마지막에 다루는 ExceptionGroup + except*는 14장 비동기 입문 (asyncio)과 18장 비동기 깊이의 TaskGroup에서 다시 만납니다. 비동기에서는 여러 작업이 동시에 진행되므로 예외도 여러 개가 함께 발생할 수 있고, 이때 ExceptionGroup이 필요합니다. 본 챕터에서 모양을 미리 잡아두면 14장을 더 쉽게 읽을 수 있습니다.
파이썬은 **예외(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 책임을 가져갑니다. 10장 컨텍스트 매니저 (with, contextlib)에서 자세히 다룹니다.
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이 만들어집니다. 14장 비동기 입문 (asyncio)에서 본 패턴을 다시 만나게 됩니다.
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는 거의 항상 버그의 시작입니다. 정말 무시해도 되는 경우라면 이유를 주석으로 남기거나, 적어도 로깅은 해주세요. 운영 환경의 로깅 표준은 31장 logging과 관측성에서 다룹니다.
4) 일반 흐름에 예외 쓰지 않기 #
예외는 비정상 상황 용입니다. 일반 분기까지 try / except로 풀면 코드를 읽는 사람이 함정에 빠집니다. (단, 파이썬은 EAFP가 관용적이라 어떤 경우는 그게 정상 — int("abc") 같은 변환 실패 처리 등.)
연습문제 #
def safe_int(value: str) -> int | None:시그니처로int(value)시도 후ValueError면None을 반환하는 함수를 작성하세요.TypeError(None이 넘어옴)도 같이 잡도록 tuple로 묶습니다.- 사용자 정의 예외 계층
AppError→ValidationError/NotFoundError를 만드세요.def find_user(id: int) -> User:가id <= 0이면ValidationError, DB에 없으면NotFoundError를 던지도록 작성합니다. 호출 측에서 두 예외를 각각 다른 메시지로 처리하는 코드를 작성하세요. ExceptionGroup을 직접 만들어 보세요. 세 작업의 결과를 모아 두 개 이상이ValueError면ExceptionGroup("일괄 실패", [...])으로 묶어 던지고, 호출 측은except* ValueError로 받아 메시지 목록을 출력합니다.
한 줄 요약: 파이썬은 EAFP — 시도하고 잡는 게 자연스럽다.
try/except/else/finally의 역할이 분명히 다르고, 자원 정리는 보통with가 더 깔끔.except Exception까지만, bare except와BaseException은 금지. 사용자 정의 예외는 계층으로,assert는 가설 점검용만. 3.11+ 의ExceptionGroup+except*는 비동기 동시 실패용.
다음 챕터 #
다음 7장 모듈, 패키지와 pyproject.toml에서는 여러 파일로 코드를 쪼개는 법 — import 시스템, 모듈과 패키지의 차이, __init__.py, __main__, 그리고 pyproject.toml까지 한곳에 정리합니다. 1부의 마지막 챕터입니다.