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

Python 개선 제안 한국어 번역

PEP 843 – DRY 재export를 위한 export 문

Author:
Neil Girdhar <mistersheik at gmail.com>
Sponsor:
Peter Bierma <peter at python.org>
Discussions-To:
Discourse thread
Status:
Draft
Type:
Standards Track
Created:
05-Aug-2026
Python-Version:
3.16
Post-History:
05-Aug-2026, 21-Aug-2026

Table of Contents

번역·라이선스 안내

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

초록

대규모 라이브러리는 구현 레이아웃(유지 관리자에게 편리한 모듈 트리)과 공개 레이아웃(사용자에게 제시하는, 더 얕고 엄선된 트리)을 분리합니다. 오늘날 그러한 공개 레이아웃을 구성하려면 불완전한 두 가지 선택지 중 하나를 골라야 합니다.

첫 번째는 export하는 모든 이름을 두 번 작성하는 것입니다. 한 번은 import 문에, 또 한 번은 __all__에 문자열로 작성합니다. 공개 레이아웃이 바뀔 때마다 두 목록을 수작업으로 동기화해야 하며, 이는 DRY (Don’t Repeat Yourself) 원칙을 위반합니다: “모든 지식은 시스템 내에서 단일하고, 모호하지 않으며, 권위 있는 표현을 가져야 합니다.” [1]

두 번째는 자기 별칭(reflexive-alias) 관용구인 from x import y as y입니다. 이는 타입 시스템의 일부입니다. 타입 검사기는 이를 해당 임포트가 의도적인 재export라는 신호로 취급합니다. 그래도 이 관례를 모르는 사람에게는 여전히 오타처럼 읽힙니다. 모듈에 선별된 __all__이 없으므로, 실제 __all__이 제공하는 와일드카드 임포트 제어를 잃게 됩니다.

이 PEP는 두 문제를 모두 피하는 문 형태를 추가합니다:

# spam/__init__.py
from ._internal.core export PublicAPI
from ._internal.widgets export Widget as PublicWidget

이 문은 이름을 임포트하되, from ... import ... as ...와 똑같이 선택적으로 별칭을 붙일 수 있으며, 같은 문 안에서 그 이름을 __all__에 추가합니다. 수작업으로 동기화할 것이 남지 않으며, 해독해야 할 별칭도 없습니다.

PEP 842와의 관계

두 PEP 모두 __all__에 대한 같은 불편함에서 출발하며, 재export를 위한 같은 핵심 메커니즘에 동의합니다. 그것은 from <module> export <name> 형태의 문과 그 지연 변형(Lazy exports 참조)입니다. 독립적으로 도달한 이 합의는 이것이 재export에 적합한 형태임을 확인해 줍니다.

PEP 842는 이 메커니즘을 다섯 가지 형태로 사용할 수 있는 키워드로 확장합니다:

  • 단독 export NAME
  • export NAME = VALUE 대입
  • export def
  • export class
  • 모듈 재export 문인 from MODULE export NAME

다섯 가지 모두 __export__를 채우며, 이는 __all__이 되기도 하고 빠진 항목에 접근하면 ExportError를 발생시킵니다.

PEP 842의 버전에는 Wildcard form에 해당하는 와일드카드 형태가 없습니다. 이 PEP는 모듈 재export 문만 채택하고 나머지는 의도적으로 제외합니다. 무엇을 왜 제외했는지는 Non-goals를 참조하십시오.

동기

NumPy, pandas, polars, Typer, FastAPI, Plotly처럼 널리 쓰이는 라이브러리들은 거의 예외 없이 공통된 패턴을 사용하여 공개 API를 export합니다. 최상위 __init__.py에는 비공개 하위 모듈로부터의 임포트가 벽처럼 늘어서 있고, 그 뒤에(또는 그 사이사이에) 같은 이름을 문자열로 반복하는 __all__ 목록이 오거나, 그 대신 자기 별칭 관용구가 전체에 걸쳐 사용됩니다. pandas와 polars는 모두 이중 목록 형태를 사용하고, FastAPI와 Typer는 모두 자기 별칭 형태를 전체에 걸쳐 사용합니다. 첫 번째 형태는 다음과 같습니다:

from ._internal.core import PublicAPI as PublicAPI
from ._internal.widgets import Widget as Widget
from ._internal.errors import SpamError as SpamError
# ... often hundreds of lines like this ...

__all__ = [
    "PublicAPI",
    "Widget",
    "SpamError",
    # ... the same names again ...
]

이 파일은 라이브러리가 자신의 구현 레이아웃을 공개 레이아웃으로 평탄화하는 곳입니다. 라이브러리가 커지면서 둘은 서로 달라집니다. 유지 관리자의 편의를 위해 코드는 하위 모듈로 재구성되지만, 공개 레이아웃은 사용자를 위해 안정적으로 유지됩니다.

평탄화 작업은 무언가가 맡아야 합니다. 오늘날 그 무언가는 손으로 관리하면서 두 번 작성하는 목록입니다. __all__ 안의 내보내기 목록과 import 문으로 이루어진 가져오기 목록이 같은 내용을 두 번 말합니다. 이름을 바꾸거나 추가하거나 제거할 때마다 두 곳을 손으로 고쳐야 하며, 두 목록은 모르는 사이에 서로 어긋날 수 있습니다. 이 PEP는 두 목록을 하나의 from <module> export <name> 문으로 합쳐 이러한 중복을 없앱니다.

대안인 import x as x는 언어에 내보내기 개념이 없어서 쓰는 우회책이며, 일부 자동 포매터는 여전히 이를 제대로 처리하지 못합니다. 이러한 포매터는 단독으로 쓰인 import x를 사용되지 않는 것으로 보고 제거합니다.

__all__은 두 가지 관심사를 뒤섞습니다

손으로 관리하는 __all__은 서로 다른 두 가지 관심사도 한 파일에 섞어 놓습니다. 하나는 가져오기 목록(평탄화를 어떻게 연결하는지에 관한 구현 세부 사항)이고, 다른 하나는 공개 API 선언(사용자에게 하는 약속)입니다. 두 가지는 파일의 서로 다른 위치에 있는 별개의 문에 들어 있습니다. 리뷰어가 눈으로 둘을 대조하거나 바로 이 경우를 위해 만든 린터 규칙을 쓰지 않는 한 둘을 동기화된 상태로 유지해 주는 것은 없습니다. 더 흔한 “사용되지 않는 가져오기” 검사는 도움이 되지 않습니다. 어떤 이름을 가져왔지만 __all__에 추가하지 않았다면 그 이름은 사용되지 않는 것으로 보이며, 자동 수정 도구는 누락을 알려 주는 대신 흔히 그 이름을 삭제해 버립니다.

밑줄은 다른 문제를 해결합니다

자연스러운 반응은 “내부 이름 앞에 밑줄을 붙이면 됩니다”일 것입니다. 하지만 이 PEP가 다루는 비공개성은 이름 수준이 아니라 패키지 수준의 문제입니다. 곧 여러 모듈로 이루어진 큰 패키지에서 어느 부분이 공개 레이아웃에 속하는지의 문제입니다. 밑줄 접두사는 이미 모듈 안에서 이름을 비공개로 표시합니다.

이 문제는 “허브” 모듈(보통 __init__.py 파일)에서 나타납니다. 이런 모듈의 유일한 역할은 내부 모듈에서 이름을 모아 안정적인 공개 이름으로 제공하는 것입니다. 허브 모듈에 도달하는 이름은 모두 이미 공개될 의도로 작성된 것입니다. 이름이 허브에 들어올 때쯤에는 밑줄 관례가 이미 제 역할을 마친 상태입니다. 허브 모듈에는 “이것도 패키지 공개 레이아웃의 일부입니다”라고 반복 없이 말할 방법이 필요합니다.

목표가 아닌 것

이 PEP는 다음을 목표로 하지 않습니다:

  • 내보내지 않은 이름에 대한 런타임 속성 접근을 제한하거나 __getattr__의 의미를 변경하는 것. Why no runtime enforcement를 참고하십시오.
  • 서드파티 atpublic 패키지가 @public/@private 데코레이터로 하는 것처럼, 새로 작성한 def, class 또는 대입문을 정의하는 위치에서 내보내기 대상으로 표시하는 것. Why only re-exports를 참고하십시오.

명세

export는 from 가져오기 문에서 import를 대신하는 소프트 키워드입니다:

from ._internal.core export PublicAPI
from ._internal.widgets export Widget as PublicWidget
from numpy.typing export NDArray

from <module> export <name> [as <alias>] 문은 from <module> import <name> [as <alias>]와 같은 일을 합니다. 즉 <name>을(<alias>가 주어졌다면 <alias>를) 현재 네임스페이스에 바인딩하고, 그 이름을 __all__에 추가합니다:

from <module> import <name> as <alias>
exported_names = globals().setdefault("__all__", [])
if not isinstance(exported_names, list):
    exported_names = list(exported_names)
    __all__ = exported_names
exported_names.append("<alias>")

이 PEP의 다른 모든 문 형태도 __all__에 이름을 추가하거나 __all__을 확장하기 전에 같은 방식으로 __all__을 정규화합니다.

export는 풀어 쓰면 일반 가져오기에 __all__ 추가를 더한 것이 되므로, import와 똑같이 제어 흐름과 함께 쓸 수 있습니다:

if sys.platform == "win32":
    from ._internal.windows export WindowsThing
else:
    from ._internal.posix export PosixThing

각 브랜치는 자체 임포트와 자체 __all__ 추가를 실행하므로, 최종적으로 내보내지는 이름은 어느 브랜치가 실행되었는지에 따라 달라지며, 별도의 __all__ 관리가 필요하지 않습니다.

import와 달리 export는 모듈 수준에서만 쓸 수 있습니다. def 또는 class 본문 안에서 쓰면 SyntaxError가 발생합니다. 다만 if, try, for, while, with 블록은 새 스코프를 만들지 않으므로, 앞의 플랫폼 예제처럼 이러한 블록 안에서는 여전히 쓸 수 있습니다. 이런 제한이 있는 이유는 함수나 클래스 본문 안에서 바인딩된 이름은 애초에 모듈 네임스페이스에 속한 적이 없으므로, export가 __all__에 추가할 것이 없기 때문입니다. export의 목적은 어디까지나 모듈의 공개 API를 채우는 것이며, 모듈 수준에서 바인딩된 이름만 그 대상이 됩니다.

특히 모듈 수준의 if typing.TYPE_CHECKING: 가드 안에서 export를 쓰면, _typeshed 같은 스텁 전용 패키지는 스텁에는 있지만 런타임에는 대응하는 것이 없는 이름을 내보낼 수 있습니다.

if typing.TYPE_CHECKING:
    from ._internal.types export InternalOnly

일반 from ... import ... 문과 똑같이 <module>은 상대 경로(from .core export Thing, from ..sub.core export Thing)일 수도 있고 절대 경로(from numpy.typing export NDArray)일 수도 있습니다. 문 하나로 여러 이름을 내보낼 수 있으며, 이때 여러 이름을 가져오는 일반 from 가져오기와 같은 문법을 씁니다. 목록이 길면 괄호로 감싸 여러 줄에 걸쳐 쓸 수도 있습니다:

from ._internal.widgets export Widget, Gadget as PublicGadget

from ._internal.widgets export (
    Widget,
    Gadget,
    Doohickey,
)

이름을 내보내는 것 자체가 그 이름을 사용하는 것이므로, “임포트되었지만 사용되지 않은” 이름에 플래그를 표시하는 도구(린터, 포매터)는 from <module> export ... 문으로 바인딩된 모든 이름을 사용된 것으로 취급해야 합니다. 이는 이러한 도구가 이미 from module import Thing as Thing을 특별히 처리하는 것과 같은 방식입니다. 이 PEP는 그런 도구들이 내리는 판단을 바꾸지 않습니다. 이는 export의 의미에서 비롯됩니다: 내보내기가 곧 사용입니다.

와일드카드 형식

이 제안에는 와일드카드 형식인 from <module> export *도 포함됩니다. 이 형식은 같은 규칙(<module>이 자체 __all__을 정의하면 그것을, 그렇지 않으면 밑줄로 시작하지 않는 모든 최상위 이름)을 사용해 from <module> import *가 바인딩할 모든 이름을 바인딩하고, 그 이름들을 모두 현재 모듈의 __all__에 추가합니다:

# spam/_internal/core.py
__all__ = ["PublicAPI", "Helper"]  # curated by the internal module itself
...

# spam/__init__.py
from ._internal.core export *
# binds PublicAPI and Helper, and adds both to spam.__all__

이는 흔히 쓰이는 2계층 구조를 지원합니다: 내부 모듈은 작성하면서 자신의 __all__을 직접 관리하고, 허브는 각 항목의 이름을 다시 나열하는 대신 한 문장으로 그 목록 전체를 다시 내보냅니다.

export *는 <module>이 자체 __all__을 정의하지 않을 때 import *의 대체 동작과 일치합니다. 밑줄로 시작하지 않는 모든 최상위 이름을 내보냅니다.

와일드카드 형식은 Specification에서처럼 __all__을 정규화한다고 할 때 다음과 동등합니다:

# from ._internal.core export *
from ._internal.core import *
__all__.extend(_names_bound_by_star_import)

여기서 _names_bound_by_star_import는 from ._internal.core import *가 방금 바인딩한 이름의 리스트로, Python의 임포트 메커니즘이 와일드카드 임포트를 실행하기 위해 이미 계산하는 것과 같은 리스트입니다.

지연 내보내기

PEP 810은 임포트된 이름이 처음 사용될 때까지 from ... import 문을 지연하는 lazy 소프트 키워드를 추가합니다: lazy from <module> import <name>은 지연 프락시를 즉시 바인딩하지만, 그 프락시에 접근하기 전까지는 <module>을 로드하지 않습니다.

export는 import와 결합하는 것과 같은 방식으로 이것과 결합합니다:

lazy from ._internal.core export PublicAPI

이는 PEP 810이 명시하는 그대로 PublicAPI를 지연 프락시에 바인딩하고, 프락시에 접근하기를 기다리지 않고 "PublicAPI"를 즉시 __all__에 추가합니다. __all__을 채우는 데는 로드된 값이 아니라 문자열로 된 이름만 있으면 되므로, 문의 임포트 부분이 지연되더라도 내보내기 부분은 즉시 실행됩니다. 수백 개의 재내보내기가 있는 허브 모듈이라면, 이를 통해 모든 내부 모듈을 미리 로드하는 비용을 치르지 않고도 임포트 시점에 완전하고 정확한 __all__과 dir()을 사용자에게 제공할 수 있습니다.

이 문은 Specification에서처럼 __all__을 정규화한다고 할 때 다음과 동등합니다:

# lazy from ._internal.core export PublicAPI
lazy from ._internal.core import PublicAPI
__all__.append("PublicAPI")

lazy from <module> export *는 서로 독립적인 두 가지 이유로 허용되지 않습니다: PEP 810이 이미 lazy from <module> import *를 금지하고 있으며, 와일드카드 내보내기 형식은 __all__에 어떤 이름이 들어 있는지 알려면 먼저 <module>이 로드되어 있어야 하는데, 이것이 바로 지연 로딩이 미루는 일입니다. lazy는 또한 PEP 810의 범위 제한을 이어받습니다: 모듈 수준에서만 유효하며 함수, 클래스, try 블록 안에서는 유효하지 않습니다.

NumPy의 numpy/__init__.py는 이것이 왜 중요한지 보여 줍니다. 이 모듈 수준의 __getattr__는 import numpy 시점에 임포트되지 않는 하위 모듈을 지연 로드하는 일과 더 이상 존재하지 않는 속성에 대해 유용한 오류를 발생시키는 일이라는, 서로 관련 없는 두 가지 일을 한꺼번에 수행합니다:

# numpy/__init__.py, today (abbreviated)
def __getattr__(attr):
    # Warn for expired attributes
    import warnings

    if attr == "linalg":
        import numpy.linalg as linalg
        return linalg
    if attr == "fft":
        import numpy.fft as fft
        return fft
    # ... one branch like this per lazily loaded submodule ...
    if attr in __expired_attributes__:
        raise AttributeError(f"`np.{attr}` was removed. ...")
    raise AttributeError(f"module {__name__!r} has no attribute {attr!r}")

이 중 지연 로딩과 관련된 것은 첫 번째 일뿐이며, 지연 내보내기가 이를 직접 대체합니다:

# numpy/__init__.py, with lazy exports
lazy from . export linalg
lazy from . export fft
# ... one statement per lazily loaded submodule ...

def __getattr__(attr):
    # Only the expired-attribute branch is left
    if attr in __expired_attributes__:
        raise AttributeError(f"`np.{attr}` was removed. ...")
    raise AttributeError(f"module {__name__!r} has no attribute {attr!r}")

이 방식은 더 짧을 뿐만 아니라 더 정확합니다. 현재 linalg는 __getattr__ 대체를 통해서만 존재하므로, NumPy가 이를 나열하는 __dir__ 재정의를 별도로 유지하지 않는 한 dir(numpy)와 탭 완성에서 보이지 않습니다. lazy from . export linalg는 실제 (지연) 속성을 즉시 바인딩하고 "linalg"를 __all__에 추가하므로, dir()와 __all__이 자동으로 정확해지며, 일반 속성 조회가 __getattr__가 실행되기 전에 성공하므로 이 이름들에 대해서는 __getattr__가 더 이상 호출조차 되지 않습니다.

두 번째 일, 즉 아예 존재하지 않게 된 속성에 대해 경고하는 일은 export가 다루는 것이 아닙니다. export는 바인딩되어야 하는 이름에만 관련되며, 제거된 이름에 대해서는 아무것도 말하지 않습니다. 그 일을 위해서는 여전히 모듈 __getattr__가 필요하지만, 지연 하위 모듈 브랜치가 빠져나가고 나면 폐지 로직만 남은 더 작은 것으로 충분합니다.

이 재작성에서 중요한 제약이 하나 있습니다. PEP 810은 함수 본문 안에서 lazy를 허용하지 않으므로, 지연 하위 모듈 브랜치는 __getattr__ 안에서 한 줄씩 대체하는 것이 아니라 __getattr__ 밖의 모듈 최상위로 옮겨야 합니다. 이는 그대로 끼워 넣는 대체가 아니라 구조 변경이지만, 허브 모듈(How to Teach This)이 이미 갖추고 있는 형태와 정확히 같기도 합니다.

__all__과의 상호 작용

모듈은 from ... export ... 문을 수동으로 관리하는 __all__이나, 파일의 다른 곳에 있는 __all__ += / __all__.append 호출과 자유롭게 섞어 쓸 수 있습니다. 각 export 문은 새 이름을 추가하기 전에, 모듈 네임스페이스에서 그 시점에 __all__에 바인딩되어 있는 객체를 확인합니다:

  • __all__이 아직 존재하지 않으면 export가 빈 리스트로 그것을 만듭니다.
  • __all__이 존재하지만 아직 리스트가 아니라면, export는 기존 내용을 보존하면서 이를 리스트로 복사합니다.
  • 그렇지 않으면 __all__은 이미 리스트이므로 그대로 사용됩니다.

그런 다음 새 이름이 추가됩니다. 따라서 export 문은 항상 __all__을 변경 가능한 일반 리스트로 남기며, 같은 모듈의 이후 코드는 일반적인 리스트 연산으로 이 리스트를 계속 확장할 수 있습니다:

from .foo export Foo

__all__ += ["Baz"]

중복된 이름도 허용됩니다. __all__에 중복이 없어야 한다는 요구 사항은 원래 없었으며, 이 PEP도 이를 바꾸지 않습니다.

from ... export ...는 __all__의 내용에만 영향을 미치며, 이는 다시 from module import *와 이미 __all__을 읽는 모든 도구(문서 생성기, 린터, IDE)에 영향을 미칩니다.

export는 <module>에 명명된 중간 하위 모듈을 허브 모듈 자체의 dir()에서 숨길 수 없습니다. spam/__init__.py에 from ._internal.core export PublicAPI가 포함되어 있으면, 문이 import, from ... import, export 중 무엇을 사용하든 _internal은 spam 모듈의 속성이 되어 dir(spam)에 나타납니다. spam/__init__.py 자체의 네임스페이스가 곧 spam.__dict__이며, Python의 임포트 시스템은 패키지와 일반 모듈 모두에 대해, 하위 모듈을 임포트하는 부수 효과로 임포트된 하위 모듈을 부모 패키지의 네임스페이스에 바인딩합니다. 이는 임포트 시스템의 특성이지, export가 도입하거나 억제할 수 있는 것이 아닙니다. 이는 속성 접근 숨기기가 이 PEP의 비목표인 또 하나의 이유입니다.

의미론적 구현

각 from <module> export <name> as <alias> 문은 Specification에 나온 그대로 디슈거링됩니다. 즉 이름을 임포트하고, __all__을 정규화한 다음, 이름을 추가합니다. 예를 들면 다음과 같습니다:

# from ._internal.core export PublicAPI
from ._internal.core import PublicAPI
__all__.append("PublicAPI")

# from ._internal.widgets export Widget as PublicWidget
from ._internal.widgets import Widget as PublicWidget
__all__.append("PublicWidget")

와일드카드 형식에 해당하는 코드는 Wildcard form에, 지연 형식에 해당하는 코드는 Lazy exports에 나와 있습니다.

다른 소프트 키워드와 마찬가지로, export는 임포트 문에서 from <module> 바로 뒤를 제외한 모든 곳에서 유효한 식별자로 남습니다.

근거

데코레이터가 아닌 키워드를 쓰는 이유

서드파티 atpublic 패키지에 있는 것과 같은 @public 스타일 데코레이터는 개별적으로 정의된 함수와 클래스에는 깔끔하게 동작하지만, import 문과는 결합되지 않습니다. “정의”가 단지 임포트를 통해 모듈에 들어오는 이름일 뿐일 때는 데코레이터를 적용할 객체가 없기 때문입니다.

atpublic은 public(some_imported_name)이라는 함수 호출 형식으로 이 문제를 우회하지만, 이는 이 PEP가 없애려는 이중 작성을 다시 들여옵니다. 이름을 임포트에서 한 번, public()의 인자로 또 한 번 적어야 합니다. 또한 별칭과도 결합되지 않습니다. 함수 호출 형식은 public(alias=name)처럼 키워드 인자만 받는데, 이는 alias를 __all__에 추가하는 동시에 바인딩하므로, 별칭을 공개하려면 from x import y as z를 사용하는 대신 호출 안에서 매핑을 직접 적어야 합니다. 문 수준의 export 키워드는 임포트 문 자체의 일부이므로 두 문제를 모두 피합니다. 어차피 존재했을 임포트 외에는 아무것도 추가하지 않습니다.

재내보내기만 다루는 이유

이 PEP는 새로 작성하는 def, class, 대입을 정의되는 위치에서 내보내는 것으로 표시하는 방법을 의도적으로 제외합니다. 일부 작은 라이브러리와 단일 파일 모듈은 함수가 정의되는 바로 그 자리에 export def public_function(): ...라고 쓰기를 선호할 수 있지만, 그 사용 사례에는 이 PEP가 해결하는 DRY 문제가 없습니다. 이름이 같은 곳에서 정의되고 내보내질 때는 한 번만 작성됩니다. 유지 관리자는 이미 앞에 밑줄을 붙일지를 선택하고 있으며, atpublic의 @public 데코레이터 같은 도구는 새로운 문 없이도 이미 그 선택을 정의 위치에서 할 수 있게 해 줍니다.

재내보내기 허브를 중심으로 공개 구조를 구성하지 않는 작은 라이브러리와 단일 파일 모듈에는 이 PEP가 전혀 필요하지 않습니다. atpublic이 이미 그러한 용도를 충족합니다. 반대로, 처음부터 허브와 내부 모듈 구조를 채택하는 새 프로젝트에서도 atpublic은 별로 쓸모가 없습니다. 공개하려는 모든 것이 이미 허브의 export 문을 통해 흘러가기 때문입니다. 그럼에도 atpublic이 충분히 널리 채택된다면, 그것 또는 그와 비슷한 것이 이 제안과 무관하게 결국 표준 라이브러리에 포함될 수도 있습니다.

이 PEP를 위해 수집한 근거(NumPy, pandas, polars, Typer, FastAPI, Plotly)는 모두 재내보내기 허브에 관한 것이며, 데코레이터를 필요로 하는 개별 정의 이름에 관한 것이 아닙니다. def, class, 대입 문에 대한 새로운 접두사 규칙을 가르치는 대신 익숙한 from ... import ...를 확장하는 단일 문 형식을 쓰면, 문법을 설명하기도 검토하기도 쉽게 유지할 수 있습니다. 그 공백에 대한 실제 근거가 나타난다면 이후의 별도 PEP가 정의 위치 표시자를 자유롭게 제안할 수 있습니다. 이 PEP는 재내보내기 문제를 해결하기 위해 그 문제까지 해결할 필요가 없습니다.

런타임 강제를 두지 않는 이유

저자는 런타임 접근 제한이 그 자체로 매력적이라고 생각하지만, 여기서는 순전히 범위상의 이유로 이를 제외합니다. PEP 842가 바로 이것을 제안합니다. 코드가 내보내지 않은 속성에 접근하면 ExportWarning을 발생시키는 것입니다. 해당 논의 스레드에서는 그러한 접근에 경고를 낼지, 예외를 발생시킬지, 아무것도 하지 않을지, 그리고 그러한 강제가 정당한 내부 접근과 어떻게 상호 작용해야 하는지를 두고 상당한 노력을 들였습니다. 그 과정에서 적대적인 문제 설정, 접근할 때마다 발생하는 성능 오버헤드, 신뢰할 수 없는 경고 필터, 그리고 pip와 같은 패턴의 파손을 이유로 상당한 반발을 샀습니다. pip에는 공개 API가 전혀 없지만, 의도적으로 pip._internal을 임포트하는 pip-tools 같은 도구를 여전히 지원합니다. 그 논쟁 중 어느 것도 이 PEP가 해결하는 DRY 문제와는 관련이 없습니다.

내보내기 목록 관리 문제와 “Python이 내부 요소에 대한 접근을 단속해야 하는가”라는 문제는 서로 분리할 수 있습니다. 이 PEP는 전자만 해결하고, 후자는 모듈 경계 관례(하위 모듈 이름 앞의 밑줄이나 비공개 하위 패키지)에 맡깁니다. 이러한 관례는 이미 잘 동작하고 있으며, 스레드에서 논의된 모든 라이브러리에 이미 적용되어 배포되고 있습니다.

나중에 런타임 강제가 필요해지면, 별도의 제안이 정확하고 중복되지 않은 __all__ 위에 이를 얹을 수 있으며, 애초에 그 __all__을 만들어 내는 문법과 얽힐 필요가 없습니다.

하위 호환성

export는 match, case, type과 같은 방식을 따르는 소프트 키워드입니다. Python은 원래 import가 와야 하는 단 하나의 위치, 즉 from <module> 바로 뒤에서만 이를 특별하게 취급합니다. export를 변수, 함수, 매개변수, 모듈 이름으로 사용하는 기존 코드는 변경 없이 계속 동작하며, 모듈 이름이 말 그대로 export인, 드물지만 유효한 경우도 마찬가지입니다.

from ... export ...는 __all__에만 영향을 주며, __all__은 모든 Python 버전이 이미 이해합니다. 이 기능이 도입되기 이전 버전을 지원하는 라이브러리는 두 형식을 모두 작성해 두었다가, 최소 지원 버전이 따라잡으면 이전 형식을 제거할 수 있습니다:

# Python < 3.16
from ._internal.core import PublicAPI
__all__ = ["PublicAPI"]

# Python >= 3.16, once adopted
from ._internal.core export PublicAPI

보안 영향

이 PEP에는 알려진 보안 영향이 없습니다.

교육 방법

문서에서는 from <module> export <name>을 독립된 문장으로 가르치기보다, 이름이 붙은 구성 방식인 hub-and-internals 패턴의 일부로 가르쳐야 합니다. 이 패턴을 따르는 패키지에는 두 종류의 모듈이 있습니다:

  • 하나는 허브 모듈로, 보통 __init__.py이며(numpy.typing처럼 패키지에 허브가 둘 이상 있을 수도 있습니다), 내부 모듈에서 이름을 모아 다시 내보내는 일만 담당합니다. 허브 모듈에는 export 문만 있고 __all__을 건드리는 다른 코드는 없습니다. export가 __all__을 만들어 주므로 허브 모듈은 __all__을 직접 선언하지 않습니다.
  • 다른 하나는 실제 구현을 담는 임의 개수의 내부 모듈로, 관례적으로 이름 앞에 밑줄을 붙이거나 이름이 밑줄로 시작하는 서브패키지 아래에 둡니다. 내부 모듈은 __all__을 선언하지 않습니다. 사용자가 직접 임포트하도록 만든 모듈이 아니므로 __all__로 골라낼 대상이 없습니다.
# spam/__init__.py  (the hub)
from ._internal.core export PublicAPI
from ._internal.widgets export Widget, Gadget

# spam/_internal/core.py  (internal -- no __all__)
class PublicAPI:
    ...

# spam/_internal/widgets.py  (internal -- no __all__)
class Widget:
    ...

class Gadget:
    ...

class _Helper:
    ...

따라서 가르칠 규칙은 하나뿐입니다. 이름이 사용자에게 제공되어야 한다면 허브에 그 이름을 위한 ``export`` 문을 하나 작성하고, 나머지는 모두 기본적으로 내보내지 않은 상태로 둡니다. 패키지 전체의 공개 구성은 정확히 한 곳에서 선언되며, 그 선언은 애초에 이름을 사용할 수 있게 하려면 어차피 있어야 할 문장들로 이루어집니다.

현재 다시 내보내기에 from <module> import <name> as <name>을 권장하는 스타일 가이드는 대신 export를 안내할 수 있습니다. export는 플랫폼별 구현을 선택하는 경우처럼 조건부 다시 내보내기도 처리합니다(Specification 참조). hub-and-internals 패턴 밖에서는 __all__을 직접 조작하는 방식을 계속 사용할 수 있으며, 플러그인을 등록하는 반복문처럼 하나의 import 문이 아니라 런타임에 프로그램으로 찾아낸 이름을 다룰 때는 여전히 그렇게 해야 합니다.

참조 구현

아직 참조 구현이 없습니다. CPython의 문법 변경을 확정하기 전에, 소스 대 소스 변환 방식으로 프로토타입을 만들 수 있습니다(match 문의 초기 프로토타입과 비슷한 방식).

기각된 아이디어

대안 표면 문법

이 PEP에서는 다시 내보내기 문에 대해 다른 두 가지 표기를 검토했습니다:

  • ECMAScript의 export ... from ...을 본뜬 export <name> from <module> 형식입니다. 이름을 모듈 앞에 두어 모든 Python import 문이 사용하는 순서를 뒤집는데, 다른 언어의 관례와 맞춘다는 것 외에는 아무런 이점이 없기 때문에 거부되었습니다.
  • 일반적인 from ... import ... 문 앞에 export를 붙이는 export from <module> import <name> 형식입니다. 이 제안의 이전 초안에서는 이 형식을 사용했습니다. 완전한 import 문 앞에 export가 붙으면 하나의 동작에 동사가 두 개인 것처럼 읽히고, import를 그 자리에서 대체하면 파서가 여러 종류의 문 앞에 붙는 접두사로 export를 인식할 필요 없이 이 키워드를 특수 처리하는 위치를 문법의 한 곳으로 한정할 수 있으므로, from <module> export <name>을 채택하고 이 형식은 기각되었습니다.

미해결 문제

export는 리스트가 아닌 __all__에 대해 경고해야 합니까?

Specification은 리스트가 아닌 __all__에 항목을 추가하기 전에 이를 조용히 리스트로 변환합니다. 반면 PEP 844의 @public 데코레이터에 대한 Guido van Rossum의 병행 제안은 리스트가 아닌 __all__을 발견하면 눈에 보이는 DeprecationWarning을 발생시키되, 그 값은 무기한 계속 지원합니다(댓글).

미해결 질문은 다음과 같습니다. export도 마찬가지로 리스트가 아닌 __all__을 변환해야 할 때 경고해야 합니까, 아니면 조용히 넘어가고 ruff의 PLE0605 같은 린터에 맡겨야 합니까?

감사의 글

이 PEP는 PEP 842에 대한 논의에서 비롯되었으며, 특히 Peter Bierma, Alex Grönholm, Guido van Rossum, Barry Warsaw, Hugo van Kemenade의 기여에 힘입었습니다. 이들은 재내보내기(re-export)가 깨지는 실제 사례를 제공하여, 이 제안이 가상의 상황이 아닌 구체적인 라이브러리에 근거하도록 해 주었습니다.

각주

변경 이력

  • 2026년 8월 23일
    • 세 번 반복되던 __all__ 정규화 의사 코드를 Specification의 한 곳으로 통합했습니다.
    • export가 리스트가 아닌 __all__에 대해 DeprecationWarning을 발생시켜야 하는지 묻는 미해결 문제 섹션을 추가했습니다.
    • DRY의 표준 정의를 인용했습니다.
    • export를 모듈 수준으로 제한했습니다.
  • 2026년 8월 22일
    • 와일드카드 형식에 관한 미해결 질문을 __all__이 없을 때의 대체 동작까지 포함하여 import *와 정확히 일치시키는 방향으로 해결했으며, 해결되어 더 이상 필요 없는 미해결 문제 섹션을 삭제했습니다.
    • “__all__과의 상호 작용”에 있는 __all__ 생성 규칙을 명시적으로 기술하고, export 문 뒤에 __all__ +=를 사용하는 예제를 추가했습니다.
    • export는 자체적인 제약 없이 import를 쓸 수 있는 곳이라면 어디서나 사용할 수 있다는 점을 명시하고, if typing.TYPE_CHECKING: 안에서의 재내보내기를 스텁 전용 패키지를 위한 의도된 사용 사례로 언급했습니다.
  • 2026년 8월 13일
    • “flat tree”라는 표현이 자기모순이었으므로 초록(Abstract)의 공개/구현 레이아웃 설명을 고쳐 썼습니다.
    • __all__이 없으면 dir()의 깔끔함이 손상된다는 잘못된 주장을 삭제했습니다. dir()은 __all__을 참조하지 않습니다.