애너테이션 모범 사례
애너테이션 모범 사례 (Annotations Best Practices)
이 문서는 객체의 __annotations__라는 애너테이션 사전을 다루는 코드를 작성할 때 지켜야 할 모범 사례를 모아 둔 가이드예요. 객체의 애너테이션을 들여다보는 코드를 Python으로 작성한다면, 아래의 지침을 따라 주시면 돼요. 이 문서는 특히 __annotations__를 어떻게 다루는지에 집중해요. '타입 힌트'를 코드에서 어떻게 사용하는지가 궁금하다면 typing 모듈 문서를 보시는 게 맞아요.
출처: Python 공식 문서
Python 3.10 이상에서 객체의 애너테이션 사전에 접근하기
Python 3.10에서 표준 라이브러리에 inspect.get_annotations()라는 함수가 새로 추가됐어요. 3.10부터 3.13까지의 Python 버전에서는, 애너테이션을 지원하는 객체의 애너테이션 사전에 접근할 때 이 함수를 호출하는 것이 모범 사례예요. 이 함수는 '문자열화된'(stringized) 애너테이션을 다시 풀어 주는 '언스트링화'(un-stringize)도 해 줘요.
Python 3.14에서는 애너테이션을 다루는 기능을 가진 annotationlib 모듈이 새로 나왔어요. 여기에는 inspect.get_annotations()를 대체하는 annotationlib.get_annotations() 함수가 포함돼 있죠.
만약 어떤 이유로 inspect.get_annotations()를 쓰기 어렵다면, __annotations__ 데이터 멤버에 직접 접근할 수도 있어요. 이때의 모범 사례도 Python 3.10에서 바뀌었어요. 3.10부터는 o.__annotations__가 Python 함수·클래스·모듈에서 항상 동작하는 것이 보장돼요. 검사하는 객체가 이 세 가지 특정 객체 중 하나임을 확신한다면, o.__annotations__만 사용해도 돼요.
하지만 다른 종류의 callable(예를 들어 functools.partial()이 만든 callable)에는 __annotations__ 속성이 정의돼 있지 않을 수 있어요. 가능성이 불확실한 객체의 __annotations__에 접근할 때, Python 3.10 이상에서의 모범 사례는 인자 세 개를 쓰는 getattr()을 호출하는 거예요. 예를 들면 getattr(o, '__annotations__', None)처럼요.
Python 3.10 이전에는, 애너테이션을 정의하지 않았지만 애너테이션이 있는 부모 클래스를 가진 클래스에서 __annotations__에 접근하면 부모의 __annotations__가 반환됐어요. Python 3.10 이상에서는 자식 클래스의 애너테이션이 대신 빈 dict가 돼요.
Python 3.9 이하에서 객체의 애너테이션 사전에 접근하기
Python 3.9 이하에서는 객체의 애너테이션 사전에 접근하는 게 최신 버전보다 훨씬 복잡해요. 문제는 이 구버전 Python의 설계 결함 때문인데, 특히 클래스 애너테이션과 관련돼 있어요.
함수·다른 callable·모듈 같은 다른 객체의 애너테이션 사전에 접근하는 모범 사례는, inspect.get_annotations()를 쓰지 않는다는 전제 아래 3.10과 동일해요. 즉 세 인자 getattr()으로 객체의 __annotations__ 속성에 접근하면 돼요.
안타깝게도 클래스에는 그게 모범 사례가 아니에요. 문제는 __annotations__가 클래스에서 선택적이고, 클래스는 기본 클래스로부터 속성을 상속받을 수 있기 때문에, 클래스의 __annotations__ 속성에 접근하다 보면 어쩌다 기본 클래스의 애너테이션 사전을 반환할 수 있다는 거예요. 예를 보면 이러해요.
class Base:
a: int = 3
b: str = 'abc'
class Derived(Base):
pass
print(Derived.__annotations__)
이건 Derived가 아니라 Base의 애너테이션 사전을 출력해요.
검사하는 객체가 클래스(isinstance(o, type))라면 코드에 별도의 분기 경로를 둬야 해요. 그 경우 모범 사례는 Python 3.9 이전의 구현 세부 사항에 기대요. 클래스에 애너테이션이 정의돼 있다면 그건 클래스의 __dict__ 사전에 저장돼요. 애너테이션이 정의돼 있을 수도, 없을 수도 있으니 모범 사례는 클래스 dict에 get() 메서드를 호출하는 거예요.
정리하면, Python 3.9 이하에서 임의의 객체의 __annotations__ 속성에 안전하게 접근하는 샘플 코드는 다음과 같아요.
if isinstance(o, type):
ann = o.__dict__.get('__annotations__', None)
else:
ann = getattr(o, '__annotations__', None)
이 코드를 실행하면 ann은 사전이거나 None이 돼요. 더 자세히 살펴보기 전에 isinstance()로 ann의 타입을 다시 확인해 보는 걸 권해요.
참고로 어떤 특이하거나 잘못 만들어진 타입 객체는 __dict__ 속성이 없을 수 있어요. 그래서 더 안전하게 하려면 __dict__에 접근할 때도 getattr()을 쓰면 좋아요.
문자열화된 애너테이션 수동으로 언스트링화하기
일부 애너테이션이 '문자열화'돼 있을 때, 그 문자열을 평가해 파이썬 값으로 되돌리고 싶다면 inspect.get_annotations()를 호출해서 이 작업을 시켜 주는 것이 정말로 최선이에요.
Python 3.9 이하를 쓰거나 어떤 이유로 inspect.get_annotations()를 쓸 수 없다면, 그 로직을 직접 재현해야 해요. 현재 Python 버전의 inspect.get_annotations() 구현을 살펴보고 비슷한 방식으로 따라가 보길 권해요.
요약하면, 임의의 객체 o에서 문자열화된 애너테이션을 평가하고 싶다면:
o가 모듈이면eval()을 호출할 때 globals로o.__dict__를 사용해요.o가 클래스면eval()호출 시 globals로sys.modules[o.__module__].__dict__를, locals로dict(vars(o))를 사용해요.o가functools.update_wrapper(),@functools.wraps, 또는functools.partial()로 감싸진 callable이면,o.__wrapped__나o.func에 접근해서 뿌리가 되는 언랩된 함수를 찾을 때까지 반복적으로 풀어줘요.o가 callable(클래스는 제외)이면eval()호출 시 globals로o.__globals__를 사용해요.
하지만 애너테이션으로 쓰인 모든 문자열 값이 eval()로 성공적으로 파이썬 값이 되는 건 아니에요. 문자열 값은 이론상 어떤 유효한 문자열도 담을 수 있고, 실제로는 특별히 평가할 수 없는 문자열 값으로 애너테이션해야 하는 타입 힌트의 유효한 사용 사례가 있어요. 예를 들면:
- Python 3.10에 지원이 추가되기 전의
|를 쓰는 PEP 604 유니언 타입. - 런타임엔 필요 없고
typing.TYPE_CHECKING이 참일 때만 임포트되는 정의.
eval()이 이런 값을 평가하려 하면 실패하며 예외를 일으켜요. 그래서 애너테이션을 다루는 라이브러리 API를 설계할 때는, 호출자가 명시적으로 요청할 때만 문자열 값을 평가하려 시도하는 게 좋아요.
모든 Python 버전에서 __annotations__를 위한 모범 사례
- 객체의
__annotations__멤버에 직접 할당하는 것은 피해야 해요.__annotations__을 설정하는 건 Python이 맡기세요. - 객체의
__annotations__멤버에 직접 할당한다면, 항상dict객체로 설정해야 해요. - 어떤 객체에서든
__annotations__에 직접 접근하는 것은 피해야 해요. 대신annotationlib.get_annotations()(Python 3.14 이상)나inspect.get_annotations()(Python 3.10 이상)를 사용하세요. - 객체의
__annotations__멤버에 직접 접근한다면, 내용을 살펴보기 전에 그게 사전인지 확인해야 해요. __annotations__사전을 수정하는 것은 피해야 해요.- 객체의
__annotations__속성을 삭제하는 것은 피해야 해요.
__annotations__의 기묘한 점들
모든 Python 3 버전에서, 함수 객체에 애너테이션이 정의돼 있지 않으면 애너테이션 사전을 게으르게(lazily) 만들기 시작해요. del fn.__annotations__를 써서 __annotations__ 속성을 삭제할 수 있지만, 그 후에 fn.__annotations__에 접근하면 객체가 새 빈 dict를 만들어 저장하고 그것을 애너테이션으로 반환해요. 함수가 애너테이션 사전을 게으르게 만들기 전에 애너테이션을 삭제하면 AttributeError가 발생해요. del fn.__annotations__를 두 번 연속 실행하는 것은 항상 AttributeError를 일으키는 게 보장돼요.
위 문단의 모든 내용은 Python 3.10 이상의 클래스와 모듈 객체에도 동일하게 적용돼요.
모든 Python 3 버전에서 함수 객체의 __annotations__를 None으로 설정할 수 있어요. 하지만 이후 그 객체의 애너테이션을 fn.__annotations__로 접근하면, 이 섹션 첫 문단의 규칙대로 빈 사전을 게으르게 만들 거예요. 이는 모듈과 클래스에는 해당하지 않아요. 어떤 Python 버전에서든 그 객체들은 __annotations__를 어떤 Python 값으로든 설정할 수 있게 허용하고, 설정된 값을 유지해요.
Python이 당신을 대신해 애너테이션을 문자열화할 때(from __future__ import annotations 사용 시), 그리고 문자열을 애너테이션으로 지정하면 그 문자열 자체가 인용돼요. 실질적으로 애너테이션이 두 번 인용되는 셈이에요. 예를 들면:
from __future__ import annotations
def foo(a: "str"): pass
print(foo.__annotations__)
이것은 {'a': "'str'"}를 출력해요. 이건 사실 '기묘한 점'이라기보다는, 의외라서 언급되는 거예요.
커스텀 메타클래스를 쓰는 클래스에서 클래스의 __annotations__에 접근하면 예상치 못한 동작이 나타날 수 있어요. 몇 가지 예는 PEP 749를 보면 돼요. 이런 기묘함은 Python 3.14 이상에서 annotationlib.get_annotations()를, Python 3.10 이상에서 inspect.get_annotations()를 사용해 피할 수 있어요. 더 이른 버전에서는 클래스의 __dict__에서 애너테이션을 접근하면(예: cls.__dict__.get('__annotations__', None)) 이런 버그를 피할 수 있어요.
어떤 Python 버전에서는 클래스의 인스턴스에 __annotations__ 속성이 있을 수 있어요. 하지만 이건 지원되는 기능이 아니에요. 인스턴스의 애너테이션이 필요하다면 type()을 써서 그 클래스에 접근하면 돼요(예: Python 3.14 이상에서 annotationlib.get_annotations(type(myinstance))).