[카테고리:] Python

Python 언어의 원리, 타입과 코드 설계를 다룹니다.

  • Python Protocol 3부 — 언제 쓰고 언제 쓰지 말아야 할까?

    Python Protocol 3부 — 언제 쓰고 언제 쓰지 말아야 할까?

    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가 작성한 코드를 어떻게 읽고 판단할 것인가라는 꽤 큰 질문까지 이어졌다.

  • 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부를 이어가려고 한다.

  • Python Protocol 1부 — Codex가 갑자기 Protocol을 썼다

    Python Protocol 1부 — Codex가 갑자기 Protocol을 썼다

    Python Protocol을 파헤쳐보다 — 1부

    요즘은 직접 코드를 작성하는 시간보다 AI가 작성한 코드를 리뷰하는 시간이 점점 늘어나고 있다.

    나 역시 최근에는 요구사항을 Codex에게 전달하고, 작성된 코드를 리뷰하면서 수정하거나 다음 작업을 지시하는 방식으로 개발하는 경우가 많다.

    흔히 말하는 바이브 코딩(Vibe Coding)에 가까운 개발 방식이다.

    그러던 중 평소에는 잘 사용하지 않던 코드를 발견했다.

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

    그리고 실제 구현체는 이랬다.

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

    이상했다.

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

    상속이라면 다음처럼 관계가 명확하다.

    class OpenAIProvider(ImageProvider):
        ...
    

    이 코드는 보는 순간 “OpenAIProvider는 ImageProvider다”라고 이해할 수 있다.

    그런데 Protocol에서는 그런 관계가 보이지 않는다.

    조금 더 들여다보다가 오히려 더 근본적인 질문이 생겼다.

    그런데 ImageProvider가 꼭 필요한가?


    사실 ImageProvider는 없어도 된다

    OpenAIProvider를 만드는 데 ImageProvider는 필요하지 않다.

    class OpenAIProvider:
        async def generate(self, prompt: str) -> bytes:
            # OpenAI API 호출
            ...
    

    이 클래스는 혼자서도 완전하다.

    서비스에서도 바로 사용할 수 있다.

    class CoverService:
        def __init__(self):
            self.provider = OpenAIProvider()
    
        async def create_cover(self, prompt: str) -> bytes:
            return await self.provider.generate(prompt)
    

    아무 문제가 없다.

    그렇다면 Codex는 왜 굳이 ImageProvider라는 것을 하나 더 만들었을까?

    이 질문에서 시작하니 Protocol이 조금씩 이해되기 시작했다.


    관점을 OpenAIProvider에서 CoverService로 바꿔보자

    처음에는 ImageProvider를 OpenAIProvider의 부모 같은 것으로 생각했다.

    하지만 관점을 사용하는 쪽, 즉 CoverService로 바꿔보자.

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

    Python에서는 이것으로 충분하다.

    provider가 어떤 클래스인지는 중요하지 않다. generate()만 가지고 있으면 된다.

    CoverService(OpenAIProvider())
    CoverService(BedrockProvider())
    CoverService(FakeProvider())
    

    모두 generate(prompt: str) -> bytes를 제공한다면 사용할 수 있다.

    이것이 Python에서 익숙한 Duck Typing이다.


    Duck Typing은 자유롭지만 필요한 조건이 보이지 않는다

    다시 생성자를 보자.

    def __init__(self, provider):
    

    이것만 보면 provider에 어떤 객체를 넣어야 하는지 알 수 없다.

    결국 내부를 봐야 한다.

    await self.provider.generate(prompt)
    

    그제야 “generate()를 가진 객체가 필요하구나”라고 알 수 있다.

    사람뿐 아니라 IDE와 정적 타입 검사기 역시 provider가 어떤 능력을 가져야 하는지 명확하게 알기 어렵다.

    여기서 Protocol이 등장한다.


    CoverService가 필요한 것을 타입으로 표현한다

    CoverService가 원하는 것을 자연어로 표현하면 간단하다.

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

    이 요구사항을 타입으로 표현한다.

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

    그리고:

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

    이제 중요한 전제가 보인다.

    ImageProvider는 OpenAIProvider를 만들기 위해 존재하는 것이 아니다.

    OpenAIProvider는 ImageProvider 없이도 잘 동작한다.

    ImageProvider는 오히려 CoverService가 자신에게 필요한 것이 무엇인지 타입으로 표현하기 위해 존재한다.


    왜 ImageProvider에는 Protocol이 있고 OpenAIProvider에는 없을까?

    둘의 역할이 다르기 때문이다.

    ImageProvider
    -------------
    계약
    
    "generate(prompt: str) -> bytes
    를 제공해야 한다."
    

    반면:

    OpenAIProvider
    --------------
    실제 구현
    
    "내가 generate()를 실제로 구현한다."
    

    Protocol은 실제 작업을 수행한다는 표시가 아니다.

    어떤 객체가 만족해야 할 구조를 정의하는 타입이다.

    그래서 계약인 ImageProvider에는 Protocol을 사용하지만 실제 구현체인 OpenAIProvider에는 붙이지 않는다.


    그런데 왜 상속하지 않아도 되는가?

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

    둘 사이에는 상속 관계가 없다.

    그런데 정적 타입 검사 관점에서는 다음이 성립할 수 있다.

    provider: ImageProvider = OpenAIProvider()
    

    타입 검사기가 상속 관계가 아니라 구조를 비교하기 때문이다.

    ImageProvider가 요구
    generate(prompt: str) -> bytes
    
                 ↕ 호환
    
    OpenAIProvider가 제공
    generate(prompt: str) -> bytes
    

    이것이 Structural Subtyping, 구조적 서브타이핑이다.


    상속과 Protocol의 질문은 다르다

    상속:

    OpenAIProvider
          │
          │ "나는 ImageProvider다"
          ▼
    ImageProvider
    

    상속은 명시적인 관계를 선언한다.

    반면 Protocol은 이렇게 생각할 수 있다.

    ImageProvider (Protocol)
    
    "generate()를 할 수 있어야 함"
                │
                ▼
          generate(...) ?
            ▲       ▲       ▲
            │       │       │
         OpenAI  Bedrock   Fake
    

    아주 단순하게 표현하면:

    상속은 “너는 누구냐?”를 묻고, Protocol은 “너는 무엇을 할 수 있느냐?”를 묻는다.


    이름을 바꿔보면 더 잘 보인다

    이해를 위해 ImageProvider를 이렇게 바꿔보자.

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

    그리고:

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

    자연어처럼 읽으면 된다.

    CoverService에는 이미지를 생성할 수 있는 무언가가 필요하다.

    OpenAI든 Bedrock이든 Fake든 필요한 구조만 가지고 있으면 된다.

    Protocol은 부모 클래스라기보다 필요한 능력을 표현하는 타입에 가깝다.


    그러면 그냥 Duck Typing 아닌가?

    상당히 비슷하다.

    Duck Typing에서는:

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

    라고 하고 실행할 때 generate()만 있으면 된다.

    Protocol을 사용하면:

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

    그 조건을 정적 타입 시스템에 표현할 수 있다.

    따라서 처음 이해할 때는 이렇게 생각해도 좋다.

    Protocol은 Duck Typing의 유연함을 유지하면서 그 규칙을 정적 타입으로 표현하는 방법이다.


    잘못된 객체를 넣으면 차이가 보인다

    class EmailSender:
        async def send(self, message: str) -> None:
            ...
    

    EmailSender에는 generate()가 없다.

    타입이 없다면 다음처럼 전달하는 것 자체는 가능하다.

    service = CoverService(EmailSender())
    

    문제는 실행하면서 generate()를 호출할 때 발생한다.

    반면 provider: ImageProvider라고 계약을 표현하면 mypy나 pyright 같은 정적 타입 검사기가 EmailSender가 필요한 구조를 만족하지 않는다는 것을 실행 전에 알려줄 수 있다.

    Protocol의 주요 목적은 런타임에서 모든 타입을 강제하는 것이 아니라 정적 타입 검사기와 IDE가 객체 사이의 계약을 이해하도록 만드는 것이다.


    그냥 OpenAIProvider 타입을 쓰면 되지 않을까?

    물론 가능하다.

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

    OpenAI만 사용할 것이라면 오히려 더 명확할 수 있다.

    선택지는 대략 세 가지다.

    1. provider
    
    자유로운 Duck Typing
    
    
    2. provider: OpenAIProvider
    
    구체적인 구현체에 의존
    
    
    3. provider: ImageProvider
    
    필요한 구조에 의존
    

    Protocol은 세 번째 선택을 가능하게 해준다.


    그렇다고 Protocol이 항상 필요한 것은 아니다

    처음 질문으로 돌아가 보자.

    ImageProvider가 없어도 OpenAIProvider를 만드는 데 아무 문제 없지 않나?

    맞다.

    상황에 따라서는 정말 없어도 된다.

    구현체가 하나뿐이고, 교체 계획도 없고, Fake 구현체도 필요 없고, 서비스가 구체적인 구현체에 직접 의존해도 문제없다면:

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

    이것이 오히려 더 단순할 수도 있다.

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

    그래서 이제 처음 질문이 다른 의미로 돌아온다.

    왜 Codex는 굳이 ImageProvider라는 Protocol을 만들었을까?

    구현체를 교체하려는 것일까?

    테스트에서 Fake Provider를 넣으려는 것일까?

    외부 API와 서비스 로직을 분리하려는 것일까?

    아니면 현재 상황에는 필요 없는 추상화를 AI가 추가한 것일까?

    여기서부터 Protocol은 Python 문법의 문제가 아니라 소프트웨어 설계의 문제가 된다.


    그냥 사용하는 것과 알고 사용하는 것은 다르다

    사실 여기까지 몰라도 개발은 할 수 있다.

    Codex가 코드를 작성하고 테스트가 통과한다면 그대로 사용할 수 있다.

    하지만 Protocol을 이해하면 다음 코드를 보고:

    class ImageProvider(Protocol):
        ...
    

    단순히 “AI가 인터페이스 같은 것을 만들었나 보다”라고 넘어가는 대신 질문할 수 있다.

    우리 코드에 이 추상화가 정말 필요한가?

    이 질문을 할 수 있다는 차이가 크다.

    AI가 코드를 작성하는 시대에는 모든 코드를 직접 타이핑하는 능력만큼 AI가 만든 코드의 의도를 읽고 그 선택이 적절한지 판단하는 능력도 중요해지고 있는 것 같다.

    AI가 짜주는 대로 개발해도 된다.

    하지만 모르고 사용하는 것과 알고 사용하는 것은 분명히 다르다.


    이제 다음 질문이 남았다

    1부에서는 가장 기본적인 질문에서 시작했다.

    ImageProvider가 없어도 OpenAIProvider는 잘 동작하는데 왜 굳이 만들었을까?

    그리고 몇 가지를 알게 됐다.

    ImageProvider는 OpenAIProvider를 만들기 위해 존재하는 것이 아니다.

    사용하는 쪽에서 필요한 능력을 타입으로 표현하기 위해 존재한다.

    또 OpenAIProvider가 ImageProvider를 상속하지 않아도 되는 이유는 상속 관계가 아니라 구조를 기준으로 판단하기 때문이다.

    그래서 Protocol은 Python의 Duck Typing과 자연스럽게 연결된다.

    그런데 아직 중요한 질문 하나가 남았다.

    그렇다면 Codex는 왜 구체적인 OpenAIProvider에 직접 의존하지 않고 ImageProvider라는 Protocol을 하나 더 만들었을까?

    이제 Protocol이 무엇인지는 어느 정도 이해했다.

    하지만 무엇인지 아는 것과 왜 사용하는지 아는 것은 또 다른 문제다.

    다음 2부에서는 Provider 교체, Fake를 이용한 테스트, 외부 시스템과 비즈니스 로직의 분리, Dependency Injection과 Dependency Inversion까지 따라가면서 Codex가 왜 Protocol을 선택했는지 살펴본다.

    그리고 더 중요한 질문도 함께 생각해본다.

    Codex가 Protocol을 사용한 이유가 있다고 해서 우리 코드에도 반드시 필요한 것일까?

    AI는 추상화를 몇 초 만에 만들어낼 수 있다.

    하지만 코드를 만드는 비용이 낮아졌다고 그 구조를 이해하고 유지하는 비용까지 사라지는 것은 아니다.


    다음 글

    2부 — Why Did Codex Choose Python Protocol?

    Protocol이 무엇인지는 알았다.

    이제 왜 사용하는지를 파헤쳐보자.