모던 파이썬 기초 #6 에러와 예외 처리

7 분 소요

#5 함수 — 인자 패턴에서 함수의 표현력을 잡았습니다. 이번 글은 그 함수가 실패할 때 어떻게 다루는가 — 예외 처리입니다.

파이썬은 **예외(exception)**로 오류를 표현하는 언어입니다. 다른 언어처럼 에러 코드를 반환하는 스타일이 아니라, “비정상 상황은 던지고, 받을 사람은 잡는다” 가 기본입니다. EAFP (Easier to Ask for Forgiveness than Permission)라는 표현으로 자주 부릅니다.

가장 간단한 try / except #

기본 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) 스타일입니다.

LBYL 스타일
if b != 0:
    result = a / b
else:
    result = float("inf")

파이썬은 EAFP가 더 관용적입니다.

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__) 같은 정보를 꺼낼 수 있습니다.

elsefinally #

try에는 두 개의 추가 절이 있습니다.

else / finally
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()
  • elsetry가 예외 없이 끝났을 때만. “성공 시에만 할 일"이 있는 경우
  • finally — 예외 여부와 무관하게 항상. 자원 정리(파일 close, 락 해제)에 씀

위 코드는 사실 with 문으로 더 깔끔하게 쓸 수 있습니다.

with — 자원 정리는 보통 이쪽
def read_file(path: str) -> str:
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError:
        return ""

withfinally의 close 책임을 가져갑니다. 다음 시리즈(파이썬 중급)에서 컨텍스트 매니저로 자세히 다룰 주제입니다.

raise — 직접 던지기 #

raise
def withdraw(balance: int, amount: int) -> int:
    if amount <= 0:
        raise ValueError("출금액은 양수여야 합니다")
    if amount > balance:
        raise ValueError(f"잔액 부족: {balance} < {amount}")
    return balance - amount

raise 다음에 예외 인스턴스 (또는 클래스)를 적습니다. raise ValueError (클래스만)도 가능하지만, 메시지를 같이 적는 raise ValueError("...")가 일반적입니다.

다시 던지기 #

except 안에서 처리는 했지만 호출자에게 알리고 싶을 때.

re-raise
try:
    do_work()
except KeyError:
    log("키 없음 발생")
    raise   # 인자 없이 raise — 잡은 예외 그대로 다시 던짐

raise만 적으면 현재 잡은 예외 그대로 위로 던집니다. trace가 보존됩니다.

raise ... from ... — 원인 연결 #

원래 예외를 다른 예외로 감싸고 싶을 때.

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 e

from 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:   # 같은 문제
    pass
✅ 모든 일반 예외 잡기
try:
    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())   # 2

finally부수 효과 정리만 하고, return이나 raise는 가능하면 안 하는 게 좋습니다.

Exception Group — except* (3.11+) #

여러 작업이 동시에 실패할 수 있는 상황(asyncio의 TaskGroup, 병렬 작업)에서, 한 번에 여러 예외를 묶어서 던질 수 있는 새 메커니즘이 들어왔습니다. 이게 ExceptionGroup 이고, 그걸 잡는 문법이 except* 입니다.

ExceptionGroup 만들기
errors = ExceptionGroup(
    "여러 작업이 실패",
    [ValueError("a"), TypeError("b"), ValueError("c")],
)
raise errors

except*는 그룹 안에서 해당 타입의 예외만 골라잡고, 나머지는 다시 던집니다.

except* 사용
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 — 개발 중 가설 점검 #

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로 해야 함
가설 vs 입력 검증
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 안에 너무 많음
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**가 보통 더 깔끔합니다
  • raiseraise ... from ... (원인 연결)
  • except Exception까지만 — BaseException이나 bare except:는 안 됩니다
  • 사용자 정의 예외는 베이스 클래스를 두고 계층으로 만듭니다
  • assert는 가설 점검용, 입력 검증은 절대 if + raise
  • ExceptionGroup + except* (3.11+) — 여러 예외 동시 처리 (asyncio TaskGroup 등)

다음 글(#7 모듈/패키지와 pyproject.toml)에서는 여러 파일로 코드를 쪼개는 법import 시스템, 모듈과 패키지의 차이, __init__.py, __main__, 그리고 pyproject.toml까지 한곳에 정리합니다.

X