PEP 765 – finally 블록을 빠져나가는 return/break/continue 금지
- Author:
- Irit Katriel <irit at python.org>, Alyssa Coghlan <ncoghlan at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Created:
- 15-Nov-2024
- Python-Version:
- 3.14
- Post-History:
- 09-Nov-2024, 16-Nov-2024
- Replaces:
- 601
- Resolution:
- Discourse message
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 PEP는 finally 블록을 빠져나오는 return, break 및 continue 문에 대한 지원을 철회할 것을 제안합니다. 이는 과거에 PEP 601에서 제안되었습니다. 현재 PEP는 이 변경의 비용과 이점에 관한 실증적 증거를 바탕으로 하며, PEP 601이 거부될 당시에는 그러한 증거가 존재하지 않았습니다. 또한 PEP 601에서 제안한 것과는 약간 다른 해결책을 제안합니다.
동기
finally 블록에서 return, break 및 continue의 의미는 많은 개발자에게 놀랍습니다. 해당 documentation 문서에서는 다음과 같이 언급합니다:
finally절이break,continue또는return문을 실행하면 예외가 다시 발생하지 않습니다.finally절에return문이 포함된 경우 반환되는 값은try절의return문에 따른 값이 아니라finally절의return문에 따른 값입니다.
이 두 동작 모두 혼란을 일으키지만, 첫 번째 동작은 잘못된 반환 값보다 삼켜진 예외가 테스트를 통과해 버릴 가능성이 높기 때문에 특히 위험합니다.
2019년에 PEP 601은 Python이 몇 개 릴리스 동안 SyntaxWarning을 출력한 다음 이를 SyntaxError로 변경하도록 제안했습니다. 이 제안은 이를 린터와 PEP 8로 처리해야 하는 프로그래밍 스타일 문제로 보는 쪽을 택하여 거부되었습니다. 실제로 PEP 8은 현재 finally 블록에서 제어 흐름 문을 사용하지 말 것을 권장하며, Pylint, Ruff 및 flake8-bugbear와 같은 린터는 이를 문제로 표시합니다.
근거
최근의 실제 코드 분석에 따르면 다음과 같습니다.
- 이러한 기능은 드뭅니다(PyPI 상위 8,000개 패키지에서 LOC 백만 줄당 2건, 무작위로 선정한 패키지에서 LOC 백만 줄당 4건입니다). 이러한 패턴을 표시하는 린터 덕분일 수 있습니다.
- 대부분의 사용은 잘못되었으며, 의도치 않게 예외를 삼키는 버그를 도입합니다.
- 코드 소유자는 일반적으로 버그 수정에 협조적이며, 이를 쉽게 수정할 수 있다고 생각합니다.
자세한 내용은 부록을 참조하십시오.
이 새로운 데이터는 Python 자체가 사용자를 이러한 유해한 기능에서 멀어지게 한다면 Python 사용자에게 도움이 될 것임을 보여줍니다.
PEP 601 논의에서 제기된 주장 중 하나는 언어 기능이 직교적이어야 하며, 컨텍스트에 따른 제한 없이 서로 결합되어야 한다는 것이었습니다. 그러나 그사이 PEP 654가 구현되었으며, except* 절은 병렬로 작동하므로 한 절의 코드가 다른 절의 호출을 억제해서는 안 된다는 특성을 위반하게 되기 때문에 except* 절에서 return, break 및 continue를 금지합니다. 이 경우에는 기능의 조합이 이를 금지하는 것이 타당할 만큼 충분히 해로울 수 있다는 점을 받아들였습니다.
사양
변경 사항은 return, break 또는 continue가 finally 블록 내부에서 그 외부의 위치로 제어 흐름을 이동시키려 할 때 Python 컴파일러가 SyntaxWarning 또는 SyntaxError를 출력할 수 있도록 언어 사양의 일부로 명시하는 것입니다.
다음 예제는 SyntaxWarning 또는 SyntaxError를 출력할 수 있습니다.
def f():
try:
...
finally:
return 42
for x in o:
try:
...
finally:
break # (or continue)
다음 예제는 경고나 오류를 출력하지 않습니다.
try:
...
finally:
def f():
return 42
try:
...
finally:
for x in o:
break # (or continue)
CPython은 버전 3.14에서 SyntaxWarning을 출력하며, 이것이 SyntaxError가 될지, 그렇게 된다면 언제 그렇게 될지는 열어 둡니다. 그러나 여기서는 언어 사양에 따라 SyntaxError가 허용된다고 명시하므로, 다른 Python 구현이 이를 구현하도록 선택할 수 있습니다.
CPython 구현은 정적 분석 및 컴파일 중에 경고가 나타나도록 하되 사전 컴파일된 코드 실행 중에는 나타나지 않도록, AST를 구성하는 동안 SyntaxWarning을 발생시킬 것입니다. 프로젝트 유지 관리자가 정적 분석 또는 사전 컴파일된 파일이 없는 CI를 실행할 때 경고를 보게 될 것으로 예상합니다. 그러나 프로젝트의 최종 사용자는 설치 시 사전 컴파일을 건너뛰거나, 설치 시 경고를 확인하거나, 종속성에 대해 정적 분석을 실행하는 경우에만 경고를 보게 됩니다.
하위 호환성
하위 호환성을 위해 CPython이 SyntaxWarning만 발생시키고, 이를 오류로 승격할 구체적인 계획은 세우지 않는 방안을 제안합니다. 이 변경이 도입되면 -We로 실행되는 코드가 작동하지 않을 수 있습니다.
보안 관련 사항
이 경고/오류는 프로그래머가 찾기 어려운 일부 버그를 피하는 데 도움을 주므로 보안상 이점이 있습니다. 새로운 SyntaxWarning또는 SyntaxError를 발생시키는 것과 관련된 보안 문제는 인지하고 있지 않습니다.
가르치는 방법
이 변경 사항은 언어 사양과 What’s New 문서에 기록됩니다. SyntaxWarning은 사용자에게 코드 변경이 필요하다는 것을 알립니다. 경험적 증거 는 필요한 변경이 대체로 매우 간단하다는 것을 보여 줍니다.
거부된 아이디어
CPython에서 SyntaxError발생시키기
PEP 601은 CPython이 몇 번의 릴리스 동안 SyntaxWarning을 발생시키고 그 후 SyntaxError를 발생시키도록 제안했습니다. SyntaxError로 변경할지, 변경한다면 언제 변경할지는 CPython에서 아직 열어 두고 있습니다. SyntaxWarning이 위험은 더 적으면서도 대부분의 이점을 제공할 것이라고 믿기 때문입니다.
의미 변경
처리 중인 예외가 finally 안의 제어 흐름 명령보다 우선하도록 그 의미를 변경하자는 제안이 있었습니다. 다시 말해 return, break 또는 continue를 허용하고 finally블록을 벗어나게 하되, 예외는 계속 발생하도록 하는 것입니다.
이 방안은 두 가지 이유로 거부되었습니다. 첫째, 디버깅하기 어려운 방식으로 정상 작동하는 코드의 의미를 변경하기 때문입니다. 모든 예외를 삼키려는 의도로 작성된 finally(문서화된 의미를 올바르게 사용한 경우)가 이제 예외를 계속 전파하도록 허용하게 됩니다. 이는 런타임의 드문 예외 상황에서만 발생할 수 있으며, 테스트에서 반드시 감지된다는 보장도 없습니다. 코드가 잘못되어 예외를 삼키는 버그가 있는 경우에도, 사용자 입장에서는 3.13에서는 그렇지 않았던 프로그램이 3.14에서 예외를 발생시키기 시작한 이유를 이해하기 어려울 수 있습니다. 반면 SyntaxWarning은 테스트 중에 발견될 가능성이 높고, 코드에서 문제의 정확한 위치를 가리키며, 프로그램 실행을 막지도 않습니다.
둘째 반론은 제안된 의미에 관한 것이었습니다. 제어 흐름 문을 허용하려는 동기는 이것이 유용하기 때문이 아니라 기능의 직교성을 바라는 데 있습니다. (서론에서 언급했듯이 이러한 직교성은 except*절의 경우 이미 위반되어 있습니다.) 그러나 제안된 의미는 finally가 처리 중인 예외 없이 실행될 때는 return, break 및 continue가 평소처럼 동작하지만, 처리 중인 예외가 있을 때는 인자 없는 raise와 같은 것으로 바뀐다는 점에서 복잡합니다. 한 기능의 존재가 다른 기능의 의미를 변경한다면 해당 기능들이 직교한다고 주장하기 어렵습니다.
부록
finally에서 return은 유해한 것으로 간주됩니다.
다음은 2024년 11월 9일에 게시된 Irit Katriel의 연구 보고서를 요약한 버전입니다. 이 보고서는 실제 코드에서 return, break 및 continue를 finally 절에서 사용하는 현상을 조사하고 다음 질문을 다룹니다. 사람들이 이를 사용하고 있습니까? 얼마나 자주 이를 잘못 사용하고 있습니까? 제안된 변경으로 인해 얼마나 많은 코드 변경이 발생하겠습니까?
방법
이 분석은 지난 30일 동안의 다운로드 횟수를 기준으로 가장 인기 있는 PyPI 패키지 8,000개를 대상으로 합니다. 이 패키지들은 10월 17일부터 18일까지 Guido van Rossum이 작성한 스크립트를 사용하여 다운로드되었으며, 이 스크립트는 가장 인기 있는 패키지 목록을 생성하는 Hugo van Kemenade의 도구에 의존합니다.
다운로드가 완료된 후, 각 파일의 AST를 구성하고 이를 순회하여 finally 블록 바로 안에 있는 break, continue 및 return 문을 식별하는 두 번째 스크립트를 사용했습니다.
그런 다음 각 사례의 현재 소스 코드를 찾아 분류했습니다. 코드가 올바르지 않아 보이는 경우에는 해당 프로젝트의 버그 추적기에 이슈를 생성했습니다. 이러한 이슈에 대한 응답도 이 조사에서 수집한 데이터의 일부입니다.
결과
이 보고서가 공개적인 망신 주기처럼 보일 수 있다는 우려 때문에 잘못된 사용 사례 목록은 포함하지 않기로 했습니다. 대신 결과를 일반적인 용어로 설명하되, 제가 발견한 일부 문제가 클라우드 보안 애플리케이션을 비롯한 매우 인기 있는 라이브러리에 나타난다는 점은 언급하겠습니다. 분석에 사용한 스크립트의 링크를 방법 절에 제공했으므로, 관심이 있는 분들이라면 제 분석을 재현하는 일이 어렵지 않을 것입니다.
조사한 프로젝트에는 총 120,964,221줄의 Python 코드가 포함되어 있었으며, 그중 스크립트는 finally 블록 내의 제어 흐름 명령 203건을 발견했습니다. 대부분은 return이었고, 일부는 break였으며, continue는 하나도 없었습니다. 이 중 다음과 같습니다.
- 46건은 올바른 사용이며, 이 패턴을 기능으로 대상으로 하는 테스트에 나타납니다(예: 이를 탐지하는 린터의 테스트).
- 8건은 올바른 사용일 가능성이 있어 보입니다. 예외를 의도적으로 삼키거나 활성 예외가 발생할 수 없는 위치에 나타나는 경우입니다. 올바른 코드이기는 하지만, 잘못된 패턴을 피하도록 다시 작성하는 일은 어렵지 않으며 코드도 더 명확해집니다. 예외를 의도적으로 삼키는 작업은
except BaseException:을 사용하여 더 명시적으로 수행할 수 있고, 예외를 삼키지 않는return은finally블록 뒤로 이동할 수 있습니다. - 149건은 명백히 잘못되었으며, 의도하지 않게 예외를 삼키는 결과를 초래할 수 있습니다. 이러한 사례는 다음 절에서 분석합니다.
오류 사례
많은 오류 사례는 다음과 같은 패턴을 따랐습니다.
try:
...
except SomeSpecificError:
...
except Exception:
logger.log(...)
finally:
return some_value
이러한 코드는 Exception의 서브클래스를 의도적으로 기록하고 삼키는 반면, BaseExceptions를 조용히 삼키므로 명백히 잘못되었습니다. 여기서 의도한 바는 BaseExceptions가 계속 전파되도록 허용하는 것이거나, 작성자가 BaseException 문제를 인식하지 못했다면 모든 예외를 기록하고 삼키는 것입니다. 그러나 except Exception을 except BaseException으로 변경하더라도 이 코드에는 finally 블록이 except 블록 내부에서 발생한 모든 예외를 삼킨다는 문제가 여전히 남으며, 이는 아마도 의도한 바가 아닐 것입니다. (의도한 것이라면 또 다른 try-except BaseException으로 이를 명시할 수 있습니다.)
실제 코드에서 발견된 이 문제의 또 다른 변형은 다음과 같습니다.
try:
...
except:
return NotImplemented
finally:
return some_value
여기서는 예외가 발생하면 NotImplemented를 반환하려는 의도로 보이지만, finally 블록의 return이 except 블록의 것을 덮어쓰게 됩니다.
Note
다음
discussion에 이어, 저는 평균적인 프로그래머가 작성한 코드를 분석하기 위해 PyPI 패키지 중에서 무작위로 선택한 것들에 대해 분석을 반복했습니다. 표본에는 총 77,398,892줄의 코드가 포함되어 있었으며, finally 블록에서 return/break/continue가 사용된 사례는 316개였습니다. 따라서 코드 100만 줄당 약 4개 사례입니다.
작성자들의 반응
finally 절에서 return 또는 break가 잘못 사용된 149개 사례 중 27개는 오래된 것이었습니다. 즉, 해당 코드는 이미 삭제되었거나 수정되어 라이브러리의 메인/마스터 브랜치에는 나타나지 않았습니다. 나머지 122개는 서로 다른 73개 패키지에 포함되어 있었으며, 작성자에게 문제를 알리기 위해 각각에 이슈를 생성했습니다. 2주 이내에 73개 이슈 중 40개에 코드 유지 관리자들의 반응이 있었습니다.
- 15개 이슈에서는 문제를 수정하기 위한 PR이 생성되었습니다.
- 20개 이슈에서는 조사할 가치가 있는 문제임을 인정하는 반응이 있었습니다.
- 3명은 해당 코드가 더 이상 유지 관리되지 않으므로 수정되지 않을 것이라고 답변했습니다.
- 2명은 이슈를 “의도한 대로 작동함”으로 종료했습니다. 한 명은 모든 예외를 무시할 의도라고 밝혔지만, 다른 한 명은
Exception과BaseException의 차이를 인지하지 못하는 듯했습니다.
한 이슈는 Ctrl-C에 응답하지 않는 문제에 관한 기존의 미해결 이슈와 연결되었으며, 둘 사이의 연관성이 추측되었습니다.
이슈 중 두 개에는 “good first issue” 라벨이 지정되었습니다.
올바른 사용 사례
기능이 올바르게 사용된 것으로 보이는 8개 사례(테스트 코드가 아닌 코드)에도 주목할 필요가 있습니다. 이는 해당 기능을 차단할 경우 발생할 “변경 부담”을 나타냅니다. 실제로 작동하는 코드가 변경되어야 하는 부분이 바로 여기이기 때문입니다. 이러한 사례에서는 작성자에게 연락하지 않았으므로, 이러한 변경을 직접 수행하는 데 필요한 난이도를 평가해야 합니다. the full report에서는 각 사례에 필요한 변경이 작다는 점을 확인할 수 있습니다.
논의
먼저 주목할 점은 1억 2천만 줄이 넘는 코드에서 finally 블록의 return/break/continue가 203개 사례에 불과할 만큼 자주 보이는 것은 아니라는 점입니다. 이는 이러한 사용을 경고하는 린터 덕분일 가능성이 있습니다.
두 번째로, 대부분의 사용 사례가 잘못되었다는 점입니다. 표본에서는 73%(203개 중 149개)가 잘못된 사용이었습니다.
마지막으로, 작성자들의 반응은 압도적으로 긍정적이었습니다. 2주 이내에 받은 40개의 반응 중 35개가 문제를 인정했으며, 그중 15개에서는 문제를 수정하기 위한 PR도 생성했습니다. 단 두 명만 코드가 현재 상태로도 괜찮다고 생각했으며, 세 명은 코드가 더 이상 유지 관리되지 않으므로 검토하지 않겠다고 밝혔습니다.
코드가 의도한 대로 작동하는 것으로 보이는 8개 사례는 다시 작성하기 어렵지 않습니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.