파이썬 예외 처리 - try except else finally 와 EAFP 스타일 (파이썬 중급 10단원)
이 단원에서 배우는 것
09단원에서 주문 데이터를 Order와 OrderLine 클래스로 세우면서 __init__ 안에 검증을 넣고 raise ValueError(...)를 썼다. 던지는 쪽은 만들었지만 받는 쪽은 아직 없다. 이번 단원에서는 주문 로그 분석 도구가 잘못된 주문 한 건 때문에 통째로 죽지 않도록 예외를 받아 내는 구조를 만든다. 기준 버전은 파이썬 3.11이고, 3.11에서 새로 들어온 add_note와 ExceptionGroup도 다룬다.
try/except/else/finally네 블록이 각각 언제 실행되는지 알고 코드를 알맞은 블록에 배치할 수 있다- 도메인 예외 계층을 설계하고
raise ... from으로 원인을 잃지 않게 전달할 수 있다 - 미리 검사하는 방식(LBYL)과 일단 해보는 방식(EAFP) 중 파이썬에서 어느 쪽이 기본인지 판단 근거를 댈 수 있다
왜 필요한가
주문 로그 10만 줄을 읽어 집계하는 배치를 돌린다. 9만 번째 줄에 단가가 문자열로 들어온 주문이 하나 있다. 예외 처리가 없으면 배치는 그 줄에서 죽고, 앞의 8만 9999건에 대한 집계 결과도 같이 사라진다. 다시 돌려도 같은 자리에서 죽는다.
그렇다고 반대편 극단으로 가면 더 나쁘다.
for raw in raw_orders:
try:
process(raw)
except:
pass
이 코드는 죽지 않는다. 대신 틀린 답을 조용히 낸다. 절반이 실패해도 집계는 그럴듯한 숫자를 뱉고, 아무도 모른 채 그 숫자로 정산을 한다. 게다가 맨 마지막 except:는 KeyboardInterrupt까지 삼켜서 Ctrl+C로 중단도 안 된다.
예외 처리의 목표는 "에러를 없애는 것"이 아니다. 어디까지가 예상한 실패이고 어디부터가 버그인지 코드로 선을 긋는 것이다. 예상한 실패는 기록하고 넘어가고, 버그는 시끄럽게 죽어야 한다.
문법과 예제
네 블록의 역할
def parse_qty(text: str) -> int:
try:
qty = int(text) # 실패할 수 있는 코드만 넣는다
except ValueError:
print(f"수량 파싱 실패: {text!r}")
return 0
else:
print("파싱 성공") # 예외가 안 났을 때만 실행
return qty
finally:
print("항상 실행") # 성공하든 실패하든, return 하더라도 실행
print(parse_qty("3"))
print(parse_qty("삼"))
실행하면 이렇게 나온다.
파싱 성공
항상 실행
3
수량 파싱 실패: '삼'
항상 실행
0
else가 있는 이유는 try 블록을 좁게 유지하기 위해서다. 성공했을 때 할 일까지 try 안에 넣으면, 그 코드에서 우연히 난 ValueError까지 "수량 파싱 실패"로 잡혀 버린다. 실제로 겪으면 원인을 찾는 데 몇 시간이 든다. try 블록에는 예외가 날 것으로 예상하는 한두 줄만 넣는다.
여러 예외를 구분해서 잡기
def parse_line(raw: dict) -> tuple[str, int, int]:
try:
sku = raw["sku"]
qty = int(raw["qty"])
price = int(raw["unit_price"])
except KeyError as e:
raise ValueError(f"필수 항목 누락: {e.args[0]}") from e
except (ValueError, TypeError) as e:
raise ValueError(f"숫자 변환 실패: {raw!r}") from e
return sku, qty, price
print(parse_line({"sku": "BOOK-01", "qty": "2", "unit_price": "12000"}))
# ('BOOK-01', 2, 12000)
주의할 점은 except 절이 위에서부터 순서대로 검사된다는 것이다. 넓은 예외를 위에 두면 아래가 영영 실행되지 않는다. except Exception을 except ValueError보다 위에 쓰면 ValueError 절은 죽은 코드다.
도메인 예외 계층
표준 예외를 그대로 던지면, 호출하는 쪽에서 "내 코드가 낸 실패"와 "라이브러리가 낸 실패"를 구분할 수 없다. 도구마다 최상위 예외를 하나 두고 그 아래로 뻗는 것이 실무 관행이다.
class OrderError(Exception):
"""주문 처리 도구가 내는 모든 예외의 최상위."""
class InvalidOrderError(OrderError):
"""주문 데이터 자체가 규칙에 맞지 않는다. 재시도해도 소용없다."""
def __init__(self, order_id: str, reason: str):
super().__init__(f"주문 {order_id} 처리 불가: {reason}")
self.order_id = order_id
self.reason = reason
class OrderStoreError(OrderError):
"""저장소 접근 실패. 재시도할 가치가 있다."""
def load_order(raw: dict) -> dict:
order_id = raw.get("id", "UNKNOWN")
if not raw.get("lines"):
raise InvalidOrderError(order_id, "주문 항목이 비어 있다")
return raw
try:
load_order({"id": "ORD-1001", "lines": []})
except InvalidOrderError as e:
print(e) # 주문 ORD-1001 처리 불가: 주문 항목이 비어 있다
print(e.order_id) # ORD-1001
예외 클래스에 order_id 같은 속성을 붙여 두는 것이 핵심이다. 메시지 문자열에서 주문 번호를 다시 정규식으로 뽑아내는 코드를 짜게 되는 순간, 설계가 잘못된 것이다. 재시도 가능한 실패와 불가능한 실패를 다른 클래스로 나누는 것도 실무에서 바로 값을 한다. 호출하는 쪽이 except OrderStoreError만 재시도하면 되기 때문이다.
raise ... from 으로 원인 유지하기
try:
int("삼")
except ValueError as e:
raise InvalidOrderError("ORD-1001", "수량이 숫자가 아니다") from e
이렇게 하면 트레이스백에 원래 ValueError가 The above exception was the direct cause of...와 함께 남는다. from e를 빼도 파이썬이 During handling of the above exception...으로 자동 연결하지만, 의도한 변환인지 처리 중 사고인지 구분이 안 된다. 원인을 일부러 끊고 싶을 때는 from None을 쓴다. 내부 구현의 예외를 사용자에게 노출하기 싫을 때 쓴다.
3.11에서 들어온 add_note
예외를 잡아서 맥락만 덧붙이고 그대로 다시 던지고 싶을 때가 있다. 예전에는 새 예외로 감싸는 수밖에 없었다.
def process(raw: dict) -> None:
try:
int(raw["qty"])
except Exception as e:
e.add_note(f"주문 번호: {raw.get('id')}") # 파이썬 3.11+
raise
process({"id": "ORD-1001", "qty": "삼"})
트레이스백 맨 아래에 주문 번호: ORD-1001이 붙어서 출력된다. 예외 타입을 바꾸지 않으므로, 위쪽에서 except ValueError로 잡던 코드가 그대로 동작한다.
배치에서 실패를 모으는 패턴
주문 로그 분석 도구가 실제로 쓸 구조다. 한 건의 실패로 전체를 죽이지 않되, 실패를 조용히 버리지도 않는다.
from dataclasses import dataclass, field
@dataclass
class BatchResult:
ok: int = 0
failures: list[tuple[str, str]] = field(default_factory=list) # 09단원의 가변 기본값 규칙
def run_batch(raws: list[dict]) -> BatchResult:
result = BatchResult()
for raw in raws:
order_id = raw.get("id", "UNKNOWN")
try:
load_order(raw)
except InvalidOrderError as e:
result.failures.append((e.order_id, e.reason))
except OrderError as e:
result.failures.append((order_id, f"처리 실패: {e}"))
else:
result.ok += 1
return result
raws = [
{"id": "ORD-1", "lines": [{"sku": "BOOK-01"}]},
{"id": "ORD-2", "lines": []},
{"id": "ORD-3", "lines": [{"sku": "PEN-07"}]},
]
r = run_batch(raws)
print(r.ok) # 2
print(r.failures) # [('ORD-2', '주문 항목이 비어 있다')]
except OrderError만 잡고 그 밖의 예외는 잡지 않은 것이 의도적이다. TypeError나 AttributeError가 났다면 그건 데이터 문제가 아니라 내 코드의 버그다. 버그는 배치를 세우고 트레이스백을 남겨야 고쳐진다.
ExceptionGroup 과 except* (3.11+)
여러 건을 병렬로 처리하다 보면 실패가 동시에 여러 개 난다. 3.11부터는 그것들을 하나로 묶어 던질 수 있다.
errors = [
InvalidOrderError("ORD-2", "주문 항목이 비어 있다"),
OrderStoreError("DB 연결 실패"),
]
try:
raise ExceptionGroup("배치 처리 중 실패", errors)
except* InvalidOrderError as eg:
print("데이터 오류:", [str(e) for e in eg.exceptions])
except* OrderStoreError as eg:
print("저장소 오류:", [str(e) for e in eg.exceptions])
except*는 except와 달리 여러 절이 모두 실행될 수 있다. 그룹 안에 두 종류가 섞여 있으면 두 절이 다 돈다. 다만 이 문법이 필요한 상황은 대부분 asyncio.TaskGroup을 쓸 때다. 일반 반복문 배치라면 앞의 BatchResult 방식이 읽기 쉽다. 표준 예외의 상속 관계는 내장 예외 계층도를 한 번 훑어 두면 어느 예외를 잡아야 할지 판단이 빨라진다.
EAFP: 파이썬의 기본값
두 스타일을 같은 문제에 놓고 보자.
raw = {"id": "ORD-1001", "qty": "2"}
# LBYL: 먼저 확인하고 뛰어든다
if "qty" in raw and raw["qty"].isdigit():
qty = int(raw["qty"])
else:
qty = 0
# EAFP: 일단 해보고 실패하면 처리한다
try:
qty = int(raw["qty"])
except (KeyError, ValueError, TypeError):
qty = 0
파이썬은 EAFP를 기본으로 친다. 이유가 취향이 아니라 두 가지 실질적인 것이다.
- 검사와 사용 사이에 상태가 바뀔 수 있다. 파일이 존재하는지 확인하고 여는 사이에 다른 프로세스가 지울 수 있다.
os.path.exists후open은 이 문제를 못 막고,try: open(...) except FileNotFoundError는 막는다. - LBYL은 조건을 빠뜨리기 쉽다. 위 코드에서
raw["qty"]가None이면isdigit에서AttributeError가 난다. 음수 문자열"-1"도isdigit()이False다.
대신 예외가 흔하게 발생하는 경로라면 이야기가 다르다. 파이썬에서 예외는 발생하지 않을 때는 거의 공짜지만, 실제로 던지고 잡는 비용은 if보다 훨씬 크다. 10만 줄 중 9만 줄이 실패하는 구조라면 조건문으로 거르는 편이 빠르다.
실무에서 자주 틀리는 것
1. except Exception 으로 뭉뚱그리고 로그에 메시지만 남긴다
try:
process(raw)
except Exception as e:
print(f"에러: {e}") # 어디서 났는지가 통째로 사라진다
str(e)에는 트레이스백이 없다. KeyError는 str()이 키 이름 하나뿐이라 에러: 'qty'만 남고, 어느 파일 몇 번째 줄인지 알 길이 없다. 로깅 모듈을 쓴다면 logging.exception("주문 처리 실패: %s", order_id)로 남긴다. except 블록 안에서 부르면 트레이스백이 자동으로 붙는다. 표준 출력만 쓸 상황이면 traceback.print_exc()를 쓴다.
2. finally 안에서 return 하거나 예외를 던진다
def load(raw):
try:
raise InvalidOrderError("ORD-1", "항목 없음")
finally:
return -1 # 예외가 통째로 사라진다
print(load({})) # -1 ... 예외는 어디로 갔나
finally의 return은 진행 중이던 예외를 말없이 삼킨다. break와 continue도 같다. 실제 사고는 정리 코드에서 더 자주 난다. finally: conn.close()인데 conn이 None이라 AttributeError가 나면, 원래의 진짜 원인이 그 AttributeError에 덮인다. 정리 코드는 with 문에 맡기고, finally에는 실패할 수 없는 코드만 넣는다.
3. 예외를 흐름 제어에 남용해서 버그를 감춘다
def get_total(order_id):
try:
order = repo.find(order_id)
return order.total
except Exception:
return 0 # 주문이 없어도 0, 코드가 터져도 0
주문이 없어서 0인지, total 계산에 버그가 있어서 0인지 구분이 안 된다. 이런 함수가 정산 코드에 들어가면 손실 금액이 조용히 발생한다. "없음"은 예외가 아니라 값으로 표현하는 편이 낫다. None을 반환하거나 Optional을 쓰고, 진짜 예외는 그대로 통과시킨다.
4. 사용자 정의 예외에서 super().__init__ 을 안 부른다
class InvalidOrderError(Exception):
def __init__(self, order_id, reason):
self.order_id = order_id
self.reason = reason # super().__init__ 호출 없음
e1 = InvalidOrderError("ORD-1", "항목 없음") # 위치 인자로 생성
print(str(e1)) # ('ORD-1', '항목 없음') ... 튜플이 그대로 찍힌다
e2 = InvalidOrderError(order_id="ORD-1", reason="항목 없음") # 키워드 인자로 생성
print(str(e2)) # '' ... 아무것도 안 찍힌다
같은 클래스인데 생성 방식에 따라 결과가 다르다. Exception.__str__은 args를 보고 만드는데, args를 채우는 것은 __init__이 아니라 __new__이고 __new__는 위치 인자만 받아 둔다. 그래서 위치 인자로 만들면 읽기 나쁜 튜플이 남고, 키워드 인자로 만들면 로그에 예외 메시지가 통째로 빈칸으로 찍힌다. 후자는 장애 대응 중에 로그를 봐도 아무 정보가 없는 상황을 만든다.
해결은 super().__init__(사람이 읽을 메시지)를 반드시 부르는 것이다. 앞의 InvalidOrderError 예제가 그렇게 돼 있다. 09단원에서 본 super() 누락과 같은 실수인데, 예외 클래스에서는 증상이 "에러가 난다"가 아니라 "로그가 비어 있다"로 나타나서 원인을 찾기가 더 어렵다.
스스로 확인하기
- 아래 함수는
"12000"을 넣어도 항상 0을 반환한다. 왜 그런지 설명하고 고쳐라.def to_won(text): try: return int(text) except ValueError: return 0 finally: return 0 - 주문 저장소가 일시적인 네트워크 오류로 실패할 수 있다. 이 실패는 3번까지 재시도하고, 주문 데이터 자체가 잘못된 경우는 재시도하지 않으려 한다. 예외 클래스를 어떻게 나누고, 재시도 코드는 어느 예외만 잡아야 하는가?
if "qty" in raw:로 확인한 뒤int(raw["qty"])를 호출하는 코드가 여전히 예외로 죽을 수 있는 입력을 하나 들어라.
정답
finally블록의return은try블록의return값과 진행 중인 예외를 모두 덮어쓴다. 성공 경로에서 반환한 12000도 버려진다.finally의return 0을 지우면 된다. 일반화하면finally에는return,break,continue를 쓰지 않는다.OrderError를 최상위로 두고, 재시도 가능한OrderStoreError와 재시도 무의미한InvalidOrderError로 나눈다. 재시도 루프는except OrderStoreError만 잡는다.except OrderError로 잡으면 절대 성공할 수 없는 데이터 오류를 3번 반복해 처리 시간만 3배가 된다.{"qty": "삼"}이면ValueError,{"qty": None}이면TypeError,{"qty": "1.5"}도ValueError가 난다. 키의 존재만 확인해서는 값의 형태를 보장하지 못한다는 것이 LBYL의 전형적인 구멍이다.