Python Protocol을 파헤쳐보다 — 3부
1부에서는 Codex가 작성한 코드에서 우연히 발견한 Protocol이 무엇인지
살펴봤다.
from typing import Protocol
class ImageProvider(Protocol):
async def generate(self, prompt: str) -> bytes:
...
그런데 실제 구현체는 ImageProvider를 상속하지 않았다.
class OpenAIImageProvider:
async def generate(self, prompt: str) -> bytes:
...
그럼 ImageProvider는 왜 필요한 걸까?
이 질문을 따라가면서 Duck Typing과 Structural Subtyping을 만났고,
2부에서는 왜 Codex가 ABC 대신 Protocol을 선택했을지 설계 관점에서
살펴봤다.
구체적인 구현 대신 계약에 의존하는 구조, Dependency Injection, Dependency Inversion, 테스트를 위한 Fake 객체까지 연결됐다.
그런데 실제 프로젝트에서 Protocol을 사용하려고 하면 또 다른 질문들이
생긴다.
from typing import Protocol, runtime_checkable
@runtime_checkable
class ImageProvider(Protocol):
async def generate(self, prompt: str) -> bytes:
...
runtime_checkable은 뭘까?
또 이런 코드도 볼 수 있다.
class Repository(Protocol[T]):
...
Protocol에 Generic까지 붙으면 갑자기 난도가 올라간다.
그리고 무엇보다 중요한 질문이 있다.
그래서 실제 프로젝트에서는 언제 Protocol을 쓰고, 언제 쓰지 말아야 할까?
이번 3부에서는 Protocol의 조금 더 실전적인 부분을 살펴보려고 한다.
Protocol은 기본적으로 런타임 기능이 아니다
Protocol의 주된 목적은 프로그램 실행 중에 객체를 검사하는 것이 아니다.
provider: ImageProvider = OpenAIImageProvider()
정적 타입 검사기는 OpenAIImageProvider가 ImageProvider를
상속했는지가 아니라 필요한 구조를 갖췄는지를 본다.
즉 코드를 분석하는 시점에
“이 객체를 여기에서 사용해도 되는가?”
를 판단할 수 있도록 타입 정보를 제공하는 것이다.
그런데 실행 중에 확인하고 싶다면?
가끔은 이런 코드가 필요할 수도 있다.
if isinstance(provider, ImageProvider):
...
이때 등장하는 것이 runtime_checkable이다.
from typing import Protocol, runtime_checkable
@runtime_checkable
class ImageProvider(Protocol):
async def generate(self, prompt: str) -> bytes:
...
이제 런타임에서도 확인할 수 있다.
provider = OpenAIImageProvider()
if isinstance(provider, ImageProvider):
print("ImageProvider compatible")
상속하지 않았는데도 검사가 가능하다.
runtime_checkable은 정확히 무엇을 검사할까?
여기서 주의해야 할 부분이 있다.
runtime_checkable이 붙었다고 해서 정적 타입 검사기처럼 메서드
시그니처의 타입까지 정밀하게 검사하는 것은 아니다.
class StrangeProvider:
async def generate(self, value: int) -> str:
return "hello"
우리가 원하는 계약은 generate(prompt: str) -> bytes다. 하지만 위
구현은 generate(value: int) -> str이다.
정적 타입 검사기는 이 차이를 잡아낼 수 있다.
반면 runtime_checkable을 이용한 isinstance() 검사는 기본적으로
필요한 속성이나 메서드가 존재하는지를 확인하는 수준이다.
그래서 runtime_checkable을 완전한 런타임 타입 검증 도구라고 생각하면
곤란하다.
대부분의 애플리케이션 코드에서는 정적 타입 검사만으로 충분하다.
Protocol
│
├─ 정적 타입 검사만 필요
│ → 그냥 Protocol
│
└─ isinstance() 검사도 필요
→ @runtime_checkable
처음부터 습관적으로 붙일 필요는 없다.
이번에는 Generic Protocol을 보자
실제 백엔드 코드를 보다 보면 비슷한 동작을 하지만 다루는 타입만 다른 객체가 자주 등장한다.
Repository가 대표적인 예다.
class UserRepository:
async def get(self, id: int) -> User:
...
class DocumentRepository:
async def get(self, id: int) -> Document:
...
둘의 동작은 비슷하고 반환하는 타입만 다르다.
이럴 때 Generic을 사용할 수 있다.
from typing import Protocol, TypeVar
T = TypeVar("T")
class Repository(Protocol[T]):
async def get(self, id: int) -> T:
...
이제 같은 Repository 계약을 다른 타입에 사용할 수 있다.
user_repo: Repository[User]
document_repo: Repository[Document]
Repository[User]
│
└── get() → User
Repository[Document]
│
└── get() → Document
실제 구현체는 여전히 Protocol을 명시적으로 상속할 필요가 없다.
class MySQLUserRepository:
async def get(self, id: int) -> User:
...
서비스에서는 이렇게 사용할 수 있다.
class UserService:
def __init__(self, repository: Repository[User]):
self.repository = repository
계약을 구현체에게 강제로 상속시키는 것이 아니라 사용하는 쪽에서 필요한 구조를 표현한다.
그런데 여기서 슬슬 위험해진다
Protocol을 이해하고 나면 뭐든 Protocol로 만들고 싶어진다.
class InvoiceServiceProtocol(Protocol):
async def extract(self, file: bytes) -> Invoice:
...
class InvoiceRepositoryProtocol(Protocol):
async def save(self, invoice: Invoice) -> None:
...
class LoggerProtocol(Protocol):
def info(self, message: str) -> None:
...
class ParserProtocol(Protocol):
def parse(self, data: bytes) -> dict:
...
Storage도 만들고, Model도 만들고, Validator도 만들고, Factory도 만든다.
어느 순간 실제 구현체보다 인터페이스가 더 많아질 수도 있다.
이건 좋은 설계일까?
반드시 그렇지는 않다.
추상화에는 비용이 있다
원래 코드가 이랬다고 해보자.
service = InvoiceService()
await service.extract(file)
아주 쉽게 읽힌다.
그런데 여러 계층을 만들기 시작하면 코드를 따라가기 위해 파일을 계속 이동해야 한다.
InvoiceEndpoint
↓
InvoiceServiceProtocol
↓
InvoiceService
↓
ExtractorProtocol
↓
VLMExtractor
↓
ModelProviderProtocol
↓
OpenAIModelProvider
대규모 시스템에서는 이런 경계가 필요할 수도 있다.
하지만 작은 프로젝트에서도 무조건 이런 구조가 좋은 것은 아니다.
추상화는 유연성을 얻는 대신 복잡성을 지불한다.
“나중에 바뀔 수도 있으니까”는 충분한 이유일까?
추상화를 만들 때 자주 등장하는 이유가 있다.
“나중에 구현체가 바뀔 수도 있으니까.”
틀린 말은 아니다.
하지만 소프트웨어에서 거의 모든 것은 언젠가 바뀔 수 있다.
MySQL이 PostgreSQL로, Redis가 Kafka로, OpenAI가 Bedrock으로 바뀔 수도 있다.
그 가능성을 모두 미리 대비하면 시스템 전체가 추상화 계층으로 가득 차게 된다.
그래서 질문을 조금 바꿔볼 필요가 있다.
바뀔 수 있는가?
보다는
이 부분을 분리해야 할 실제 이유가 있는가?
가 더 중요하다.
그렇다면 언제 Protocol을 쓰는 것이 좋을까?
이번에 Protocol을 공부하면서 가장 실용적이라고 느낀 기준은
경계였다.
Application
│
├── LLM
├── Storage
├── Search Engine
├── Repository
├── Message Queue
└── External API
이들은 애플리케이션의 핵심 로직 바깥에 있는 기술들이다.
그리고 구현이 바뀌거나 테스트에서 대체될 가능성이 비교적 높다.
예를 들어 LLM Provider라면 자연스럽다.
class LLMProvider(Protocol):
async def generate(self, prompt: str) -> str:
...
구현체는 다양할 수 있다.
OpenAIProvider
BedrockProvider
GeminiProvider
LocalLLMProvider
FakeLLMProvider
이 정도라면 Protocol을 사용하는 이유가 비교적 명확하다.
테스트에서 Fake가 필요하다면 좋은 신호다
실제 이미지 생성 API를 테스트할 때마다 호출하고 싶지는 않다.
그래서 Fake를 만든다.
class FakeImageProvider:
async def generate(self, prompt: str) -> bytes:
return b"fake-image"
서비스는 동일한 계약만 바라본다.
class CoverService:
def __init__(self, provider: ImageProvider):
self.provider = provider
운영에서는 실제 구현체를 넣는다.
service = CoverService(OpenAIImageProvider())
테스트에서는 Fake를 넣는다.
service = CoverService(FakeImageProvider())
이런 상황에서는 Protocol이 꽤 자연스럽다.
반대로 구현체가 하나뿐이라면?
예를 들어 프로젝트에 이런 클래스가 있다.
class MarkdownParser:
def parse(self, text: str) -> Document:
...
다른 Parser를 만들 계획도 없고, 테스트에서 대체할 필요도 없고, 외부 시스템과의 경계도 아니다.
그런데 단지 “좋은 설계 같아서” Protocol을 하나 추가한다.
class Parser(Protocol):
def parse(self, text: str) -> Document:
...
이게 틀린 코드는 아니다.
하지만 질문은 이것이다.
이 추상화가 지금 해결하고 있는 문제가 무엇인가?
대답이 없다면 아직 필요하지 않을 가능성이 높다.
ABC는 언제 더 자연스러울까?
그렇다고 Protocol만 사용해야 하는 것도 아니다.
공통 동작을 구현하고 싶다고 해보자.
from abc import ABC, abstractmethod
class BaseImageProvider(ABC):
def validate_prompt(self, prompt: str) -> None:
if not prompt:
raise ValueError("prompt is empty")
@abstractmethod
async def generate(self, prompt: str) -> bytes:
...
구현체들은 공통 기능을 물려받는다.
class OpenAIImageProvider(BaseImageProvider):
async def generate(self, prompt: str) -> bytes:
self.validate_prompt(prompt)
...
이 경우 상속 관계 자체에 의미가 있다.
BaseImageProvider
│
┌─────────┴─────────┐
│ │
OpenAIImageProvider BedrockImageProvider
공통 동작과
타입 계층 공유
이런 경우라면 ABC가 훨씬 자연스러울 수 있다.
그냥 Duck Typing이면 충분한 경우도 있다
Python에서는 아예 Protocol도 ABC도 사용하지 않을 수 있다.
async def create_cover(provider, prompt):
return await provider.generate(prompt)
generate()만 있으면 그냥 호출한다.
전형적인 Duck Typing이다.
작은 스크립트나 간단한 내부 도구라면 이것만으로 충분할 수도 있다.
모든 코드에 타입 시스템과 추상화를 적용해야 좋은 Python 코드가 되는 것은 아니다.
세 가지를 나란히 놓아보자
Duck Typing
"generate()가 있으면 그냥 사용한다."
가장 자유롭다.
가장 단순하다.
정적 타입 검사의 도움은 적다.
Protocol
"generate()를 제공하는 객체가 필요하다."
구조를 계약으로 표현한다.
명시적인 상속은 필요 없다.
정적 타입 검사의 도움을 받을 수 있다.
ABC
"이 클래스들은 같은 타입 계층에 속한다."
명시적인 상속 관계를 만든다.
공통 구현을 제공할 수 있다.
런타임 관계도 명확하다.
어느 하나가 항상 정답인 것은 아니다.
나는 이렇게 판단하면 이해하기 쉬웠다
아주 단순화하면 다음처럼 생각할 수 있다.
"추상화가 필요한가?"
│
├── 아니오
│ ↓
│ 그냥 구현
│
└── 예
│
▼
"공통 구현이나 명시적인
타입 계층이 필요한가?"
│
┌─────┴─────┐
│ │
예 아니오
│ │
▼ ▼
ABC Protocol
그리고 더 단순한 코드라면 아예 Duck Typing으로 충분할 수도 있다.
물론 실제 설계를 이렇게 기계적으로 결정할 수는 없다.
하지만 적어도
“Protocol이 더 최신 방식이니까 Protocol을 쓴다.”
라는 식으로 접근하는 것보다는 훨씬 나은 출발점이라고 생각한다.
Protocol은 인터페이스를 만드는 기술이 아니라 경계를 표현하는 도구다
서비스가 OpenAI를 직접 사용하면 서비스는 구체적인 기술을 알고 있다.
class CoverService:
def __init__(self):
self.provider = OpenAIImageProvider()
CoverService
│
▼
OpenAIImageProvider
Protocol을 도입하면 서비스는 자신에게 필요한 능력만 알도록 만들 수
있다.
class ImageProvider(Protocol):
async def generate(self, prompt: str) -> bytes:
...
class CoverService:
def __init__(self, provider: ImageProvider):
self.provider = provider
CoverService 입장에서 중요한 것은 딱 하나다.
이미지를 생성할 수 있는 객체가 필요하다.
OpenAI인지, Bedrock인지, 테스트용 Fake인지는 서비스가 알 필요가 없다.
이렇게 보면 Protocol은 단순히 인터페이스를 하나 추가한 것이 아니다.
서비스가 알아야 하는 것과 몰라도 되는 것 사이에 경계를 만든 것에 가깝다.
그리고 이 경계는 사용하는 쪽에서 결정한다
보통 상속을 사용하면 구현체가 자신의 관계를 선언한다.
class OpenAIImageProvider(BaseImageProvider):
...
즉 구현체가 “나는 BaseImageProvider다”라고 선언한다.
하지만 Protocol에서는 구현체가 아무것도 선언하지 않아도 된다.
class OpenAIImageProvider:
async def generate(self, prompt: str) -> bytes:
...
대신 사용하는 쪽에서 말한다.
class CoverService:
def __init__(self, provider: ImageProvider):
self.provider = provider
“나는 ImageProvider의 구조를 만족하는 객체가 필요하다.”
관계의 중심이 구현체가 아니라 사용하는 쪽에 있다.
이 차이가 처음에는 사소해 보였는데 실제 설계에서는 꽤 중요하다.
필요한 것만 요구할 수도 있다
어떤 외부 클라이언트가 여러 기능을 제공한다고 해보자.
class AIClient:
async def generate_image(self, prompt: str) -> bytes:
...
async def generate_text(self, prompt: str) -> str:
...
async def create_embedding(self, text: str) -> list[float]:
...
그런데 CoverService에서 필요한 것은 이미지 생성뿐이다.
그렇다면 서비스가 알아야 할 계약도 작게 만들 수 있다.
class ImageGenerator(Protocol):
async def generate_image(self, prompt: str) -> bytes:
...
전체 AI Client의 기능을 계약으로 만들 필요가 없다.
AIClient
├── generate_image()
├── generate_text()
└── create_embedding()
↓
CoverService가 필요한 것
ImageGenerator
└── generate_image()
Protocol은 큰 인터페이스를 만드는 도구라기보다 사용하는 코드가
실제로 필요한 최소한의 능력을 표현하는 도구라고 보는 편이 더
자연스럽다.
인터페이스는 작을수록 이해하기 쉽다
예를 들어 이런 Protocol 하나에 모든 기능을 몰아넣을 수도 있다.
class AIProvider(Protocol):
async def generate_text(self, prompt: str) -> str:
...
async def generate_image(self, prompt: str) -> bytes:
...
async def embed(self, text: str) -> list[float]:
...
하지만 이미지 생성만 필요한 서비스도 AIProvider 전체에 의존하게 된다.
목적에 따라 나누는 편이 더 명확할 수 있다.
class ImageGenerator(Protocol):
async def generate(self, prompt: str) -> bytes:
...
class TextGenerator(Protocol):
async def generate(self, prompt: str) -> str:
...
class Embedder(Protocol):
async def embed(self, text: str) -> list[float]:
...
결국 Protocol을 사용한다는 사실보다 어떤 경계를 어떤 크기로
정의했는가가 더 중요하다.
AI가 코드를 작성하는 시대에는 이 질문이 더 중요해진다
이번에 Protocol을 살펴보게 된 이유도 Codex가 작성한 코드 때문이었다.
AI에게 기능을 만들어달라고 하면 꽤 그럴듯한 구조를 빠르게 만들어준다.
Protocol도 만들고, Factory도 만들고, Dependency Injection도 적용하고,
Repository도 분리한다.
코드를 작성하는 데 걸리는 비용이 거의 없기 때문이다.
문제는 코드를 만드는 비용이 줄어든 것과 코드를 이해하는 비용이 줄어든 것은 아니라는 점이다.
오히려 AI가 너무 쉽게 추상화를 만들어주기 때문에 개발자가 더 자주 물어야 할 수도 있다.
이게 정말 필요한가?
Codex가 만들었다고 그대로 둘 필요는 없다
이번 시리즈의 출발점으로 돌아가 보자.
Codex가 이런 코드를 만들었다.
class ImageProvider(Protocol):
async def generate(self, prompt: str) -> bytes:
...
처음에는 생각했다.
왜 굳이 이것을 만들었지?
OpenAIImageProvider만 있어도 코드는 잘 동작한다.
class OpenAIImageProvider:
async def generate(self, prompt: str) -> bytes:
...
그리고 실제로 작은 프로젝트라면 ImageProvider가 없어도 아무 문제가
없을 수 있다.
하지만 여러 구현체를 사용할 가능성이 있고, 테스트에서 Fake Provider를 넣고, 서비스가 특정 AI 업체에 직접 의존하지 않도록 경계를 만들고 싶다면 이야기가 달라진다.
그때는 ImageProvider가 의미를 갖는다.
결국 중요한 것은
Protocol을 사용했다
가 아니다.
그 뒤에 있는
왜 이 경계를 만들었는가?
다.
처음에는 이상한 코드 한 줄이었다
이번 시리즈는 아주 작은 의문에서 시작했다.
class ImageProvider(Protocol):
처음에는 단순히 Python에서 처음 보는 문법이었다.
그런데 하나씩 따라가다 보니 꽤 많은 개념이 연결되어 있었다.
Protocol
│
▼
Duck Typing
│
▼
Structural Subtyping
│
▼
Dependency Injection
│
▼
Dependency Inversion
│
▼
테스트 가능한 구조
│
▼
시스템의 경계
그리고 마지막에는 문법보다 설계에 가까운 질문에 도착했다.
어디까지 추상화해야 하는가?
이 질문에는 Python이 정답을 알려주지 않는다.
Codex도 정답을 알고 있는 것은 아니다.
결국 프로젝트의 규모와 변화 가능성, 테스트 방식, 팀의 상황을 보고 개발자가 판단해야 한다.
Protocol을 사용할 때 기억할 기준
이번에 정리하면서 다음 정도만 기억해도 충분하다고 생각했다.
1. 단순한 코드인가?
→ 그냥 구현하거나 Duck Typing
2. 필요한 동작을 타입으로 표현하고 싶은가?
→ Protocol 고려
3. 구현체와 사용하는 코드를 분리할 실제 이유가 있는가?
→ Protocol 고려
4. 테스트에서 Fake 구현체로 교체해야 하는가?
→ Protocol이 유용할 수 있음
5. 기존 클래스나 외부 라이브러리를
상속 없이 계약에 맞추고 싶은가?
→ Protocol이 특히 편리함
6. 공통 구현과 명시적인 클래스 계층이 중요한가?
→ ABC 고려
7. 단지 "나중에 바뀔 수도 있어서" 만드는 추상화인가?
→ 정말 필요한지 다시 생각
그리고 한 가지를 더 추가하고 싶다.
Protocol을 사용할 수 있다는 것과 Protocol을 사용해야 한다는 것은 다르다.
결국 중요한 것은 선택의 이유다
Protocol을 사용하는 방법 자체는 어렵지 않다.
class ImageProvider(Protocol):
async def generate(self, prompt: str) -> bytes:
...
Generic도 사용할 수 있다.
T = TypeVar("T")
class Repository(Protocol[T]):
async def get(self, id: int) -> T:
...
필요하다면 런타임 검사도 가능하다.
@runtime_checkable
class ImageProvider(Protocol):
...
하지만 이런 문법을 아는 것보다 더 중요한 질문이 있다.
왜 여기에는 Protocol이 필요한가?
그리고 그다음 질문도 있다.
Protocol이 없어도 되는 것은 아닌가?
좋은 설계는 패턴을 많이 사용하는 코드가 아니라 왜 그 구조가 필요한지 설명할 수 있는 코드에 더 가까운 것 같다.
AI가 코드를 작성해도 설계 판단은 남는다
Codex가 코드를 만들어주는 시대에는 개발자가 모든 코드를 직접 작성하지 않아도 된다.
앞으로는 그 비중이 더 커질지도 모른다.
하지만 AI가 작성한 코드에 Protocol이 들어 있다고 해서 그냥 넘어갈 수는
없다.
왜 만들었는지 이해해야 한다.
필요하다면 유지하고, 과하다면 제거하고, 부족하다면 다시 설계해야 한다.
코드를 작성하는 능력
↓
코드를 읽는 능력
↓
설계 의도를 이해하는 능력
↓
그 설계를 받아들일지
바꿀지 판단하는 능력
이번에 Protocol이라는 작은 코드 하나를 따라가면서 가장 크게 느낀
부분도 이것이었다.
Codex가 짜주는 대로 개발해도 된다.
하지만
왜 이렇게 짰는지는 알고 쓰자.
모르고 사용하는 것과 알고 선택하는 것은 분명히 다르니까.
시리즈 정리
1부 — What Is Python Protocol?
ImageProvider를 상속하지 않았는데 왜 OpenAIImageProvider를 사용할 수
있는지에서 시작해 Duck Typing과 Structural Subtyping을 살펴봤다.
2부 — Why Did Codex Choose Python Protocol?
왜 구체적인 구현체 대신 Protocol에 의존하는지, Dependency Injection과
Dependency Inversion, Fake를 이용한 테스트, 그리고 ABC와의 차이를
살펴봤다.
3부 — Python Protocol in Practice: When to Use It and When Not To
runtime_checkable, Generic Protocol을 살펴보고 실제 프로젝트에서
Protocol, ABC, Duck Typing 중 무엇을 선택할지, 그리고 어디까지
추상화해야 하는지를 정리했다.
처음에는 낯선 Python 문법 하나였지만, 결국 이 작은 Protocol 하나가
AI가 작성한 코드를 어떻게 읽고 판단할 것인가라는 꽤 큰 질문까지
이어졌다.
