Python 3.15의 lazy import, Ruff 0.16.8이 TYPE_CHECKING 대신 고른 이유
Python에서 타입 힌트 때문에만 필요한 모듈은 흔히 if TYPE_CHECKING: 안으로 옮깁니다. 실행할 때는 import 비용을 내지 않는 대신, 그 이름도 런타임에는 존재하지 않습니다. Pydantic이나 SQLAlchemy처럼 어노테이션을 실행 중에 읽는 프레임워크를 만나면 이 차이가 바로 드러납니다.
현재 3.15.0rc2 문서에는 다른 선택지가 들어 있습니다. lazy import는 이름을 런타임에 남겨 두되 실제 모듈 로딩과 실행은 첫 사용까지 미룹니다. 2026년 9월 16일 나온 Ruff 0.16.8은 이 새 문법을 TC001, TC002, TC003 수정안에 반영했습니다. 대상 버전이 Python 3.15 이상이고 형태가 단순하다면 타입 전용 import를 TYPE_CHECKING 블록으로 옮기기보다 lazy를 붙이는 쪽을 먼저 제안합니다.
import 문은 실행되지만 모듈은 아직 실행되지 않는다
보통의 import는 해당 줄에서 모듈을 찾고 로드한 뒤 최상위 코드를 실행합니다. lazy import는 그 자리에 프록시를 이름에 바인딩합니다. 모듈은 아직 sys.modules에 들어가지 않습니다. 코드가 그 이름을 처음 사용할 때 모듈을 로드하고, 그 시점에 실제 객체로 바뀝니다.
그림 1. lazy import는 모듈 로딩 비용과 import 오류를 없애지 않고 첫 사용 지점으로 옮긴다. 출처: docs.python.org/3.15/reference/simple_stmts.html#lazy-imports.
이 차이는 시작 시간을 줄일 수 있지만 작업을 없애는 것은 아닙니다. 비용과 실패 시점을 뒤로 옮깁니다. 모듈에 ImportError나 SyntaxError가 있으면 import 줄이 아니라 첫 사용 지점에서 예외가 납니다. 프로그램 시작 직후 모든 선택 의존성을 확인하던 테스트라면 lazy import 뒤에도 같은 보장이 남는지 따로 검사해야 합니다.
PEP 810은 이 기능을 전역 기본값으로 만들지 않았습니다. lazy는 import 바로 앞에서만 의미를 갖는 soft keyword이며, 표시한 import에만 적용됩니다. 하위 모듈의 import까지 연쇄적으로 lazy가 되지도 않습니다. 기존 import는 그대로 eager입니다.
사용할 수 있는 위치도 일부러 좁다
lazy import는 모듈 최상위에서만 허용됩니다. 함수나 클래스 본문, try, except, finally 블록 안에서는 SyntaxError가 납니다. lazy from module import *와 future import도 허용되지 않습니다.
lazy from package import A, B에서는 A와 B가 각각 프록시로 묶입니다. 둘 중 하나를 처음 사용하면 모듈 전체는 로드되지만, 실제 값으로 해결되는 이름은 사용한 쪽입니다. 다른 이름은 접근하기 전까지 프록시로 남습니다. "모듈은 한 번 로드된다"와 "모든 from-import 이름이 동시에 실제 객체로 바뀐다"를 같은 말로 보면 안 됩니다.
그림 2. 세 방식은 런타임 이름의 존재와 구버전 소스 호환성에서 서로 다르다. 출처: peps.python.org/pep-0810.
이 제약은 불편해 보이지만 실패 위치를 추적하는 데 도움이 됩니다. 예외 처리를 위해 try: import optional_dependency를 썼다면 그 import를 그대로 lazy로 바꿀 수 없습니다. 첫 사용까지 예외가 미뤄지면 원래의 fallback 범위를 벗어나기 때문입니다.
TYPE_CHECKING과 lazy는 같은 최적화가 아니다
TYPE_CHECKING 블록은 정적 분석기에는 이름을 보여 주고 런타임에는 import 자체를 건너뜁니다. 지연 import는 런타임 이름을 유지합니다. 그래서 어노테이션을 나중에 평가하거나 typing.get_type_hints()로 읽는 코드에는 lazy가 더 자연스러울 수 있습니다.
반대 경우도 있습니다. 타입 검사에만 쓰이고 런타임 반사도 전혀 필요 없는 모듈이라면 TYPE_CHECKING이 더 강한 경계입니다. 프로그램이 끝날 때까지 import가 일어나지 않습니다. lazy import는 해당 이름을 한 번이라도 런타임 코드가 건드리면 결국 모듈을 로드합니다.
Ruff 문서가 lazy 수정안을 안전하다고 부르지 않는 이유도 여기에 있습니다. import 시점이 바뀌면 등록 코드, 플러그인 발견, 환경 변수 검사, 경고 설정 같은 import-time side effect의 순서가 달라질 수 있습니다. Ruff는 이 계열 fix를 unsafe로 표시합니다. 자동 수정을 적용한 뒤 테스트 없이 끝낼 수 있는 문법 정리는 아닙니다.
Ruff 0.16.8은 단순한 경우만 고친다
Ruff 0.16.8의 TC001부터 TC003까지는 Python 3.15 이상에서 이미 lazy인 import를 위반으로 보지 않습니다. 수정할 때도 단일 이름 import이고 문법상 lazy를 붙일 수 있으며 ban-lazy 설정이 허용할 때 lazy를 우선합니다.
여러 이름을 한 줄에서 가져오거나 __lazy_modules__까지 수정해야 하는 경우에는 기존 TYPE_CHECKING 방식으로 물러납니다. 구현 PR은 이 fallback을 명시합니다. 하나의 수정 규칙이 파일 전체의 지연 상태를 추론하고 바꾸려 들지 않도록 범위를 좁힌 셈입니다.
그림 3. Ruff는 단순하고 허용된 import에만 lazy를 우선하며, 복잡한 경우 기존 TYPE_CHECKING 수정으로 물러난다. 출처: github.com/astral-sh/ruff/pull/28541.
Ruff 릴리스 계보도 따로 확인해야 합니다. 관련 PR #28541은 9월 16일 병합됐고, 같은 날 공개된 0.16.8 릴리스에 commit 304ab86b로 들어갔습니다. 앞서 __lazy_modules__ 인식을 추가한 PR #28459의 commit 67d03a54도 0.16.7과 0.16.8 사이 비교에 포함됩니다. 문서만 먼저 바뀐 기능이 아니라 0.16.8 안정 태그가 실제로 담은 동작입니다.
구버전과 함께 갈 때는 __lazy_modules__가 있다
Python 3.15 문법을 소스에 바로 쓰면 이전 인터프리터는 파일을 파싱하지 못합니다. 이를 피하기 위해 모듈 최상위에 __lazy_modules__ 컨테이너를 둘 수 있습니다. 여기에 완전한 모듈 이름을 문자열로 적으면, 3.15에서는 같은 파일의 일반 import가 lazy로 처리됩니다. 이전 Python은 평범한 변수 선언과 일반 import로 읽으므로 eager 동작을 유지합니다.
이 방식은 호환성에는 유리하지만 줄 하나만 보고 동작을 알기 어렵습니다. 선언을 바꾸면 같은 모듈의 뒤쪽 import 여러 개가 함께 영향을 받을 수 있습니다. Ruff가 __lazy_modules__ 목록까지 고치는 fix를 보수적으로 피하는 이유입니다. 상대 import도 비교 전에 절대 모듈 이름으로 해석되므로 목록에는 완전한 이름을 적어야 합니다.
도입 순서는 성능 수치보다 실패 위치부터 잡아야 한다
첫 단계는 실제 시작 경로를 측정하는 것입니다. CLI의 --help, 짧은 서브커맨드, 테스트 수집처럼 시작 시간이 중요한 경로에서 어떤 모듈이 쓰이지 않은 채 로드되는지 봅니다. 그다음 import-time side effect, 플러그인 등록, 선택 의존성 오류 처리, 런타임 어노테이션 평가 여부를 목록으로 나눕니다.
3.15 전용 코드라면 영향 범위가 작은 import부터 lazy를 적용할 수 있습니다. 다중 버전 라이브러리는 __lazy_modules__가 소스 호환성에는 편하지만 비지역 효과가 있다는 점을 코드 리뷰 규칙에 남겨야 합니다. 타입 검사에만 필요하고 런타임 이름도 필요 없다면 TYPE_CHECKING을 유지하는 편이 단순합니다.
마지막 검증은 시작 시간이 빨라졌는지만 보면 부족합니다. import 오류가 언제 나타나는지, 첫 요청이나 첫 명령의 지연이 얼마나 늘었는지, 등록형 프레임워크가 같은 순서로 초기화되는지 확인해야 합니다. lazy import가 주는 것은 공짜 성능이 아니라 로딩 시점을 선택할 권한입니다. Ruff 0.16.8은 그 선택을 자동화하되, 바뀐 실행 순서의 책임까지 대신 지지는 않습니다.
오늘 글과 겹치지 않는 지점
오늘 아침 글은 uv가 Linux wheel의 libc 하한을 해석하고 호환 버전으로 되돌아가는 해상도 문제를 다뤘습니다. 오후 글은 코딩 에이전트 A/B 테스트용 과제를 고르는 DeltaSelect의 비용과 불확실성을 분석했습니다. 이번 글은 Python 3.15의 언어 의미와 Ruff 자동 수정이 import 실행 시점을 어떻게 바꾸는지 다룹니다. 패키지 해상도나 에이전트 평가가 아니라 런타임 이름, 오류 시점, import side effect가 중심입니다.



댓글
댓글 쓰기