PEP 690 – 지연 임포트
- Author:
- Germán Méndez Bravo <german.mb at gmail.com>, Carl Meyer <carl at oddbird.net>
- Sponsor:
- Barry Warsaw <barry at python.org>
- Discussions-To:
- Discourse thread
- Status:
- Rejected
- Type:
- Standards Track
- Created:
- 29-Apr-2022
- Python-Version:
- 3.12
- Post-History:
- 03-May-2022, 03-May-2022
- Resolution:
- Discourse message
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 PEP는 가져온 모듈을 탐색하고 실행하는 작업을 가져온 객체가 처음 사용되는 순간까지 투명하게 지연하는 기능을 제안합니다. Python 프로그램은 일반적으로 실제로 한 번 실행할 때 사용할 가능성이 있는 모듈보다 훨씬 많은 모듈을 가져오므로, 지연 임포트는 로드되는 전체 모듈 수를 크게 줄여 시작 시간과 메모리 사용량을 개선할 수 있습니다. 지연 임포트는 또한 임포트 순환의 위험을 대부분 제거합니다.
동기
일반적인 Python 코드 스타일은 모듈 수준에서 가져오기를 선호하므로 prefers 가져온 객체가 사용되는 각 범위에서 가져오기를 반복하지 않아도 되며, 런타임에 임포트 시스템을 반복해서 실행하는 비효율도 피할 수 있습니다. 이는 프로그램의 주 모듈을 가져오면 일반적으로 프로그램에서 언젠가 필요할 수 있는 모듈 대부분 또는 전부를 즉시 연쇄적으로 가져오게 된다는 의미입니다.
여러 하위 명령을 포함하는 Python 명령줄 프로그램(CLI)의 예를 생각해 보십시오. 각 하위 명령은 서로 다른 작업을 수행할 수 있으므로 서로 다른 의존성을 가져와야 할 수 있습니다. 그러나 프로그램을 한 번 실행할 때는 하나의 하위 명령만 실행하거나, 아무 하위 명령도 실행하지 않을 수 있습니다(예를 들어 --help 사용 정보만 요청하는 경우). 이러한 프로그램에서 최상위 수준의 즉시 임포트는 전혀 사용되지 않을 많은 모듈을 가져오게 하며, 이러한 모듈을 (컴파일하고) 실행하는 데 걸리는 시간은 순전히 낭비입니다.
시작 시간을 개선하기 위해 일부 대규모 Python CLI는 비용이 큰 하위 시스템의 임포트를 지연하려고 함수 내부에 임포트문을 수동으로 배치하여 가져오기를 지연 임포트로 만듭니다. 이러한 수동 접근 방식은 작업량이 많고 취약합니다. 임포트 하나를 잘못 배치하거나 리팩터링하면 공들여 수행한 최적화 작업이 쉽게 무효화될 수 있습니다.
Python 표준 라이브러리에는 이미 importlib.util.LazyLoader 를 통한 지연 임포트 내장 지원이 포함되어 있습니다. demandimport 같은 서드파티 패키지도 있습니다. 이러한 패키지는 첫 번째 속성 접근이 이루어질 때까지 자체 임포트를 지연하는 “지연 모듈 객체”를 제공합니다. 이는 모든 가져오기를 지연 임포트로 만들기에는 충분하지 않습니다. from foo import a, b 같은 가져오기는 foo 모듈의 속성에 즉시 접근하므로 여전히 해당 모듈을 즉시 가져옵니다. 또한 Python 수준의 __getattr__ 또는 __getattribute__ 구현이 필요하므로 모든 모듈 속성 접근에 눈에 띄는 런타임 오버헤드를 부과합니다.
과학용 Python 패키지의 작성자들도 지연 임포트를 광범위하게 사용해 왔습니다. 이를 통해 사용자는 예를 들어 import scipy as sp를 작성한 다음 모든 하위 모듈을 미리 가져오지 않고도 sp.linalg와 같은 여러 하위 모듈에 쉽게 접근할 수 있습니다. SPEC 1 은 패키지의 __init__.py에서 명시적으로 사용할 수 있는 lazy_loader 라이브러리 형태로 이 관행을 공식화하여 하위 모듈에 지연 방식으로 접근할 수 있게 합니다.
정적 타이핑 사용자는 런타임에 전혀 사용되지 않을 수 있는 타입 어노테이션용 이름도 가져와야 합니다(PEP 563 또는 향후 사용될 가능성이 있는 PEP 649를 사용해 어노테이션의 런타임 즉시 평가를 피하는 경우). 이 상황에서는 불필요한 가져오기의 오버헤드를 피할 수 있으므로 지연 임포트가 매우 유용합니다.
이 PEP는 위의 모든 사용 사례를 포괄할 수 있고 실제 환경에서 감지 가능한 오버헤드를 부과하지 않는, 지연 임포트를 위한 보다 일반적이고 포괄적인 해결책을 제안합니다. 이 PEP의 구현은 실제 Python CLI에서 이미 demonstrated 시작 시간 최대 70% 개선과 메모리 사용량 최대 40% 감소를 입증했습니다.
지연 임포트는 또한 대부분의 임포트 순환을 제거합니다. 즉시 임포트를 사용하면 “거짓 순환”이 쉽게 발생할 수 있으며, 이러한 순환은 임포트를 모듈의 맨 아래나 함수 내부로 단순히 옮기거나 from foo import bar에서 import foo로 전환하면 해결됩니다. 지연 임포트를 사용하면 이러한 “순환”이 그대로 작동합니다. 남게 되는 유일한 순환은 두 모듈이 모듈 수준에서 서로의 이름을 실제로 사용하는 경우입니다. 이러한 “진짜” 순환은 관련 클래스나 함수를 리팩터링해야만 해결할 수 있습니다.
설계 근거
이 기능의 목표는 가져오기를 투명하게 지연 임포트로 만드는 것입니다. “지연”이란 모듈의 임포트(모듈 본문의 실행 및 모듈 객체의 sys.modules에 추가)가 해당 모듈(또는 해당 모듈에서 가져온 이름)을 실행 중에 실제로 참조할 때까지 발생하지 않아야 한다는 의미입니다. “투명”이란 지연된 임포트와 그에 따라 관찰 가능한 효과(예: 지연된 임포트 부작용 및 sys.modules의 변경)를 제외하면 동작에 관찰 가능한 다른 변화가 없다는 의미입니다. 즉, 가져온 객체는 평소처럼 모듈 네임스페이스에 존재하고 처음 사용될 때마다 투명하게 로드되며, “지연 임포트된 객체”라는 상태는 Python이나 C 확장 코드에서 직접 관찰할 수 없습니다.
실제 임포트가 발생하기 전에도 가져온 객체가 평소처럼 모듈 네임스페이스에 존재해야 한다는 요구 사항은 아직 가져오지 않은 객체를 나타내는 일종의 “지연 객체” 자리표시자가 필요하다는 의미입니다. 투명성 요구 사항에 따르면 이 자리표시자는 Python 코드에 절대 노출되어서는 안 됩니다. 자리표시자에 대한 모든 참조는 임포트를 트리거하고 이를 실제 가져온 객체로 교체해야 합니다.
Python(또는 C 확장) 코드가 모듈 __dict__에서 객체를 직접 가져올 수 있으므로, 지연 객체가 우발적으로 노출되는 것을 확실하게 방지하는 유일한 방법은 조회 시 지연 객체의 해석을 보장하는 책임을 딕셔너리 자체에 부여하는 것입니다.
조회에서 키가 지연 객체를 참조하는 것으로 확인되면, 해당 객체를 반환하기 전에 지연 객체를 즉시 해석합니다. 반복 도중 부작용으로 인해 딕셔너리가 변경되는 것을 방지하기 위해, 반복을 시작하기 전에 딕셔너리의 모든 지연 객체를 해석합니다. 이로 인해 대량 반복(iter(dict), reversed(dict), dict.__reversed__(), dict.keys(), iter(dict.keys()) 및 reversed(dict.keys()))을 사용할 때 성능 저하가 발생할 수 있습니다. 지연 객체를 전혀 포함하지 않는 대부분의 딕셔너리에서 이러한 성능 저하를 방지하기 위해, dk_kind 필드에서 비트 하나를 가져와 새 dk_lazy_imports 플래그에 사용하고, 딕셔너리가 지연 객체를 포함할 수 있는지 여부를 추적합니다.
이 구현은 지연 객체가 노출되는 것을 포괄적으로 방지하여, 누구든 어떤 용도로든 지연 객체를 가져가기 전에 항상 실제로 가져온 객체로 해석되도록 보장하면서도 일반적인 딕셔너리에는 유의미한 성능 영향을 주지 않습니다.
사양
지연 임포트는 선택적으로 활성화되며, Python 인터프리터에 새 -L 플래그를 지정하거나 새 importlib.set_lazy_imports() 함수를 호출하여 전역적으로 활성화할 수 있습니다. 이 함수는 불리언 enabled와 excluding 컨테이너라는 두 인자를 받습니다. enabled가 참이면, 그 시점부터 지연 임포트를 활성화합니다. 거짓이면, 그 시점부터 지연 임포트를 비활성화합니다. (excluding 키워드의 사용법은 아래의 “모듈별 옵트아웃”에서 설명합니다.)
-L 플래그를 Python 인터프리터에 전달하면 새 sys.flags.lazy_imports가 True로 설정되고, 그렇지 않으면 False로 존재합니다. 이 플래그는 새 Python 하위 프로세스에 -L을 전달하는 데 사용됩니다.
sys.flags.lazy_imports의 플래그는 지연 임포트의 현재 상태를 반드시 반영하는 것은 아니며, 인터프리터가 -L 옵션으로 시작되었는지만 나타냅니다. 지연 임포트가 활성화되어 있는지 여부의 실제 현재 상태는 언제든 importlib.is_lazy_imports_enabled()를 사용하여 가져올 수 있으며, 호출 시점에 지연 임포트가 활성화되어 있으면 True를, 그렇지 않으면 False를 반환합니다.
지연 임포트가 활성화되면 모든(그리고 오직) 최상위 임포트의 로딩과 실행이 가져온 이름을 처음 사용할 때까지 지연됩니다. 이는 즉시 발생할 수도 있고(예를 들어 임포트 문 바로 다음 줄에서) 훨씬 나중에 발생할 수도 있습니다(예를 들어 나중에 다른 코드가 호출한 함수 내부에서 해당 이름을 사용하는 경우).
이러한 최상위 임포트에는 해당 임포트를 지연하지 않고 즉시 수행하게 만드는 두 가지 컨텍스트가 있습니다: try / except / finally 또는 with 블록 내부의 임포트와 별표 임포트(from foo import *.)입니다. 예외 처리 블록 내부의 임포트(with 블록도 포함합니다. 이러한 블록 역시 예외를 “catch”하고 처리할 수 있기 때문입니다)는 임포트에서 발생하는 모든 예외를 처리할 수 있도록 즉시 수행되는 상태로 유지됩니다. 별표 임포트는 어떤 이름을 네임스페이스에 추가해야 하는지 알 수 있는 유일한 방법이 임포트를 수행하는 것이므로 계속 즉시 임포트여야 합니다.
클래스 정의 내부 또는 함수/메서드 내부의 임포트는 “최상위”가 아니므로 절대 지연되지 않습니다.
__import__() 또는 importlib.import_module()을 사용하는 동적 임포트도 절대 지연되지 않습니다.
지연 임포트 상태(즉, 지연 임포트가 활성화되었는지 여부와 제외된 모듈(아래 참조))는 인터프리터별로 지정되지만 인터프리터 내에서는 전역적입니다(즉, 모든 스레드에 영향을 줍니다).
예제
spam.py라는 모듈이 있다고 합시다.:
# simulate some work
import time
time.sleep(10)
print("spam loaded")
그리고 이를 임포트하는 eggs.py라는 모듈이 있다고 합시다.:
import spam
print("imports done")
python -L eggs.py를 실행하면 spam 모듈은 절대 임포트되지 않고(임포트 이후 참조되지 않기 때문입니다), "spam loaded"도 절대 출력되지 않으며, 10초의 지연도 발생하지 않습니다.
그러나 eggs.py가 임포트한 후 spam이라는 이름을 단순히 참조하기만 해도 spam.py의 임포트를 트리거하기에 충분합니다.:
import spam
print("imports done")
spam
이제 python -L eggs.py를 실행하면 먼저 "imports done"이 출력되고, 그 다음 10초의 지연이 발생한 후 "spam loaded"가 출력되는 것을 볼 수 있습니다.
물론 실제 사용 사례에서는(특히 지연 임포트를 사용할 때) 이와 같은 임포트 부작용에 의존하여 실제 작업을 트리거하는 것은 권장되지 않습니다. 이 예제는 지연 임포트의 동작을 명확히 설명하기 위한 것일 뿐입니다.
지연 임포트의 효과를 설명하는 또 다른 방법은 각 지연 임포트 문이 소스 코드에서 가져온 이름을 사용할 때마다 그 바로 앞에 인라인으로 작성된 것과 같다고 보는 것입니다. 따라서 지연 임포트를 다음 코드를 변환하는 것과 유사하다고 생각할 수 있습니다.:
import foo
def func1():
return foo.bar()
def func2():
return foo.baz()
다음과 같이 변환하는 것입니다.:
def func1():
import foo
return foo.bar()
def func2():
import foo
return foo.baz()
이는 지연 임포트에서 foo의 임포트가 언제 발생하는지 잘 보여 주지만, 지연 임포트는 실제로 이 코드 변환과 동등하지는 않습니다. 주목할 만한 차이점이 몇 가지 있습니다.
- 후자의 코드와 달리 지연 임포트에서는 이름
foo가 모듈의 전역 네임스페이스에 여전히 존재하며, 이 모듈을 임포트하는 다른 모듈에서 임포트하거나 참조할 수 있습니다. (이러한 참조도 임포트를 트리거합니다.) - 지연 임포트의 런타임 오버헤드는 후자의 코드보다 훨씬 낮습니다. 임포트를 트리거하는 이름
foo에 대한 첫 번째 참조 이후의 후속 참조에서는 임포트 시스템 오버헤드가 전혀 발생하지 않으며, 일반적인 이름 참조와 구별할 수 없습니다.
어떤 의미에서 지연 임포트는 임포트 문을 임포트된 이름 또는 이름들의 선언으로만 바꾸며, 나중에 참조될 때 완전히 해석합니다.
from foo import bar형식의 임포트도 지연 임포트로 만들 수 있습니다. 임포트가 발생하면 이름 bar가 지연 임포트로 모듈 네임스페이스에 추가됩니다. bar에 대한 첫 번째 참조는 foo를 임포트하고 bar를 foo.bar로 해석합니다.
의도된 사용 방식
지연 임포트는 잠재적으로 호환성을 깨뜨릴 수 있는 의미 변경이므로, 새로운 의미 체계에서 애플리케이션을 철저히 테스트하고, 예상대로 동작하는지 확인하며, 필요에 따라 특정 임포트를 제외할 준비가 된 Python 애플리케이션의 작성자 또는 유지 관리자만 활성화해야 합니다(아래 참조). Python 애플리케이션의 최종 사용자는 성공을 기대하며 지연 임포트를 추측에 따라 활성화해서는 안 됩니다.
애플리케이션에서 지연 임포트를 활성화하는 애플리케이션 개발자는 애플리케이션이 올바르게 작동하려면 즉시 임포트되어야 하는 것으로 판명된 모든 라이브러리 임포트를 제외할 책임이 있으며, 지연 임포트에서 라이브러리가 정확히 동일하게 동작하도록 보장할 책임은 라이브러리 작성자에게 있지 않습니다.
이 기능, -L플래그 및 새로운 importlibAPI의 문서에는 테스트 없이 도입할 경우의 의도된 사용 방식과 위험이 명확히 설명됩니다.
구현
지연 임포트는 내부적으로 “지연 임포트” 객체로 표현됩니다. 지연 임포트가 발생하면(예를 들어 import foo또는 from foo import bar), 키 "foo"또는 "bar"가 즉시 모듈 네임스페이스 딕셔너리에 추가되지만, 그 값은 나중에 임포트를 실행하는 데 필요한 모든 메타데이터를 보존하는 내부 전용 “지연 임포트” 객체로 설정됩니다.
PyDictKeysObject의 새로운 불리언 플래그(dk_lazy_imports)가 설정되어 이 특정 딕셔너리에 지연 임포트 객체가 포함될 수 있음을 나타냅니다. 이 플래그는 딕셔너리에 지연 객체가 포함될 수 있을 때 “일괄” 작업에서 모든 지연 객체를 효율적으로 해석하는 데만 사용됩니다.
값을 추출하기 위해 딕셔너리에서 키를 조회할 때마다 해당 값이 지연 임포트 객체인지 확인합니다. 지연 임포트 객체인 경우 지연 객체가 즉시 해석되고, 관련 임포트 모듈이 실행되며, 가능하면 딕셔너리의 지연 임포트 객체가 실제 임포트 값으로 대체되고, 해석된 값이 조회 함수에서 반환됩니다. 지연 임포트 객체를 해석하는 동안 임포트 부작용의 일부로 딕셔너리가 변경될 수 있습니다. 이 경우 키의 값을 해석된 객체로 효율적으로 대체할 수 없습니다. 이 경우 지연 임포트 객체는 해석된 객체에 대한 캐시된 포인터를 획득합니다. 다음 액세스에서는 캐시된 참조가 반환되고, 딕셔너리의 지연 임포트 객체가 해석된 값으로 대체됩니다.
이 모든 과정이 딕셔너리 구현에 의해 내부적으로 처리되므로, 지연 임포트 객체가 모듈 네임스페이스 밖으로 빠져나가 Python 코드에 표시되는 일은 결코 없습니다. 지연 임포트 객체는 항상 첫 번째 참조에서 해석됩니다. 스텁, 더미 또는 썽크 객체는 어느 것도 Python 코드에 표시되거나 sys.modules에 배치되지 않습니다. 모듈이 지연 임포트되면 첫 번째 참조에서 실제로 임포트될 때까지 해당 모듈의 항목은 sys.modules에 전혀 나타나지 않습니다.
서로 다른 두 모듈(moda 및 modb)에 모두 지연된 import foo가 포함되어 있으면, 각 모듈의 네임스페이스 딕셔너리에는 키 "foo"아래에 독립적인 지연 임포트 객체가 존재하여 동일한 foo 모듈의 임포트를 지연합니다. 이는 문제가 되지 않습니다. 예를 들어 moda.foo에 대한 참조가 처음 발생하면, 일반적인 경우와 마찬가지로 foo 모듈이 임포트되어 sys.modules에 배치되고, 키 moda.__dict__["foo"]아래의 지연 객체가 실제 foo 모듈로 대체됩니다. 이 시점에도 modb.__dict__["foo"]는 지연 임포트 객체로 남아 있습니다. 이후 modb.foo가 참조되면 이 객체도 import foo를 시도합니다. 이 임포트는 Python에서 동일한 모듈을 후속 임포트할 때 일반적으로 발생하는 것처럼 sys.modules에 이미 존재하는 모듈을 찾으며, 이 시점에 modb.__dict__["foo"]의 지연 임포트 객체를 실제 foo 모듈로 대체합니다.
지연 임포트 객체가 딕셔너리 밖으로 빠져나갈 수 있는 경우는 두 가지입니다.
- 다른 딕셔너리로 복사되는 경우:
dict.update()및dict.copy()와 같은 대량 복사 연산의 성능을 유지하기 위해 이러한 연산은 지연 임포트 객체를 검사하거나 해결하지 않습니다. 그러나 원본 딕셔너리에 지연 객체를 포함할 수 있음을 나타내는dk_lazy_imports플래그가 설정되어 있으면, 해당 플래그가 갱신되거나 복사되는 딕셔너리로 전달됩니다. 이렇게 하면 지연 임포트 객체가 해결되지 않은 채 Python 코드로 빠져나가는 일이 방지됩니다. - 가비지 수집기를 통하는 경우: 지연 임포트된 객체도 여전히 Python 객체이며 가비지 수집기 내부에 존재하므로, 수집될 수 있고
gc.get_objects()와 같은 방법으로 확인할 수 있습니다. 지연 객체가 이런 방식으로 Python 코드에 표시되면 불투명하고 비활성 상태이며, 유용한 메서드나 속성이 없습니다. 이 객체의repr()은 다음과 같은 형태로 표시됩니다:<lazy_object 'fully.qualified.name'>.
지연 객체가 딕셔너리에 추가되면 dk_lazy_imports플래그가 설정됩니다. 한 번 설정된 플래그는 딕셔너리의 모든 지연 임포트 객체가 해결된 경우에만, 예를 들어 딕셔너리 순회를 시작하기 전에 해제됩니다.
값이 포함된 모든 딕셔너리 순회 메서드(예: dict.items(), dict.values(), PyDict_Next() 등)는 순회를 시작하기 전에 딕셔너리의 모든 지연 임포트 객체를 해결하려고 시도합니다. dk_lazy_imports가 설정되는 딕셔너리는 일부 모듈 네임스페이스 딕셔너리뿐이므로, 딕셔너리 내부의 모든 지연 임포트 객체를 해결하는 추가 비용은 해당 비용이 필요한 딕셔너리에서만 발생합니다. 일반적인 비지연 딕셔너리의 오버헤드를 최소화하는 것이 dk_lazy_imports플래그의 유일한 목적입니다.
PyDict_Next는 위치 0에 처음 액세스할 때 모든 지연 임포트 객체를 해결하려고 시도하며, 해당 임포트가 예외와 함께 실패할 수 있습니다. PyDict_Next는 예외를 설정할 수 없으므로, 이 경우 PyDict_Next는 즉시 0을 반환하고 모든 예외는 처리할 수 없는 예외로 stderr에 출력됩니다.
이러한 이유로 이 PEP는 PyDict_Next와 동일한 방식으로 작동하지만 0을 반환할 때 오류를 설정할 수 있는 PyDict_NextWithError를 도입하며, 호출 후 PyErr_Occurred()를 통해 이를 확인해야 합니다.
try / except / with 블록 내부 또는 클래스나 함수 본문 내부 임포트의 즉시성은 항상 즉시 임포트하는 새로운 EAGER_IMPORT_NAME옵코드를 통해 컴파일러에서 처리됩니다. 최상위 임포트에는 IMPORT_NAME이 사용되며, 이는 -L 및/또는 importlib.set_lazy_imports()에 따라 지연 임포트일 수도 있고 즉시 임포트일 수도 있습니다.
디버깅
python -v의 디버그 로깅에는 임포트 문이 발견되었지만 임포트 실행이 지연될 때마다 해당 내용이 기록됩니다.
임포트 비용을 프로파일링하는 Python의 -X importtime 기능은 지연 임포트에 자연스럽게 적용되며, 프로파일링된 시간은 실제로 임포트하는 데 소요된 시간입니다.
지연 임포트 객체는 일반적으로 Python 코드에 표시되지 않지만, 일부 디버깅 상황에서는 해결을 트리거하지 않고 특정 딕셔너리의 특정 키에 있는 값이 지연 임포트 객체인지 Python 코드에서 확인하는 것이 유용할 수 있습니다. 이러한 목적에는 importlib.is_lazy_import()를 사용할 수 있습니다.:
from importlib import is_lazy_import
import foo
is_lazy_import(globals(), "foo")
foo
is_lazy_import(globals(), "foo")
이 예에서 지연 임포트가 활성화되어 있으면 is_lazy_import에 대한 첫 번째 호출은 True를 반환하고 두 번째 호출은 False를 반환합니다.
모듈별 옵트아웃
아래에서 언급하는 하위 호환성 문제로 인해 지연 임포트를 사용하는 애플리케이션에서는 일부 임포트를 즉시 임포트로 강제해야 할 수 있습니다.
퍼스트 파티 코드에서는 try 또는 with 블록 내부의 임포트가 절대 지연되지 않으므로, 이를 쉽게 수행할 수 있습니다.:
try: # force these imports to be eager
import foo
import bar
finally:
pass
이 PEP는 새로운 importlib.eager_imports() 컨텍스트 관리자를 추가하여 위 기법을 더 간결하게 만들고, 그 의도를 명확히 하기 위한 주석이 필요하지 않도록 제안합니다.:
from importlib import eager_imports
with eager_imports():
import foo
import bar
컨텍스트 관리자 내부의 임포트는 항상 즉시 수행되므로, eager_imports() 컨텍스트 관리자는 널 컨텍스트 관리자의 별칭으로 사용할 수 있습니다. 컨텍스트 관리자의 효과는 전이되지 않습니다. foo 및 bar는 즉시 임포트되지만, 해당 모듈 내부의 임포트는 계속 일반적인 지연 규칙을 따릅니다.
쉽게 수정할 수 없는 서드파티 코드의 임포트를 즉시 수행하도록 강제해야 하는 경우에는 더 어려운 상황이 발생할 수 있습니다. 이를 위해 importlib.set_lazy_imports()는 두 번째 선택적 키워드 전용 excluding 인자를 받으며, 이 인자를 모든 임포트가 즉시 수행될 모듈 이름 컨테이너로 설정할 수 있습니다.:
from importlib import set_lazy_imports
set_lazy_imports(excluding=["one.mod", "another"])
이 효과 역시 얕습니다. one.mod 내부의 모든 임포트는 즉시 수행되지만, one.mod가 임포트하는 모든 모듈 내부의 임포트까지 즉시 수행되지는 않습니다.
set_lazy_imports()의 excluding 매개변수는 모듈 이름을 포함하는지 확인할 수 있는 모든 종류의 컨테이너가 될 수 있습니다. 모듈 이름이 해당 객체에 포함되어 있으면 해당 모듈 내부의 임포트는 즉시 수행됩니다. 따라서 임의의 옵트아웃 로직을 __contains__ 메서드에 인코딩할 수 있습니다.:
import re
from importlib import set_lazy_imports
class Checker:
def __contains__(self, name):
return re.match(r"foo\.[^.]+\.logger", name)
set_lazy_imports(excluding=Checker())
Python이 -L 플래그로 실행되었다면 지연 임포트가 이미 전역적으로 활성화되며, set_lazy_imports(True, excluding=...)를 호출했을 때의 유일한 효과는 즉시 임포트할 모듈 이름/콜백을 전역적으로 설정하는 것입니다. set_lazy_imports(True)를 excluding 인자 없이 호출하면 제외 목록/콜백이 지워지고, 그 시점부터 모든 적격 임포트(모듈 수준 임포트 중 try/except/with 내부에 있지 않고 import *도 아닌 임포트)가 지연됩니다.
이 옵트아웃 시스템은 임포트의 지연 여부를 로컬에서 추론할 수 있는 가능성을 유지하도록 설계되었습니다. 한 모듈의 코드와, 있는 경우 set_lazy_imports의 excluding 인자만 확인하면 특정 임포트가 즉시 수행될지 지연될지 알 수 있습니다.
테스트
지연 임포트를 활성화해도 CPython 테스트 스위트는 통과합니다(일부 테스트는 건너뜁니다). 하나의 빌드봇은 지연 임포트를 활성화한 상태로 테스트 스위트를 실행해야 합니다.
C API
C 확장 모듈 작성자를 위해, 제안된 공개 C API는 다음과 같습니다:
| C API | Python API |
|---|---|
PyObject *PyImport_SetLazyImports(PyObject *enabled, PyObject *excluding) |
importlib.set_lazy_imports(enabled: bool = True, *, excluding: typing.Container[str] | None = None) |
int PyDict_IsLazyImport(PyObject *dict, PyObject *name) |
importlib.is_lazy_import(dict: typing.Dict[str, object], name: str) -> bool |
int PyImport_IsLazyImportsEnabled() |
importlib.is_lazy_imports_enabled() -> bool |
void PyDict_ResolveLazyImports(PyObject *dict) |
|
PyDict_NextWithError() |
void PyDict_ResolveLazyImports(PyObject *dict)는 딕셔너리에 지연된 객체가 있으면 모두 해결합니다.PyDict_NextWithError()또는PyDict_Next()를 호출하기 전에 사용합니다.PyDict_NextWithError()는PyDict_Next()와 동일하게 작동하지만, 오류가 발생하면 예외를 설정하고0을 반환하여 호출자에게 전파한다는 점이 다릅니다. 호출자는 오류가 있는지 확인하기 위해PyErr_Occurred()를 사용해야 합니다.
하위 호환성
이 제안은 기능이 비활성화된 경우(기본값) 완전한 하위 호환성을 유지합니다.
기능이 활성화된 경우에도 대부분의 코드는 관찰 가능한 변경 없이 정상적으로 계속 작동합니다(시작 시간과 메모리 사용량이 개선되는 경우는 제외합니다). 네임스페이스 패키지는 영향을 받지 않으며, 현재와 동일하게 작동하되 지연된다는 점만 다릅니다.
기존 코드 중 일부에서는 지연 임포트로 인해 현재 예상하지 못한 결과와 동작이 발생할 수 있습니다. 기존 코드베이스에서 지연 임포트를 활성화할 때 발생할 수 있는 문제는 다음과 관련됩니다.
임포트 부작용
임포트 문이 실행되는 동안 임포트된 모듈을 실행하여 발생했을 임포트 부작용은 임포트된 객체가 사용될 때까지 지연됩니다.
이러한 임포트 부작용에는 다음이 포함될 수 있습니다.
- 임포트 중 부작용을 일으키는 로직을 실행하는 코드;
- 임포트된 서브모듈이 부모 모듈의 속성으로 설정된다는 사실에 의존하는 것.
관련성이 높고 전형적인 영향을 받는 사례는 Python 명령줄 인터페이스를 구축하기 위한 click 라이브러리입니다. 예를 들어 cli = click.group()이 main.py에 정의되어 있고, sub.py가 main에서 cli를 가져와 데코레이터(@cli.command(...))를 통해 하위 명령을 추가하지만 실제 cli() 호출은 main.py에 있다면, 지연 가져오기로 인해 하위 명령이 등록되지 않을 수 있습니다. 이 경우 Click은 sub.py의 가져오기로 인한 부수 효과에 의존하기 때문입니다. 이 경우의 해결 방법은 sub.py의 가져오기가 즉시 수행되도록 보장하는 것이며, 예를 들어 importlib.eager_imports() 컨텍스트 관리자를 사용하면 됩니다.
동적 경로
동적인 Python 가져오기 경로와 관련된 문제가 발생할 수 있으며, 특히 sys.path에 경로를 추가한 다음 가져오기가 끝난 후 제거하는 경우가 그렇습니다.:
sys.path.insert(0, "/path/to/foo/module")
import foo
del sys.path[0]
foo.Bar()
이 경우 지연 가져오기를 활성화하면 sys.path에 경로가 추가되어 있는 동안 foo의 가져오기가 실제로 수행되지 않습니다.
이를 위한 쉬운 해결 방법은 코드 스타일도 개선하고 정리도 보장하도록 sys.path 수정 작업을 컨텍스트 관리자 안에 배치하는 것입니다. with 블록 내부의 가져오기는 항상 즉시 수행되므로 이 방법으로 문제가 해결됩니다.
지연된 예외
지연 가져오기 중 발생한 예외는 정상적인 가져오기 중 발생한 예외와 마찬가지로 상위로 전파되고, sys.modules에서 부분적으로 구성된 모듈을 삭제합니다.
지연 가져오기 중 발생하는 오류는 가져오기가 즉시 수행되었을 때보다 나중에 발생하므로(즉, 이름이 처음 참조되는 시점에 발생하므로), 가져오기가 자신의 try 블록 안에서 실행될 것으로 예상하지 않았던 예외 처리기에 실수로 포착될 수도 있으며, 이로 인해 혼란이 발생할 수 있습니다.
단점
이 PEP의 단점은 다음과 같습니다.
- Python 가져오기의 동작에 대해 미묘하게 호환되지 않는 의미 체계를 제공합니다. 사용자가 두 의미 체계를 모두 지원하도록 요구할 수 있는 라이브러리 작성자에게 잠재적인 부담이 되며, Python 사용자와 독자가 인지해야 할 가능성이 하나 더 생깁니다.
- 일부 널리 사용되는 Python 코딩 패턴(특히 데코레이터로 채워지는 중앙 집중식 레지스트리)은 가져오기의 부수 효과에 의존하므로, 지연 가져오기에서 예상대로 작동하려면 명시적으로 선택 해제해야 할 수 있습니다.
- 지연 가져오기를 나타내는 이름에 접근하는 동안 어느 시점에서든 예외가 발생할 수 있으며, 이로 인해 예상하지 못한 예외로 인한 혼란과 디버깅 문제가 발생할 수 있습니다.
지연 가져오기의 의미 체계는 이미 가능하고 현재 Python 표준 라이브러리에서도 지원되므로, 이러한 단점은 이 PEP에서 새롭게 도입되는 것이 아닙니다. 지금까지 일부 애플리케이션에서 지연 가져오기를 사용한 기존 사례는 문제가 되지 않았습니다. 그러나 이 PEP로 인해 지연 가져오기의 사용이 더욱 널리 퍼질 수 있으며, 잠재적으로 이러한 단점이 악화될 수 있습니다.
이러한 단점은 이 PEP의 지연 가져오기 구현이 제공하는 상당한 이점과 비교하여 평가해야 합니다. 궁극적으로 이 기능이 널리 사용되면 이러한 비용은 더 커지지만, 널리 사용된다는 것은 이 기능이 큰 가치를 제공한다는 의미이기도 하므로 비용을 정당화할 수 있습니다.
보안 관련 사항
프로세스 소유자, 셸 경로, sys.path, 또는 기타 민감한 환경이나 컨텍스트 상태가 import 문이 실행되는 시점과 가져온 객체가 처음 참조되는 시점 사이에 변경되면 코드 실행 지연으로 인해 보안 문제가 발생할 수 있습니다.
성능 영향
참조 구현은 기존의 실제 코드베이스(Instagram Server, Meta의 여러 CLI 프로그램, Meta 연구원들이 사용하는 Jupyter 노트북)에서 이 기능이 성능에 미치는 영향은 무시할 수 있는 수준인 반면, 시작 시간과 메모리 사용량은 크게 개선한다는 것을 보여주었습니다.
참조 구현은 no measurable change가 pyperformance benchmark suite의 종합 성능에서 나타났음을 보여줍니다.
이 내용을 가르치는 방법
이 기능은 선택적으로 활성화되므로 초보자가 기본적으로 접하게 되지는 않습니다. -L 플래그와 importlib.set_lazy_imports()에 대한 문서에서 지연 가져오기의 동작을 명확히 설명할 수 있습니다.
문서에서는 지연 임포트를 선택하는 것이 Python 임포트에 대한 비표준 의미 체계를 선택하는 것이며, 이로 인해 Python 라이브러리가 예상치 못한 방식으로 중단될 수 있다는 점도 명확히 해야 합니다. 이러한 중단을 식별하고 옵트아웃하거나 지연 임포트 사용을 중단하여 우회할 책임은 라이브러리 작성자가 아니라 애플리케이션에서 지연 임포트를 활성화하기로 선택한 사람에게 전적으로 있습니다. Python 라이브러리는 지연 임포트 의미 체계를 지원할 의무가 없습니다. 비호환성을 정중하게 보고하는 것은 라이브러리 작성자에게 유용할 수 있지만, 작성자는 자신의 라이브러리가 지연 임포트와 함께 사용되는 것을 지원하지 않는다고 단순히 말할 수도 있으며, 이는 유효한 선택입니다.
발생할 수 있는 일부 문제에 대처하고 지연 임포트를 더 잘 활용하기 위한 몇 가지 모범 사례는 다음과 같습니다.
- 임포트 부수 효과에 의존하지 마십시오. 임포트 부수 효과에 의존하는 가장 일반적인 사례는 아마도 레지스트리 패턴일 것입니다. 이 패턴에서는 모듈을 임포트하는 동안, 흔히 데코레이터를 통해 일부 외부 레지스트리가 암묵적으로 채워집니다. 대신 명시적으로 지정된 모듈에서 데코레이트된 함수나 클래스를 찾는 검색 프로세스를 수행하는 명시적 호출을 통해 레지스트리를 구축해야 합니다.
- 필요한 서브모듈은 항상 명시적으로 임포트하고, 다른 임포트가 모듈의 서브모듈을 속성으로 갖도록 보장할 것이라고 의존하지 마십시오. 즉,
foo/__init__.py에 명시적인from . import bar가 없는 한 항상import foo.bar; foo.bar.Baz를 사용하고,import foo; foo.bar.Baz를 사용하지 마십시오. 후자는 다른 곳에서foo.bar가 임포트된 부수 효과로foo.bar속성이 추가되기 때문에 (신뢰할 수 없지만) 작동할 뿐입니다. 지연 임포트를 사용하면 이러한 일이 항상 제때 발생하지 않을 수 있습니다. - 스타 임포트는 항상 즉시 수행되므로 사용하지 마십시오.
참조 구현
초기 구현은 Cinder의 일부로 제공됩니다. 이 참조 구현은 Meta에서 사용 중이며, 일반적인 실행 흐름에서 결국 사용되지 않는 임포트를 실행할 필요가 없으므로 시작 시간(일부 애플리케이션에서는 전체 실행 시간도)을 40%~70% 개선하고 메모리 사용량을 최대 40%까지 크게 줄이는 효과가 입증되었습니다.
CPython 메인 브랜치를 기반으로 업데이트된 참조 구현도 제공됩니다.
거부된 아이디어
지연된 예외 래핑
혼동 가능성을 줄이기 위해 지연 임포트를 실행하는 과정에서 발생한 예외를 ImportError의 서브클래스인 LazyImportError 예외로 대체하고, __cause__를 원래 예외로 설정할 수 있습니다.
모든 지연 임포트 오류가 LazyImportError로 발생하도록 보장하면 해당 오류가 실수로 포착되어 다른 예상 예외로 오인될 가능성을 줄일 수 있습니다. 그러나 실제로는 실패한 모듈이 unittest.SkipTest 예외를 발생시키는 경우(예: 테스트 내부)를 보았으며, 이 예외 역시 LazyImportError로 래핑되어 실제 예외 유형이 숨겨지기 때문에 이러한 테스트가 실패하게 됩니다. 여기서의 단점은 예상치 못한 지연 예외가 실수로 포착되는 가상의 경우보다 더 큰 것으로 보입니다.
모듈별 옵트인
퓨처 임포트를 사용하는 모듈별 옵트인(즉, from __future__ import lazy_imports)은 의미가 없습니다. __future__ 임포트는 기능 플래그가 아니라, 향후 기본값이 될 동작으로 전환하기 위한 것이기 때문입니다. 지연 임포트가 기본 동작으로서 언젠가 의미가 있을지는 분명하지 않으므로, __future__ 임포트로 이를 약속해서는 안 됩니다.
라이브러리가 특정 모듈에 대해 지연 임포트를 로컬에서 옵트인하려는 다른 경우도 있습니다. 예를 들어 대규모 라이브러리의 최상위 __init__.py를 지연 방식으로 만들어 그 하위 구성 요소에 지연 속성으로 접근할 수 있게 하는 경우입니다. 현재 이 PEP는 기능을 더 단순하게 유지하기 위해 “애플리케이션” 사용 사례에 초점을 맞추며 라이브러리 사용 사례는 다루지 않습니다. 이 PEP에서 도입한 기본 지연 메커니즘은 향후 이 사용 사례를 다루는 데에도 사용할 수 있습니다.
개별 지연 임포트를 위한 명시적 구문
지연 임포트의 주요 목적이 오로지 임포트 순환과 전방 참조를 우회하는 것이라면, 특정 대상 임포트를 지연 방식으로 만들기 위한 명시적으로 표시된 구문은 매우 합리적일 것입니다. 그러나 실제로는 이 접근 방식으로 안정적인 시작 시간 또는 메모리 사용량 개선을 얻기가 매우 어렵습니다. 코드베이스 내 대부분의 임포트와 서드파티 의존성의 임포트를 지연 임포트 구문으로 변환해야 하기 때문입니다.
주 모듈에서 서브시스템을 임포트하는 최상위 임포트만 명시적으로 지연하는 “얕은” 지연을 목표로 할 수도 있지만, 그러면 서브시스템 내부의 임포트는 모두 즉시 수행됩니다. 그러나 이는 매우 취약합니다 – 한 번 잘못 배치된 임포트만으로도 공들여 구성한 얕은 지연성이 무너집니다. 반면 지연 임포트를 전역적으로 활성화하면, 사용하는 임포트에 대해서만 항상 비용을 지불하는 심층적이고 견고한 지연성을 제공합니다.
순방향 참조를 피하기 위해 개별적으로 표시된 지연 임포트가 바람직한 사용 사례(예: 정적 타이핑)가 있을 수 있지만, 전역 지연 임포트의 perf/memory 이점은 필요하지 않습니다. 이는 동기를 부여하는 사용 사례의 집합이 다르고 새로운 구문이 필요하므로, 이 PEP에는 포함하지 않는 편을 선호합니다. 다른 PEP에서 이 구현을 기반으로 추가 구문을 제안할 수 있습니다.
지연 임포트를 활성화하는 환경 변수
환경 변수 옵트인을 제공하면 이 기능이 너무 쉽게 악용될 수 있습니다. Python 사용자가 예를 들어 자신이 실행하는 모든 Python 프로그램의 속도를 높이기를 기대하면서 셸에서 환경 변수를 전역적으로 설정하고 싶어 할 수 있습니다. 테스트되지 않은 프로그램에서 이러한 사용을 하면 해당 도구의 작성자에게 근거 없는 버그 보고와 유지 관리 부담이 발생할 가능성이 큽니다. 이를 피하기 위해 환경 변수 옵트인을 전혀 제공하지 않기로 합니다.
-L 플래그 제거
python somescript.py 또는 python -m somescript로 실행되는 개별 Python 프로그램을 최종 사용자가 실행할 때( Python 패키징 도구를 통해 배포되는 경우가 아니라면), 이론적으로 유사한 방식으로 악용할 수 있는 -L CLI 플래그를 제공하기는 합니다. 그러나 -L을 통한 오용의 잠재적 범위는 환경 변수보다 훨씬 작고, 일부 애플리케이션에서는 프로세스 시작 시점부터 모든 임포트가 지연되도록 보장하여 시작 시간 측면의 이점을 극대화하는 데 -L이 유용하므로, 이를 유지하기로 합니다.
임의의 Python 프로그램을 해당 프로그램에서 사용하도록 의도되지 않은 명령줄 플래그(예: -s, -S, -E 또는 -I)와 함께 실행하면 예기치 않은 문제가 발생하거나 프로그램이 중단될 수 있다는 것은 이미 알려진 사실입니다. 이 점에서 -L은 새로운 것이 아닙니다.
반지연 임포트
모듈 소스를 찾는 시점까지 임포트 로더를 즉시 실행한 다음, 모듈의 실제 실행과 모듈 객체 생성을 지연할 수 있습니다. 이렇게 하면 특정 종류의 임포트 오류(예: 모듈 이름의 단순한 오타)를 임포트된 이름을 사용할 때까지 지연하는 대신 즉시 포착할 수 있다는 장점이 있습니다.
단점은 사용되지 않는 임포트에도 최소한 파일 시스템 stat() 호출이 필요하므로 지연 임포트의 시작 시간 이점이 크게 줄어든다는 것입니다. 또한 지연 임포트가 활성화된 경우 어떤 임포트 오류가 즉시 발생하고 어떤 오류가 지연되는지에 대해, 쉽게 알아차리기 어려운 구분이 생길 수 있습니다.
실제로는 임포트 오타에 대한 혼란이 참조 구현에서 관찰된 문제가 아니었다는 점을 근거로, 현재로서는 이 아이디어를 받아들이지 않습니다. 일반적으로 지연된 임포트가 영원히 지연되는 것은 아니며, 오류는 이를 발견하고 수정할 수 있을 만큼 빠르게 나타납니다(임포트가 실제로 사용되지 않는 경우는 제외합니다).
반지연 임포트의 또 다른 가능한 동기는 모듈 자체가 어떤 플래그를 통해 해당 모듈을 지연 또는 즉시 임포트할지 제어하도록 허용하는 것입니다. 이는 반지연 임포트를 필요로 하여 임포트 지연성의 성능 이점 일부를 포기하게 만들 뿐 아니라, 일반적으로 모듈은 자신이 언제 또는 어떻게 임포트되는지를 결정하지 않고 해당 모듈을 임포트하는 모듈이 이를 결정한다는 점에서도 받아들이지 않습니다. 이 PEP가 그러한 제어를 뒤집어야 할 명확한 근거는 없습니다. 대신 임포트하는 코드가 결정을 내릴 수 있도록 더 많은 선택지를 제공합니다.
지연 동적 임포트
__import__() 및/또는 importlib.import_module()에 lazy=True 또는 이와 유사한 옵션을 추가하여 지연 임포트를 수행하도록 할 수 있습니다. 명확한 사용 사례가 없으므로 이 PEP에서는 이 아이디어를 받아들이지 않습니다. 동적 임포트는 이미 임포트에 관한 PEP 8의 코드 스타일 권고에서 크게 벗어나며, 코드 흐름에서 원하는 지점에 배치하면 원하는 만큼 정확하게 지연되도록 쉽게 만들 수 있습니다. 이는 지연 임포트가 적용되는 모듈 최상위 수준에서 일반적으로 사용되지 않습니다.
심층 즉시 임포트 오버라이드
제안된 importlib.eager_imports() 컨텍스트 관리자와 importlib.set_lazy_imports(excluding=...) 재정의에서 제외된 모듈은 모두 얕은 효과만 가집니다. 즉, 전이적으로 적용되는 것이 아니라 적용된 위치에서만 즉시 임포트를 강제합니다. 둘 중 하나 또는 둘 다에 대해 심층/전이 버전을 제공하는 것은 가능합니다. 이 아이디어는 구현이 복잡하고(스레드와 비동기 코드를 고려해야 합니다), 참조 구현을 사용한 경험상 필요성이 입증되지 않았으며, 임포트의 지연성에 대해 국소적으로 추론할 수 없게 만들기 때문에 이 PEP에서는 거부합니다.
심층 재정의는 혼란스러운 동작을 일으킬 수 있습니다. 전이적으로 임포트된 모듈이 여러 위치에서 임포트될 수 있으며, 그중 일부는 “심층 즉시 재정의”를 사용하고 일부는 사용하지 않기 때문입니다. 따라서 해당 모듈이 먼저 재정의가 적용되지 않는 위치에서 임포트되면, 처음에는 여전히 지연 방식으로 임포트될 수 있습니다.
심층 재정의를 사용하면 특정 임포트가 지연 방식으로 수행될지 즉시 수행될지 국소적으로 추론할 수 없습니다. 이 PEP에서 명시한 동작을 사용하면 그러한 국소적 추론이 가능합니다.
지연 임포트를 기본 동작으로 만들기
선택적 활성화 방식 대신 지연 임포트를 Python 임포트의 기본 동작 또는 유일한 동작으로 만들면 장기적으로 일부 이점이 있습니다. 라이브러리 작성자가 (결국에는) 두 의미 체계가 모두 존재할 가능성을 더 이상 고려하지 않아도 되기 때문입니다.
그러나 하위 호환성 문제가 심각하므로, 이는 __future__ 임포트를 통해 장기간에 걸쳐서만 고려할 수 있습니다. 지연 임포트가 Python의 기본 임포트 의미 체계가 되어야 하는지는 전혀 명확하지 않습니다.
이 PEP는 Python 커뮤니티가 지연 임포트를 기본 동작으로 만들지 고려하기 전에 이에 대한 더 많은 경험을 쌓아야 한다는 입장이므로, 이 문제는 전적으로 향후 작성될 수 있는 PEP에 맡깁니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.