[파이썬 100강] 77강. typing.Literal과 TypedDict로 입력 스키마를 명확하게 고정하기

[파이썬 100강] 77강. typing.Literal과 TypedDict로 입력 스키마를 명확하게 고정하기

API 응답, 설정 파일, 메시지 큐 페이로드를 다루다 보면 “문자열 키는 맞는데 값 의미가 조금씩 다른” 문제가 반복됩니다. 이번 강의에서는 서론 길게 끌지 않고 바로 Literal, TypedDict입력 스키마를 코드에 고정하는 방법을 익혀 보겠습니다.


핵심 개념

  • Literal은 “허용 가능한 값의 집합”을 타입 수준에서 명시합니다. 예: Literal["dev", "prod"]
  • TypedDict는 딕셔너리 구조(필수/선택 키, 각 키의 타입)를 명확하게 선언합니다.
  • 둘을 같이 쓰면 “키 이름은 맞지만 값 규칙이 깨지는 버그”를 리뷰 단계와 테스트 단계에서 더 빨리 발견할 수 있습니다.

실무에서는 JSON처럼 유연한 데이터 포맷이 편리한 대신, 팀이 커질수록 규칙이 느슨해지기 쉽습니다. 예를 들어 {"status": "ok"}를 기대했는데 누군가는 "OK", 또 누군가는 "success"를 보내기 시작하면 분기문이 조용히 틀어집니다. Literal은 이런 값을 좁혀서 오타와 의미 확장을 막아 줍니다. TypedDict는 “이 키는 꼭 있어야 함”, “이 키는 선택” 같은 구조 규칙을 코드에 드러내 주기 때문에 협업 시 커뮤니케이션 비용이 줄어듭니다. 특히 백엔드-프론트엔드, 수집 파이프라인, 배치 작업처럼 여러 컴포넌트가 같은 데이터를 주고받는 상황에서 효과가 큽니다.

기본 사용

예제 1) Literal로 모드 값 제한하기

>>> from typing import Literal
>>>
>>> RunMode = Literal["dev", "staging", "prod"]
>>>
>>> def build_db_url(mode: RunMode) -> str:
...     if mode == "dev":
...         return "postgresql://localhost/devdb"
...     if mode == "staging":
...         return "postgresql://staging/stagedb"
...     return "postgresql://prod/proddb"
...
>>> build_db_url("dev")
'postgresql://localhost/devdb'
>>> build_db_url("prod")
'postgresql://prod/proddb'

해설:

  • 런타임에서 Literal이 자동 차단을 하지는 않지만, 타입체커(예: pyright, mypy)와 IDE가 잘못된 인자를 빠르게 경고해 줍니다.
  • 중요한 포인트는 “허용 값이 문서가 아니라 코드 시그니처에 박혀 있다”는 점입니다.

예제 2) TypedDict로 이벤트 페이로드 구조 명시하기

>>> from typing import TypedDict, Literal
>>>
>>> class PurchaseEvent(TypedDict):
...     event_type: Literal["purchase"]
...     user_id: int
...     amount: float
...     currency: Literal["KRW", "USD"]
...
>>> event: PurchaseEvent = {
...     "event_type": "purchase",
...     "user_id": 101,
...     "amount": 12900.0,
...     "currency": "KRW",
... }
>>> event["user_id"]
101

해설:

  • 단순 dict[str, object]보다 의도가 훨씬 명확합니다.
  • “이벤트 타입은 purchase만 가능”, “통화는 KRW/USD만 허용” 같은 도메인 규칙을 타입에 녹일 수 있습니다.

예제 3) 선택 키(total=False)와 필수 키를 함께 설계하기

>>> from typing import TypedDict
>>>
>>> class UserProfile(TypedDict):
...     id: int
...     name: str
...
>>> class UserProfilePatch(TypedDict, total=False):
...     name: str
...     phone: str
...     marketing_opt_in: bool
...
>>> base: UserProfile = {"id": 7, "name": "Kim"}
>>> patch: UserProfilePatch = {"phone": "010-1234-5678"}
>>> {**base, **patch}
{'id': 7, 'name': 'Kim', 'phone': '010-1234-5678'}

해설:

  • 생성(필수값 많은 구조)과 수정(부분 업데이트 허용)을 타입으로 분리하면 API 설계가 깔끔해집니다.
  • 실무에서 PATCH 엔드포인트를 다룰 때 특히 유용합니다.

예제 4) 런타임 검증 함수와 결합하기

>>> from typing import TypedDict, Literal
>>>
>>> class JobRequest(TypedDict):
...     kind: Literal["thumbnail", "transcode"]
...     source: str
...     priority: Literal["low", "normal", "high"]
...
>>> def validate_job(req: dict) -> JobRequest:
...     if req.get("kind") not in {"thumbnail", "transcode"}:
...         raise ValueError("invalid kind")
...     if req.get("priority") not in {"low", "normal", "high"}:
...         raise ValueError("invalid priority")
...     if not isinstance(req.get("source"), str) or not req["source"].strip():
...         raise ValueError("invalid source")
...     return req  # 타입체커 관점에서 JobRequest로 취급
...
>>> validate_job({"kind": "thumbnail", "source": "s3://a.mp4", "priority": "high"})
{'kind': 'thumbnail', 'source': 's3://a.mp4', 'priority': 'high'}

해설:

  • 타입 힌트는 개발 단계 안전망이고, 운영 단계 안전망은 런타임 검증입니다.
  • 둘을 같이 가져가야 “개발 중 경고 + 운영 중 방어”가 동시에 됩니다.

자주 하는 실수

실수 1) TypedDict를 런타임 검증기로 오해

>>> from typing import TypedDict
>>> class Config(TypedDict):
...     host: str
...     port: int
...
>>> bad = {"host": "localhost", "port": "5432"}  # 문자열 포트
>>> bad["port"]
'5432'

원인:

  • TypedDict를 선언하면 실행 시점에도 타입이 강제될 거라고 착각합니다.
  • 실제로는 일반 dict라서 런타임에서 자동 예외가 나지 않습니다.

해결:

>>> def parse_config(raw: dict) -> Config:
...     if not isinstance(raw.get("host"), str):
...         raise ValueError("host must be str")
...     if not isinstance(raw.get("port"), int):
...         raise ValueError("port must be int")
...     return raw
...
>>> parse_config({"host": "localhost", "port": 5432})
{'host': 'localhost', 'port': 5432}

실수 2) Literal을 넓은 str로 다시 풀어버림

>>> from typing import Literal
>>> Env = Literal["dev", "prod"]
>>>
>>> def deploy(env: str) -> None:  # 실수: 좁은 타입을 넓혀버림
...     print(f"deploy to {env}")
...
>>> deploy("production")
deploy to production

원인:

  • 함수 시그니처에서 Literal을 쓰지 않아, 팀원들이 임의 문자열을 계속 주입하게 됩니다.

해결:

>>> def deploy(env: Env) -> None:
...     print(f"deploy to {env}")
...
>>> deploy("dev")
deploy to dev

실수 3) 하나의 TypedDict에 생성/조회/수정 규칙을 전부 섞음

  • 증상: 어떤 API에서는 필수, 어떤 API에서는 선택인 키가 한 타입에 뒤섞여 가독성이 무너짐
  • 원인: “재사용” 욕심으로 도메인 시나리오를 분리하지 않음
  • 해결: CreateUser, UserView, UpdateUserPatch처럼 목적별 타입을 분리하고 엔드포인트 단위로 매핑

실수 4) 외부 입력 정규화를 빼먹고 Literal 비교만 수행

  • 증상: "KRW ", "krw" 같은 값이 섞여 운영 데이터 품질 저하
  • 원인: 경계 계층에서 trim/소문자화/대문자화 정책이 없음
  • 해결: 정규화 함수(normalize_currency)를 먼저 통과시킨 뒤 Literal 집합 검증

실무 패턴

  • 입력 검증 규칙

    • 경계(HTTP handler, consumer)에서 정규화 → 검증 → 내부 타입 변환 순서를 고정합니다.
    • 핵심 도메인 함수에는 이미 검증된 타입(TypedDict/도메인 객체)만 넘기도록 계층을 분리합니다.
  • 로그/예외 처리 규칙

    • 검증 실패는 400 또는 도메인 에러로 즉시 반환하고, 어떤 키가 왜 실패했는지 구조화 로그로 남깁니다.
    • 단, 민감정보(토큰, 개인정보)는 마스킹 후 로깅합니다.
  • 재사용 함수/구조화 팁

    • parse_xxx(raw: dict) -> XxxPayload 함수를 엔드포인트별로 두면 중복 if문이 줄어듭니다.
    • Literal 상수 집합은 중앙 모듈에서 관리해 팀 내 표기법 드리프트를 막습니다.
  • 성능/메모리 체크 포인트

    • TypedDict 자체는 런타임 오버헤드가 거의 없지만, 검증 로직이 커지면 반복 파싱 비용이 커집니다.
    • 고트래픽 구간에서는 검증 공통 함수를 최적화하고, 불필요한 딕셔너리 복사를 줄이세요.

오늘의 결론

한 줄 요약: Literal은 값의 경계를, TypedDict는 구조의 경계를 고정해 데이터 버그를 초기에 잘라냅니다.

기억할 것:

  • 타입 힌트는 문서가 아니라 “협업 계약”입니다.
  • TypedDict는 런타임 강제가 아니므로, 운영에서는 반드시 검증 함수를 함께 둡니다.
  • 생성/조회/수정 시나리오를 타입으로 분리하면 API 유지보수가 쉬워집니다.

연습문제

  1. Literal로 결제 수단 "card" | "bank" | "kakao"를 정의하고, 결제 수단별 수수료율을 반환하는 fee_rate() 함수를 작성하세요.
  2. TypedDictSignupPayload(필수: email, password, 선택: referrer)를 정의하고, 유효성 검사 함수 parse_signup()를 작성하세요.
  3. 주문 이벤트 타입 2개("created", "canceled")를 Literal로 두고, 이벤트별 필수 키가 다른 TypedDict 설계를 작성해 보세요.

이전 강의 정답

  1. parse_order_phase() 구현
>>> from enum import Enum
>>> class OrderPhase(Enum):
...     CREATED = "created"
...     PAID = "paid"
...     PACKED = "packed"
...     SHIPPED = "shipped"
...
>>> def parse_order_phase(raw: str) -> OrderPhase:
...     return OrderPhase(raw)
...
>>> parse_order_phase("paid")
<OrderPhase.PAID: 'paid'>
>>> parse_order_phase("done")
Traceback (most recent call last):
...
ValueError: 'done' is not a valid OrderPhase
  1. IntEnum 우선순위 정렬
>>> from enum import IntEnum
>>> class Priority(IntEnum):
...     LOW = 1
...     MEDIUM = 2
...     HIGH = 3
...
>>> levels = [Priority.LOW, Priority.HIGH, Priority.MEDIUM]
>>> sorted(levels, reverse=True)
[<Priority.HIGH: 3>, <Priority.MEDIUM: 2>, <Priority.LOW: 1>]
  1. next_states() 구현
>>> from enum import Enum
>>> class Ticket(Enum):
...     OPEN = "open"
...     IN_PROGRESS = "in_progress"
...     RESOLVED = "resolved"
...     CLOSED = "closed"
...
>>> TRANSITIONS = {
...     Ticket.OPEN: [Ticket.IN_PROGRESS, Ticket.CLOSED],
...     Ticket.IN_PROGRESS: [Ticket.RESOLVED, Ticket.CLOSED],
...     Ticket.RESOLVED: [Ticket.CLOSED],
...     Ticket.CLOSED: [],
... }
>>> def next_states(current: Ticket):
...     return TRANSITIONS[current]
...
>>> next_states(Ticket.IN_PROGRESS)
[<Ticket.RESOLVED: 'resolved'>, <Ticket.CLOSED: 'closed'>]

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: typing (Literal, TypedDict)
  • 타입 점검 예시: pyright 또는 mypy로 함수 인자/페이로드 계약 검사
  • 재현 체크:
    • Literal 허용값 외 인자를 넣었을 때 타입체커 경고가 나는지 확인
    • TypedDict 필수 키 누락/잘못된 값 타입을 검증 함수가 차단하는지 확인
    • 생성/수정 타입 분리 후 API 코드 가독성이 개선되는지 확인