왜 고급 타입 힌트가 필요한가
기본적인 int, str, list[str] 수준의 타입 힌트는 대부분의 개발자가 이미 사용하고 있습니다. 하지만 라이브러리나 프레임워크를 설계할 때는 이것만으로 부족합니다. 함수가 받는 타입에 따라 반환 타입이 달라지거나, 특정 메서드만 구현하면 어떤 클래스든 받아들이는 유연한 인터페이스가 필요하기 때문입니다.
Python의 typing 모듈이 제공하는 TypeVar, Generic, Protocol은 이런 문제를 해결하는 핵심 도구입니다.
타입 힌트는 런타임에 영향을 주지 않지만, mypy 같은 정적 분석 도구와 IDE의 자동완성을 통해 버그를 사전에 차단합니다.
핵심 개념 한눈에 보기
| 도구 | 역할 | 핵심 키워드 |
|---|---|---|
TypeVar |
타입 변수 선언 — 제네릭 함수·클래스에서 타입을 매개변수화 | 타입 매개변수 |
Generic |
클래스를 타입 매개변수로 일반화 | 타입 안전한 컨테이너 |
Protocol |
구조적 서브타이핑(덕 타이핑의 정적 버전) | 인터페이스 정의 |
TypeVar — 타입을 변수처럼 다루기
TypeVar는 “어떤 타입이든 될 수 있지만, 한번 결정되면 일관되게 유지되는 타입”을 표현합니다.
기본 사용법
from typing import TypeVar
T = TypeVar('T')
def first(items: list[T]) -> T:
return items[0]
# mypy가 자동으로 타입을 추론합니다
name = first(["Alice", "Bob"]) # str로 추론
count = first([1, 2, 3]) # int로 추론
T가 없다면 반환 타입을 Any로 써야 하고, 호출 쪽에서 타입 정보를 잃게 됩니다.
bound로 타입 범위 제한하기
from typing import TypeVar
from collections.abc import Sized
ST = TypeVar('ST', bound=Sized)
def longest(a: ST, b: ST) -> ST:
return a if len(a) >= len(b) else b
result = longest([1, 2], [3, 4, 5]) # list[int]
result = longest("hi", "hello") # str
bound=Sized는 len()을 지원하는 타입만 허용한다는 의미입니다.
TypeVar 옵션 정리
| 옵션 | 문법 | 효과 |
|---|---|---|
| 제한 없음 | TypeVar('T') |
모든 타입 허용 |
| 상한 제한 | TypeVar('T', bound=Base) |
Base와 그 하위 타입만 허용 |
| 값 제한 | TypeVar('T', int, str) |
int 또는 str만 허용 |
Generic — 타입 안전한 컨테이너 만들기
Generic은 클래스 자체를 타입 매개변수로 일반화합니다. list[int]처럼 내 클래스도 타입을 받을 수 있게 만드는 것입니다.
실무 예시: 결과 래퍼 클래스
API 응답이나 데이터베이스 조회 결과를 감싸는 패턴은 실무에서 매우 흔합니다.
from typing import TypeVar, Generic
from dataclasses import dataclass
T = TypeVar('T')
@dataclass
class Result(Generic[T]):
data: T | None
error: str | None = None
@property
def is_ok(self) -> bool:
return self.error is None
def unwrap(self) -> T:
if self.data is None:
raise ValueError(f"Result has no data: {self.error}")
return self.data
# 사용 시 타입이 정확하게 추론됩니다
def fetch_user(user_id: int) -> Result[dict]:
try:
user = {"id": user_id, "name": "Alice"}
return Result(data=user)
except Exception as e:
return Result(data=None, error=str(e))
result = fetch_user(1)
if result.is_ok:
user = result.unwrap() # mypy가 dict 타입으로 인식
Result[dict],Result[list[str]]처럼 사용할 수 있어, Rust의Result<T, E>와 유사한 패턴을 Python에서 구현할 수 있습니다.
다중 타입 매개변수
K = TypeVar('K')
V = TypeVar('V')
class Registry(Generic[K, V]):
def __init__(self) -> None:
self._store: dict[K, V] = {}
def register(self, key: K, value: V) -> None:
self._store[key] = value
def get(self, key: K) -> V | None:
return self._store.get(key)
# 키는 str, 값은 int로 고정
counter = Registry[str, int]()
counter.register("visits", 42)
counter.register("visits", "많음") # mypy 에러!
Protocol — 덕 타이핑을 정적으로 검증하기
Python의 철학은 “오리처럼 걷고 꽥꽥거리면 오리다” 입니다. Protocol은 이 덕 타이핑을 정적 분석 시점에 검증할 수 있게 합니다. 상속이 필요 없다는 것이 핵심입니다.
기본 사용법
from typing import Protocol, runtime_checkable
@runtime_checkable
class Renderable(Protocol):
def render(self) -> str: ...
class HtmlWidget:
def render(self) -> str:
return "<div>Widget</div>"
class JsonResponse:
def render(self) -> str:
return '{"status": "ok"}'
def display(item: Renderable) -> None:
print(item.render())
# HtmlWidget과 JsonResponse는 Renderable을 상속하지 않았지만
# render() 메서드가 있으므로 타입 체크를 통과합니다
display(HtmlWidget()) # OK
display(JsonResponse()) # OK
display(42) # mypy 에러!
Protocol vs ABC 비교
| 특성 | Protocol |
ABC |
|---|---|---|
| 상속 필요 | 불필요 (구조적 타이핑) | 필수 (명목적 타이핑) |
| 외부 라이브러리 클래스 | 수정 없이 호환 가능 | 래퍼 필요 |
isinstance 체크 |
@runtime_checkable 필요 |
기본 지원 |
| 적합한 상황 | 인터페이스 정의, 플러그인 시스템 | 공통 구현 공유 |
외부 라이브러리의 클래스를 수정할 수 없을 때, Protocol은 상속 없이 타입 호환성을 보장하는 유일한 방법입니다.
실전 패턴: 세 가지를 조합한 리포지토리 설계
세 가지 도구를 조합하면 타입 안전하면서도 유연한 설계가 가능합니다.
from typing import TypeVar, Generic, Protocol
from dataclasses import dataclass
# 엔티티가 갖춰야 할 최소 인터페이스
class HasId(Protocol):
@property
def id(self) -> int: ...
E = TypeVar('E', bound=HasId)
class Repository(Generic[E]):
"""어떤 엔티티든 저장·조회할 수 있는 제네릭 저장소"""
def __init__(self) -> None:
self._items: dict[int, E] = {}
def save(self, entity: E) -> None:
self._items[entity.id] = entity
def find_by_id(self, entity_id: int) -> E | None:
return self._items.get(entity_id)
def find_all(self) -> list[E]:
return list(self._items.values())
# 엔티티 정의 — HasId를 상속하지 않아도 됩니다
@dataclass
class User:
id: int
name: str
email: str
@dataclass
class Product:
id: int
title: str
price: float
# 각각 타입 안전한 저장소
user_repo = Repository[User]()
user_repo.save(User(id=1, name="Alice", email="a@b.com"))
found = user_repo.find_by_id(1) # User | None으로 추론
product_repo = Repository[Product]()
product_repo.save(Product(id=1, title="키보드", price=89000))
이 패턴의 장점을 정리하면 다음과 같습니다:
- TypeVar(
E): 저장소가 다루는 엔티티 타입을 매개변수화 - Generic:
Repository[User],Repository[Product]처럼 타입별 저장소 생성 - Protocol(
HasId):id속성만 있으면 어떤 클래스든 저장 가능
Python 3.12+ 새 문법
Python 3.12부터는 TypeVar를 별도로 선언하지 않아도 됩니다.
# Python 3.12 이전
T = TypeVar('T')
def first(items: list[T]) -> T: ...
# Python 3.12 이후 — 타입 매개변수 문법
def first[T](items: list[T]) -> T: ...
# 클래스도 마찬가지
class Result[T]:
data: T | None
error: str | None = None
훨씬 간결해졌지만, 3.11 이하 호환이 필요하다면 기존 방식을 사용해야 합니다.
마무리
Python의 고급 타입 힌트 도구를 요약하면 다음과 같습니다:
- TypeVar — 함수나 클래스에서 타입을 변수처럼 사용하여 입력·출력 타입의 일관성을 보장합니다.
- Generic — 클래스를 타입 매개변수화하여
list[int]처럼 재사용 가능한 타입 안전 컨테이너를 만듭니다. - Protocol — 상속 없이 “이 메서드만 있으면 된다”는 인터페이스를 정의하여, 덕 타이핑을 정적으로 검증합니다.
세 가지를 조합하면 유연하면서도 타입 안전한 라이브러리 설계가 가능합니다. 처음에는 낯설 수 있지만, 코드베이스가 커질수록 이런 패턴이 주는 안정감은 확실히 체감됩니다. mypy --strict를 켜고 하나씩 적용해 보시기를 권합니다.
Did you find this helpful?
☕ Buy me a coffee
Leave a Reply