상위 목록: 하위 목록: 작성 날짜: 읽는 데 18 분 소요

타입 힌트(Type Hints)

타입 힌트(Type Hints)란 변수, 매개변수, 반환값에 어떤 자료형의 값이 사용되는지 표기하는 문법입니다.

제 34강 - 함수 주석에서 다룬 함수 주석(Function Annotations) 문법에 자료형을 작성하는 방식으로 사용합니다.

Python은 동적 타입 언어이므로, 타입 힌트를 작성하더라도 실행 중에 자료형을 검사하거나 강제하지 않습니다.

타입 힌트는 코드를 읽는 사람에게 의도를 전달하고, 편집기의 자동 완성과 mypy, pyright 같은 정적 타입 검사기(Static Type Checker)가 실행 전에 오류를 찾는 데 활용됩니다.

이번 강좌에서는 타입 힌트가 없을 때 생기는 문제를 먼저 확인하고, 기본 문법, 내장 제네릭, 유니온 타입, Any·Callable·Literal·TypedDict·Protocol 같은 특수 타입, Python 3.12 이상의 제네릭·타입 별칭 문법, Python 3.14의 지연 평가 주석, 그리고 정적 타입 검사기의 역할까지 다룹니다.



타입 힌트가 필요한 이유

def total_price(prices, rate):
    return sum(prices) * (1 - rate)


print(total_price([1000, 2000], 0.1))

try:
    print(total_price("1000", 0.1))
except TypeError as e:
    print("TypeError :", e)
결과
2700.0
TypeError : unsupported operand type(s) for +: ‘int’ and ‘str’

타입 힌트가 없는 함수입니다. 함수 이름과 매개변수 이름만 보고는 prices에 무엇을 전달해야 하는지 알기 어렵습니다. 가격 하나인지, 가격의 리스트인지, 문자열도 되는지는 함수 본문을 읽거나 직접 실행해 봐야 알 수 있습니다.

total_price("1000", 0.1)처럼 잘못된 값을 전달해도 함수를 호출하는 줄에서는 아무 문제가 없습니다. 오류는 함수 내부의 sum()이 문자열의 각 글자를 더하려는 순간에야 발생하며, 오류 메시지도 int와 str을 더할 수 없다는 내용이라 어디서 잘못된 값이 들어왔는지 바로 알기 어렵습니다.

이 코드가 거의 실행되지 않는 분기 안에 있었다면, 이 오류는 배포한 뒤에 실제 사용자에게서 처음 발견될 수도 있습니다.


def total_price(prices: list[int], rate: float) -> float:
    return sum(prices) * (1 - rate)


print(total_price([1000, 2000], 0.1))
print(total_price.__annotations__)
결과
2700.0
{‘prices’: list[int], ‘rate’: <class ‘float’>, ‘return’: <class ‘float’>}

같은 함수에 타입 힌트를 추가한 코드입니다. 실행 결과는 같지만 다음과 같은 점이 달라집니다.

  1. 함수 정의만 읽어도 prices는 정수의 리스트, rate는 실수, 반환값은 실수라는 것을 알 수 있습니다.
  2. 편집기는 prices가 리스트라는 것을 알고 리스트의 메서드를 자동 완성으로 보여 줍니다.
  3. 정적 타입 검사기는 total_price("1000", 0.1)처럼 호출하는 줄을 실행하기 전에 오류로 표시합니다.

즉, 타입 힌트는 실행 중에 늦게 발견되던 오류를 코드를 작성하는 시점으로 앞당기는 도구입니다. 단, 타입 힌트 자체는 실행 결과를 바꾸지 않는다는 점을 다음 섹션에서 확인합니다.



기본 타입 힌트

def greet(name: str, count: int = 1) -> str:
    return f"Hello, {name}! " * count


price: float = 1000.0

print(greet("Python", 2))
print(greet.__annotations__)
print(greet(10, 2))
결과
Hello, Python! Hello, Python!
{‘name’: <class ‘str’>, ‘count’: <class ‘int’>, ‘return’: <class ‘str’>}
Hello, 10! Hello, 10!

매개변수는 매개변수: 자료형, 반환값은 -> 자료형의 형태로 작성합니다.

기본값이 있는 매개변수는 매개변수: 자료형 = 기본값의 형태로 작성합니다.

변수에도 변수: 자료형 = 값의 형태로 타입 힌트를 작성할 수 있습니다.

작성한 타입 힌트는 함수의 __annotations__ 속성에 사전 형태로 저장됩니다.

greet(10, 2)처럼 str 대신 int를 전달해도 오류가 발생하지 않고 정상적으로 실행됩니다.

즉, 타입 힌트는 런타임에 강제되지 않습니다. 이와 같은 잘못된 호출은 정적 타입 검사기를 실행해야 발견할 수 있습니다.

결과를 한 줄씩 보면, 첫 줄은 정상적인 호출 결과입니다. 두 번째 줄은 __annotations__에 저장된 주석으로, 매개변수 이름이 키가 되고 반환값의 주석은 'return' 키에 저장됩니다. 세 번째 줄은 str 자리에 int를 전달한 결과로, f-string이 정수도 문자열로 바꿔 주기 때문에 우연히 정상적으로 동작했습니다.

이처럼 타입 힌트와 다른 값이 들어와도 우연히 동작하는 경우가 있어, 실행 결과만으로는 잘못된 호출을 발견하기 어렵습니다.

  • Tip : 반환값이 없는 함수는 -> None으로 작성합니다.


import sys

count: int
name: str = "Python"
print(sys.modules[__name__].__annotations__)

try:
    print(count)
except NameError as e:
    print("NameError :", e)
결과
{‘count’: <class ‘int’>, ‘name’: <class ‘str’>}
NameError : name ‘count’ is not defined

변수의 타입 힌트는 변수: 자료형처럼 값 없이 작성할 수도 있습니다. 모듈 수준에서 작성한 변수의 주석은 모듈 객체의 __annotations__에 저장됩니다.

하지만 값 없이 작성한 count: int는 주석만 기록할 뿐 변수를 만들지 않습니다. 그러므로 count를 사용하면 NameError가 발생합니다.

값을 나중에 할당할 변수의 자료형을 미리 알리거나, 클래스에서 인스턴스 변수의 자료형을 선언할 때 이 형태를 사용합니다. 제 50강 - 데이터 클래스는 이렇게 작성한 클래스 변수의 주석을 읽어 필드를 만듭니다.



내장 제네릭(Built-in Generics)

scores: list라고만 작성하면 리스트라는 것만 알 수 있을 뿐, 안에 무엇이 들어 있는지는 알 수 없습니다. 리스트에서 꺼낸 값으로 무엇을 할 수 있는지 알려면 요소의 자료형까지 표기해야 합니다.

def average(scores: list[int]) -> float:
    return sum(scores) / len(scores)


def count_words(words: list[str]) -> dict[str, int]:
    result: dict[str, int] = {}
    for word in words:
        result[word] = result.get(word, 0) + 1
    return result


point: tuple[int, int] = (3, 4)
values: tuple[float, ...] = (1.0, 2.0, 3.0)
unique: set[str] = {"a"}

print(average([80, 90, 100]))
print(count_words(["a", "b", "a"]))
print(list[int])
결과
90.0
{‘a’: 2, ‘b’: 1}
list[int]

Python 3.9 이상부터는 list, dict, tuple, set 같은 내장 자료형에 대괄호로 요소의 자료형을 지정할 수 있습니다.

표기 의미
list[int] 정수를 요소로 갖는 리스트
dict[str, int] 키가 문자열, 값이 정수인 사전
tuple[int, int] 정수 두 개로 구성된 튜플
tuple[float, …] 길이와 관계없이 실수로 구성된 튜플
set[str] 문자열을 요소로 갖는 집합


이전 버전에서는 typing 모듈의 List, Dict, Tuple, Set을 가져와 List[int]처럼 작성해야 했습니다.

현재는 typing.List 등이 비권장(deprecated) 별칭이므로, 내장 자료형을 직접 사용합니다.

예제의 마지막 줄에서 list[int]를 출력하면 list[int]가 그대로 출력됩니다. 대괄호를 붙인 list[int]는 리스트가 아니라 “정수 리스트”라는 자료형 정보를 담은 객체이며, 실제 리스트를 만들거나 검사하지 않습니다.

tuple은 다른 컬렉션과 표기 방식이 다르다는 점에 주의합니다. list[int]는 길이와 관계없이 정수만 담는 리스트지만, tuple[int, int]는 정확히 두 개의 정수를 담는 튜플입니다. 길이가 정해지지 않은 튜플은 tuple[int, ...]처럼 ...을 사용합니다.


scores: list[int] = ["A", "B"]
print(scores)

try:
    isinstance(scores, list[int])
except TypeError as e:
    print("TypeError :", e)
결과
[‘A’, ‘B’]
TypeError : isinstance() argument 2 cannot be a parameterized generic

list[int]로 선언한 변수에 문자열 리스트를 할당해도 오류 없이 저장됩니다.

또한 isinstance() 함수에는 list[int]처럼 요소 자료형을 지정한 타입을 사용할 수 없습니다. 리스트의 모든 요소를 검사해야 하는 비용이 크고, 리스트는 나중에 다른 값이 추가될 수도 있기 때문입니다.

실행 중에 확인해야 한다면 isinstance(scores, list)로 리스트 여부만 검사하고, 요소는 반복문으로 직접 검사합니다.



유니온 타입(Union Type)

검색 결과가 없을 때 None을 반환하는 함수처럼, 하나의 매개변수나 반환값이 상황에 따라 여러 자료형을 가질 수 있습니다. 이런 경우 가능한 자료형을 모두 나열해 표기합니다.

def find(items: list[str], target: str) -> int | None:
    if target in items:
        return items.index(target)
    return None


def to_text(value: int | float | str) -> str:
    return str(value)


print(find(["a", "b", "c"], "b"))
print(find(["a", "b", "c"], "z"))
print(int | None)
print(isinstance(3.14, int | float))
결과
1
None
int | None
True

Python 3.10 이상부터는 X | Y의 형태로 여러 자료형 중 하나를 의미하는 유니온 타입(Union Type)을 작성할 수 있습니다.

int | None은 정수 또는 None을 의미하며, 값이 없을 수 있는 경우에 주로 사용합니다.

이전 버전에서는 typing 모듈의 Union[int, str]과 Optional[int]를 사용해야 했습니다. Optional[int]는 int | None과 같은 의미입니다.

X | Y 형태의 유니온 타입은 isinstance() 함수의 두 번째 인수로도 사용할 수 있습니다.

결과의 첫 두 줄은 "b"를 찾아 인덱스 1을, "z"를 찾지 못해 None을 반환한 결과입니다. 세 번째 줄은 int | None 자체를 출력한 것으로, 이 표기도 하나의 객체입니다. 마지막 줄은 3.14가 int 또는 float 중 하나에 해당하므로 True입니다.


def find(items: list[str], target: str) -> int | None:
    if target in items:
        return items.index(target)
    return None


index = find(["a", "b", "c"], "z")

try:
    print(index + 1)
except TypeError as e:
    print("TypeError :", e)

if index is not None:
    print(index + 1)
else:
    print("찾지 못함")
결과
TypeError : unsupported operand type(s) for +: ‘NoneType’ and ‘int’
찾지 못함

int | None을 반환하는 함수의 결과를 None 검사 없이 사용하면 위와 같은 오류가 발생합니다.

반환값이 int | None이라고 표기되어 있으면, 정적 타입 검사기는 index + 1처럼 None일 가능성을 무시한 코드를 실행 전에 경고합니다.

if index is not None: 블록 안에서는 index가 int라는 것이 확실하므로 검사기도 경고하지 않습니다. 이처럼 조건문으로 가능한 자료형을 좁히는 것을 타입 좁히기(Type Narrowing)라 합니다.

  • Tip : if index:로 검사하면 인덱스가 0일 때도 거짓이 되어 찾지 못한 것으로 처리됩니다. None 여부는 반드시 is not None으로 검사합니다.



Any & Callable

자료형을 하나로 정할 수 없는 값이나, 함수를 인수로 받는 매개변수에는 특별한 표기가 필요합니다. 제 47강 - 데코레이터처럼 함수를 주고받는 코드를 작성할 때 특히 자주 사용합니다.

from collections.abc import Callable, Iterable
from typing import Any


def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
    return func(a, b)


def show(value: Any) -> None:
    print(type(value).__name__, value)


def total(values: Iterable[float]) -> float:
    return sum(values)


print(apply(lambda x, y: x * y, 3, 4))
show([1, 2])
show("text")
print(total((1.5, 2.5)))
결과
12
list [1, 2]
str text
4.0

typing.Any는 모든 자료형을 허용하는 특수한 타입입니다. 정적 타입 검사기는 Any로 표기된 값의 자료형을 검사하지 않습니다.

Callable[[매개변수 자료형, ...], 반환 자료형]은 호출 가능한 객체를 의미합니다.

Callable[[int, int], int]는 정수 두 개를 입력받아 정수를 반환하는 함수를 의미합니다.

Callable, Iterable, Sequence, Mapping 같은 추상 자료형은 typing 모듈이 아닌 collections.abc 모듈에서 가져오는 것을 권장합니다.

Python 3.9 이상부터 collections.abc의 클래스에 대괄호로 자료형을 지정할 수 있으며, typing.Callable 등은 비권장 별칭입니다.

  • Tip : 매개변수의 자료형에는 list[float]보다 Iterable[float]처럼 필요한 기능만 표현하는 추상 자료형을 사용하면, 튜플이나 생성자 등 더 많은 값을 허용할 수 있습니다.

결과를 보면 apply()는 전달받은 람다 함수로 3 * 4를 계산해 12를 반환하고, show()는 리스트와 문자열을 모두 받아 자료형 이름과 값을 출력합니다. total()은 리스트가 아닌 튜플을 받았지만, Iterable[float]로 표기했으므로 의도에 맞는 호출입니다.

Any와 object는 둘 다 모든 값을 받을 수 있지만 의미가 반대입니다.

표기 받을 수 있는 값 받은 값으로 할 수 있는 일
Any 모든 값 모든 연산을 허용(검사를 끔)
object 모든 값 모든 객체가 공통으로 가진 기능만 허용


Any는 정적 타입 검사를 사실상 끄는 것이므로, 오류를 찾아 주는 타입 힌트의 장점이 사라집니다. 모든 값을 받되 안전하게 다루고 싶다면 object를, 자료형을 정말 알 수 없을 때만 Any를 사용합니다.



Literal

open() 함수의 mode처럼 매개변수가 문자열이긴 하지만 정해진 몇 가지 값만 의미가 있는 경우가 있습니다. str로 표기하면 오타가 있는 문자열도 통과하므로, 허용할 값을 직접 나열합니다.

from typing import Literal, get_args

Mode = Literal["r", "w", "a"]


def open_file(path: str, mode: Mode) -> str:
    return f"{path} : {mode}"


print(open_file("data.txt", "r"))
print(get_args(Mode))
print(open_file("data.txt", "x"))
결과
data.txt : r
(‘r’, ‘w’, ‘a’)
data.txt : x

typing.Literal은 특정한 값만 허용하는 타입입니다. (Python 3.8 이상)

Literal["r", "w", "a"]는 문자열 중에서도 "r", "w", "a" 세 값만 허용한다는 의미입니다.

typing.get_args() 함수로 Literal에 포함된 값을 튜플로 확인할 수 있습니다.

마지막 호출처럼 허용되지 않은 "x"를 전달해도 실행 중에는 오류가 발생하지 않으며, 정적 타입 검사기에서 오류로 보고됩니다.

예제에서는 Mode = Literal[...]처럼 긴 표기를 변수에 저장해 재사용했습니다. 뒤의 타입 별칭 섹션에서 다루는 type 문으로도 같은 별칭을 만들 수 있습니다.

get_args(Mode)의 결과를 이용하면 실행 중에 if mode not in get_args(Mode):처럼 허용 값 검사 코드를 작성할 수 있어, 타입 힌트와 실제 검사가 같은 목록을 공유하게 됩니다.

  • Tip : 허용할 값이 많거나 값마다 동작이 붙어 있다면 enum.Enum을 사용하는 것이 더 적합합니다. Literal은 몇 개의 단순한 상수를 표현할 때 사용합니다.



TypedDict

JSON 응답이나 설정 파일을 읽으면 {"name": "Python", "age": 35}처럼 키마다 값의 자료형이 다른 사전을 다루게 됩니다. 이 사전을 dict[str, str | int]로 표기하면 user["name"]이 문자열인지 정수인지 구분할 수 없습니다.

from typing import NotRequired, TypedDict


class User(TypedDict):
    name: str
    age: int
    email: NotRequired[str]


user: User = {"name": "Python", "age": 35}
print(user)
print(type(user))
print(sorted(User.__required_keys__))
print(sorted(User.__optional_keys__))
결과
{‘name’: ‘Python’, ‘age’: 35}
<class ‘dict’>
[‘age’, ‘name’]
[‘email’]

typing.TypedDict는 키마다 값의 자료형이 정해진 사전을 표현합니다. (Python 3.8 이상)

클래스 문법으로 키와 값의 자료형을 정의하며, 실제로 생성되는 객체는 일반 dict입니다.

dict[str, int]처럼 모든 값이 같은 자료형인 경우와 달리, JSON 데이터처럼 키마다 자료형이 다른 사전을 표현할 때 유용합니다.

NotRequired[자료형]은 생략할 수 있는 키를 의미합니다. (Python 3.11 이상)

필수 키와 선택 키는 각각 __required_keys__와 __optional_keys__ 속성에 저장됩니다.

  • Tip : __required_keys__와 __optional_keys__는 frozenset이므로, 예제에서는 출력 순서를 고정하기 위해 sorted() 함수를 사용했습니다.

결과의 두 번째 줄에서 type(user)가 dict인 것처럼, TypedDict는 새로운 자료형을 만드는 것이 아니라 일반 사전에 키와 자료형 정보를 덧붙이는 것입니다. 그러므로 실행 속도나 메모리 사용량은 일반 사전과 같습니다.

email 키를 넣지 않았지만 NotRequired로 선언했으므로 올바른 사전이며, name이나 age를 빠뜨리면 정적 타입 검사기가 오류로 보고합니다.

  • Tip : TypedDict는 실행 중에 키나 값을 검사하지 않습니다. 외부에서 받은 JSON처럼 신뢰할 수 없는 데이터라면 별도로 검증해야 합니다.



Protocol

Python은 “오리처럼 걷고 오리처럼 꽥꽥거리면 오리다”라는 덕 타이핑(Duck Typing) 방식을 사용합니다. 함수는 전달받은 객체의 클래스가 무엇인지가 아니라, 필요한 메서드를 가지고 있는지만 중요합니다.

그런데 타입 힌트에 Circle처럼 특정 클래스를 적으면 같은 메서드를 가진 다른 클래스는 허용되지 않습니다. Protocol은 덕 타이핑을 타입 힌트로 표현하는 방법입니다.

from typing import Protocol, runtime_checkable


@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> str: ...


class Circle:
    def draw(self) -> str:
        return "원 그리기"


class Square:
    def draw(self) -> str:
        return "사각형 그리기"


def render(shape: Drawable) -> None:
    print(shape.draw())


render(Circle())
render(Square())
print(isinstance(Circle(), Drawable))
print(isinstance("text", Drawable))
결과
원 그리기
사각형 그리기
True
False

typing.Protocol은 필요한 메서드와 속성의 형태를 정의하는 클래스입니다. (Python 3.8 이상)

Circle과 Square 클래스는 Drawable을 상속하지 않았지만, draw() 메서드를 가지고 있으므로 Drawable 타입으로 인정됩니다.

이처럼 상속 관계가 아닌 구조(메서드와 속성)로 타입을 판단하는 방식을 구조적 서브타이핑(Structural Subtyping)이라 합니다.

@runtime_checkable 데코레이터를 적용하면 isinstance() 함수로 프로토콜을 만족하는지 확인할 수 있습니다.

  • Tip : isinstance()는 메서드의 존재 여부만 확인하며, 매개변수나 반환값의 자료형까지 검사하지는 않습니다.

  • Tip : 메서드 본문의 ...은 Ellipsis 객체로, 구현을 생략한다는 의미로 사용합니다.

결과의 마지막 두 줄을 보면 Circle은 draw()가 있으므로 True, 문자열은 draw()가 없으므로 False입니다.

상속으로 타입을 판단하는 방식을 명목적 서브타이핑(Nominal Subtyping)이라 합니다. 추상 클래스를 상속하는 방식은 클래스를 정의할 때 부모를 미리 지정해야 하지만, Protocol은 이미 만들어진 클래스나 외부 라이브러리의 클래스도 메서드만 맞으면 수정 없이 받아들일 수 있습니다.


from typing import Protocol, runtime_checkable


@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> str: ...


class Wrong:
    def draw(self, color):
        return 42


print(isinstance(Wrong(), Drawable))
결과
True

Wrong 클래스의 draw()는 매개변수가 하나 더 있고 문자열 대신 정수를 반환하지만, isinstance()는 True를 반환합니다.

runtime_checkable 프로토콜의 isinstance() 검사는 같은 이름의 메서드가 있는지만 확인하기 때문입니다. 매개변수와 반환값까지 맞는지는 정적 타입 검사기가 확인합니다.



제네릭(Generic)

리스트의 첫 번째 요소를 반환하는 함수를 생각해 봅니다. 반환값의 자료형은 전달한 리스트의 요소 자료형에 따라 달라집니다.

-> Any로 표기하면 어떤 리스트든 받을 수 있지만, 반환값이 무엇인지에 대한 정보가 사라져 검사기가 도움을 줄 수 없습니다. 입력과 출력의 자료형 관계를 표현하려면 제네릭이 필요합니다.

def first[T](items: list[T]) -> T:
    return items[0]


class Box[T]:
    def __init__(self, item: T) -> None:
        self.item = item

    def get(self) -> T:
        return self.item


def pair[K, V](key: K, value: V) -> tuple[K, V]:
    return key, value


print(first([1, 2, 3]))
print(first(["a", "b"]))
box = Box[int](10)
print(box.get())
print(first.__type_params__)
print(Box.__type_params__)
print(pair("a", 1))
결과
1
a
10
(T,)
(T,)
(‘a’, 1)

제네릭(Generic)이란 자료형을 매개변수처럼 받아 여러 자료형에 대해 같은 방식으로 동작하는 함수나 클래스를 의미합니다.

Python 3.12 이상부터는 함수나 클래스 이름 뒤에 [T]를 작성해 타입 매개변수(Type Parameter)를 선언할 수 있습니다.

def first[T](items: list[T]) -> T는 T 자료형의 리스트를 입력받아 T 자료형의 값을 반환한다는 의미입니다.

정수 리스트를 전달하면 반환값은 정수, 문자열 리스트를 전달하면 반환값은 문자열로 추론됩니다.

class Box[T]는 T 자료형의 값을 담는 제네릭 클래스이며, Box[int]처럼 자료형을 지정할 수 있습니다.

선언된 타입 매개변수는 __type_params__ 속성에서 확인할 수 있습니다.

  • Tip : 이전 버전에서는 T = TypeVar("T")로 타입 변수를 정의하고, 클래스는 Generic[T]를 상속해야 했습니다.

결과를 보면 first()는 정수 리스트에서 1, 문자열 리스트에서 a를 반환합니다. 실행 결과만 보면 타입 매개변수가 없는 함수와 다르지 않지만, 정적 타입 검사기는 first(["a", "b"])의 결과를 str로 알고 있으므로 .upper() 같은 문자열 메서드를 허용하고 정수 메서드는 오류로 표시합니다.

T는 함수가 호출될 때마다 실제로 전달된 자료형으로 바뀌어 읽히는 자리 표시자라고 생각하면 됩니다. pair[K, V]처럼 타입 매개변수를 여러 개 선언하면, 키와 값의 자료형을 각각 따로 기억할 수 있습니다.


def biggest[T: (int, float)](a: T, b: T) -> T:
    return a if a > b else b


def longest[S: str](a: S, b: S) -> S:
    return a if len(a) >= len(b) else b


print(biggest(3, 7))
print(longest("abc", "de"))
T = biggest.__type_params__[0]
print(T.__constraints__)
print(longest.__type_params__[0].__bound__)
결과
7
abc
(<class ‘int’>, <class ‘float’>)
<class ‘str’>

타입 매개변수에 허용할 자료형을 제한할 수 있습니다.

[T: (int, float)]처럼 튜플로 작성하면 나열한 자료형 중 하나로 제한하는 제약(Constraints)이 됩니다.

[S: str]처럼 하나의 자료형을 작성하면 해당 자료형이나 그 하위 클래스로 제한하는 상한(Bound)이 됩니다.

제한이 필요한 이유는 함수 본문에서 사용하는 연산 때문입니다. biggest()는 > 비교를, longest()는 len()을 사용하므로 아무 자료형이나 받으면 안 됩니다. 제한을 걸어 두면 검사기는 biggest("a", "b")처럼 허용되지 않은 자료형을 오류로 표시합니다.

결과의 마지막 두 줄은 각 타입 매개변수에 저장된 제약과 상한입니다. 제약은 __constraints__에 튜플로, 상한은 __bound__에 저장됩니다.

  • Tip : 제약을 사용하면 T는 호출할 때마다 나열한 자료형 중 정확히 하나로 결정됩니다. 반면 상한을 사용하면 T는 상한 자료형의 어떤 하위 클래스로든 결정될 수 있습니다.



타입 별칭(Type Alias)

type Vector = list[float]
type Matrix = list[Vector]
type Pair[T] = tuple[T, T]


def scale(v: Vector, k: float) -> Vector:
    return [x * k for x in v]


print(scale([1.0, 2.0], 2))
print(Vector)
print(type(Vector))
print(Vector.__value__)
print(Matrix.__value__)
print(Pair[int])
결과
[2.0, 4.0]
Vector
<class ‘typing.TypeAliasType’>
list[float]
list[Vector]
Pair[int]

Python 3.12 이상부터는 type 별칭 = 자료형의 형태로 타입 별칭(Type Alias)을 선언할 수 있습니다.

복잡한 타입 표기에 이름을 붙여 재사용하고 가독성을 높일 수 있습니다.

type 문으로 선언한 별칭은 typing.TypeAliasType 객체가 되며, 실제 값은 __value__ 속성에 저장됩니다.

type 문의 값은 사용할 때 지연 평가되므로, 아직 정의되지 않은 이름이나 자기 자신을 참조하는 재귀적인 별칭도 작성할 수 있습니다.

type Pair[T] = tuple[T, T]처럼 타입 매개변수를 갖는 제네릭 별칭도 선언할 수 있습니다.

  • Tip : type은 soft keyword이므로, type() 함수나 type이라는 이름의 변수는 이전과 같이 사용할 수 있습니다.

결과에서 print(Vector)가 list[float]이 아니라 Vector를 출력하는 것은, 별칭이 이름을 가진 별도의 객체이기 때문입니다. Matrix.__value__도 list[list[float]]이 아니라 list[Vector]로 출력되어 별칭이 펼쳐지지 않고 이름 그대로 유지됩니다.


Vector1 = list[float]
type Vector2 = list[float]

print(Vector1, type(Vector1))
print(Vector2, type(Vector2))
print(Vector1([1.0, 2.0]))

try:
    Vector2([1.0, 2.0])
except TypeError as e:
    print("TypeError :", e)
결과
list[float] <class ‘types.GenericAlias’>
Vector2 <class ‘typing.TypeAliasType’>
[1.0, 2.0]
TypeError : ‘typing.TypeAliasType’ object is not callable

일반 변수 할당으로 만든 별칭과 type 문으로 만든 별칭의 차이입니다.

Vector1 = list[float]은 단순히 list[float] 객체를 변수에 저장한 것이므로, 출력하면 list[float]이 나오고 list처럼 호출해 리스트를 만들 수도 있습니다. 또한 오른쪽 식이 할당하는 순간 바로 평가되므로 아직 정의되지 않은 이름을 사용할 수 없습니다.

type Vector2 = ...는 TypeAliasType 객체를 만들며, 타입 힌트 전용이므로 호출할 수 없습니다. 대신 값이 지연 평가되고, 검사기와 사람이 이것이 타입 별칭이라는 것을 명확하게 알 수 있습니다.

Python 3.12 이상을 사용한다면 타입 별칭은 type 문으로 선언하는 것이 권장됩니다.



타입 매개변수 기본값

class Box[T = int]:
    def __init__(self, item: T) -> None:
        self.item = item


T = Box.__type_params__[0]
print(T)
print(T.__default__)
print(T.has_default())

from typing import TypeVar
U = TypeVar("U")
print(U.has_default())
결과
T
<class ‘int’>
True
False

Python 3.13 이상부터는 [T = 자료형]의 형태로 타입 매개변수의 기본값을 지정할 수 있습니다. (PEP 696)

Box처럼 자료형을 지정하지 않고 사용하면, 정적 타입 검사기는 T를 기본값인 int로 간주합니다.

기본값은 __default__ 속성에 저장되며, has_default() 메서드로 기본값의 존재 여부를 확인할 수 있습니다.

  • Tip : TypeVar("T", default=int)처럼 TypeVar에도 default 매개변수로 기본값을 지정할 수 있습니다.

결과를 보면 Box의 타입 매개변수 T는 기본값 int를 가지고 있어 has_default()가 True이며, 기본값 없이 만든 TypeVar U는 False입니다.

함수의 매개변수 기본값과 같은 개념을 자료형에 적용한 것입니다. 대부분의 경우 특정 자료형으로 사용되는 제네릭 클래스라면, 기본값을 지정해 두어 사용하는 쪽에서 매번 Box[int]처럼 적지 않아도 되게 할 수 있습니다.



지연 평가 주석(Deferred Evaluation of Annotations)

import annotationlib


def process(data: Undefined) -> int:
    return 0


print("함수 정의 성공")

try:
    print(process.__annotations__)
except NameError as e:
    print("NameError :", e)

print(annotationlib.get_annotations(process, format=annotationlib.Format.STRING))

refs = annotationlib.get_annotations(process, format=annotationlib.Format.FORWARDREF)
print(type(refs["data"]).__name__, refs["return"])


class Undefined:
    pass


print(process.__annotations__)
결과
함수 정의 성공
NameError : name ‘Undefined’ is not defined
{‘data’: ‘Undefined’, ‘return’: ‘int’}
ForwardRef <class ‘int’>
{‘data’: <class ‘__main__.Undefined’>, ‘return’: <class ‘int’>}

Python 3.14 이상부터는 함수, 클래스, 모듈의 주석이 정의 시점에 평가되지 않고, 필요할 때 지연 평가됩니다. (PEP 649, PEP 749)

그러므로 아직 정의되지 않은 Undefined 클래스를 주석에 사용해도 함수 정의에서 오류가 발생하지 않습니다.

주석은 __annotations__ 속성에 접근하는 시점에 평가되므로, Undefined가 정의되기 전에 접근하면 NameError가 발생하고 정의된 후에 접근하면 정상적으로 평가됩니다.

Python 3.14에 추가된 annotationlib 모듈의 get_annotations() 함수를 사용하면 주석을 원하는 형식으로 가져올 수 있습니다.

결과를 순서대로 읽어 보면 다음과 같습니다.

  1. Undefined가 없는데도 함수 정의가 성공해 함수 정의 성공이 출력됩니다.
  2. __annotations__에 접근하는 순간 주석이 평가되어, Undefined를 찾지 못한 NameError가 발생합니다.
  3. Format.STRING은 평가하지 않고 작성한 그대로의 문자열을 반환하므로 오류가 없습니다.
  4. Format.FORWARDREF는 평가할 수 있는 int는 실제 클래스로, 평가할 수 없는 Undefined는 ForwardRef 객체로 반환합니다.
  5. Undefined 클래스를 정의한 뒤 다시 접근하면 정상적으로 평가된 사전이 반환됩니다.

지연 평가가 동작하는 원리는, 인터프리터가 주석을 바로 계산하는 대신 주석을 계산하는 함수(__annotate__)를 만들어 두고 주석이 필요할 때 그 함수를 호출하는 것입니다. 그래서 주석에 비용이 큰 식이 있더라도 주석을 사용하지 않는 프로그램은 그 비용을 치르지 않습니다.

형식 설명
Format.VALUE 주석을 평가한 값으로 반환(정의되지 않은 이름이 있으면 NameError 발생)
Format.FORWARDREF 평가할 수 있는 이름은 값으로, 정의되지 않은 이름은 ForwardRef 객체로 반환
Format.STRING 주석을 소스 코드와 같은 문자열로 반환


class Node:
    def __init__(self, value: int, next: Node | None = None) -> None:
        self.value = value
        self.next = next


node = Node(1, Node(2))
print(node.next.value)
print(Node.__init__.__annotations__)
결과
2
{‘value’: <class ‘int’>, ‘next’: __main__.Node | None, ‘return’: None}

지연 평가 덕분에 클래스 내부에서 아직 정의가 끝나지 않은 자기 자신의 클래스를 따옴표 없이 주석에 사용할 수 있습니다.

이전 버전에서는 "Node"처럼 문자열로 작성하거나, from __future__ import annotations 구문을 사용해야 했습니다.

Node.__init__의 주석은 __init__ 메서드를 정의하는 시점에는 아직 Node 클래스가 완성되지 않았지만, __annotations__에 접근하는 마지막 줄에서는 이미 클래스가 만들어졌으므로 정상적으로 평가됩니다.

연결 리스트나 트리처럼 자기 자신을 참조하는 자료구조를 작성할 때 특히 편리합니다.


from __future__ import annotations


def add(a: int, b: int) -> int:
    return a + b


print(add.__annotations__)
결과
{‘a’: ‘int’, ‘b’: ‘int’, ‘return’: ‘int’}

from __future__ import annotations 구문은 Python 3.7 이상에서 사용할 수 있으며, 모든 주석을 문자열로 저장합니다.

Python 3.14에서도 이 구문은 계속 지원되며, 사용하면 지연 평가 대신 이전과 같이 주석이 문자열로 저장됩니다.

  • Tip : 문자열로 저장된 주석을 실제 자료형으로 변환하려면 typing.get_type_hints() 함수나 annotationlib.get_annotations(객체, eval_str=True)를 사용합니다.



정적 타입 검사기(Static Type Checker)

앞의 예제에서 확인한 것처럼, Python 인터프리터는 타입 힌트와 다른 자료형의 값이 전달되어도 오류를 발생시키지 않습니다.

타입 힌트에 맞지 않는 코드를 찾으려면 mypy, pyright 같은 정적 타입 검사기를 사용해야 합니다.

정적 타입 검사기는 코드를 실행하지 않고 분석해, 타입 힌트와 맞지 않는 호출이나 반환값을 실행 전에 보고합니다.

예를 들어 greet(10, 2)처럼 str 매개변수에 int를 전달하거나, Literal["r", "w", "a"] 매개변수에 "x"를 전달하는 코드를 오류로 보고합니다.

정적 타입 검사기는 별도로 설치해야 하는 외부 도구이며, 많은 편집기에서 플러그인 형태로 통합되어 코드를 작성하는 중에 오류를 표시합니다.

  • Tip : 실행 중에 자료형을 검사해야 한다면 isinstance() 함수로 직접 검사하거나, 타입 힌트를 기반으로 데이터를 검증하는 외부 라이브러리를 사용합니다.


python -m pip install mypy
python -m mypy example.py

mypy는 위와 같이 설치한 뒤, 검사할 파일이나 폴더를 지정해 실행합니다. 오류가 있으면 파일 이름, 줄 번호, 오류 내용을 출력하고, 오류가 없으면 성공 메시지를 출력합니다.

mypy는 기본 설정에서 타입 힌트가 없는 함수의 내부를 검사하지 않으므로, 기존 프로젝트에 도입할 때에는 중요한 함수부터 타입 힌트를 추가해 나가는 방식으로 점진적으로 적용할 수 있습니다.


from typing import get_type_hints


def greet(name: str, count: int = 1) -> str:
    return f"Hello, {name}! " * count


def check_types(func, *args):
    hints = get_type_hints(func)
    names = func.__code__.co_varnames[:func.__code__.co_argcount]
    for name, value in zip(names, args):
        expected = hints[name]
        if not isinstance(value, expected):
            raise TypeError(f"{name}은(는) {expected.__name__}이어야 합니다 : {value!r}")
    return func(*args)


print(check_types(greet, "Python", 2))

try:
    check_types(greet, 10, 2)
except TypeError as e:
    print("TypeError :", e)
결과
Hello, Python! Hello, Python!
TypeError : name은(는) str이어야 합니다 : 10

타입 힌트는 실행 중에 강제되지 않지만, 값으로 읽을 수 있으므로 직접 검사 코드를 만들 수 있습니다.

get_type_hints()로 주석 사전을 가져오고, 함수의 매개변수 이름 순서대로 전달된 인수를 isinstance()로 확인합니다. 첫 번째 호출은 모든 인수가 맞아 정상적으로 실행되고, 두 번째 호출은 name에 정수가 전달되어 TypeError가 발생합니다.

이 예제는 원리를 보여 주기 위해 str, int처럼 단순한 자료형만 처리합니다. list[int]나 int | None 같은 표기까지 검사하려면 훨씬 복잡한 처리가 필요하므로, 실제 프로젝트에서는 이 기능을 제공하는 외부 라이브러리를 사용합니다.



정리

표기 설명 도입 버전/특징
x: int, -> str 변수·매개변수·반환값의 자료형 실행 중 검사하지 않음
list[int], dict[str, int] 요소 자료형을 지정한 컬렉션 Python 3.9 이상
int | None 여러 자료형 중 하나 Python 3.10 이상, Optional 대체
Any 모든 자료형 허용, 검사 생략 남용하면 검사 효과가 사라짐
Callable[[int], str] 호출 가능한 객체 collections.abc에서 가져오기
Literal["r", "w"] 특정 값만 허용 Python 3.8 이상
TypedDict 키마다 자료형이 정해진 사전 실제 객체는 dict
Protocol 메서드 구조로 판단하는 타입 상속 불필요
def f[T](...) 제네릭 함수·클래스 Python 3.12 이상
type 별칭 = 자료형 타입 별칭 Python 3.12 이상, 지연 평가
[T = int] 타입 매개변수 기본값 Python 3.13 이상
annotationlib 지연 평가 주석 조회 Python 3.14 이상


타입 힌트는 실행 결과를 바꾸지 않는 주석이며, 그 가치는 편집기와 정적 타입 검사기가 실행 전에 오류를 찾아 줄 때 나타납니다. 모든 코드에 한 번에 적용하기보다, 여러 곳에서 호출되는 함수의 매개변수와 반환값부터 작성하는 것이 효과적입니다.

댓글 남기기