Python Protocol 2부 — 그런데 Codex는 왜 Protocol을 선택했을까?

Python Protocol 2부 — 그런데 Codex는 왜 Protocol을 선택했을까?

작성자

카테고리:

그런데 Codex는 왜 Protocol을 선택했을까?

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:
        ...

그런데도 다음과 같이 사용할 수 있었다.

service = CoverService(OpenAIImageProvider())

처음에는 둘 사이에 어떤 관계가 숨어 있는 줄 알았다.

하지만 파고들어 보니 그렇지 않았다.

OpenAIImageProvider와 ImageProvider 사이에 명시적인 상속 관계는 없다.

Protocol이 런타임에서 둘을 연결해주는 것도 아니다.

ImageProvider가 필요한 구조를 타입으로 표현하고, Pyright나 mypy 같은 정적 타입 검사기가 OpenAIImageProvider가 그 구조를 만족하는지 확인할 수 있는 것이다.

1부에서는 이것을 따라가면서 결국 이런 결론에 도달했다.

Protocol은 Python의 Duck Typing을 버리지 않으면서, 그 유연한 관계를 정적 타입 검사에서도 표현할 수 있게 해주는 도구다.

그런데 여기까지 이해하고 나니 오히려 새로운 질문이 생겼다.

그렇다면 Codex는 왜 굳이 여기서 Protocol을 사용했을까?

그냥 Duck Typing을 사용해도 될 것 같다.

ABC를 이용해 명시적인 추상 클래스를 만들 수도 있다.

심지어 구현체가 하나뿐이라면 OpenAIImageProvider를 직접 사용해도 된다.

이번에는 Protocol이 어떻게 동작하는가가 아니라,

왜 이런 설계를 선택했는가를 따라가 보려고 한다.


가장 단순하게 만들면 Protocol은 필요 없다

이미지를 생성하는 서비스를 하나 만든다고 생각해보자.

처음에는 OpenAI만 사용한다.

class OpenAIImageProvider:
    async def generate(self, prompt: str) -> bytes:
        ...

서비스에서 직접 사용하면 된다.

class CoverService:
    def __init__(self):
        self.provider = OpenAIImageProvider()

    async def create_cover(self, prompt: str) -> bytes:
        return await self.provider.generate(prompt)

아주 단순하다.

그리고 잘 동작한다.

사실 요구사항이 여기서 끝난다면 굳이 Protocol을 만들 이유도 없다.

CoverService
     │
     ▼
OpenAIImageProvider

코드를 읽기도 쉽다.

CoverService가 무엇을 사용하는지도 바로 알 수 있다.

그런데 시간이 지나면서 요구사항이 하나 추가됐다고 해보자.

AWS Bedrock을 이용해서도 이미지를 생성하고 싶다.

새로운 구현체가 생긴다.

class BedrockImageProvider:
    async def generate(self, prompt: str) -> bytes:
        ...

이제 어떻게 해야 할까?


CoverService 안에서 선택하면 되지 않을까?

가장 먼저 생각할 수 있는 방법은 CoverService가 직접 Provider를 선택하는 것이다.

class CoverService:
    def __init__(self, provider_type: str):
        if provider_type == "openai":
            self.provider = OpenAIImageProvider()
        elif provider_type == "bedrock":
            self.provider = BedrockImageProvider()

    async def create_cover(self, prompt: str) -> bytes:
        return await self.provider.generate(prompt)

이 코드도 잘 동작한다.

그런데 Provider가 하나 더 생기면 어떻게 될까?

elif provider_type == "gemini":
    self.provider = GeminiImageProvider()

Local Model도 지원한다면?

elif provider_type == "local":
    self.provider = LocalImageProvider()

점점 이런 모습이 된다.

CoverService
    │
    ├── OpenAIImageProvider
    ├── BedrockImageProvider
    ├── GeminiImageProvider
    └── LocalImageProvider

여기서 조금 이상한 점이 보인다.

CoverService의 역할은 커버 이미지를 만드는 비즈니스 로직을 처리하는 것이다.

그런데 이제는 OpenAI도 알고,

Bedrock도 알고,

Gemini도 알고,

각 Provider를 어떻게 생성해야 하는지도 알아야 한다.

그래서 질문을 하나 바꿔볼 수 있다.

CoverService가 정말 OpenAI나 Bedrock을 알아야 할까?


CoverService가 실제로 필요한 것은 무엇일까?

CoverService 입장에서 생각해보자.

사실 OpenAI라는 회사가 중요한 것은 아니다.

Bedrock이라는 서비스도 중요하지 않다.

CoverService에게 필요한 것은 단순하다.

프롬프트를 전달하면 이미지 데이터를 돌려주는 객체.

코드로 표현하면 이것뿐이다.

async def generate(prompt: str) -> bytes:
    ...

그렇다면 CoverService가 구체적인 Provider를 알지 않도록 바꿀 수 있다.

class CoverService:
    def __init__(self, provider):
        self.provider = provider

    async def create_cover(self, prompt: str) -> bytes:
        return await self.provider.generate(prompt)

그리고 밖에서 Provider를 넣어준다.

service = CoverService(OpenAIImageProvider())

Bedrock을 사용하려면:

service = CoverService(BedrockImageProvider())

이렇게 하면 된다.

이것만으로도 CoverService에서 구체적인 Provider를 분리할 수 있다.

여기까지는 Protocol이 없어도 된다.

Python의 Duck Typing만으로 충분하다.


그런데 타입 검사기는 provider가 무엇인지 모른다

문제는 타입 정보를 사용하고 싶을 때 생긴다.

class CoverService:
    def __init__(self, provider):
        self.provider = provider

사람이 코드를 읽으면 어느 정도 추측할 수 있다.

하지만 provider가 어떤 동작을 제공해야 하는지는 코드에 명확하게 표현되어 있지 않다.

타입 검사기도 마찬가지다.

그렇다고 이렇게 쓰면 어떨까?

class CoverService:
    def __init__(self, provider: OpenAIImageProvider):
        self.provider = provider

이번에는 너무 구체적이다.

CoverService는 다시 OpenAI라는 구현 타입을 알게 된다.

Bedrock을 전달하면 타입 관점에서는 맞지 않는다.

service = CoverService(BedrockImageProvider())

우리가 원하는 것은 이것도 아니다.

원하는 것은:

OpenAI든 Bedrock이든 상관없다.
generate()를 제공하면 된다.

바로 이 요구사항을 타입으로 표현할 수 있는 것이 Protocol이다.


그래서 ImageProvider가 등장한다

from typing import Protocol

class ImageProvider(Protocol):
    async def generate(self, prompt: str) -> bytes:
        ...

그리고 CoverService는 구체적인 구현이 아니라 이 타입을 사용한다.

class CoverService:
    def __init__(self, provider: ImageProvider):
        self.provider = provider

    async def create_cover(self, prompt: str) -> bytes:
        return await self.provider.generate(prompt)

이제 이 코드가 말하는 것이 훨씬 명확하다.

CoverService는 ImageProvider가 요구하는 구조를 가진 객체가 필요하다.

OpenAI를 사용할 수 있다.

service = CoverService(OpenAIImageProvider())

Bedrock도 사용할 수 있다.

service = CoverService(BedrockImageProvider())

두 구현체 모두 ImageProvider를 상속하지 않아도 된다.

필요한 구조를 만족하면 된다.

1부에서 살펴본 Structural Subtyping이 실제 설계에서 사용되는 지점이다.


여기서 중요한 것은 Provider 교체 자체가 아니다

처음 보면 Protocol을 사용하는 이유가 단순히 Provider를 쉽게 바꾸기 위해서처럼 보인다.

OpenAI
  ↓
Bedrock

하지만 Provider 교체만 목적이라면 Protocol이 반드시 필요한 것은 아니다.

앞에서 본 것처럼 그냥 Duck Typing으로도 가능하다.

class CoverService:
    def __init__(self, provider):
        self.provider = provider

중요한 차이는 이것이다.

provider

라고만 작성하면:

어떤 객체가 들어와야 하는가?

가 코드에 명확하게 드러나지 않는다.

반면:

provider: ImageProvider

라고 작성하면:

generate(str) -> bytes를 제공하는 객체가 필요하다.

라는 계약이 코드에 표현된다.

그리고 정적 타입 검사기도 그 계약을 이해할 수 있다.

즉 Protocol이 하는 일은 Provider를 교체하는 것이 아니다.

교체 가능한 객체들이 만족해야 하는 공통 구조를 타입으로 표현하는 것

이다.


Dependency Injection도 Protocol이 해주는 것은 아니다

여기서 또 하나 구분할 것이 있다.

다음 코드를 보자.

service = CoverService(OpenAIImageProvider())

OpenAIImageProvider를 CoverService 바깥에서 만들어 전달하고 있다.

이것을 Dependency Injection의 한 형태로 볼 수 있다.

하지만 이것 역시 Protocol이 해주는 것은 아니다.

다음 코드도 Dependency Injection이다.

class CoverService:
    def __init__(self, provider):
        self.provider = provider
service = CoverService(OpenAIImageProvider())

Protocol이 없어도 의존성을 외부에서 주입할 수 있다.

따라서 두 개념을 구분할 필요가 있다.

Dependency Injection

객체가 사용할 의존성을
외부에서 전달한다.


Protocol

그 의존성이 어떤 구조를
가져야 하는지 타입으로 표현한다.

둘은 함께 사용하기 좋지만 같은 것은 아니다.

이 구분을 하고 나니 처음 Codex의 코드가 조금 더 명확하게 보였다.


그럼 Dependency Inversion은 무엇일까?

처음 구조에서는 CoverService가 구체적인 구현체에 직접 의존했다.

CoverService
     │
     ▼
OpenAIImageProvider

이 구조에서는 CoverService가 OpenAI를 알고 있다.

반면 ImageProvider라는 계약을 기준으로 생각하면 구조가 달라진다.

             ImageProvider
                  ▲
                  │
             CoverService

                  ▲
                  │
       ┌──────────┴──────────┐
       │                     │
OpenAIImageProvider   BedrockImageProvider

CoverService의 관심사는 이제 특정 SDK가 아니다.

자신에게 필요한 동작이다.

generate(prompt) -> bytes

이런 설계는 Dependency Inversion Principle과 연결된다.

상위 수준의 비즈니스 로직이 구체적인 하위 구현에 직접 묶이기보다 추상화된 계약을 바라보도록 만드는 것이다.

하지만 여기에서도 주의할 점이 있다.

Protocol을 사용했다고 자동으로 좋은 Dependency Inversion 설계가 되는 것은 아니다.

Protocol은 그 계약을 Python의 타입 시스템에서 표현할 수 있는 방법 중 하나일 뿐이다.

설계 원칙과 Python 문법은 같은 것이 아니다.


테스트에서 Protocol의 장점이 더 잘 보인다

실제 OpenAI API를 호출하는 테스트를 생각해보자.

테스트를 실행할 때마다 실제 API를 호출하면 여러 문제가 생긴다.

느리다.

네트워크 상태에 영향을 받는다.

비용이 발생할 수도 있다.

외부 API 장애 때문에 테스트가 실패할 수도 있다.

그래서 테스트에서는 Fake Provider를 사용할 수 있다.

class FakeImageProvider:
    async def generate(self, prompt: str) -> bytes:
        return b"fake-image"

테스트에서는 이것을 주입한다.

provider = FakeImageProvider()
service = CoverService(provider)

result = await service.create_cover("duck developer")

FakeImageProvider는 ImageProvider를 상속하지 않는다.

그래도 필요한 구조를 만족한다.

조금 더 현실적인 Fake를 만들 수도 있다.

class FakeImageProvider:
    def __init__(self):
        self.prompts = []

    async def generate(self, prompt: str) -> bytes:
        self.prompts.append(prompt)
        return b"fake-image"

그러면 호출 내용도 검사할 수 있다.

provider = FakeImageProvider()
service = CoverService(provider)

await service.create_cover("duck developer")

assert provider.prompts == ["duck developer"]

여기서 Protocol의 장점이 꽤 잘 드러난다.

테스트를 위해 Fake 클래스까지 어떤 상속 계층에 넣을 필요가 없다.

필요한 구조만 만족하면 된다.


그런데 ABC로도 할 수 있지 않나?

맞다.

여기까지의 설계는 ABC로도 만들 수 있다.

from abc import ABC, abstractmethod

class ImageProvider(ABC):
    @abstractmethod
    async def generate(self, prompt: str) -> bytes:
        ...

그리고 구현체가 상속한다.

class OpenAIImageProvider(ImageProvider):
    async def generate(self, prompt: str) -> bytes:
        ...

Bedrock도 마찬가지다.

class BedrockImageProvider(ImageProvider):
    async def generate(self, prompt: str) -> bytes:
        ...

이 방식도 좋은 설계가 될 수 있다.

오히려 모든 Image Provider가 명확한 하나의 타입 계층에 속해야 한다면 ABC가 더 자연스러울 수도 있다.

그렇다면 Codex가 Protocol을 선택한 이유는 무엇일까?


Protocol은 구현체에게 관계를 강제하지 않는다

ABC 방식에서는 구현체가 자신의 관계를 명시한다.

class OpenAIImageProvider(ImageProvider):

즉:

나는 ImageProvider다.

라고 구현체가 선언한다.

반면 Protocol을 사용하면 구현체는 ImageProvider를 알 필요가 없다.

class OpenAIImageProvider:
    async def generate(self, prompt: str) -> bytes:
        ...

그냥 자신의 역할을 구현하면 된다.

사용하는 쪽에서:

provider: ImageProvider

라고 필요한 구조를 정의한다.

이 차이는 외부 라이브러리를 사용할 때 더 분명해진다.


이미 존재하는 외부 클래스를 사용한다면?

외부 라이브러리에 이런 클래스가 있다고 해보자.

class ExternalImageClient:
    async def generate(self, prompt: str) -> bytes:
        ...

우리가 만든 클래스가 아니다.

당연히 우리가 만든 ImageProvider를 상속하지 않는다.

그런데 우연히 우리가 원하는 구조와 정확하게 일치한다.

Protocol을 사용한다면 그대로 사용할 수 있다.

provider: ImageProvider = ExternalImageClient()

구조가 맞기 때문이다.

ABC 기반의 타입 계층에 넣고 싶다면 Adapter를 만들 수도 있다.

class ExternalImageAdapter(ImageProvider):
    def __init__(self, client: ExternalImageClient):
        self.client = client

    async def generate(self, prompt: str) -> bytes:
        return await self.client.generate(prompt)

Adapter가 나쁜 것은 아니다.

외부 라이브러리의 인터페이스가 우리 애플리케이션과 다르다면 오히려 Adapter가 필요하다.

하지만 이미 구조가 정확하게 일치한다면 Protocol은 굳이 새로운 상속 관계를 만들지 않아도 된다.

이것이 Structural Typing이 실제 설계에서 주는 자유다.


잠깐, 그런데 이건 Factory와 뭐가 다른 걸까?

여기까지 오면서 또 하나 헷갈리는 부분이 있었다.

OpenAI를 사용할 때:

service = CoverService(OpenAIImageProvider())

Bedrock을 사용하려면:

service = CoverService(BedrockImageProvider())

라고 한다.

구현체를 바꾸는 이야기라면 Factory와 비슷해 보인다.

예를 들어 Factory를 만들 수 있다.

class ImageProviderFactory:
    @staticmethod
    def create(name: str):
        if name == "openai":
            return OpenAIImageProvider()

        if name == "bedrock":
            return BedrockImageProvider()

        raise ValueError(name)

그러면:

provider = ImageProviderFactory.create("bedrock")
service = CoverService(provider)

라고 사용할 수 있다.

처음 보면 Protocol과 Factory가 같은 문제를 해결하는 것처럼 보일 수도 있다.

하지만 역할이 완전히 다르다.

Protocol이 답하는 질문은:

CoverService가 어떤 객체를 받을 수 있는가?

Factory가 답하는 질문은:

OpenAI와 Bedrock 중 어떤 객체를 만들어 전달할 것인가?

즉:

Protocol

무엇을 할 수 있어야 하는가?
→ 계약


Factory

어떤 구현체를 만들 것인가?
→ 선택과 생성

서로 대체 관계가 아니다.

필요하다면 같이 사용할 수 있다.


Protocol과 Factory를 같이 사용하면

Factory의 반환 타입을 ImageProvider로 선언할 수도 있다.

class ImageProviderFactory:
    @staticmethod
    def create(name: str) -> ImageProvider:
        if name == "openai":
            return OpenAIImageProvider()

        if name == "bedrock":
            return BedrockImageProvider()

        raise ValueError(name)

이렇게 하면 Factory가 실제로 어떤 구현체를 생성하든 반환되는 객체는 ImageProvider가 요구하는 구조를 만족해야 한다는 의도를 표현할 수 있다.

사용하는 쪽에서는 여전히 구체적인 구현체를 알 필요가 없다.

provider = ImageProviderFactory.create(settings.image_provider)
service = CoverService(provider)

각자의 역할을 나누면 이렇게 볼 수 있다.

ImageProvider

"어떤 객체를 사용할 수 있는가?"
        ↓
계약


ImageProviderFactory

"어떤 구현체를 만들 것인가?"
        ↓
선택과 생성


CoverService

"전달받은 객체로 무엇을 할 것인가?"
        ↓
비즈니스 로직

이렇게 구분하고 나니 Protocol, Dependency Injection, Factory가 한꺼번에 등장했을 때 왜 헷갈렸는지도 이해됐다.

서로 관련은 있지만 같은 문제를 해결하는 것은 아니다.


그렇다면 Factory도 반드시 필요한 걸까?

여기서 또 조심해야 할 부분이 있다.

Protocol을 사용한다고 Factory까지 만들어야 하는 것은 아니다.

Provider 선택이 단순하다면 그냥 이렇게 해도 된다.

provider = OpenAIImageProvider()
service = CoverService(provider)

설정값 하나에 따라 결정하는 정도라면 이것도 충분할 수 있다.

if settings.image_provider == "openai":
    provider = OpenAIImageProvider()
else:
    provider = BedrockImageProvider()

service = CoverService(provider)

Provider를 생성하는 과정이 복잡해지고 종류가 많아지면서 생성 로직을 한곳에서 관리할 필요가 생긴다면 그때 Factory를 고려할 수 있다.

중요한 것은 패턴을 몇 개 사용했느냐가 아니다.

현재 해결하려는 문제가 무엇인가다.


여기까지 오니 각각의 역할이 보이기 시작한다

처음 Codex가 작성한 코드를 봤을 때는 Protocol, Dependency Injection, Factory 같은 개념들이 한 덩어리처럼 느껴졌다.

하지만 하나씩 분리해보면 역할이 다르다.

Protocol
    ↓
필요한 구조를 타입으로 표현


Dependency Injection
    ↓
의존성을 외부에서 전달


Dependency Inversion
    ↓
구체적인 구현보다 추상화된 계약에 의존


Factory
    ↓
구현체의 선택과 생성

이 개념들은 함께 사용될 수 있다.

하지만 하나를 사용한다고 나머지가 자동으로 따라오는 것은 아니다.

예를 들어 다음 코드만으로도 Dependency Injection은 가능하다.

class CoverService:
    def __init__(self, provider):
        self.provider = provider

Protocol이 없어도 된다.

Factory도 없어도 된다.

반대로 Protocol만 정의한다고 의존성이 자동으로 주입되는 것도 아니다.

class ImageProvider(Protocol):
    async def generate(self, prompt: str) -> bytes:
        ...

이 코드는 객체를 생성하지 않는다.

OpenAI와 Bedrock 중 하나를 선택하지도 않는다.

그저:

이런 구조를 가진 객체가 필요하다.

라는 계약을 타입으로 표현한다.

이 구분은 생각보다 중요했다.


그런데 여기서 더 근본적인 질문이 생긴다

지금까지는 구현체가 여러 개 있다고 가정했다.

OpenAIImageProvider
BedrockImageProvider

테스트에서는:

FakeImageProvider

도 사용한다.

이런 상황이라면 ImageProvider라는 계약이 꽤 자연스럽게 느껴진다.

그런데 현실의 코드가 이렇다면 어떨까?

OpenAIImageProvider

구현체는 하나뿐이다.

Bedrock으로 바꿀 계획도 없다.

테스트에서도 Fake Provider를 사용할 필요가 없다.

외부 라이브러리를 같은 타입으로 취급할 이유도 없다.

그런데 Codex가 이렇게 만들어줬다고 해보자.

class ImageProvider(Protocol):
    async def generate(self, prompt: str) -> bytes:
        ...

그리고:

class OpenAIImageProvider:
    async def generate(self, prompt: str) -> bytes:
        ...

서비스에서는:

class CoverService:
    def __init__(self, provider: ImageProvider):
        self.provider = provider

Factory까지 있다.

class ImageProviderFactory:
    ...

구조만 보면 꽤 그럴듯하다.

SOLID 원칙을 잘 적용한 코드처럼 보이기도 한다.

하지만 질문을 바꿔보자.

지금 이 프로젝트에 정말 이 정도의 추상화가 필요한가?


Protocol을 사용했다고 좋은 설계가 되는 것은 아니다

처음 Protocol을 이해했을 때는 장점이 많이 보였다.

구현체를 쉽게 교체할 수 있다.

정적 타입 검사를 사용할 수 있다.

테스트에서 Fake를 넣기 쉽다.

외부 구현체를 상속 없이 사용할 수 있다.

비즈니스 로직과 외부 기술 사이에 경계를 만들 수 있다.

모두 맞는 이야기다.

그렇다고 모든 Provider 앞에 Protocol을 만들어야 하는 것은 아니다.

예를 들어 아주 단순한 프로그램이라면:

class CoverService:
    def __init__(self):
        self.provider = OpenAIImageProvider()

이 코드가 더 좋은 코드일 수도 있다.

왜냐하면 보는 순간 무엇을 사용하는지 알 수 있기 때문이다.

반면:

class CoverService:
    def __init__(self, provider: ImageProvider):
        self.provider = provider

라고 되어 있다면 실제 실행 시 어떤 Provider가 들어오는지 다른 곳을 찾아봐야 한다.

Factory까지 있다면:

CoverService
      ↓
ImageProvider
      ↓
ImageProviderFactory
      ↓
OpenAIImageProvider

를 따라가야 할 수도 있다.

유연성은 높아졌지만 이해해야 할 것도 많아졌다.


추상화는 공짜가 아니다

추상화는 보통 좋은 단어처럼 느껴진다.

결합도를 낮춘다.

확장성을 높인다.

테스트하기 쉽게 만든다.

구현체를 교체하기 쉽게 만든다.

하지만 그 대가도 있다.

파일이 늘어난다.

타입이 늘어난다.

실제 구현을 찾기 위해 코드를 이동해야 한다.

어떤 구현체가 실제로 사용되는지 추적해야 한다.

새로운 개발자가 구조를 이해하는 데 시간이 필요하다.

결국:

유연성을 얻는 대신 복잡성을 지불한다.

는 말로 정리할 수 있다.

따라서 Protocol을 사용할 것인가를 판단할 때는 단순히:

좋은 설계 패턴인가?

만 물어서는 부족하다.

오히려 이런 질문이 필요하다.

이 유연성을 얻기 위해 지금 이 복잡성을 지불할 가치가 있는가?


언제 Protocol이라는 경계가 의미가 있을까?

정답이 있는 것은 아니지만 몇 가지 질문을 던져볼 수 있다.

구현체가 실제로 둘 이상 존재하는가?

가까운 시점에 구현체가 교체될 가능성이 높은가?

테스트에서 실제 구현체 대신 Fake를 사용하고 싶은가?

외부 API나 인프라 기술과 비즈니스 로직의 경계를 분리하고 싶은가?

기존 클래스나 외부 라이브러리를 상속 관계 없이 같은 타입으로 사용해야 하는가?

정적 타입 검사를 적극적으로 사용하고 있는가?

이런 질문에 여러 번 “그렇다”고 답한다면 Protocol이 꽤 의미 있는 선택일 수 있다.

예를 들어 이런 영역이다.

LLM Provider

OpenAI
Bedrock
Gemini
Local LLM

또는:

Storage

S3
MinIO
Local File

Repository도 비슷하다.

UserRepository

MySQLUserRepository
MemoryUserRepository

메시징 시스템도 그렇다.

MessagePublisher

Redis Streams
RabbitMQ
Kafka
SQS

이런 경계에서는 구현 기술이 바뀔 가능성이 실제로 존재한다.

테스트용 구현체가 필요할 가능성도 높다.

그럴 때 Protocol은 단순한 장식이 아니라 의미 있는 계약이 될 수 있다.


반대로 이런 상황이라면 다시 생각해볼 수 있다

구현체가 하나뿐이다.

교체 계획도 없다.

테스트용 Fake도 필요하지 않다.

외부 시스템과의 경계도 아니다.

정적 타입 검사도 사용하지 않는다.

그런데 미래에 필요할지도 모른다는 이유만으로:

UserServiceProtocol
UserRepositoryProtocol
ImageProviderProtocol
ParserProtocol
ValidatorProtocol
ProcessorProtocol

을 미리 만들어 놓는다면 어떨까?

구현체는 각각 하나인데 계약만 계속 늘어날 수 있다.

그 순간부터 이것은 유연성을 위한 추상화인지,

아니면 추상화를 위한 추상화인지 고민해볼 필요가 있다.

필요해졌을 때 추출해도 늦지 않은 경우가 많다.


AI가 코드를 작성하면서 이 문제가 더 중요해졌다

여기에서 다시 Codex 이야기로 돌아온다.

사람이 직접 개발할 때는 추상화 하나를 추가하는 것도 어느 정도 비용이 든다.

파일을 만든다.

인터페이스를 정의한다.

구현체를 분리한다.

Factory를 만든다.

테스트를 수정한다.

그래서 필요하지 않은 구조를 만드는 것 자체가 귀찮았다.

그런데 AI에게는 이 비용이 거의 없다.

예를 들어:

이미지 Provider를 추상화하고 OpenAI와 Bedrock을 지원할 수 있도록 구조를 개선해줘.

라고 요청하면 순식간에 이런 구조를 만들어낼 수 있다.

protocols.py

providers/
    openai.py
    bedrock.py

factories/
    image_provider_factory.py

services/
    cover_service.py

Dependency Injection도 적용하고,

Factory도 만들고,

Protocol도 정의하고,

테스트용 Fake까지 만들어줄 수 있다.

코드를 만드는 비용은 정말 낮아졌다.

그런데 한 가지는 그대로다.

그 구조를 이해하고 유지하는 비용이다.

AI는 몇 초 만에 추상화 계층을 세 개 만들 수 있다.

하지만 장애가 발생했을 때 그 구조를 따라가야 하는 사람은 결국 개발자다.

요구사항이 바뀌었을 때 무엇을 수정해야 하는지 판단하는 것도 개발자다.

그래서 AI 시대에는 오히려 이런 질문이 더 중요해지는 것 같다.

왜 이렇게 만들었지?


“잘 짜줬다”와 “적절하게 짜줬다”는 다르다

Codex가 작성한 코드가 기술적으로 훌륭할 수 있다.

SOLID 원칙을 적용했다.

Dependency Injection을 사용했다.

Protocol로 계약을 분리했다.

Factory도 만들었다.

테스트하기도 쉽다.

그렇다면 좋은 코드일까?

그럴 수도 있다.

하지만 반드시 그런 것은 아니다.

프로젝트가 아주 작고 구현체가 하나뿐이라면:

Service
   ↓
Client

정도로 충분했을 수도 있다.

그런데 AI가:

Service
   ↓
Protocol
   ↓
Factory
   ↓
Provider
   ↓
Client

를 만들어줬다면 기술적으로 틀린 구조는 아니다.

오히려 굉장히 잘 설계된 것처럼 보일 수도 있다.

하지만 현재 문제에 필요한 복잡성보다 훨씬 큰 구조일 수도 있다.

그래서 두 질문은 다르다.

이 코드는 잘 작성됐는가?

그리고:

이 코드가 현재 문제에 적절한가?

AI가 코드를 빠르게 만들어주는 시대에는 두 번째 질문의 중요성이 더 커지는 것 같다.


다시 Codex가 작성한 Protocol로 돌아가 보자

처음에는 이 코드가 낯설었다.

class ImageProvider(Protocol):
    async def generate(self, prompt: str) -> bytes:
        ...

1부에서는:

상속도 하지 않았는데 어떻게 OpenAIImageProvider가 ImageProvider 타입으로 사용될 수 있지?

라는 질문을 따라갔다.

그리고 Structural Subtyping과 정적 타입 검사까지 도달했다.

이번에는:

그렇다면 왜 Codex는 굳이 ImageProvider라는 Protocol을 만들었을까?

를 따라가 봤다.

이제 어느 정도 이유를 설명할 수 있다.

CoverService가 OpenAI라는 구체적인 기술을 직접 알지 않도록 하고,

서비스가 자신에게 필요한 generate()라는 동작만 바라보게 만들고,

OpenAI와 Bedrock 같은 여러 구현체를 같은 계약으로 사용할 수 있게 하고,

테스트에서는 간단한 Fake 구현체를 넣을 수 있게 하면서,

그 관계를 정적 타입 검사기가 확인할 수 있도록 하기 위해서다.

이런 요구가 실제로 존재한다면 Protocol은 꽤 자연스러운 선택이다.

특히 Python의 Duck Typing을 유지하면서 타입 안정성까지 얻고 싶다면 더 그렇다.

하지만 중요한 조건이 하나 붙는다.

정말 그런 유연성이 필요한 경우라면.


결국 Codex가 Protocol을 선택한 이유보다 중요한 것

처음에는 Codex의 선택에 정답이 있다고 생각했다.

왜 ABC가 아니라 Protocol이지?

왜 그냥 Duck Typing을 사용하지 않았지?

왜 Factory까지 만들었지?

하나씩 따라가다 보니 정답은 특정 패턴에 있는 것이 아니었다.

상황에 따라 그냥 Duck Typing이 가장 좋은 선택일 수도 있다.

명시적인 타입 계층과 공통 구현이 필요하다면 ABC가 더 자연스러울 수도 있다.

구현체 생성 과정이 복잡하다면 Factory가 필요할 수도 있다.

그리고 여러 구현체가 같은 구조를 만족하도록 하면서 정적 타입 검사의 도움을 받고 싶다면 Protocol이 좋은 선택일 수 있다.

결국 중요한 것은:

왜 이 구조가 필요한가?

를 설명할 수 있는가였다.


AI가 작성한 코드를 이해한다는 것

Codex가 코드를 잘 만들어준다면 사실 이런 것을 몰라도 기능은 만들 수 있다.

에러가 발생하면 다시 물어보면 된다.

리팩터링도 시킬 수 있다.

테스트도 작성해달라고 하면 된다.

하지만 내가 운영해야 하는 코드라면 결국 어느 순간 판단해야 한다.

이 추상화를 유지할 것인가?

더 단순하게 만들 것인가?

구현체가 늘어났으니 이제 추상화할 것인가?

Factory가 정말 필요한가?

ABC가 더 적절한가?

Protocol이 실제로 우리 프로젝트에서 가치를 주고 있는가?

AI는 선택지를 만들어줄 수 있다.

하지만 어떤 복잡성을 받아들일 것인지는 결국 개발자가 결정해야 한다.


2부에서 얻은 결론

1부에서 얻은 결론은 이것이었다.

Protocol은 Python의 Duck Typing을 버리지 않으면서, 그 유연한 관계를 정적 타입 검사에서도 표현할 수 있게 해주는 도구다.

2부에서는 한 단계 더 들어가 봤다.

Protocol 자체가 Provider를 교체해주는 것은 아니다.

Dependency Injection을 해주는 것도 아니다.

Factory처럼 객체를 생성해주는 것도 아니다.

각자의 역할은 다르다.

Protocol
    ↓
필요한 구조를 타입으로 표현한다


Dependency Injection
    ↓
의존성을 외부에서 전달한다


Dependency Inversion
    ↓
구체적인 구현보다 계약을 바라보게 한다


Factory
    ↓
어떤 구현체를 생성할지 결정한다

이들이 함께 사용되면 꽤 유연한 구조를 만들 수 있다.

하지만 그 구조가 항상 필요한 것은 아니다.

그래서 이번 글에서 가장 중요하게 남은 문장은 이것인 것 같다.

좋은 설계는 패턴을 많이 사용하는 것이 아니라, 현재 문제에 필요한 만큼의 복잡성을 사용하는 것이다.

Codex가 Protocol을 선택했다는 사실보다 중요한 것은 그 선택의 이유를 이해하고,

우리 코드에도 정말 필요한지를 판단하는 것이었다.


그런데 아직 끝난 것은 아니다

Protocol이 무엇인지 알았다.

왜 사용할 수 있는지도 알았다.

Codex가 왜 이런 설계를 선택했는지도 어느 정도 이해했다.

그런데 실제 개발에서는 더 현실적인 질문이 남는다.

그래서 언제 Protocol을 쓰고, 언제 쓰지 말아야 할까?

ABC와 Protocol 중 실제 코드에서는 무엇을 선택해야 할까?

그냥 Duck Typing으로 두는 것이 더 나은 경우는 언제일까?

@runtime_checkable은 무엇이고, 정말 런타임에서도 Protocol을 검사할 수 있는 걸까?

Generic과 Protocol을 같이 사용하면 무엇이 달라질까?

그리고 무엇보다:

실무에서 Protocol을 남용하지 않으려면 어떤 기준으로 판단해야 할까?

이제 문법과 설계 이야기를 넘어 실제 사용 기준을 정리해볼 차례다.


다음 글

Python Protocol을 파헤쳐보다 — 3부

Protocol, 언제 쓰고 언제 쓰지 말아야 할까?

1부에서는 “상속하지 않았는데 어떻게 같은 타입이 될 수 있는가?”를 따라갔다.

2부에서는 “Codex는 왜 Protocol을 선택했는가?”를 따라갔다.

3부에서는 실제 코드 앞에서 선택할 수 있도록 정리해보려고 한다.

Duck Typing
vs
Protocol
vs
ABC

그리고 @runtime_checkable, Generic Protocol 같은 기능도 살펴보면서 Protocol이 유용한 경우와 오히려 불필요한 복잡성을 만드는 경우를 구분해본다.

결국 알고 싶은 것은 문법이 아니다.

내 코드에서는 어떤 선택이 가장 단순하고 적절한가?

이 질문으로 3부를 이어가려고 한다.