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 복습
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 | None이 Optional[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 NoneLangChain·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 — 명시적 상속 없음Protocol은 BaseAgent처럼 명시적 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 참조):
PR 시점에 type 회귀를 즉시 잡는다.
10 자주 발생하는 오류 패턴
from typing import List, Dict, Optional, Union
def parse(items: List[Dict[str, Optional[Union[int, str]]]]) -> Optional[str]:
...CORRECT (3.10+):
3.9부터 List/Dict/Tuple/Set import가 불필요하다. 3.10부터 X | Y가 Union[X, Y], X | None이 Optional[X]를 대체한다. 새 코드는 신문법 사용.
class State(TypedDict):
query: str
answer: str
state = State(query="Q", answer="A") # 일부 타입 검사기에서 경고CORRECT:
TypedDict는 런타임에 일반 dict이지 클래스가 아니다. State(...) 호출 가능하지만 의도는 dict 리터럴로 만드는 것이다.
def append_item(items: list[int] = []) -> list[int]:
items.append(1) # 모든 호출이 같은 리스트 공유
return itemsCORRECT:
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로 우회.
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 관련 주제
선행 학습
- Python async/await 기초 –
AsyncIterator[T],Coroutine[T]같은 async typing - Pydantic – BaseModel 정의에서 typing 활용
바로 이어 읽을 글 (Tier 1 다음 편)
- 환경변수·.env 운영 — 작성 예정
MINERVA 시리즈 응용
- MINERVA State 설계 (15) – TypedDict + Annotated reducer 4 패턴
- MINERVA RAG Chain 분해 (14) – TypedDict로 노드 입출력 정의
- MINERVA BaseAgent 계약 (02-0) – ABC + Pydantic 결합
- MINERVA BaseAgent 계약 v2 (02-1) – Generic으로 graph 타입 보존