Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

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

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

이 PEP는 임포트된 모듈을 찾고 실행하는 작업을 임포트된 객체가 처음 사용되는 순간까지 투명하게 미루는 기능을 제안합니다. Python 프로그램은 보통 프로그램을 한 번 실행할 때 실제로 사용할 가능성이 있는 것보다 훨씬 많은 모듈을 임포트하므로, 지연 임포트는 로드되는 모듈의 전체 수를 크게 줄여 시작 시간과 메모리 사용량을 개선할 수 있습니다. 지연 임포트는 또한 순환 임포트의 위험도 대부분 없애 줍니다.

동기

일반적인 Python 코드 스타일은 임포트된 객체가 사용되는 각 스코프에서 임포트를 반복하지 않아도 되도록, 그리고 런타임에 임포트 시스템을 반복 실행하는 비효율을 피하도록 임포트를 모듈 수준에 두는 것을 선호합니다. 이는 프로그램의 메인 모듈을 임포트하면 일반적으로 프로그램에 언젠가 필요할 수 있는 모듈의 대부분 또는 전부가 즉시 연쇄적으로 임포트된다는 뜻입니다.

여러 개의 하위 명령을 가진 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은 이 관행을 lazy_loader 라이브러리 형태로 정리했으며, 이 라이브러리를 패키지의 __init__.py에서 명시적으로 사용하면 지연 접근이 가능한 하위 모듈을 제공할 수 있습니다.

정적 타이핑 사용자도 런타임에는 전혀 사용되지 않을 수 있는 이름을 타입 어노테이션에 쓰기 위해 임포트해야 합니다(어노테이션의 즉시 런타임 평가를 피하려고 PEP 563이나 향후에는 PEP 649를 사용하는 경우). 이런 상황에서 지연 임포트는 불필요한 임포트의 오버헤드를 피할 수 있어 매우 매력적입니다.

이 PEP는 위의 모든 사용 사례를 아우를 수 있고 실제 사용 환경에서 감지할 수 있는 오버헤드를 일으키지 않는, 더 일반적이고 포괄적인 지연 임포트 해법을 제안합니다. 이 PEP의 구현은 실제 Python CLI에서 시작 시간을 최대 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 필드에서 1비트를 빌려 새로운 dk_lazy_imports 플래그로 사용하며, 이 플래그로 딕셔너리가 지연 객체를 포함할 수 있는지 여부를 추적합니다.

이 구현은 지연 객체의 유출을 포괄적으로 방지하여, 누군가 어떤 용도로든 지연 객체를 손에 넣기 전에 항상 실제로 임포트된 객체로 해석되도록 보장하면서도, 딕셔너리 전반의 성능에는 큰 영향을 주지 않습니다.

명세

지연 임포트는 옵트인 방식이며, Python 인터프리터의 새로운 -L 플래그를 사용하거나 새로운 importlib.set_lazy_imports() 함수를 호출하여 전역적으로 활성화할 수 있습니다. 이 함수는 불리언 enabled와 excluding 컨테이너라는 두 개의 인자를 받습니다. enabled가 참이면, 그 시점부터 지연 임포트가 켜집니다. 거짓이면, 그 시점부터 지연 임포트가 꺼집니다. (excluding 키워드의 사용법은 아래의 “모듈별 옵트아웃”에서 설명합니다.)

Python 인터프리터에 -L 플래그가 전달되면 새로운 sys.flags.lazy_imports가 True로 설정되고, 그렇지 않으면 False로 존재합니다. 이 플래그는 새로운 Python 하위 프로세스에 -L을 전파하는 데 사용됩니다.

sys.flags.lazy_imports의 플래그는 지연 임포트의 현재 상태를 반드시 반영하지는 않으며, 인터프리터가 -L 옵션으로 시작되었는지 여부만을 나타냅니다. 특정 시점에 지연 임포트가 활성화되어 있는지에 대한 실제 현재 상태는 importlib.is_lazy_imports_enabled()를 사용하여 확인할 수 있으며, 이 함수는 호출 지점에서 지연 임포트가 활성화되어 있으면 True를, 그렇지 않으면 False를 반환합니다.

지연 임포트가 활성화되면, 모든 (그리고 오직) 최상위 임포트의 로딩과 실행이 임포트된 이름이 처음 사용될 때까지 지연됩니다. 이는 즉시 일어날 수도 있고(예: import 문 바로 다음 줄에서), 훨씬 나중에 일어날 수도 있습니다(예: 나중에 다른 코드가 호출하는 함수 내부에서 그 이름을 사용할 때).

이러한 최상위 임포트에 대해, 임포트를 즉시(지연되지 않게) 실행하도록 만드는 두 가지 상황이 있습니다. try / except / finally 또는 with 블록 내부의 임포트와 스타 임포트(from foo import *)입니다. 예외 처리 블록 내부의 임포트(with 블록도 예외를 “잡아서” 처리할 수 있으므로 여기에 포함됩니다)는 임포트에서 발생하는 모든 예외를 처리할 수 있도록 즉시 실행 상태로 유지됩니다. 스타 임포트는 어떤 이름을 네임스페이스에 추가해야 하는지 알 수 있는 유일한 방법이 임포트를 실제로 수행하는 것이므로 즉시 실행 상태로 유지되어야 합니다.

클래스 정의 내부나 함수/메서드 내부의 임포트는 “최상위”가 아니므로 절대 지연되지 않습니다.

__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 문이 소스 코드에서 임포트된 이름이 사용되는 각 위치 바로 앞에 인라인으로 작성된 것과 같다고 보는 것입니다. 따라서 지연 임포트는 다음 코드를 변환하는 것과 비슷하다고 생각할 수 있습니다.:

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에 대한 첫 번째 참조 이후, 뒤이은 참조에는 임포트 시스템 오버헤드가 전혀 없으며 일반적인 이름 참조와 구별할 수 없습니다.

어떤 의미에서 지연 임포트는 import 문을 하나 이상의 임포트된 이름에 대한 선언으로 바꿀 뿐이며, 그 이름은 나중에 참조될 때 완전히 해석됩니다.

from foo import bar 형태의 임포트도 지연시킬 수 있습니다. 임포트가 일어나면 이름 bar가 지연 임포트로서 모듈 네임스페이스에 추가됩니다. bar에 대한 첫 번째 참조는 foo를 임포트하고 bar를 foo.bar로 해석합니다.

의도된 사용법

지연 임포트는 기존 동작을 깨뜨릴 수 있는 의미론적 변경이므로, 새로운 의미론에서 애플리케이션을 철저히 테스트하고, 애플리케이션이 예상대로 동작하는지 확인하며, 필요에 따라 특정 임포트를 옵트아웃(아래 참조)할 준비가 된 Python 애플리케이션의 작성자나 유지 관리자만 이를 활성화해야 합니다. Python 애플리케이션의 최종 사용자가 성공을 기대하며 지연 임포트를 막연히 활성화해서는 안 됩니다.

애플리케이션이 올바르게 동작하려면 즉시 임포트되어야 하는 것으로 밝혀진 라이브러리 임포트를 옵트아웃하는 것은 자신의 애플리케이션에 지연 임포트를 활성화하는 애플리케이션 개발자의 책임입니다. 라이브러리가 지연 임포트에서도 정확히 똑같이 동작하도록 보장하는 것은 라이브러리 작성자의 책임이 아닙니다.

이 기능과 -L 플래그, 새로운 importlib API에 대한 문서는 의도된 사용법과 테스트 없이 도입할 때의 위험을 명확하게 설명할 것입니다.

구현

지연 임포트는 내부적으로 “지연 임포트” 객체로 표현됩니다. 지연 임포트가 일어나면(예를 들어 import foo 또는 from foo import bar), 키 "foo" 또는 "bar"가 모듈 네임스페이스 딕셔너리에 즉시 추가됩니다. 다만 그 값은 나중에 임포트를 실행하는 데 필요한 모든 메타데이터를 보존하는 내부 전용 “지연 임포트” 객체로 설정됩니다.

PyDictKeysObject의 새로운 불리언 플래그(dk_lazy_imports)가 설정되어 이 특정 딕셔너리에 지연 임포트 객체가 들어 있을 수 있음을 알립니다. 이 플래그는 딕셔너리에 지연 객체가 들어 있을 수 있을 때 “일괄” 연산에서 모든 지연 객체를 효율적으로 해석하는 데에만 사용됩니다.

값을 꺼내기 위해 딕셔너리에서 키를 조회할 때마다, 그 값이 지연 임포트 객체인지 검사합니다. 지연 임포트 객체라면 그 지연 객체를 즉시 해석하고, 관련된 임포트 대상 모듈을 실행하며, 딕셔너리의 지연 임포트 객체를 (가능하다면) 실제로 임포트된 값으로 교체하고, 해석된 값을 조회 함수에서 반환합니다. 지연 임포트 객체를 해석하는 동안 임포트의 부수 효과로 딕셔너리가 변경될 수 있습니다. 이 경우에는 키의 값을 해석된 객체로 효율적으로 교체할 수 없습니다. 이 경우 지연 임포트 객체는 해석된 객체를 가리키는 캐시된 포인터를 갖게 됩니다. 다음 접근 시 그 캐시된 참조가 반환되고, 딕셔너리의 지연 임포트 객체가 해석된 값으로 교체됩니다.

이 모든 것이 딕셔너리 구현 내부에서 처리되므로, 지연 임포트 객체가 모듈 네임스페이스 밖으로 새어 나가 Python 코드에 노출되는 일은 결코 없습니다. 지연 임포트 객체는 항상 첫 번째 참조 시점에 해석됩니다. 스텁(stub), 더미(dummy), 썽크(thunk) 객체가 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을 반환하고, 발생한 예외는 발생시킬 수 없는 예외(unraisable exception)로 stderr에 출력됩니다.

이런 이유로 이 PEP는 PyDict_NextWithError를 도입합니다. 이 함수는 PyDict_Next와 같은 방식으로 동작하지만, 0을 반환할 때 오류를 설정할 수 있으므로 호출 후 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() 컨텍스트 관리자는 단순히 null 컨텍스트 관리자의 별칭이면 됩니다. 이 컨텍스트 관리자의 효과는 전이되지 않습니다. 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=...)를 호출했을 때의 유일한 효과는 즉시 임포트할 모듈 이름/콜백을 전역적으로 설정하는 것입니다. excluding 인자 없이 set_lazy_imports(True)를 호출하면 제외 목록/콜백이 지워지고, 그 시점부터 적격한 모든 임포트(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()를 사용하여 오류가 있는지 확인해야 합니다.

하위 호환성

이 제안은 기능이 비활성화되어 있을 때(기본값) 완전한 하위 호환성을 유지합니다.

기능이 활성화된 경우에도 대부분의 코드는 (시작 시간과 메모리 사용량이 개선되는 것 외에는) 눈에 띄는 변화 없이 계속 정상적으로 동작합니다. 네임스페이스 패키지는 영향을 받지 않습니다. 지연 방식으로 동작한다는 점만 제외하면 현재와 똑같이 동작합니다.

일부 기존 코드에서는 지연 임포트가 현재로서는 예상하지 못한 결과와 동작을 일으킬 수 있습니다. 기존 코드베이스에서 지연 임포트를 활성화할 때 나타날 수 있는 문제는 다음과 관련이 있습니다.

임포트 부작용

원래는 import 문이 실행되는 동안 임포트된 모듈이 실행되면서 발생했을 임포트 부작용이 임포트된 객체가 사용될 때까지 지연됩니다.

이러한 임포트 부작용에는 다음이 포함될 수 있습니다.

  • 임포트 중에 부작용이 있는 로직을 실행하는 코드;
  • 임포트된 하위 모듈이 부모 모듈의 속성으로 설정되는 것에 의존하는 경우.

이와 관련하여 영향을 받는 전형적인 사례는 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 서버, Meta의 여러 CLI 프로그램, Meta 연구원들이 사용하는 Jupyter 노트북)의 성능에 미치는 영향은 무시할 만한 수준이면서도 시작 시간과 메모리 사용량은 크게 개선한다는 것을 보여 주었습니다.

참조 구현은 pyperformance 벤치마크 모음의 종합 성능에서 측정 가능한 변화가 없음을 보여 줍니다.

교육 방법

이 기능은 옵트인 방식이므로 초보자는 기본적으로 이 기능을 접하지 않습니다. -L 플래그와 importlib.set_lazy_imports()에 대한 문서가 지연 임포트의 동작을 명확히 설명할 수 있습니다.

또한 문서에서는 지연 임포트를 선택하는 것이 Python 임포트의 비표준 의미 체계를 선택하는 것이며, 이로 인해 Python 라이브러리가 예상치 못한 방식으로 깨질 수 있다는 점을 명확히 해야 합니다. 이러한 문제를 찾아내고 옵트아웃으로 우회하는(또는 지연 임포트 사용을 중단하는) 책임은 전적으로 자신의 애플리케이션에 지연 임포트를 활성화하기로 선택한 사람에게 있으며, 라이브러리 작성자에게 있지 않습니다. Python 라이브러리는 지연 임포트 의미 체계를 지원할 의무가 전혀 없습니다. 비호환성을 정중하게 보고하면 라이브러리 작성자에게 도움이 될 수 있지만, 작성자는 자신의 라이브러리가 지연 임포트와 함께 사용하는 것을 지원하지 않는다고 답할 수도 있으며, 이는 정당한 선택입니다.

발생할 수 있는 몇 가지 문제에 대처하고 지연 임포트를 더 잘 활용하기 위한 모범 사례는 다음과 같습니다:

  • 임포트 부수 효과에 의존하지 마십시오. 임포트 부수 효과에 의존하는 가장 흔한 경우는 아마도 레지스트리 패턴일 것입니다. 이 패턴에서는 모듈을 임포트하는 동안 어떤 외부 레지스트리가 암묵적으로 채워지며, 흔히 데코레이터를 통해 채워집니다. 그 대신 레지스트리는 명시적으로 지정한 모듈에서 데코레이터가 적용된 함수나 클래스를 찾는 탐색 과정을 수행하는 명시적 호출을 통해 구축해야 합니다.
  • 필요한 하위 모듈은 항상 명시적으로 임포트하십시오. 모듈이 하위 모듈을 속성으로 갖도록 보장하는 데 다른 임포트에 의존하지 마십시오. 즉, foo/__init__.py에 명시적인 from . import bar가 없다면 항상 import foo; foo.bar.Baz가 아니라 import foo.bar; foo.bar.Baz를 사용하십시오. 후자는 foo.bar가 다른 곳에서 임포트되는 부수 효과로 foo.bar 속성이 추가되기 때문에 (불안정하게) 동작할 뿐입니다. 지연 임포트를 사용하면 이 일이 항상 제때 일어나지는 않을 수 있습니다.
  • 스타 임포트는 항상 즉시 수행되므로 사용하지 마십시오.

참조 구현

초기 구현은 Cinder의 일부로 제공됩니다. 이 참조 구현은 Meta 내부에서 사용되고 있으며, 일반적인 흐름에서 결국 사용되지 않는 임포트를 실행할 필요가 없어진 덕분에 시작 시간(일부 애플리케이션에서는 전체 런타임)을 40%-70% 범위로 개선하고 메모리 사용량을 크게(최대 40%) 줄이는 것으로 입증되었습니다.

CPython main 브랜치를 기반으로 업데이트된 참조 구현도 제공됩니다.

기각된 아이디어

지연된 예외 감싸기

혼란의 가능성을 줄이기 위해 지연 임포트를 실행하는 과정에서 발생한 예외를 LazyImportError 예외(ImportError의 서브클래스)로 대체하고 __cause__를 원래 예외로 설정하는 방법을 생각해 볼 수 있습니다.

모든 지연 임포트 오류가 LazyImportError로 발생하도록 보장하면, 이 오류가 실수로 포착되어 예상된 다른 예외로 오인될 가능성이 줄어듭니다. 그러나 실제로는 테스트 내부 등에서 실패하는 모듈이 unittest.SkipTest 예외를 발생시키는 경우를 보았는데, 이 예외 역시 LazyImportError로 감싸지면 실제 예외 타입이 가려지므로 해당 테스트가 실패하게 됩니다. 여기서의 단점이 예상치 못한 지연된 예외가 실수로 포착되는 가상의 경우보다 더 큰 것으로 보입니다.

모듈별 옵트인

future 임포트(즉, from __future__ import lazy_imports)를 사용하는 모듈 단위 옵트인은 타당하지 않습니다. __future__ 임포트는 기능 플래그가 아니라 향후 기본값이 될 동작으로 전환하기 위한 것이기 때문입니다. 지연 임포트가 언젠가 기본 동작으로서 타당해질지는 분명하지 않으므로, __future__ 임포트로 이를 약속해서는 안 됩니다.

라이브러리가 특정 모듈에 대해 국지적으로 지연 임포트에 옵트인하기를 원하는 다른 경우도 있습니다. 예를 들어 대규모 라이브러리에서 하위 구성 요소를 지연 속성으로 접근할 수 있도록 최상위 __init__.py를 지연 방식으로 만드는 경우입니다. 당분간은 기능을 더 단순하게 유지하기 위해 이 PEP는 “애플리케이션” 사용 사례에 집중하며, 라이브러리 사용 사례는 다루지 않습니다. 이 PEP에서 도입하는 기반 지연 메커니즘은 향후 이 사용 사례를 해결하는 데에도 사용될 수 있습니다.

개별 지연 임포트를 위한 명시적 문법

지연 임포트의 주된 목적이 오로지 임포트 순환과 전방 참조를 우회하는 것이라면, 특정 대상 임포트를 지연시키도록 명시적으로 표시하는 문법이 매우 타당할 것입니다. 하지만 실제로는 이 접근 방식으로 시작 시간이나 메모리 사용량에서 확실한 이점을 얻기가 매우 어려울 것입니다. 코드베이스 내(그리고 서드파티 의존성 내)의 임포트 대부분을 지연 임포트 문법을 사용하도록 변환해야 하기 때문입니다.

메인 모듈에서 하위 시스템을 가져오는 최상위 임포트만 명시적으로 지연시키는 “얕은” 지연을 목표로 할 수도 있지만, 그러면 하위 시스템 내부의 임포트는 모두 즉시 수행됩니다. 하지만 이는 극도로 취약합니다. 잘못 배치된 임포트 하나만으로도 공들여 구성한 얕은 지연이 무너집니다. 반면에 지연 임포트를 전역적으로 활성화하면 깊고 견고한 지연이 제공되어, 항상 사용하는 임포트에 대해서만 비용을 지불하게 됩니다.

(예를 들어 정적 타이핑을 위해) 전방 참조를 피하려고 개별적으로 표시한 지연 임포트가 필요하지만, 전역 지연 임포트의 성능/메모리 이점은 필요하지 않은 사용 사례가 있을 수 있습니다. 이는 동기가 되는 사용 사례가 다르고 새로운 문법을 요구하므로, 이 PEP에는 포함하지 않는 편을 택합니다. 다른 PEP가 이 구현을 기반으로 추가 문법을 제안할 수 있습니다.

지연 임포트를 활성화하는 환경 변수

환경 변수 옵트인을 제공하면 기능이 너무 쉽게 남용될 수 있습니다. 예를 들어 Python 사용자는 자신이 실행하는 모든 Python 프로그램의 속도를 높이기를 기대하며 셸에서 환경 변수를 전역적으로 설정하고 싶은 유혹을 느낄 수 있습니다. 테스트되지 않은 프로그램에서 이렇게 사용하면 해당 도구의 작성자에게 잘못된 버그 보고와 유지 보수 부담을 안길 가능성이 높습니다. 이를 피하기 위해 환경 변수 옵트인은 아예 제공하지 않기로 합니다.

-L 플래그 제거

-L CLI 플래그는 제공합니다. 이론적으로는 (Python 패키징 도구를 통해 배포되는 것이 아니라) python somescript.py 또는 python -m somescript로 실행되는 개별 Python 프로그램을 실행하는 최종 사용자가 이 플래그를 비슷한 방식으로 남용할 수 있습니다. 그러나 오용될 수 있는 잠재적 범위는 환경 변수보다 -L이 훨씬 작습니다. 또한 -L은 프로세스 시작 시점부터 모든 임포트가 지연되도록 보장하여 일부 애플리케이션에서 시작 시간상의 이점을 극대화하는 데 유용하므로, 이 플래그는 유지하기로 합니다.

임의의 Python 프로그램을 그 프로그램에 쓰도록 의도되지 않은 명령줄 플래그(예: -s, -S, -E, -I)와 함께 실행하면 예기치 않은 결과가 나오거나 프로그램이 망가질 수 있다는 것은 이미 사실입니다. 이 점에서 -L도 새로운 것이 아닙니다.

절반 지연 임포트

모듈 소스를 찾는 시점까지는 임포트 로더를 즉시 실행하고, 모듈의 실제 실행과 모듈 객체 생성은 지연시킬 수도 있습니다. 이 방식의 장점은 특정 부류의 임포트 오류(예: 모듈 이름의 단순한 오타)가 임포트된 이름을 사용할 때까지 지연되지 않고 즉시 포착된다는 것입니다.

단점은 사용되지 않는 임포트에도 최소한 파일 시스템 stat() 호출이 여전히 필요하므로, 지연 임포트의 시작 시간 이점이 크게 줄어든다는 것입니다. 또한 지연 임포트가 활성화되었을 때 어떤 임포트 오류는 즉시 발생하고 어떤 오류는 지연되는지에 대해, 명확하지 않을 수 있는 구분이 생깁니다.

실제로 참조 구현에서 임포트 오타로 인한 혼란이 문제로 관찰된 적이 없다는 근거로, 이 아이디어는 당분간 기각합니다. 일반적으로 지연된 임포트는 영원히 지연되지는 않으며, 오류는 포착되어 수정될 수 있을 만큼 충분히 일찍 드러납니다(해당 임포트가 정말로 사용되지 않는 경우는 제외합니다).

반지연(half-lazy) 임포트의 또 다른 동기로는 모듈이 어떤 플래그를 통해 자신이 지연 임포트될지 즉시 임포트될지를 직접 제어할 수 있게 하는 것을 생각해 볼 수 있습니다. 이 방안은 두 가지 이유로 기각되었습니다. 첫째, 반지연 임포트를 필요로 하므로 임포트 지연이 주는 성능상 이점의 일부를 포기하게 됩니다. 둘째, 일반적으로 모듈은 자신이 어떻게 또는 언제 임포트될지를 스스로 결정하지 않으며, 그 모듈을 임포트하는 모듈이 이를 결정합니다. 이 PEP가 그 제어 관계를 뒤집어야 할 명확한 근거는 없습니다. 대신 이 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에 전적으로 맡깁니다.