Python with 블록에서 예외가 사라지는 이유: __exit__ 반환값과 finally 속 return
__exit__가 참 값을 돌려주면 with 문은 예외를 억제합니다. finally 속 return과는 무엇이 다른지, 원하는 예외만 삼키는 억제기는 어떻게 쓰는지 Python 공식 문서 기준으로 정리합니다.
with 블록 안에서 예외가 났는데 프로그램은 블록 다음 문장부터 계속 실행된다. 런타임 버그가 아니다. with 문이 언제 예외를 없애도 되는지는 언어 레퍼런스에 정해져 있고, 기준은 __exit__()가 돌려주는 값이다. finally 절에서 return을 실행해도 예외가 사라지지만, 이쪽은 규칙이 다르고 둘을 헷갈리기 쉽다.
이 글은 2026-10-18에 확인한 Python 3.15.0 문서를 기준으로 한다. 버전에 따라 달라지는 부분은 해당 대목에서 따로 밝혔다. contextlib.suppress는 3.12에서 예외 그룹을 지원하게 됐고, finally 속 return에 대한 컴파일러 경고는 3.14부터다. 아래 코드의 주석에 적은 결과는 Python 3.14.7에서 예제를 실행해 확인했다. 설명의 기준은 그대로 3.15.0 문서다.
__exit__가 참 값을 돌려주면 예외가 억제된다
언어 레퍼런스의 with 문 절 7단계에 규칙이 있다. 블록이 예외 때문에 끝나면 그 예외의 타입, 값, 트레이스백이 __exit__()에 인자로 넘어간다. 반환값이 거짓이면 예외를 다시 일으키고, 참이면 예외를 억제한 뒤 with 문 다음 문장부터 실행을 이어 간다. 예외 없이 끝났다면 __exit__()는 None 세 개를 받고, 반환값은 무시된다.
같은 절에 실린 동등 코드를 보면 더 분명하다.
# 언어 레퍼런스의 동등 코드(개념 설명용, 그대로 실행되지는 않음)
manager = (EXPRESSION)
enter = manager.__enter__
exit = manager.__exit__
value = enter()
hit_except = False
try:
TARGET = value
SUITE
except:
hit_except = True
if not exit(*sys.exc_info()):
raise
finally:
if not hit_except:
exit(None, None, None)
검사는 if not exit(...)이므로 결과가 True인지가 아니라 참으로 평가되는지가 기준이다. return 없이 끝나는 메서드는 None을 돌려주고, None은 거짓이므로 예외가 전파된다. 데이터 모델 문서도 같은 계약을 적어 둔다. 예외를 억제하려면 참 값을 돌려주고, 그렇지 않으면 메서드가 끝난 뒤 예외가 평소대로 처리된다.
예외가 억제되면 블록의 나머지는 실행되지 않는다. 실행이 이어지는 곳은 예외가 난 줄의 다음이 아니라 with 문의 다음이다.
의도하지 않은 참 값
진릿값 검사 절에 따르면, 클래스가 False를 돌려주는 __bool__()이나 0을 돌려주는 __len__()을 정의하지 않은 객체는 기본적으로 참이다. 예외 클래스나 비어 있지 않은 문자열 같은 값은 그래서 참이다.
class Quiet:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return exc_type # "예외 타입을 그대로 넘긴다"는 생각으로 쓴 코드
with Quiet():
1 / 0
print("not reached")
print("after with") # 이 줄이 실행된다
예외가 없을 때 exc_type은 None이라 아무 일도 없다. 예외가 나면 exc_type은 예외 클래스이고 이 값은 참이므로, 모든 예외가 억제된다. __exit__()가 어떤 정리 메서드의 결과를 그대로 돌려주는데 그 결과가 참일 수 있는 경우도 마찬가지다.
조건 없는 return True는 범위가 더 넓다. 내장 예외 문서에 따르면 KeyboardInterrupt는 Exception을 잡는 코드에 실수로 잡히지 않도록 BaseException을 상속한다. 하지만 __exit__()는 블록을 빠져나가는 모든 예외를 받으며, BaseException 하위 클래스도 빠지지 않는다. 그래서 늘 참을 돌려주는 매니저는 블록 안에서 일어난 KeyboardInterrupt까지 억제한다.
좁은 억제기 쓰기
직접 쓴다면 예외가 실제로 들어왔고 그것이 지정한 타입일 때만 참을 돌려준다.
class Ignore:
def __init__(self, *exceptions):
self.exceptions = exceptions
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return exc_type is not None and issubclass(exc_type, self.exceptions)
with Ignore(FileNotFoundError):
open("no-such-file.txt")
print("continues") # FileNotFoundError는 억제됨
with Ignore(FileNotFoundError):
int("x") # ValueError는 전파됨
issubclass는 하위 클래스도 받아들인다. FileNotFoundError는 OSError의 하위 클래스이므로 Ignore(OSError)라고 쓰면 이것도 억제된다. 넘기는 타입이 넓을수록 흔적 없이 사라지는 실패도 늘어난다.
실제로는 표준 라이브러리에 이미 있는 contextlib.suppress(3.4에서 추가)를 쓰면 된다.
import os
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove("somefile.tmp")
문서는 이것이 try: ... except FileNotFoundError: pass와 같다고 설명하고, 예외를 완전히 억제하는 다른 수단과 마찬가지로 조용히 계속 진행하는 것이 옳다고 알고 있는 아주 구체적인 오류에만 쓰라고 당부한다. 블록에 실패할 수 있는 호출 하나만 넣어 두면 무엇을 무시하는지 읽기 쉽다.
손으로 쓴 Ignore와 다른 점이 하나 있다. 3.12부터 suppress는 블록이 BaseExceptionGroup을 일으키면 그룹에서 지정한 예외를 빼고, 나머지는 원래 그룹의 derive()로 만든 새 그룹으로 다시 일으킨다. 위의 Ignore는 들어온 예외 자체의 타입만 보므로, FileNotFoundError가 들어 있는 그룹은 억제하지 않는다.
__exit__에서 다시 raise하지 말 것, 그리고 raise하면 그 예외가 이긴다
데이터 모델 문서는 __exit__()가 넘겨받은 예외를 다시 일으키면 안 되며, 그것은 호출한 쪽의 책임이라고 적는다. 표준 타입 문서의 컨텍스트 매니저 타입 절도 같은 내용과 함께 이유를 밝힌다. 거짓 값을 돌려주는 방식이어야 컨텍스트 관리 코드가 __exit__() 자체가 실패했는지를 구별할 수 있다. 억제하지 않으려면 거짓 값을 돌려주고, raise exc는 쓰지 않는다.
반대로 __exit__()가 새로 일으킨 예외는 그대로 전파된다. 같은 절에 따르면 블록의 예외를 처리하던 중 __exit__()가 예외를 일으키면 새 예외가 전파되고, 원래 예외는 그 __context__ 속성에 저장된다.
class Broken:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
raise RuntimeError("cleanup failed")
try:
with Broken():
raise ValueError("body failed")
except RuntimeError as e:
print(repr(e)) # RuntimeError('cleanup failed')
print(repr(e.__context__)) # ValueError('body failed')
블록의 ValueError가 사라지지는 않지만, 호출한 쪽이 받는 것은 RuntimeError이고 except ValueError: 핸들러는 실행되지 않는다. Java의 try-with-resources는 반대로 본문 예외를 전파하고 close() 예외를 suppressed로 붙인다. 이 내용은 Java try-with-resources에서 close() 예외는 어디로 가나에서 다뤘다.
@contextmanager에서는 잡고 다시 올리지 않으면 억제된다
contextlib.contextmanager로 만든 매니저에서는 블록의 예외가 제너레이터 안의 yield 지점에서 다시 일어난다. 문서는 분명하게 적는다. 억제할 생각 없이 로그를 남기거나 어떤 동작을 하려고 잡았다면 제너레이터가 그 예외를 다시 일으켜야 한다. 그렇지 않으면 제너레이터 컨텍스트 매니저는 with 문에 예외가 처리됐다고 알리고, 실행은 with 문 다음에서 이어진다.
from contextlib import contextmanager
@contextmanager
def logged():
try:
yield
except Exception as e:
print("log:", repr(e))
# raise가 없으므로 예외는 여기서 억제된다
with logged():
1 / 0
print("after with") # 이 줄이 실행된다
로그를 남기고 예외도 전파하려면 except 블록 끝에 인자 없는 raise를 둔다. 반드시 실행돼야 하는 정리 코드는 문서 예제처럼 finally에 둔다.
finally 속 return은 다른 메커니즘이다
언어 레퍼런스의 try 문 절은 finally를 이렇게 설명한다. 처리되지 않은 예외가 나면 잠시 저장해 두었다가 finally 절 끝에서 다시 일으킨다. finally 절이 return, break, continue를 실행하면 저장한 예외는 버려진다. 레퍼런스의 예제는 42를 돌려준다.
def f():
try:
1/0
finally:
return 42
여기에는 컨텍스트 매니저가 필요 없다. try 문 자체의 규칙이고 모든 함수와 반복문에 적용된다. __exit__()와 다른 점은 소스 코드만 봐도 드러난다는 것이다. Python 3.14부터 컴파일러는 return, break, continue가 finally 블록을 빠져나가면 SyntaxWarning을 낸다(What’s New in 3.14, PEP 765). __exit__()가 돌려주는 참 값은 실행 중에 정해지므로 이런 경고가 없다.
두 규칙은 @contextmanager 제너레이터 안에서 만날 수 있다.
from contextlib import contextmanager
@contextmanager
def cleanup():
try:
yield
finally:
print("cleanup")
return # 3.14 이상에서 SyntaxWarning
블록이 예외를 일으키면 그 예외는 yield에서 다시 일어나고, finally 속 return이 그것을 버린다. 제너레이터는 예외를 다시 일으키지 않고 끝나므로, 앞의 contextmanager 규칙에 따라 예외가 처리된 것으로 취급된다. 정리를 보장하려고 쓴 함수가 모든 예외를 억제하게 되는 것이다. finally에서 return을 빼고, 일찍 끝낼 필요가 있다면 try 본문에서 조건으로 처리한다.
정리
| 예외를 없애는 것 | 언제 정해지나 | 경고 |
|---|---|---|
__exit__()가 참 값을 반환 |
실행 중, 반환값으로 | 없음 |
@contextmanager 제너레이터가 잡고 다시 올리지 않음 |
실행 중, 거친 except 경로로 |
없음 |
finally 속 return, break, continue |
소스 코드에서 이미 정해짐 | 3.14 이상에서 SyntaxWarning |
__exit__()는 지정한 예외 타입일 때만 참을 돌려준다.return exc_type같은 식은 예외가 있을 때마다 참이 된다.- 정해진 예외만 무시하려면
contextlib.suppress가 가장 간단하고, 블록에는 실패할 수 있는 호출만 넣는다. - 예외를 그대로 보내려면 다시 일으키지 말고 거짓 값을 돌려준다.
__exit__()에서 일어난 예외는 블록의 예외를 대신하고, 원래 예외는__context__에 남는다. @contextmanager제너레이터에서 로그만 남길 예외는raise로 다시 일으킨다.finally에는return을 두지 않는다. 컨텍스트 매니저가 없어도 예외를 버린다.
참고 자료
모두 2026-10-18에 확인했다(Python 3.15.0 문서).
- The Python Language Reference: The with statement
- The Python Language Reference: The try statement, finally clause
- The Python Language Reference: 3.3.9. With Statement Context Managers
- Built-in Types: Context Manager Types
- Built-in Types: Truth Value Testing
- contextlib: suppress, contextmanager
- Built-in Exceptions (KeyboardInterrupt, OSError 하위 클래스, __context__)
- What’s New in Python 3.14: PEP 765, control flow in finally blocks