[파이썬 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 유지보수가 쉬워집니다.
연습문제
Literal로 결제 수단"card" | "bank" | "kakao"를 정의하고, 결제 수단별 수수료율을 반환하는fee_rate()함수를 작성하세요.TypedDict로SignupPayload(필수:email,password, 선택:referrer)를 정의하고, 유효성 검사 함수parse_signup()를 작성하세요.- 주문 이벤트 타입 2개(
"created","canceled")를Literal로 두고, 이벤트별 필수 키가 다른TypedDict설계를 작성해 보세요.
이전 강의 정답
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
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>]
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'>]
실습 환경/재현 정보
- 실행 환경:
condaenvpython100(Python 3.11.14) - 가정한 OS: macOS/Linux 공통
- 사용 모듈:
typing(Literal,TypedDict) - 타입 점검 예시:
pyright또는mypy로 함수 인자/페이로드 계약 검사 - 재현 체크:
Literal허용값 외 인자를 넣었을 때 타입체커 경고가 나는지 확인TypedDict필수 키 누락/잘못된 값 타입을 검증 함수가 차단하는지 확인- 생성/수정 타입 분리 후 API 코드 가독성이 개선되는지 확인