왜 이 내용을 정리하나
데코레이터는 함수를 감싸서 로깅, 인자 검사, 재시도, 권한 확인 같은 공통 로직을 끼워 넣을 때 자주 쓰인다. 이때 래퍼 내부에서 발생한 예외가 원래 함수를 호출한 지점까지 그대로 올라오는지, 중간에서 사라지거나 다른 예외로 바뀌지는 않는지 알아두어야 한다. 특히 래퍼가 try/except로 예외를 잡고 있다면 어디서 잡고 어디서 다시 던지는지에 따라 호출자가 받는 결과가 달라진다. 이 노트에서는 데코레이터 래퍼에서 발생한 ValueError가 호출 지점까지 전파되는 과정을 try/except로 직접 확인해 본다.
개념 설명
파이썬에서 예외는 발생한 지점에서 호출 스택을 따라 위로 전파된다. 함수 A가 함수 B를 호출하고 B가 예외를 일으키면, B에 처리 구문이 없을 경우 예외는 A로 넘어간다. 데코레이터는 원래 함수를 감싼 래퍼 함수를 반환하므로, 호출 스택에는 원래 함수 앞에 래퍼가 한 층 더 끼어든다. 따라서 원래 함수에서 발생한 ValueError는 먼저 래퍼를 통과한 뒤 호출자에게 도달한다.
래퍼가 예외를 잡지 않고 호출만 하면 예외는 투명하게 통과한다. 반대로 래퍼가 except 절에서 예외를 잡고 아무것도 다시 던지지 않으면 호출자는 예외를 전혀 받지 못하고 함수는 None을 반환하는 것처럼 보인다. 또 except 절에서 다른 예외를 새로 발생시키면 원래 ValueError는 __context__로 연결되어 추적 정보에 남지만, 호출자가 잡는 예외 타입은 바뀐다. 이 세 경우를 구분해 확인하는 것이 이 노트의 목적이다.
코드 예제
아래 코드는 래퍼가 예외를 잡아 로그만 남기고 다시 던지는 경우, 그대로 통과시키는 경우, 예외를 삼켜 버리는 경우를 한 파일에서 비교한다.
python
import functools
def passthrough(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def logging_reraise(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except ValueError as e:
print(f'[log] {func.__name__} 에서 ValueError 발생: {e}')
raise
return wrapper
def swallowing(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except ValueError:
return None
return wrapper
@passthrough
def parse_a(text):
return int(text)
@logging_reraise
def parse_b(text):
return int(text)
@swallowing
def parse_c(text):
return int(text)
for name, fn in [('parse_a', parse_a), ('parse_b', parse_b), ('parse_c', parse_c)]:
try:
result = fn('abc')
print(f'{name} 결과: {result}')
except ValueError as e:
print(f'{name} 호출 지점에서 ValueError 잡음: {e}')
실행하면 parse_a는 래퍼를 그대로 통과한 ValueError를 호출 지점에서 잡는다. parse_b는 래퍼 안에서 먼저 로그를 찍은 뒤 raise로 같은 예외를 다시 던지므로 로그 한 줄과 함께 호출 지점에서도 같은 ValueError를 잡는다. parse_c는 래퍼가 예외를 삼키고 None을 반환하므로 호출 지점에서는 예외가 발생하지 않고 결과가 None으로 출력된다. 즉 세 경우 모두 래퍼의 except 처리 방식에 따라 호출자가 보는 결과가 달라진다는 것을 확인할 수 있다.
헷갈리는 점
첫째, 단순히 except 절에 raise를 쓰는 것과 raise e를 쓰는 것은 다르다. raise만 쓰면 원래 예외 객체와 추적 정보가 그대로 유지되지만, raise e로 다시 던지면 추적 정보에 현재 줄이 추가로 붙는다. 대부분의 경우 추적 정보를 보존하려면 raise만 쓰는 편이 낫다.
둘째, 래퍼가 except 절에서 다른 예외 타입으로 바꿔 던지면 원래 ValueError는 사라지지 않고 __context__에 남는다. 이 경우 호출자가 잡는 예외 타입은 바뀌므로 except ValueError로 잡던 코드가 동작하지 않을 수 있다. 예외 타입을 바꾸려면 raise NewError from e처럼 from 절로 원인을 명시하는 편이 추적하기 쉽다.
셋째, 래퍼가 값을 반환하지 않는 경우에도 조심해야 한다. 래퍼에서 return을 빠뜨리면 원래 함수가 정상 반환해도 호출자는 None을 받는다. 이것은 예외 문제가 아니지만 예외를 확인하는 것처럼 보여서 혼동하기 쉽다.
정리
데코레이터 래퍼 안에서 발생한 ValueError가 호출 지점까지 전파되는지는 래퍼의 except 처리 방식에 따라 결정된다. 예외를 잡지 않거나 잡고 raise로 다시 던지면 호출자는 같은 ValueError를 받고, 예외를 삼키면 호출자에게 아무 예외도 보이지 않는다. 예외 타입을 바꿔 던질 때는 from 절을 붙여 원인을 남기는 것이 좋다. 이 노트에서는 세 가지 래퍼 패턴을 한 코드에서 비교해 전파 경로를 확인해 보았다.
#파이썬 #데코레이터 #예외처리 #try except #ValueError
'Python' 카테고리의 다른 글
| sorted()와 list.sort()에서 key 함수가 같은 값을 돌려줄 때의 안정성 확인 (0) | 2026.10.31 |
|---|---|
| 제너레이터 throw()로 외부 예외를 주입했을 때 yield 지점의 try/except 처리 흐름 확인하기 (0) | 2026.10.31 |
| collections.deque에 maxlen을 지정했을 때 append와 appendleft는 어느 쪽 원소를 밀어내는가 (0) | 2026.10.29 |
| 중첩 with 문에서 __enter__와 __exit__의 실행 순서 직접 확인하기 (0) | 2026.10.28 |
| contextlib.contextmanager로 리소스 해제 보장하기: 예외가 나도 finally가 실행되는지 확인 (0) | 2026.10.27 |