Protocol: 구조적 서브타이핑
Protocol (Python 3.8+)은 명시적 상속 없이 **구조(메서드/속성)**만으로 타입을 정의합니다. 덕 타이핑에 정적 타입 힌트를 적용한 것으로, 클래스가 특정 인터페이스를 구현했는지 타입 검사 도구가 확인할 수 있게 합니다.
Protocol vs ABC
from abc import ABC, abstractmethod
from typing import Protocol
# ABC 방식: 명시적 상속 필요
class Drawable(ABC):
@abstractmethod
def draw(self) -> str: ...
class Circle(Drawable): # 반드시 Drawable을 상속해야 함
def draw(self) -> str:
return "원 그리기"
# Protocol 방식: 상속 없이 구조만 맞으면 됨
class Renderable(Protocol):
def render(self) -> str: ...
class Square: # Renderable을 상속하지 않아도!
def render(self) -> str:
return "사각형 렌더링"
class Triangle:
def render(self) -> str:
return "삼각형 렌더링"
def display(item: Renderable) -> None:
"""Renderable Protocol을 만족하는 것은 무엇이든 사용 가능"""
print(item.render())
display(Square()) # 사각형 렌더링
display(Triangle()) # 삼각형 렌더링
# display("hello") # mypy가 str은 render()가 없다고 오류 보고
typing.Protocol 사용법
from typing import Protocol, runtime_checkable
class Serializable(Protocol):
"""직렬화 가능한 객체 Protocol"""
def to_dict(self) -> dict:
"""딕셔너리로 변환"""
...
def to_json(self) -> str:
"""JSON 문자열로 변환"""
...
@classmethod
def from_dict(cls, data: dict) -> "Serializable":
"""딕셔너리에서 객체 생성"""
...
class User:
def __init__(self, id: int, name: str, email: str):
self.id = id
self.name = name
self.email = email
def to_dict(self) -> dict:
return {"id": self.id, "name": self.name, "email": self.email}
def to_json(self) -> str:
import json
return json.dumps(self.to_dict(), ensure_ascii=False)
@classmethod
def from_dict(cls, data: dict) -> "User":
return cls(data["id"], data["name"], data["email"])
class Product:
def __init__(self, sku: str, name: str, price: float):
self.sku = sku
self.name = name
self.price = price
def to_dict(self) -> dict:
return {"sku": self.sku, "name": self.name, "price": self.price}
def to_json(self) -> str:
import json
return json.dumps(self.to_dict(), ensure_ascii=False)
@classmethod
def from_dict(cls, data: dict) -> "Product":
return cls(data["sku"], data["name"], data["price"])
def save_to_cache(item: Serializable, key: str) -> None:
"""Serializable Protocol을 만족하면 무엇이든 저장 가능"""
data = item.to_json()
print(f"캐시[{key}] = {data}")
# User와 Product 모두 Serializable Protocol 만족
save_to_cache(User(1, "김철수", "kim@example.com"), "user:1")
save_to_cache(Product("P001", "Python 책", 35000), "product:P001")
runtime_checkable: isinstance() 지원
기본적으로 Protocol은 정적 타입 검사에만 사용됩니다. @runtime_checkable 데코레이터를 추가하면 런타임에 isinstance()로 확인할 수 있습니다.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Comparable(Protocol):
def __lt__(self, other) -> bool: ...
def __eq__(self, other) -> bool: ...
class Point:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __lt__(self, other: "Point") -> bool:
return (self.x ** 2 + self.y ** 2) < (other.x ** 2 + other.y ** 2)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
class Score:
def __init__(self, value: int):
self.value = value
def __lt__(self, other: "Score") -> bool:
return self.value < other.value
def __eq__(self, other: object) -> bool:
return isinstance(other, Score) and self.value == other.value
p = Point(3, 4)
s = Score(95)
# runtime_checkable로 isinstance() 사용 가능
print(isinstance(p, Comparable)) # True
print(isinstance(s, Comparable)) # True
print(isinstance("hello", Comparable)) # False (str은 __lt__는 있지만...)
print(isinstance(42, Comparable)) # True (int는 비교 연산 지원)
# 주의: runtime_checkable은 메서드 존재만 확인, 시그니처는 확인 안 함
실전 Protocol 패턴
Drawable Protocol
from typing import Protocol
class Drawable(Protocol):
"""그릴 수 있는 객체"""
x: float
y: float
def draw(self, canvas: "Canvas") -> None: ...
def bounding_box(self) -> tuple[float, float, float, float]: ...
class Canvas:
def __init__(self, width: int, height: int):
self.width = width
self.height = height
self._objects: list = []
def add(self, obj: Drawable) -> None:
self._objects.append(obj)
def render_all(self) -> None:
for obj in self._objects:
obj.draw(self)
class CircleShape:
def __init__(self, x: float, y: float, radius: float):
self.x = x
self.y = y
self.radius = radius
def draw(self, canvas: Canvas) -> None:
print(f"캔버스({canvas.width}x{canvas.height})에 원 그리기: "
f"중심({self.x}, {self.y}), 반지름={self.radius}")
def bounding_box(self) -> tuple[float, float, float, float]:
return (self.x - self.radius, self.y - self.radius,
self.x + self.radius, self.y + self.radius)
class RectShape:
def __init__(self, x: float, y: float, width: float, height: float):
self.x = x
self.y = y
self.width = width
self.height = height
def draw(self, canvas: Canvas) -> None:
print(f"캔버스에 사각형 그리기: ({self.x}, {self.y}), {self.width}x{self.height}")
def bounding_box(self) -> tuple[float, float, float, float]:
return (self.x, self.y, self.x + self.width, self.y + self.height)
canvas = Canvas(800, 600)
canvas.add(CircleShape(100, 100, 50))
canvas.add(RectShape(200, 200, 100, 80))
canvas.render_all()
Serializable Protocol
from typing import Protocol, TypeVar
import json
T = TypeVar("T", bound="JsonSerializable")
class JsonSerializable(Protocol):
def to_json(self) -> str: ...
@classmethod
def from_json(cls: type[T], json_str: str) -> T: ...
class Config:
def __init__(self, host: str, port: int, debug: bool):
self.host = host
self.port = port
self.debug = debug
def to_json(self) -> str:
return json.dumps({"host": self.host, "port": self.port, "debug": self.debug})
@classmethod
def from_json(cls, json_str: str) -> "Config":
data = json.loads(json_str)
return cls(**data)
class UserSettings:
def __init__(self, theme: str, language: str, notifications: bool):
self.theme = theme
self.language = language
self.notifications = notifications
def to_json(self) -> str:
return json.dumps(vars(self))
@classmethod
def from_json(cls, json_str: str) -> "UserSettings":
return cls(**json.loads(json_str))
def save_settings(settings: JsonSerializable, filename: str) -> None:
json_str = settings.to_json()
print(f"{filename}에 저장: {json_str}")
def load_and_print(cls: type, json_str: str) -> None:
obj = cls.from_json(json_str)
print(f"로드됨: {vars(obj)}")
cfg = Config("localhost", 8080, True)
user_settings = UserSettings("dark", "ko", True)
save_settings(cfg, "config.json")
save_settings(user_settings, "user.json")
Comparable Protocol
from typing import Protocol, TypeVar
T = TypeVar("T")
class Comparable(Protocol):
def __lt__(self, other: "Comparable") -> bool: ...
def __le__(self, other: "Comparable") -> bool: ...
def __gt__(self, other: "Comparable") -> bool: ...
def __ge__(self, other: "Comparable") -> bool: ...
def __eq__(self, other: object) -> bool: ...
def find_min(items: list[T]) -> T:
"""Comparable Protocol을 만족하는 어떤 타입의 리스트에도 사용 가능"""
if not items:
raise ValueError("빈 리스트")
result = items[0]
for item in items[1:]:
if item < result: # type: ignore
result = item
return result
def find_max(items: list[T]) -> T:
if not items:
raise ValueError("빈 리스트")
result = items[0]
for item in items[1:]:
if item > result: # type: ignore
result = item
return result
# 다양한 타입에 동일한 함수 사용
print(find_min([3, 1, 4, 1, 5])) # 1
print(find_max(["banana", "apple"])) # banana
print(find_min([3.14, 2.71, 1.41])) # 1.41
Protocol vs ABC 선택 기준
언제 Protocol을 사용하나?
- 서드파티 클래스나 내장 타입을 인터페이스에 포함시키고 싶을 때
- 명시적 상속 없이 덕 타이핑을 타입 힌트로 표현하고 싶을 때
- 클래스 계층이 없거나 필요 없을 때
- typing.Protocol이 더 유연하고 결합도가 낮을 때
언제 ABC를 사용하나?
- 구체적인 구현(공통 로직)을 기반 클래스에 넣고 싶을 때
- 런타임에 isinstance()로 타입 체크를 자주 해야 할 때
- 팀/코드베이스가 명시적 계층 구조를 선호할 때
- @abstractmethod로 구현을 강제하고 싶을 때
# ABC 적합: 공통 구현 공유
from abc import ABC, abstractmethod
class BaseRepository(ABC):
def __init__(self, db_url: str):
self._db_url = db_url
self._connection = None
def connect(self) -> None: # 공통 구현
print(f"{self._db_url}에 연결")
self._connection = f"conn:{self._db_url}"
@abstractmethod
def find_by_id(self, id: int) -> dict | None: ...
@abstractmethod
def save(self, data: dict) -> bool: ...
# Protocol 적합: 외부 클래스와 통합
class SupportsRead(Protocol):
def read(self, n: int = -1) -> str: ...
def count_words(stream: SupportsRead) -> int:
"""파일, StringIO, 소켓 등 read()가 있으면 무엇이든"""
return len(stream.read().split())
고수 팁
1. Protocol 메서드에 기본 구현 제공
from typing import Protocol, runtime_checkable
@runtime_checkable
class Loggable(Protocol):
def get_log_level(self) -> str: ...
def log(self, message: str) -> None:
"""기본 구현도 가질 수 있음 (단, Protocol의 필수 메서드는 아님)"""
print(f"[{self.get_log_level()}] {message}")
2. Generic Protocol
from typing import Protocol, TypeVar
T_co = TypeVar("T_co", covariant=True)
class Iterable(Protocol[T_co]):
def __iter__(self) -> "Iterator[T_co]": ...
class Iterator(Protocol[T_co]):
def __next__(self) -> T_co: ...
def __iter__(self) -> "Iterator[T_co]": ...
3. Protocol 상속으로 더 구체적인 Protocol 정의
from typing import Protocol
class Readable(Protocol):
def read(self) -> str: ...
class Writable(Protocol):
def write(self, data: str) -> int: ...
class ReadWritable(Readable, Writable, Protocol):
"""읽기와 쓰기 모두 가능"""
...
def copy(src: Readable, dst: Writable) -> None:
dst.write(src.read())
정리
| 특징 | Protocol | ABC |
|---|---|---|
| 상속 필요 여부 | 불필요 (구조적 서브타이핑) | 필요 (명목적 서브타이핑) |
| 런타임 isinstance() | @runtime_checkable 필요 | 기본 지원 |
| 공통 구현 공유 | 제한적 | 완전 지원 |
| 결합도 | 낮음 | 높음 |
| 서드파티 클래스 통합 | 쉬움 | 어려움 |
| 주요 사용처 | 타입 힌트, 덕 타이핑 표현 | 인터페이스 강제 + 공통 로직 |
Protocol은 Python의 덕 타이핑 철학을 정적 타입 시스템과 우아하게 결합합니다.