Python 타입 힌트

src/content/documents/python/python-type-hints-introduction.json

결론: 타입 힌트는 실행에 영향을 주지 않는 문서이자 정적 검사의 재료다

타입 힌트는 매개변수와 반환값이 어떤 형태의 데이터인지 코드에 직접 적어 두는 문법이다. Python은 런타임에 이 힌트를 강제하지 않으므로 타입이 틀려도 프로그램은 일단 실행된다. 실제 효과는 두 곳에서 나온다. 에디터가 자동완성·오타 경고를 더 정확히 보여주고, mypy 같은 정적 검사 도구가 코드를 실행하지 않고도 타입이 맞지 않는 호출을 찾아낸다. 데이터 분석 코드는 함수 사이로 DataFrame·딕셔너리·결측 가능한 값이 자주 오가므로, 타입 힌트로 그 형태를 미리 밝혀 두면 오류를 실행 전에 줄일 수 있다.

기본 타입 힌트

매개변수 이름 뒤에 : 타입을 붙이고, 함수 정의 끝에 -> 타입으로 반환 타입을 표시한다.

def add(x: int, y: int) -> int:
    return x + y

def clean_name(s: str) -> str:
    return s.strip().lower()

print(add(1, 2))
print(clean_name("  Alice  "))
3
alice

이 힌트는 주석과 비슷하게 동작한다. add("1", "2")처럼 타입이 틀린 값을 넣어도 Python 자체는 막지 않는다. 대신 에디터와 정적 검사 도구가 그 불일치를 알려 준다.

컬렉션 타입 힌트

Python 3.9부터는 typing.List·typing.Dict 같은 별도 이름 없이 내장 타입 list·dict·tuple에 바로 대괄호로 원소 타입을 붙일 수 있다. 기존 typing 모듈의 대문자 별칭은 이전 버전과의 호환을 위해 여전히 쓰이지만, 새 코드에서는 내장 타입 표기가 더 짧고 읽기 쉽다.

def compute_avg(values: list[float]) -> float:
    return sum(values) / len(values)

def lookup(d: dict[str, float], key: str, default: float = 0.0) -> float:
    return d.get(key, default)

def bounds(values: tuple[int, ...]) -> tuple[int, int]:
    return min(values), max(values)

print(compute_avg([1.0, 2.0, 3.0]))
print(lookup({"a": 1.5}, "b"))
print(bounds((3, 1, 4, 1, 5)))
2.0
0.0
(1, 5)

tuple[int, ...]의 말줄임표는 정해진 길이가 아니라 같은 타입 int의 원소가 몇 개든 이어진다는 뜻이다. 반면 tuple[int, str]처럼 타입을 나열하면 정확히 그 순서와 개수로 고정된 튜플을 의미한다.

Optional: 값이 없을 수 있다는 표시

Optional[X]X 타입이거나 None일 수 있다는 뜻이며, 내부적으로 Union[X, None]과 같다. 결측일 수 있는 값을 표현할 때 쓴다.

from typing import Optional

def get_val(d: dict, key: str) -> Optional[float]:
    return d.get(key)  # 키가 없으면 None을 반환할 수 있다

value = get_val({"amount": 12.5}, "discount")
print(value)
None

Optional[str]은 값이 정말 없을 수 있다는 신호이지, 자동으로 None을 걸러 주지는 않는다. 힌트를 본 사람이 호출부에서 if value is not None:처럼 직접 확인해야 한다. Python 3.10부터는 같은 뜻을 str | None으로도 쓸 수 있다.

Union과 Any: 여러 타입 허용과 그 대가

Union[X, Y]는 값이 X 또는 Y 중 하나임을 표시한다. Python 3.10부터는 X | Y로 더 짧게 쓸 수 있다.

def parse(value: int | str | None) -> str:
    return str(value) if value is not None else ""

print(repr(parse(123)))
print(repr(parse("hello")))
print(repr(parse(None)))
'123'
'hello'
''

Any는 모든 타입을 허용해 사실상 타입 검사를 포기한다. 외부에서 어떤 값이 올지 정말 예측할 수 없는 극히 일부 경계(예: 아직 구조화하지 않은 원시 JSON)에서만 제한적으로 쓰고, 습관적으로 붙이지 않는다. Any가 늘어날수록 자동완성과 mypy 검사의 효과가 함께 줄어든다.

Literal: 정해진 값만 허용

Literal은 타입이 아니라 허용하는 값 자체를 제한한다. 결측치 처리 전략처럼 정해진 문자열 몇 개 중 하나만 받아야 하는 매개변수에 Enum보다 가볍게 쓸 수 있다.

from typing import Literal

def fill_strategy(method: Literal["mean", "median", "drop"]) -> str:
    return f"{method} 전략 적용"

print(fill_strategy("mean"))
mean 전략 적용

런타임에는 fill_strategy("mode")처럼 목록에 없는 문자열을 넣어도 예외 없이 그대로 실행된다. 목록을 벗어난 값을 실제로 막으려면 mypy로 검사하거나, 함수 안에서 값을 직접 검증하는 코드를 추가해야 한다.

Callable: 함수를 인자로 받을 때의 타입

Callable[[인자타입, ...], 반환타입]은 함수(또는 함수처럼 호출 가능한 객체)를 매개변수로 받을 때 그 함수의 시그니처를 명시한다.

from typing import Callable

def apply_fn(data: list[float], fn: Callable[[float], float]) -> list[float]:
    return [fn(x) for x in data]

print(apply_fn([1.0, 2.0, 3.0], lambda x: x * 2))
[2.0, 4.0, 6.0]

Protocol: 상속 없이 구조로 검사하기

Python은 실제 타입보다 어떤 메서드를 지원하는지로 객체를 다루는 덕 타이핑(duck typing) 관례가 강하다. Protocol은 이 관례를 정적 검사가 이해할 수 있는 형태로 표현한다. Protocol을 상속해 필요한 메서드 시그니처만 선언해 두면, 그 메서드를 갖춘 어떤 클래스든 Protocol을 명시적으로 상속하지 않아도 그 타입으로 인정된다.

from typing import Protocol

class SupportsWrite(Protocol):
    def write(self, s: str) -> None: ...

def write_hello(writer: SupportsWrite) -> None:
    writer.write("Hello\n")

class FileLike:
    # SupportsWrite를 상속하지 않았지만 write(str)를 가지고 있다
    def write(self, s: str) -> None:
        print(f"Writing: {s}")

write_hello(FileLike())
Writing: Hello

라이브러리를 만들 때 사용자가 특정 클래스를 상속하도록 강제하지 않고, writeclose 같은 메서드 규약만 지키게 하고 싶을 때 Protocol이 유용하다. 파일 객체, 로거, 커스텀 writer를 같은 함수로 받을 수 있다.

mypy로 타입 오류를 실행 전에 찾기

타입 힌트 자체는 실행에 영향이 없으므로, 실제로 오류를 잡으려면 정적 검사 도구가 필요하다. mypy는 가장 널리 쓰이는 정적 타입 검사기다.

pip install mypy
def add(x: int, y: int) -> int:
    return x + y

add(1, 2)
add("a", "b")
mypy bad_call.py
bad_call.py:6: error: Argument 1 to "add" has incompatible type "str"; expected "int"  [arg-type]
bad_call.py:6: error: Argument 2 to "add" has incompatible type "str"; expected "int"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

add("a", "b")는 Python 인터프리터만 있으면 여전히 실행되어 "ab"를 돌려준다. mypy는 코드를 실제로 실행하지 않고 소스를 읽어 타입 불일치만 미리 알려 준다. 프로젝트 전체를 검사하려면 파일 대신 폴더를 지정하고, 설정은 mypy.ini 같은 설정 파일로 분리한다.

mypy src/
mypy --config-file mypy.ini
[mypy]
python_version = 3.11
strict = True
disallow_untyped_defs = True
ignore_missing_imports = True

strict = True는 여러 엄격한 옵션을 한 번에 켜는 통합 스위치이고, disallow_untyped_defs는 타입 힌트가 아예 없는 함수 정의를 오류로 표시해 점진적으로 힌트를 붙이도록 강제한다. 기존 코드베이스에 처음 도입할 때는 strict 모드를 바로 켜기보다, 느슨한 설정에서 시작해 점차 옵션을 늘리는 편이 저항이 적다.

타입 힌트 정리

문법

의미

list[X], dict[K, V]

원소 타입을 명시한 컬렉션

list[float], dict[str, int]

Optional[X] (= X | None)

X 또는 None

Optional[str]

Union[X, Y] (= X | Y)

X 또는 Y 중 하나

int | str

Literal[값, ...]

나열한 값만 허용

Literal["mean", "median"]

Any

타입 검사를 사실상 포기

가능하면 피한다

Callable[[인자...], 반환]

함수 시그니처

Callable[[float], float]

Protocol

상속 없이 메서드 구조로 검사

SupportsWrite

자주 발생하는 문제

  • 타입 힌트를 붙이면 Python이 자동으로 검사해 줄 것이라 착각하기 쉽다. 힌트는 실행에 영향이 없으며, 실제 검사는 mypy 같은 도구를 따로 돌려야 한다.

  • Optional[X]라고 표시해 놓고 실제 코드에서 None 여부를 확인하지 않으면 힌트가 무색해진다. 값을 쓰기 전에 항상 is not None 검사를 한다.

  • 막힐 때마다 습관적으로 Any를 붙이면 코드베이스 전체의 타입 검사 효과가 조용히 사라진다. 정말 알 수 없는 값에만 좁게 쓴다.

  • Literal에 나열한 값과 실제 코드의 분기 조건(예: if/elif 문자열 비교)이 어긋나면, 정적 검사는 통과해도 런타임에 예상 밖의 분기로 빠질 수 있다. 값 목록을 한 곳에서만 정의해 재사용한다.

  • 레거시 코드에 mypy --strict를 바로 적용하면 오류가 수백 개 쏟아져 손을 놓게 된다. 느슨한 설정으로 시작해 파일·모듈 단위로 점진적으로 강화한다.

다음 글: Pydantic으로 외부 데이터를 검증하기

타입 힌트는 개발 중 오류를 줄여 주지만 런타임에는 아무것도 강제하지 않는다. 파일이나 API에서 들어오는 데이터처럼 신뢰할 수 없는 입력은 실행 중에도 실제로 검증해야 한다. 다음 글에서는 Pydantic v2 모델로 이번 글의 타입 힌트를 실행 시점의 검증·변환·직렬화 규칙으로 확장한다.

참고 자료

댓글 0

댓글을 불러오는 중…