Python typing 심화

TypedDict·Annotated·Generic·Protocol — LangGraph State 설계의 토대

LangGraph는 TypedDict로 State를 정의하고 Annotated[list, add]로 reducer를 부착한다. Pydantic은 Generic·Optional·Literal을 사용하고, BaseAgent ABC는 Protocol에 가깝다. 본 글은 type hint 기초 위에 TypedDict·Annotated·Generic·Protocol·Literal·Final까지의 심화 사용을 정리한다. MINERVA 15편(State 설계)의 reducer 패턴이 어떻게 가능한지 토대를 깐다.

Engineering
저자

Kwangmin Kim

공개

2026년 05월 06일

1 왜 typing 심화가 필요한가

MINERVA 15편 State 설계의 핵심 코드를 보자.

from typing import Annotated, TypedDict
from operator import add
from langchain_core.documents import Document

class QnaState(TypedDict):
    query: Query
    docs: list[Document]
    answer: str
    log_messages: Annotated[list[str], add]    # 누적

TypedDict, Annotated, add가 결합되어 “노드별 반환 값을 누적할지 덮어쓸지” 결정하는 메커니즘이 만들어진다. typing 모듈이 단순한 힌트가 아니라 메타데이터 컨테이너로 쓰인다는 점이 핵심이다.

MINERVA 글 typing 사용 본 글 절
02-0 BaseAgent 계약 ABC, Optional, list[X] 기본 + Protocol
11-0 Config 의존성 dataclass, Optional, field(default_factory=...) 기본
14·15 State 설계 TypedDict, Annotated[list, add], Annotated[..., reducer] 핵심 (TypedDict + Annotated)
02-1 BaseAgent v2 CompiledStateGraph 타입 힌트, AsyncIterator Generic 응용

본 글이 그 토대를 한 호흡으로 정리한다.

2 기본 type hint 복습

정의: type hint

Python type hint는 변수·함수 시그니처에 타입 정보를 메타데이터로 부착하는 문법이다. 런타임에는 검사되지 않는다 (성능 0). 외부 도구(mypy, pyright, IDE, FastAPI, Pydantic)가 이 정보를 읽어 정적 검사·자동 검증·자동 문서화를 수행한다.

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


users: list[str] = []
config: dict[str, int] = {}
maybe_user: str | None = None              # 3.10+ — 또는 Optional[str]

Python 3.9부터 List, Dict, Tuple 같은 typing 모듈 import가 불필요해졌다. 소문자 빌트인(list, dict, tuple)을 직접 type hint로 쓴다. 3.10부터 X | NoneOptional[X]를 대체한다.

3 Optional·Union·Literal — 변형 표현

from typing import Optional, Union, Literal

def find_user(user_id: int) -> Optional[dict]:    # = Union[dict, None] = dict | None
    ...

def parse(value: int | str | float) -> str:       # 3.10+ — Union[int, str, float]
    ...

def set_log_level(level: Literal["DEBUG", "INFO", "WARNING", "ERROR"]):
    """Literal — 허용된 값만 받음."""
    ...

Literal은 enum의 가벼운 대안이다. mypy가 set_log_level("FOO") 호출을 정적으로 잡는다. MINERVA의 LLM_PROVIDER 환경변수가 "azure" | "ollama" 둘 중 하나여야 하는 상황 같은 곳에 적합하다.

4 TypedDict — 구조 있는 dict

dict를 쓰지만 키와 값 타입이 정해져 있을 때 TypedDict를 사용한다.

from typing import TypedDict


class UserProfile(TypedDict):
    id: int
    name: str
    email: str | None


def create_user(profile: UserProfile) -> int:
    return profile["id"]                       # IDE 자동완성 동작


# 사용
profile: UserProfile = {"id": 1, "name": "Alice", "email": None}   # OK
bad: UserProfile = {"id": "abc"}                                    # mypy 에러 (str ≠ int)

TypedDict는 런타임에는 그냥 dict다. isinstance(profile, dict)가 True. mypy/pyright만 키·타입을 검증한다.

4.1 LangGraph가 TypedDict를 쓰는 이유

LangGraph의 StateGraph는 dict를 노드 사이에 흘려보낸다. 일반 dict로 정의하면 IDE 자동완성·정적 검사가 동작하지 않는다. TypedDict로 정의하면:

  • dict의 가벼움 유지 — 객체 생성 비용 없음, 직렬화 자유로움 (Checkpointer가 JSON으로 저장)
  • 타입 안전성 추가state["query"].text 접근 시 IDE가 타입 추론
  • 노드 반환값 검증 — TypedDict 키와 일치하지 않으면 mypy가 잡음
from typing import TypedDict
from core.contracts import Query, Response

class QnaState(TypedDict):
    query: Query                               # Pydantic 객체를 dict 안에 넣어도 됨
    docs: list                                  # 검색 결과
    answer: str
    response: Response


def retrieve_node(state: QnaState) -> dict:
    docs = retriever.invoke(state["query"].text)
    return {"docs": docs}                       # State 일부만 갱신

4.2 TypedDict의 두 가지 변형

from typing import TypedDict, NotRequired, Required


class StrictState(TypedDict):
    """기본 — 모든 키 필수."""
    query: str
    answer: str


class FlexibleState(TypedDict, total=False):
    """모든 키 선택."""
    query: str
    answer: str
    optional_meta: dict


class MixedState(TypedDict):
    """키별로 필수/선택 지정 (3.11+)."""
    query: Required[str]
    answer: NotRequired[str]

LangGraph State는 보통 total=False 또는 NotRequired로 부분 갱신을 자연스럽게 표현한다 — 노드가 일부 키만 반환해도 타입 검사를 통과한다.

5 Annotated — 메타데이터 부착

Annotated[T, metadata]는 타입 T 위에 임의 메타데이터를 붙이는 문법이다. 런타임에 메타데이터를 읽는 라이브러리(LangGraph, Pydantic, FastAPI)가 활용한다.

5.1 LangGraph — reducer를 부착

from typing import Annotated, TypedDict
from operator import add


class State(TypedDict):
    answer: str                                # reducer 없음 → 덮어쓰기
    log_messages: Annotated[list[str], add]    # reducer = add → 누적

LangGraph가 노드 반환값을 합칠 때 Annotated의 두 번째 인자(add)를 reducer로 꺼내 사용한다.

이전 state["log_messages"] = ["a"]
노드 반환                  = {"log_messages": ["b"]}

reducer 미지정:  state["log_messages"] = ["b"]                    # 덮어쓰기
add reducer:     state["log_messages"] = add(["a"], ["b"]) = ["a", "b"]   # 누적

이 메커니즘이 MINERVA 15편 State 설계의 4가지 reducer 패턴(덮어쓰기·누적·병합·커스텀)의 토대다.

5.2 Pydantic — Field 메타데이터

from pydantic import BaseModel, Field
from typing import Annotated


class UserCreate(BaseModel):
    age: Annotated[int, Field(ge=0, le=150)]                 # 검증 규칙을 타입에 부착
    email: Annotated[str, Field(pattern=r"^[^@]+@[^@]+$")]

Field가 검증 규칙(min/max, regex, alias 등)을 부착한다. Pydantic이 모델 생성 시 이 메타데이터를 읽어 자동 검증한다.

5.3 FastAPI — 의존성·검증 부착

from fastapi import FastAPI, Query, Header
from typing import Annotated

app = FastAPI()

@app.get("/items")
def list_items(
    limit: Annotated[int, Query(ge=1, le=100)] = 10,
    user_agent: Annotated[str | None, Header()] = None,
):
    ...

FastAPI가 Annotated를 보고 query parameter·header·dependency를 자동 분기한다.

5.4 Annotated의 본질

Annotated[T, *metadata]의 첫 번째 인자가 실제 타입이고 나머지는 자유로운 메타데이터다. 타입 검사기는 첫 인자만 본다. 라이브러리가 typing.get_type_hints(..., include_extras=True)로 메타데이터를 꺼내 사용한다. 즉:

  • add는 함수 객체가 아니라 메타데이터로 전달
  • LangGraph가 state schema의 각 필드의 Annotated 메타데이터를 보고 reducer를 결정

이 메커니즘이 가능한 이유 — Annotated가 단순 컨테이너이기 때문이다.

6 Generic — 재사용 가능한 타입

여러 타입을 받을 수 있는 재사용 컨테이너를 정의할 때 사용한다.

from typing import Generic, TypeVar

T = TypeVar("T")


class Cache(Generic[T]):
    def __init__(self):
        self._items: dict[str, T] = {}

    def get(self, key: str) -> T | None:
        return self._items.get(key)

    def set(self, key: str, value: T) -> None:
        self._items[key] = value


# 사용
user_cache: Cache[dict] = Cache()
str_cache: Cache[str] = Cache()

TypeVar의 bound로 상위 제약을 둘 수 있다.

from langchain_core.documents import Document

DocT = TypeVar("DocT", bound=Document)


def first_doc(docs: list[DocT]) -> DocT | None:
    """반환 타입이 입력 타입과 동일."""
    return docs[0] if docs else None

LangChain·LangGraph 내부가 이런 Generic을 광범위하게 사용해 CompiledStateGraph[State]처럼 State 타입을 보존한다.

7 Protocol — duck typing의 명시화

ABC 상속 없이 “이런 메서드를 가진 객체면 OK”를 표현한다 (구조적 부분 타입, structural subtyping).

from typing import Protocol


class Closeable(Protocol):
    def close(self) -> None: ...


def safe_close(resource: Closeable) -> None:
    """close() 메서드만 있으면 무엇이든 받음."""
    resource.close()


# 어떤 클래스든 close()만 있으면 OK
class FileWrapper:
    def close(self) -> None:
        print("닫음")


safe_close(FileWrapper())                   # OK — 명시적 상속 없음

ProtocolBaseAgent처럼 명시적 ABC가 부담스러울 때, 또는 외부 라이브러리 클래스에 인터페이스를 추가하고 싶을 때 유용하다.

7.1 ABC vs Protocol

항목 ABC (abc.ABC + abstractmethod) Protocol
구현 방식 명시적 상속 (class X(BaseAgent)) 구조적 (메서드만 있으면)
런타임 검사 isinstance()로 가능 runtime_checkable 데코레이터 필요
실수 방지 잊으면 인스턴스화 실패 정적 검사로만 잡힘
사용처 프로젝트 내부 계약 외부 라이브러리·duck typing

MINERVA의 BaseAgent는 ABC를 사용한다 — 신규 에이전트 작성 시 명시적 상속이 의도를 명확히 표현하기 때문이다. Protocol은 외부 라이브러리 객체에 통일 인터페이스를 부여할 때 적합하다.

8 Final·ClassVar — 변경 의도 표현

from typing import Final, ClassVar


MAX_RETRIES: Final = 3                      # 재할당 금지
TIMEOUT_SEC: Final[int] = 60


class Settings:
    SCHEMA_VERSION: ClassVar[str] = "v2"     # 클래스 속성 (인스턴스 속성 아님)
    instance_value: int                       # 인스턴스 속성

Final은 mypy가 재할당을 잡는다(런타임 강제 X). 상수임을 명확히 표현하는 정적 도구다.

9 정적 검사 — mypy/pyright 통합

type hint 자체는 런타임에 검사되지 않으므로 별도 도구가 필요하다.

# mypy
pip install mypy
mypy src/

# pyright (Microsoft, VS Code 통합 우수)
npm install -g pyright
pyright src/

설정 예시.

# pyproject.toml
[tool.mypy]
python_version = "3.11"
strict = true
disallow_untyped_defs = true
warn_unused_ignores = true

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false               # 테스트는 type hint 강제 안 함

CI에 통합 (07-1편 GitHub Actions pr-check.yml 참조):

- name: Type check
  run: mypy src/

PR 시점에 type 회귀를 즉시 잡는다.

10 자주 발생하는 오류 패턴

WRONG (3.8 이전 스타일을 3.9+에서):

from typing import List, Dict, Optional, Union

def parse(items: List[Dict[str, Optional[Union[int, str]]]]) -> Optional[str]:
    ...

CORRECT (3.10+):

def parse(items: list[dict[str, int | str | None]]) -> str | None:
    ...

3.9부터 List/Dict/Tuple/Set import가 불필요하다. 3.10부터 X | YUnion[X, Y], X | NoneOptional[X]를 대체한다. 새 코드는 신문법 사용.

WRONG:

class State(TypedDict):
    query: str
    answer: str

state = State(query="Q", answer="A")        # 일부 타입 검사기에서 경고

CORRECT:

state: State = {"query": "Q", "answer": "A"}    # 또는
state = {"query": "Q", "answer": "A"}             # 컨텍스트로 추론

TypedDict는 런타임에 일반 dict이지 클래스가 아니다. State(...) 호출 가능하지만 의도는 dict 리터럴로 만드는 것이다.

WRONG:

def append_item(items: list[int] = []) -> list[int]:
    items.append(1)                          # 모든 호출이 같은 리스트 공유
    return items

CORRECT:

def append_item(items: list[int] | None = None) -> list[int]:
    if items is None:
        items = []
    items.append(1)
    return items

# 또는 dataclass
from dataclasses import dataclass, field

@dataclass
class Config:
    items: list[int] = field(default_factory=list)

mutable default는 함수 정의 시점에 한 번 평가되어 모든 호출이 공유한다. type hint와 무관하게 Python 일반 문법 함정. dataclass·Pydantic은 default_factory로 우회.

WRONG:

class State(TypedDict):
    log: Annotated[list[str], list]          # list는 reducer 함수가 아님

CORRECT:

from operator import add

class State(TypedDict):
    log: Annotated[list[str], add]           # add(prev, new) → list 결합

Annotated[T, metadata]의 두 번째 인자는 LangGraph가 reducer(prev, new) → merged로 호출한다. list 타입 자체는 호출 가능하지만 reducer로 동작하지 않는다.

11 정리

도구 언제 쓰는가 런타임 비용
기본 type hint (list[X], X \| None) 모든 함수·변수 0
Optional/Union/Literal 변형 표현 0
TypedDict 키 정해진 dict (LangGraph State, JSON 응답) 0 (그냥 dict)
Annotated[T, metadata] reducer/검증 규칙/의존성을 타입에 부착 0 (라이브러리가 사용)
Generic[T] + TypeVar 재사용 컨테이너 0
Protocol duck typing 명시화 (외부 라이브러리 호환) 0
Final/ClassVar 변경 의도 표현 0
mypy/pyright 정적 검사 빌드 타임

핵심은 typing이 런타임에 비용 0이지만 외부 도구가 이 정보를 읽어 검증·문서·메타데이터로 활용한다는 점이다. LangGraph의 Annotated[list, add] 메커니즘이 가장 우아한 사례 — 같은 자료구조에 동작 규칙을 함께 담는다.

12 응용 분야

MINERVA 시리즈 사용처 본 글 절
02-0 BaseAgent ABC ABC vs Protocol
02-0 Citation·Response Pydantic 모델 Annotated + Field 메타데이터
11-0 RAGConfig dataclass dataclass + field(default_factory)
14·15 LangGraph State TypedDict + Annotated[…, reducer]
02-1 BaseAgent v2 graph 속성 Generic[State]
12-0/12-1 테스트 fixture·mock 시그니처 기본 type hint + Optional·Generic

13 관련 주제

선행 학습

바로 이어 읽을 글 (Tier 1 다음 편)

  • 환경변수·.env 운영 — 작성 예정

MINERVA 시리즈 응용

Subscribe

Enjoy this blog? Get notified of new posts by email: