TypedDict, NamedTuple, Protocol
파이썬에서 구조화된 데이터를 타입 안전하게 다루는 세 가지 핵심 도구를 다룹니다. 각각 딕셔너리 구조(TypedDict), 불변 레코드(NamedTuple), 덕 타이핑 인터페이스(Protocol)에 특화되어 있습니다.
TypedDict — 딕셔너리 타입 정의
from typing import TypedDict, Required, NotRequired
# 기본 TypedDict
class Point(TypedDict):
x: float
y: float
class UserProfile(TypedDict):
id: int
name: str
email: str
age: int
# 사용
p: Point = {"x": 1.0, "y": 2.0}
user: UserProfile = {"id": 1, "name": "Alice", "email": "alice@example.com", "age": 30}
print(p["x"])
print(user["name"])
# Python 3.11+: Required / NotRequired 키 지정
class Config(TypedDict):
host: Required[str] # 반드시 있어야 함
port: Required[int]
debug: NotRequired[bool] # 없어도 됨 (기본값 없음)
timeout: NotRequired[float] # 없어도 됨
config: Config = {"host": "localhost", "port": 8080} # debug, timeout 생략 가능
config_full: Config = {"host": "localhost", "port": 8080, "debug": True, "timeout": 30.0}
# total=False: 모든 키를 선택적으로
class PartialUser(TypedDict, total=False):
name: str
email: str
age: int
partial: PartialUser = {"name": "Bob"} # 나머지 생략 가능
TypedDict 상속과 중첩
from typing import TypedDict
class BaseEntity(TypedDict):
id: int
created_at: str
updated_at: str
class Address(TypedDict):
street: str
city: str
country: str
postal_code: str
class Person(BaseEntity): # BaseEntity를 상속
name: str
email: str
address: Address # 중첩 TypedDict
# 타입 안전한 JSON 직렬화 함수
import json
from typing import Any
def to_json(data: dict[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, indent=2)
person: Person = {
"id": 1,
"created_at": "2024-01-01",
"updated_at": "2024-01-15",
"name": "Alice",
"email": "alice@example.com",
"address": {
"street": "강남대로 1",
"city": "서울",
"country": "대한민국",
"postal_code": "06000",
},
}
print(to_json(person))
NamedTuple — 불변 레코드
from typing import NamedTuple
class Point2D(NamedTuple):
x: float
y: float
class Point3D(NamedTuple):
x: float
y: float
z: float = 0.0 # 기본값 지원
class Employee(NamedTuple):
name: str
department: str
salary: float
is_remote: bool = False
# 사용
p = Point2D(1.0, 2.0)
print(p.x, p.y) # 속성 접근
print(p[0], p[1]) # 인덱스 접근 (튜플이므로)
print(p._asdict()) # {'x': 1.0, 'y': 2.0}
# 불변성
try:
p.x = 3.0 # AttributeError!
except AttributeError as e:
print(f"오류: {e}")
# _replace()로 새 인스턴스 생성
p2 = p._replace(x=10.0)
print(p2) # Point2D(x=10.0, y=2.0)
# 구조적 패턴 매칭과 함께
def classify_employee(emp: Employee) -> str:
match emp:
case Employee(department="Engineering", salary=s) if s > 10000:
return "고연봉 엔지니어"
case Employee(is_remote=True):
return "원격 근무자"
case _:
return "일반 직원"
alice = Employee("Alice", "Engineering", 12000.0)
bob = Employee("Bob", "Marketing", 8000.0, is_remote=True)
print(classify_employee(alice)) # 고연봉 엔지니어
print(classify_employee(bob)) # 원격 근무자
Protocol — 구조적 서브타이핑
from typing import Protocol, runtime_checkable
# Protocol 정의: 메서드 시그니처만 명시
class Drawable(Protocol):
def draw(self) -> None: ...
def get_color(self) -> str: ...
class Resizable(Protocol):
def resize(self, factor: float) -> None: ...
# 프로토콜을 구현하는 클래스들 (명시적 상속 불필요)
class Circle:
def __init__(self, radius: float, color: str = "red"):
self.radius = radius
self.color = color
def draw(self) -> None:
print(f"원 그리기: 반지름={self.radius}, 색={self.color}")
def get_color(self) -> str:
return self.color
def resize(self, factor: float) -> None:
self.radius *= factor
class Square:
def __init__(self, side: float, color: str = "blue"):
self.side = side
self.color = color
def draw(self) -> None:
print(f"정사각형 그리기: 변={self.side}, 색={self.color}")
def get_color(self) -> str:
return self.color
def render(shapes: list[Drawable]) -> None:
for shape in shapes:
shape.draw()
render([Circle(5.0), Square(3.0)]) # 둘 다 Drawable 프로토콜 만족
# @runtime_checkable: isinstance() 검사 가능
@runtime_checkable
class Serializable(Protocol):
def to_dict(self) -> dict: ...
def to_json(self) -> str: ...
class Product:
def __init__(self, name: str, price: float):
self.name = name
self.price = price
def to_dict(self) -> dict:
return {"name": self.name, "price": self.price}
def to_json(self) -> str:
import json
return json.dumps(self.to_dict())
p = Product("Python 책", 35000)
print(isinstance(p, Serializable)) # True
Protocol 상속과 복합 프로토콜
from typing import Protocol, runtime_checkable
class Reader(Protocol):
def read(self, n: int = -1) -> bytes: ...
class Writer(Protocol):
def write(self, data: bytes) -> int: ...
class ReadWriter(Reader, Writer, Protocol):
"""Reader + Writer 복합 프로토콜"""
pass
class Closeable(Protocol):
def close(self) -> None: ...
class Stream(ReadWriter, Closeable, Protocol):
"""완전한 스트림 프로토콜"""
pass
# 실전: 파일-유사 객체를 처리하는 함수
def copy_data(src: Reader, dst: Writer, chunk_size: int = 4096) -> int:
total = 0
while True:
data = src.read(chunk_size)
if not data:
break
written = dst.write(data)
total += written
return total
# 메모리 버퍼 구현 (명시적 상속 없이 프로토콜 만족)
import io
src_buf = io.BytesIO(b"Hello, Protocol World!")
dst_buf = io.BytesIO()
bytes_copied = copy_data(src_buf, dst_buf)
print(f"복사된 바이트: {bytes_copied}")
print(dst_buf.getvalue()) # b'Hello, Protocol World!'
고수 팁
1. TypedDict vs dataclass vs NamedTuple 선택 기준
from dataclasses import dataclass
from typing import TypedDict, NamedTuple
# TypedDict: JSON/dict 인터페이스, 타입 검사만 필요할 때
class APIResponse(TypedDict):
status: int
data: dict
message: str
# NamedTuple: 불변 레코드, 인덱스 접근이 필요할 때, 메모리 효율
class Coordinate(NamedTuple):
lat: float
lon: float
alt: float = 0.0
# dataclass: 뮤터블 객체, 메서드 추가, 기본값, 상속이 필요할 때
@dataclass
class Rectangle:
width: float
height: float
@property
def area(self) -> float:
return self.width * self.height
# 비교
coord = Coordinate(37.5665, 126.9780)
print(coord.lat, coord.lon)
print(coord[0], coord[1]) # 인덱스 접근 가능
rect = Rectangle(3.0, 4.0)
rect.width = 5.0 # 변경 가능
print(rect.area) # 20.0
2. Protocol로 의존성 역전
from typing import Protocol
from abc import abstractmethod
# 나쁜 예: 구체 클래스에 의존
class MySQLDatabase:
def execute(self, sql: str) -> list:
return [] # 실제 구현
class UserService:
def __init__(self, db: MySQLDatabase): # MySQL에 강하게 결합
self.db = db
# 좋은 예: Protocol에 의존 (의존성 역전)
class DatabaseProtocol(Protocol):
def execute(self, sql: str) -> list: ...
def commit(self) -> None: ...
def rollback(self) -> None: ...
class UserServiceV2:
def __init__(self, db: DatabaseProtocol): # 어떤 DB든 OK
self.db = db
def get_user(self, user_id: int) -> dict | None:
rows = self.db.execute(f"SELECT * FROM users WHERE id = {user_id}")
return rows[0] if rows else None
# 테스트: 가짜 DB 주입
class FakeDatabase:
def execute(self, sql: str) -> list:
return [{"id": 1, "name": "Alice"}]
def commit(self) -> None:
pass
def rollback(self) -> None:
pass
service = UserServiceV2(FakeDatabase())
print(service.get_user(1))
정리
| 도구 | 특징 | 주요 사용처 |
|---|---|---|
TypedDict | 딕셔너리 구조 타입 정의 | JSON API, 설정, 외부 데이터 |
NamedTuple | 불변 튜플 + 이름 접근 | 좌표, 레코드, 경량 값 객체 |
Protocol | 구조적 서브타이핑 | 인터페이스 정의, 의존성 역전 |
세 도구 모두 명시적 상속 없이 타입 안전성을 확보하는 파이썬다운 방식입니다.