파이썬 3.15 lazy import로 프로그램 시작 시간 줄이기

파이썬 3.15 lazy import로 프로그램 시작 시간 줄이기

파이썬으로 만든 CLI 도구에서 --version만 찍어 봐도 묘하게 굼뜬 느낌을 받은 적 없으신가요? 🤔 버전 문자열 하나 출력하는 데 쓰지도 않을 asyncio나 urllib.request 같은 모듈까지 전부 불러오고 있기 때문인데요. 파이썬의 import 문은 실행되는 순간 모듈을 찾아서 끝까지 실행하기 때문에, 파일 맨 위에 적어 둔 모듈은 쓰든 안 쓰든 시작할 때 모두 비용을 치릅니다.

그래서 지금까지는 무거운 모듈을 함수 안에서 import하는 꼼수를 많이 써 왔습니다. 실제로 파이썬 표준 라이브러리에서도 테스트를 뺀 import의 약 17%가 이런 이유로 함수 안에 들어가 있다고 하는데요. 파이썬 3.15에서 드디어 이 문제를 언어 차원에서 해결하는 지연 임포트(lazy import) 문법이 추가되었습니다(PEP 810).

이번 글에서는 lazy import의 기본 문법과 실제로 시작 시간이 얼마나 줄어드는지 측정해 보고, 에러가 드러나는 시점과 쓸 수 없는 곳, 기존 코드에 한 번에 적용하는 방법까지 살펴보겠습니다.

import는 왜 시작 시간을 잡아먹을까요?

예제로 하위 명령(subcommand)이 세 개 있는 CLI 도구를 만들어 보겠습니다. fetch는 HTTP 요청을 보내고, serve는 웹 서버를 띄우고, sync는 비동기 작업을 돌립니다.

cli.py
import argparse
import asyncio
import json
import urllib.request
from http.server import HTTPServer, SimpleHTTPRequestHandler


def main():
    parser = argparse.ArgumentParser(prog="mytool")
    parser.add_argument("--version", action="version", version="mytool 1.0")
    sub = parser.add_subparsers(dest="command")
    sub.add_parser("fetch")
    sub.add_parser("serve")
    sub.add_parser("sync")
    args = parser.parse_args()

    if args.command == "fetch":
        with urllib.request.urlopen("https://api.github.com") as res:
            print(json.load(res))
    elif args.command == "serve":
        HTTPServer(("", 8000), SimpleHTTPRequestHandler).serve_forever()
    elif args.command == "sync":
        asyncio.run(asyncio.sleep(1))


main()

어떤 명령을 실행하든 맨 위의 import 다섯 줄은 모두 실행됩니다. -X importtime 옵션을 주면 모듈마다 불러오는 데 걸린 시간을 마이크로초 단위로 보여 줍니다.

python -X importtime cli.py --version 2>&1 | grep -E "\| (argparse|asyncio|json|urllib.request|http.server)$"
결과
import time:       798 |       4832 | argparse
import time:       419 |      35999 | asyncio
import time:       287 |       1923 | json
import time:      1019 |      19692 | urllib.request
import time:       612 |       2414 | http.server

두 번째 열이 그 모듈이 의존하는 모듈까지 포함한 누적 시간인데요. asyncio 하나에 36ms, urllib.request에 20ms가 들어갑니다. --version에는 하나도 필요 없는 모듈들이죠. 😅

lazy import 기본 문법

파이썬 3.15에서는 import 앞에 lazy만 붙이면 됩니다. lazy는 소프트 키워드(soft keyword)라서 import 문 앞에서만 키워드로 동작하고, 기존 코드에서 lazy를 변수 이름으로 쓰고 있어도 문제가 없습니다.

basic.py
import sys

lazy import json

print("json" in sys.modules)
print(type(globals()["json"]))

print(json.dumps({"name": "Dale"}))
print("json" in sys.modules)
print(type(globals()["json"]))
결과
False
<class 'lazy_import'>
{"name": "Dale"}
True
<class 'module'>

lazy import json이 실행된 직후에는 json 모듈이 아직 sys.modules에 없습니다. json이라는 이름에는 진짜 모듈 대신 lazy_import 타입의 프록시(proxy) 객체가 들어 있을 뿐인데요. json.dumps()처럼 이 이름을 처음 사용하는 순간 그제야 모듈을 불러오고, 프록시를 진짜 모듈로 바꿔치기합니다. PEP에서는 이 과정을 실체화(reification)라고 부릅니다.

from ... import 문에도 똑같이 붙이면 됩니다.

lazy from json import dumps, loads

이때는 dumps와 loads 중 하나라도 처음 사용하면 json 모듈 전체를 불러오고, 실제로 사용한 이름만 진짜 객체로 바뀝니다.

시작 시간 비교

앞의 CLI 도구에서 argparse를 뺀 나머지 import 네 줄 앞에 lazy를 붙여 보겠습니다. argparse는 어떤 명령이든 바로 쓰니까 그대로 두는 게 맞습니다.

cli.py
import argparse
lazy import asyncio
lazy import json
lazy import urllib.request
lazy from http.server import HTTPServer, SimpleHTTPRequestHandler

다시 -X importtime으로 확인해 보면 argparse만 남고 나머지 모듈은 아예 불러오지 않습니다.

결과
import time:       650 |       3903 | argparse

실행 시간도 재 봤습니다. --version을 30번 실행해 중앙값을 구했더니, 원래 코드는 50.4ms, lazy를 붙인 코드는 25.2ms가 걸렸습니다. import 네 줄을 고친 것만으로 시작 시간이 절반으로 줄어든 거죠. 파이썬 인터프리터 자체가 뜨는 데 걸리는 시간이 이 환경에서 15ms 정도라는 걸 생각하면, 우리 코드가 쓰던 시간의 상당 부분이 사라진 셈입니다.

측정 환경은 Apple Silicon Mac과 파이썬 3.15.0입니다. 숫자는 환경에 따라 달라지니 비율 위주로 봐 주세요.

물론 fetch나 sync 명령을 실행하면 그 순간 urllib.request나 asyncio를 불러오기 때문에, 전체 비용이 없어지는 건 아닙니다. 필요한 명령에서만 필요한 만큼 비용을 치르게 되는 거예요. 자바스크립트의 Dynamic Import로 코드를 나눠 필요할 때 불러오는 것과 비슷한 효과를, 문법을 거의 바꾸지 않고 얻는 셈입니다.

에러는 처음 사용할 때 발생합니다

지연 임포트에서 가장 먼저 익숙해져야 하는 점은 에러가 드러나는 시점입니다. 설치되지 않은 모듈을 lazy import로 불러와 보겠습니다.

missing.py
lazy import yaml

print("프로그램 시작")
print(yaml.safe_load("name: Dale"))
결과
프로그램 시작
Traceback (most recent call last):
  File "missing.py", line 1, in <module>
    lazy import yaml
ImportError: lazy import of 'yaml' raised an exception during resolution

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "missing.py", line 4, in <module>
    print(yaml.safe_load("name: Dale"))
          ^^^^
ModuleNotFoundError: No module named 'yaml'

import 문이 있는 1번째 줄은 그냥 지나가고, 프로그램 시작까지 출력된 뒤 yaml을 처음 쓰는 4번째 줄에서 ModuleNotFoundError가 발생합니다. 대신 트레이스백(traceback)에 lazy import 문이 있던 위치와 실제로 사용한 위치가 함께 찍혀서 원인을 찾기는 어렵지 않습니다.

이 차이가 은근히 중요한데요. 예전에는 의존성이 빠지면 프로그램이 시작하자마자 죽었지만, 이제는 그 모듈을 쓰는 기능을 실행할 때까지 문제가 숨어 있기도 합니다. 테스트가 모든 기능을 한 번씩은 실행하도록 해 두어야 하는 이유가 하나 더 생긴 셈입니다.

lazy import를 쓸 수 없는 곳

lazy import는 모듈 최상위에서만 허용됩니다. 함수 안에서 쓰면 SyntaxError가 발생합니다.

def load():
    lazy import json
결과
SyntaxError: lazy import not allowed inside functions

함수 안의 import는 원래 함수가 호출될 때 실행되니, 이미 지연되어 있는 것과 마찬가지라서 굳이 lazy를 붙일 이유가 없습니다.

try 블록 안에서도 쓸 수 없습니다.

try:
    lazy import json
except ImportError:
    json = None
결과
SyntaxError: lazy import not allowed inside try/except blocks

이건 앞에서 본 에러 시점 때문인데요. lazy import 문은 모듈을 불러오지 않으니 그 자리에서 ImportError가 날 일이 없고, except 절은 절대 실행되지 않습니다. 선택적 의존성을 이런 식으로 처리하던 코드라면 지금처럼 일반 import를 그대로 써야 합니다.

마지막으로 lazy from json import *처럼 별표로 모든 이름을 가져오는 것도 허용되지 않습니다. 어떤 이름이 들어올지 모듈을 실행해 보기 전에는 알 수 없기 때문입니다.

부수 효과가 있는 모듈은 조심하세요

모듈을 import하는 것만으로 무언가 일어나도록 만든 코드도 있습니다. 플러그인을 등록하거나, 설정을 바꾸거나, 로깅을 초기화하는 식인데요.

plugins.py
print("plugins 모듈 실행: 플러그인 등록")
app.py
lazy import plugins

print("앱 시작")
결과
앱 시작

plugins라는 이름을 한 번도 사용하지 않으니 모듈이 끝내 실행되지 않고, 플러그인도 등록되지 않습니다. lazy를 떼면 plugins 모듈 실행: 플러그인 등록이 먼저 출력되고요. 이렇게 부수 효과(side effect)를 위해 import하는 모듈에는 lazy를 붙이면 안 됩니다.

타입 힌트 전용 import 정리하기

타입 힌트에서만 쓰는 모듈을 불러올 때, 지금까지는 typing.TYPE_CHECKING으로 감싸는 방법을 많이 썼습니다. 실행할 때 불필요한 import 비용을 줄이고 순환 import도 피하기 때문인데요. lazy import가 생기면서 이런 코드가 더 간단해집니다.

typecheck.py
import sys

lazy from decimal import Decimal


def total(prices: list[Decimal]) -> Decimal:
    return sum(prices, start=Decimal(0))


print("decimal" in sys.modules)
print(total.__annotations__)
print("decimal" in sys.modules)
결과
False
{'prices': list[decimal.Decimal], 'return': <class 'decimal.Decimal'>}
True

파이썬 3.14부터 어노테이션은 실제로 읽을 때 평가되기 때문에, 함수를 정의하는 것만으로는 decimal 모듈을 불러오지 않습니다. __annotations__를 읽는 순간에야 비로소 모듈을 불러오죠. TYPE_CHECKING 방식과 달리 런타임에 어노테이션을 읽어도 NameError가 나지 않는다는 장점도 있습니다.

타입 힌트에서 쓰는 typing 모듈은 typing 내장 모듈로 타입 표시하기에서 자세히 다루고 있습니다.

기존 코드에 한 번에 적용하기

import 문마다 lazy를 붙이기 부담스럽다면 몇 가지 다른 방법이 있습니다.

먼저 모듈 맨 위에 __lazy_modules__ 변수로 지연시킬 모듈 이름을 적어 두는 방법이 있습니다. 목록에 있는 모듈은 lazy를 붙인 것처럼 동작합니다.

modules_attr.py
__lazy_modules__ = ["asyncio", "json", "urllib.request", "http.server"]

import sys
import asyncio
import json

print("asyncio" in sys.modules, "json" in sys.modules)

이 코드를 파이썬 3.15에서 실행하면 False False가, 파이썬 3.14에서 실행하면 True True가 출력됩니다. 3.14 이하에서는 __lazy_modules__가 평범한 변수일 뿐이라 그냥 무시되고 모두 즉시 불러오는 건데요. 그래서 여러 버전을 지원해야 하는 라이브러리라면 새 문법 대신 이 방법으로 3.15 사용자에게만 지연 임포트의 혜택을 주면 됩니다.

코드를 아예 고치지 않고 프로그램 전체의 모듈 최상위 import를 지연시킬 수도 있습니다. -X lazy_imports=all 옵션을 주거나 PYTHON_LAZY_IMPORTS 환경 변수를 all로 설정하면 됩니다.

PYTHON_LAZY_IMPORTS=all python cli.py --version

처음의 고치기 전 CLI 도구를 이렇게 실행해 봤더니, 코드를 한 줄도 바꾸지 않았는데 중앙값이 48.3ms에서 24.0ms로 줄었습니다. 다만 앞에서 본 부수 효과 문제가 우리가 고칠 수 없는 서드파티 패키지에서 터지기도 하니, 실제 서비스에 적용하기 전에 충분히 테스트해 봐야 합니다.

모든 모듈을 지연시키기가 불안하다면 sys.set_lazy_imports_filter()로 지연시킬 모듈을 고르면 됩니다. 필터 함수는 import하는 모듈, 불러올 모듈, from 절의 이름 목록을 받아 True를 반환할 때만 지연시킵니다.

import sys


def only_myapp(importer, name, fromlist):
    return name.startswith("myapp.")


sys.set_lazy_imports_filter(only_myapp)
sys.set_lazy_imports("all")

참고로 PEP 문서에는 지연 임포트를 모두 끄는 none 모드도 나오지만, 파이썬 3.15.0에서는 normal과 all만 받습니다. -X lazy_imports=none으로 실행하면 인터프리터가 시작조차 하지 않으니 주의하세요.

마치며

파이썬 3.15의 lazy import를 살펴봤습니다. import 앞에 lazy 한 단어만 붙이면 모듈을 처음 사용할 때까지 불러오기를 미룰 수 있고, 예제 CLI 도구에서는 시작 시간이 절반으로 줄었습니다.

다만 에러가 처음 사용하는 시점으로 밀린다는 점, 함수와 try 블록 안에서는 쓸 수 없다는 점, 부수 효과를 위해 불러오는 모듈에는 쓰면 안 된다는 점은 꼭 기억해 두세요. CLI 도구처럼 시작 시간이 체감되는 프로그램부터 무거운 모듈 몇 개에 적용해 보시길 추천드립니다.

uv를 쓰신다면 uv python install 3.15로 바로 설치해서 실습해 보세요. 다만 오래된 uv는 3.15 정식 버전을 모르고 알파 버전을 설치하니, uv를 먼저 최신 버전으로 올려 주세요. Ruff 0.17.1과 Mypy 2.4.0은 이미 lazy import 문법을 인식하고, Pyright 1.1.414도 대상 파이썬 버전을 3.15로 지정하면 문제없이 검사합니다.

파이썬 3.15에서 바뀐 다른 내용은 파이썬 3.15의 새로운 기능 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord