질문의 역사
Python 2.5가 PEP 343를 통해 with 문을 도입하기 전까지, 자원 관리는 코드베이스 전역에 분산되어 있는 명시적 try/finally 블록을 필요로 했습니다. 기능적으로는 작동했지만, 이 패턴은 간단한 자원 획득 및 해제 시나리오에 대해 장황하고 오류가 발생하기 쉬웠습니다. contextlib 모듈은 개발자가 @contextmanager 데코레이터를 사용하여 순차적으로 보이는 제너레이터를 context management 프로토콜을 충족하는 객체로 변환하여 context manager를 제너레이터 함수로 작성할 수 있게 하여 이 보일러플레이트를 줄이기 위해 도입되었습니다.
문제
제너레이터 함수는 본래 이터레이터 프로토콜(__iter__, __next__)을 구현하며, context manager 프로토콜(__enter__, __exit__)은 구현하지 않습니다. 근본적인 도전은 이러한 별개의 프로토콜 간의 교량을 놓는 데 있습니다: with 블록에 들어갈 때 yield 이전의 설정 코드가 실행되어야 하며, 나올 때 yield 이후의 정리 코드가 예외와 관계없이 실행되어야 합니다. 더불어, with 블록 내에서 발생하는 예외는 정확한 yield 정지 지점에서 제너레이터에 주입될 수 있어야 하며, 제너레이터의 자체 예외 처리 로직이 정리 작업을 수행하도록 해야 합니다.
해결책
데코레이터는 제너레이터 함수를 GeneratorContextManager 클래스(C로 구현됨)로 래핑합니다. 각 호출은 새로운 제너레이터 이터레이터를 생성합니다. __enter__ 메서드는 이 이터레이터에서 next()를 호출하여 함수가 yield 문까지 실행되도록 하고, as 변수에 바인딩될 값을 반환합니다. __exit__ 메서드는 예외 세부정보를 수신합니다; 예외가 발생하지 않았다면 next()를 다시 호출하여 제너레이터를 계속 진행 및 소모합니다. 예외가 발생한 경우 제너레이터의 throw() 메서드를 호출하여 예외를 정지된 yield 지점에 주입합니다. 이는 제너레이터의 except 또는 finally 블록이 정리를 처리할 수 있도록 합니다. throw()가 정상적으로 반환되면(예외를 잡은 경우), __exit__는 예외를 억제하기 위해 True를 반환하며, 그렇지 않으면 전파됩니다.
from contextlib import contextmanager @contextmanager def managed_connection(): conn = create_connection() try: print("Connection established") yield conn except NetworkError: conn.rollback() raise finally: conn.close() print("Connection closed") with managed_connection() as c: c.query("SELECT * FROM data")
문제 설명: 고처리량 데이터 처리 서비스는 메모리 버퍼가 한계를 초과할 때 임시 스필 파일을 처리해야 했습니다. 레거시 구현은 12개의 다른 처리 모듈 전반에 걸쳐 파일 생성 및 삭제 로직이 중복되어 파일 설명자 누수가 발생하고, 엣지 케이스 오류 조건에서 유지 보수가 복잡해졌습니다.
고려된 솔루션:
수동 try/finally 블록은 초기 접근 방식이었습니다. 모든 사용 위치는 os.unlink()를 보장하기 위해 직접적으로 파일 작업을 try/finally로 감쌌습니다. 이는 제로 추상화 오버헤드로 명시적 제어 흐름을 제공했지만, 사용 위치마다 여덟 줄에 달하는 장황함과 오류 발생 가능성 높은 문제를 노출했습니다. 개발자들은 종종 잘못된 finally 블록에 정리 로직을 배치하고, 로깅 요구 사항이 추가될 때 모든 모듈 전반에 걸쳐 일관되게 동작을 수정하는 것이 어려웠습니다.
클래스 기반의 context manager가 재사용 가능한 대안으로 고려되었습니다. TempSpillFile 클래스는 파일 생성을 위해 __enter__를 구현하고 삭제를 위해 __exit__를 구현하게 됩니다. 재사용 가능하며 표준 프로토콜을 따르지만, 클래스 정의는 설정과 정리를 시각적으로 분리하여 가독성을 해쳤고, 사실상 간단한 자원 생애 주기를 위한 15줄의 보일러플레이트가 필요했습니다.
@contextmanager와 함께 제너레이터 접근 방식이 최종 옵션으로 남았습니다. temp_spill_file()의 제너레이터 함수는 파일을 생성하고 이를 yield한 뒤 삭제를 위해 try/finally를 사용했습니다. 이는 코드 중복을 최소화하고 설정과 정리를 소스 코드에서 인접하게 유지하며, 익숙한 예외 처리 문법을 활용할 수 있도록 했습니다. 하지만 이는 한 번만 사용할 수 있는 제한을 부과했으며, yield 정지 지점은 동기식 실행을 기대하는 개발자들을 혼란스럽게 했습니다.
선택된 솔루션과 결과: @contextmanager 접근 방식이 코드 중복을 최소화하는 동시에 코드 리뷰에서 명료성을 극대화하기 위해 선택되었습니다. 자원 획득 및 해제 로직의 인접성은 자원 생애 주기를 즉시 명확히 하였습니다. 리팩토링을 통해 자원 관리 코드를 코드베이스 전반에 걸쳐 96줄에서 12줄로 줄였습니다. 정적 분석을 통해 후속 분기 동안 파일 설명자 누수가 전혀 없음을 확인했습니다.
GeneratorContextManager는 설정 단계(문자 그대로 생성기에서 yield 전에)와 정리 단계(yield 이후)에서 발생하는 예외를 어떻게 처리합니까?
만약 제너레이터에서 yield 이전에 예외가 발생하면, 제너레이터는 결코 정지되지 않으며; __enter__는 즉시 이 예외를 전파하고 __exit__는 호출되지 않습니다. 만약 with 블록 내에서(즉, yield 이후) 예외가 발생하면, 제너레이터는 정지합니다. 이때 __exit__는 generator.throw(exc_type, exc_val, exc_tb)를 호출하여 예외가 활성 상태인 채로 yield 라인에서 제너레이터를 다시 시작합니다. 이를 통해 제너레이터의 자신의 except 또는 finally 블록이 실행될 수 있습니다. 후보자들은 종종 throw()가 실제로 실행을 재개한다는 것을 놓치며, 예외는 제너레이터의 관점에서 yield 표현식에서 발생한 것으로 간주됩니다.
contextmanager로 장식된 제너레이터가 단일 yield 지점을 강제하는 이유는 무엇이며, 이 제약 조건이 위반될 경우 발생하는 특정 오류는 무엇입니까?
context manager 프로토콜은 단일 진입 및 종료를 가정합니다. 만약 제너레이터가 두 번째로 yield를 전달하면—__exit__가 next()를 호출하여(예외 없음) 제너레이터가 반환이 아닌 다시 한 번 yield하는 경우 또는 throw()가 호출되어 제너레이터가 예외를 처리한 후 다시 yield하는 경우—GeneratorContextManager는 "generator didn't stop"라는 메시지를 가진 RuntimeError를 발생시킵니다. 이는 상태 기계를 초기화해야 한다고 예상했기 때문입니다. 후보자들은 종종 이것을 여러 개의 yield가 유효한 표준 반복과 혼동하며; 여기서의 yield는 문맥의 일시 정지/재개 경계로 작용하며, 값 생산 시퀀스가 아니라는 것을 깨닫지 못합니다.
GeneratorContextManager의 __exit__ 메서드는 with 블록 내에서 발생한 예외를 어떤 경우에 억제하며, 이것이 제너레이터의 예외 처리와 어떻게 상호작용합니까?
__exit__는 예외가 주입된 예외가 제너레이터에서 포착되었고 제너레이터가 종료(즉, StopIteration을 발생)하면서 예외를 다시 발생시키지 않거나 새로운 예외를 발생시키지 않는 경우에만 예외를 억제합니다(True 반환). 만약 제너레이터가 예외를 포착하고 throw() 호출을 정상적으로 반환하게 하였다면, __exit__는 이를 성공적인 처리로 해석하고 True를 반환합니다. 반대로, 제너레이터가 예외를 포착하지 않으면 throw()는 예외를 전파하며, __exit__는 None(거짓)으로 반환하여 예외가 전파될 수 있도록 합니다. 후보자들은 종종 제너레이터 내부에 단순히 try/except가 있는 것만으로는 충분하지 않다는 것을 놓치며; 예외는 특히 throw() 호출에서 잡혀야 하며, 다시 발생시켜서는 안 되며, 억제되기 위해서는 명시적인 return이나 예외를 잡은 후 끝가지 떨어져 있어야 합니다.