[파이썬 100강] 78강. typing.Protocol로 유연한 인터페이스 계약 만들기

[파이썬 100강] 78강. typing.Protocol로 유연한 인터페이스 계약 만들기

이번 강의에서는 추상 클래스처럼 무겁게 상속 구조를 만들지 않아도, “이 메서드가 있으면 같은 방식으로 쓸 수 있다”는 계약을 명확하게 잡는 typing.Protocol을 다룹니다. 서론은 여기까지 하고 바로 코드로 들어가겠습니다.


핵심 개념

  • Protocol구조적 타이핑(duck typing) 계약을 타입 힌트로 명시하는 도구입니다.
  • 클래스가 특정 베이스 클래스를 상속하지 않아도, 필요한 속성/메서드가 있으면 해당 프로토콜을 만족한다고 볼 수 있습니다.
  • “플러그인 교체”, “저장소 구현 교체”, “알고리즘 주입”처럼 구현은 다르지만 사용하는 방식이 같은 구조에서 특히 강력합니다.

파이썬은 원래 “오리처럼 걷고 오리처럼 울면 오리”라는 철학이 강한 언어입니다. 그래서 런타임에서는 어떤 객체가 정확히 어떤 클래스를 상속했는지보다, 지금 필요한 메서드를 실제로 가지고 있는지가 더 중요할 때가 많습니다. 문제는 팀 개발으로 넘어가면 이 규칙이 문서와 구두 설명에만 남고 코드에는 흐릿하게 남는다는 점입니다. Protocol은 바로 그 흐릿한 부분을 코드 시그니처에 고정해 줍니다.

예를 들어 “저장소 객체는 save()find_by_id()를 제공해야 한다” 같은 규칙을 프로토콜로 선언하면, 구현체가 파일 기반이든 DB 기반이든 메모리 기반이든 상관없이 동일 계약으로 다룰 수 있습니다. 덕분에 테스트에서는 가벼운 페이크 객체를 쉽게 넣고, 운영에서는 진짜 구현으로 바꿔 끼우는 구성이 단순해집니다.

기본 사용

예제 1) 가장 기본 패턴: Protocol로 인터페이스 계약 선언

>>> from typing import Protocol
>>>
>>> class Notifier(Protocol):
...     def send(self, message: str) -> None:
...         ...
...
>>> class SlackNotifier:
...     def send(self, message: str) -> None:
...         print(f"[slack] {message}")
...
>>> class SmsNotifier:
...     def send(self, message: str) -> None:
...         print(f"[sms] {message}")
...
>>> def alert_admin(notifier: Notifier, text: str) -> None:
...     notifier.send(f"ALERT: {text}")
...
>>> alert_admin(SlackNotifier(), "DB connection slow")
[slack] ALERT: DB connection slow
>>> alert_admin(SmsNotifier(), "CPU usage high")
[sms] ALERT: CPU usage high

해설:

  • SlackNotifier, SmsNotifierNotifier를 상속하지 않았지만 send(message: str) 시그니처가 있으므로 계약을 만족합니다.
  • 즉, “상속 관계” 대신 “행동(구조) 관계”로 결합을 낮춥니다.

예제 2) 조건/조합 확장: 저장소 구현 교체

>>> from typing import Protocol
>>>
>>> class UserRepo(Protocol):
...     def save(self, user_id: int, name: str) -> None:
...         ...
...     def find(self, user_id: int) -> str | None:
...         ...
...
>>> class MemoryUserRepo:
...     def __init__(self):
...         self._data = {}
...     def save(self, user_id: int, name: str) -> None:
...         self._data[user_id] = name
...     def find(self, user_id: int) -> str | None:
...         return self._data.get(user_id)
...
>>> class LoggingUserRepo:
...     def __init__(self, inner: UserRepo):
...         self.inner = inner
...     def save(self, user_id: int, name: str) -> None:
...         print(f"save user={user_id}")
...         self.inner.save(user_id, name)
...     def find(self, user_id: int) -> str | None:
...         print(f"find user={user_id}")
...         return self.inner.find(user_id)
...
>>> repo = LoggingUserRepo(MemoryUserRepo())
>>> repo.save(1, "Gunwoo")
save user=1
>>> repo.find(1)
find user=1
'Gunwoo'

해설:

  • 데코레이터/래퍼 패턴과 프로토콜을 결합하면 기능 확장(로그, 캐시, 측정)을 안전하게 덧붙일 수 있습니다.
  • 핵심은 “안쪽 구현이 무엇인지 몰라도, 계약만 맞으면 동작한다”는 점입니다.

예제 3) 실전형 미니 케이스: 결제 게이트웨이 주입

>>> from typing import Protocol
>>>
>>> class PaymentGateway(Protocol):
...     def charge(self, order_id: str, amount: int) -> str:
...         ...
...
>>> class FakeGateway:
...     def charge(self, order_id: str, amount: int) -> str:
...         return f"fake-{order_id}-{amount}"
...
>>> class RealGateway:
...     def charge(self, order_id: str, amount: int) -> str:
...         # 실제로는 외부 API 호출
...         return f"real-tx-{order_id}"
...
>>> def pay_order(gateway: PaymentGateway, order_id: str, amount: int) -> str:
...     tx_id = gateway.charge(order_id, amount)
...     return f"PAID:{tx_id}"
...
>>> pay_order(FakeGateway(), "ORD-100", 15000)
'PAID:fake-ORD-100-15000'
>>> pay_order(RealGateway(), "ORD-101", 22000)
'PAID:real-tx-ORD-101'

해설:

  • 테스트에서는 FakeGateway를 넣어 비용/속도/불안정성을 줄이고,
  • 운영에서는 RealGateway를 넣어 실제 결제를 수행합니다.
  • 함수 본문은 구현체를 몰라도 동일하게 유지되어 유지보수성이 올라갑니다.

예제 4) 속성까지 포함한 프로토콜 설계

>>> from typing import Protocol
>>>
>>> class HasName(Protocol):
...     name: str
...
>>> class User:
...     def __init__(self, name: str):
...         self.name = name
...
>>> class Team:
...     def __init__(self, name: str):
...         self.name = name
...
>>> def label(obj: HasName) -> str:
...     return f"<{obj.name}>"
...
>>> label(User("alice"))
'<alice>'
>>> label(Team("platform"))
'<platform>'

해설:

  • 프로토콜은 메서드뿐 아니라 속성 계약도 표현할 수 있습니다.
  • 화면 표기, 공통 로깅, 감사 로그처럼 “이름/ID 속성” 기반의 공통 처리에서 유용합니다.

자주 하는 실수

실수 1) Protocol을 선언만 해두고 시그니처를 느슨하게 둠

>>> from typing import Protocol
>>>
>>> class BadNotifier(Protocol):
...     def send(self, message):
...         ...
...
>>> class MyNotifier:
...     def send(self, message: str, urgent: bool = False) -> None:
...         print(message, urgent)
...

원인:

  • 파라미터/반환 타입을 구체적으로 적지 않아 계약이 사실상 문서 수준으로 약해집니다.
  • 팀원이 구현체를 만들 때 서로 다른 시그니처를 넣어도 초기에 잡기 어렵습니다.

해결:

>>> class Notifier(Protocol):
...     def send(self, message: str) -> None:
...         ...
...
>>> class StrictNotifier:
...     def send(self, message: str) -> None:
...         print(message)
...

실수 2) 런타임 isinstance() 검사에 바로 사용하려다 실패

>>> from typing import Protocol
>>>
>>> class Runner(Protocol):
...     def run(self) -> None:
...         ...
...
>>> class Job:
...     def run(self) -> None:
...         print("ok")
...
>>> isinstance(Job(), Runner)
Traceback (most recent call last):
...
TypeError: Instance and class checks can only be used with @runtime_checkable protocols

원인:

  • 기본 Protocol은 타입체커용 계약입니다. 런타임 isinstance/issubclass에 바로 쓰면 안 됩니다.

해결:

>>> from typing import runtime_checkable
>>>
>>> @runtime_checkable
... class Runner(Protocol):
...     def run(self) -> None:
...         ...
...
>>> isinstance(Job(), Runner)
True

실수 3) 모든 곳에 프로토콜을 남발해 코드가 더 복잡해짐

  • 증상: 단순 함수 2~3개짜리 코드까지 인터페이스를 분리해 파일 수와 추적 비용만 증가
  • 원인: “좋은 패턴”이라는 이유로 문제 크기보다 설계를 과하게 적용
  • 해결: 교체 가능성이 실제로 있는 경계(외부 API, 저장소, 알림, 결제) 위주로만 프로토콜 적용

실수 4) 추상 클래스(ABC)와 Protocol의 역할 구분을 안 함

  • 증상: 공통 구현이 필요한데도 Protocol만 쓰다가 중복 코드가 퍼짐
  • 원인: 계약 표현과 구현 재사용을 같은 문제로 봄
  • 해결: “공통 구현 필요”면 ABC/베이스 클래스, “구조 계약 필요”면 Protocol을 선택해 목적을 분리

실무 패턴

  • 입력 검증 규칙

    • 경계 계층(HTTP 핸들러, CLI 파서)에서 입력 검증을 먼저 끝내고, 도메인 계층에는 프로토콜 계약을 만족하는 객체만 주입합니다.
    • 프로토콜은 데이터 검증기가 아니라 객체 협력 규칙이므로, 값 검증 책임을 섞지 않습니다.
  • 로그/예외 처리 규칙

    • 프로토콜 구현체를 교체 가능하게 둘 때, 실패 예외 타입을 팀 내에서 통일합니다(예: PaymentError, NotificationError).
    • 래퍼 구현체(LoggingRepo, MetricsGateway)에서 공통 로그 포맷을 유지하면 운영 관측이 쉬워집니다.
  • 재사용 함수/구조화 팁

    • 함수 시그니처를 ConcreteClass 대신 Protocol로 받으면 테스트 더블(Fake/Stub) 작성이 쉬워집니다.
    • 파일 경계마다 “주입 지점”을 명확히 두세요. 예: service.pyGatewayProtocol만 알고, 실제 구현 선택은 bootstrap.py에서 담당.
  • 성능/메모리 체크 포인트

    • Protocol 자체의 런타임 오버헤드는 거의 없지만, 과도한 동적 래핑/다중 래퍼는 호출 스택과 디버깅 비용을 키울 수 있습니다.
    • 핫패스에서 인터페이스 계층이 과하게 깊어지면 프로파일링으로 병목 지점을 확인하세요.

오늘의 결론

한 줄 요약: Protocol은 상속 고정 없이도 협업 계약을 코드에 명시해, 교체 가능한 설계를 안전하게 만든다.

기억할 것:

  • 구현체 이름보다 “필요한 메서드/속성 시그니처”를 먼저 정의하세요.
  • 테스트 대역(Fake/Stub)과 운영 구현을 같은 계약으로 연결하면 개발 속도와 안정성이 함께 올라갑니다.
  • @runtime_checkable은 필요한 곳에서만 제한적으로 사용하세요.

연습문제

  1. Storage 프로토콜을 정의하고 save(key: str, value: str) -> None, load(key: str) -> str | None 계약을 만드세요. 메모리 구현체 MemoryStorage를 작성해 동작을 확인해 보세요.
  2. ImageResizer 프로토콜(resize(path: str, width: int) -> str)을 만들고, FakeResizerLocalResizer 두 구현체를 주입해 같은 서비스 함수가 동작하도록 작성해 보세요.
  3. 런타임 검사 시나리오를 실습해 보세요. @runtime_checkable이 없는 프로토콜/있는 프로토콜 각각에서 isinstance 결과가 어떻게 달라지는지 확인하고, 언제 런타임 검사까지 필요한지 정리해 보세요.

이전 강의 정답

  1. fee_rate() 구현
>>> from typing import Literal
>>> PayMethod = Literal["card", "bank", "kakao"]
>>>
>>> def fee_rate(method: PayMethod) -> float:
...     table = {"card": 0.032, "bank": 0.010, "kakao": 0.028}
...     return table[method]
...
>>> fee_rate("card")
0.032
>>> fee_rate("bank")
0.01
  1. SignupPayloadparse_signup()
>>> from typing import TypedDict
>>>
>>> class SignupPayload(TypedDict, total=False):
...     email: str
...     password: str
...     referrer: str
...
>>> def parse_signup(raw: dict) -> SignupPayload:
...     email = raw.get("email", "").strip()
...     password = raw.get("password", "")
...     if "@" not in email:
...         raise ValueError("invalid email")
...     if len(password) < 8:
...         raise ValueError("password too short")
...     out: SignupPayload = {"email": email, "password": password}
...     if "referrer" in raw and str(raw["referrer"]).strip():
...         out["referrer"] = str(raw["referrer"]).strip()
...     return out
...
>>> parse_signup({"email": "[email protected]", "password": "abcd1234"})
{'email': '[email protected]', 'password': 'abcd1234'}
  1. 주문 이벤트 스키마 분리 예시
>>> from typing import TypedDict, Literal, Union
>>>
>>> class OrderCreated(TypedDict):
...     type: Literal["created"]
...     order_id: str
...     amount: int
...
>>> class OrderCanceled(TypedDict):
...     type: Literal["canceled"]
...     order_id: str
...     reason: str
...
>>> OrderEvent = Union[OrderCreated, OrderCanceled]
>>>
>>> e1: OrderEvent = {"type": "created", "order_id": "O-1", "amount": 12000}
>>> e2: OrderEvent = {"type": "canceled", "order_id": "O-2", "reason": "user_request"}
>>> (e1["type"], e2["type"])
('created', 'canceled')

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: typing (Protocol, runtime_checkable)
  • 타입 점검 예시:
    • mypy your_file.py
    • pyright your_file.py
  • 재현 체크:
    • 서로 다른 구현체가 동일 Protocol 시그니처로 함수에 주입되는지
    • @runtime_checkable 유무에 따라 isinstance 동작이 달라지는지
    • 테스트 더블(Fake) 주입으로 외부 의존성 없이 서비스 로직 검증이 가능한지