Python 상속과 Interface 설계: super·MRO·Protocol·Composition 선택 기준

반응형

상속은 코드 복사 없이 기능을 확장하는 문법이지만, 재사용을 위한 기본 선택지는 아니다. 부모의 내부 변화가 자식의 동작을 흔들 수 있고, 계층이 깊어지면 어느 method가 실행되는지 추적하기 어려워진다. 먼저 하위 타입 관계가 진짜 필요한지, 아니면 단지 기능을 빌려 쓰고 싶은지를 나눠 봐야 한다.

Python에서는 interface를 표현하는 방법도 하나가 아니다. duck typing, abc.ABC, typing.Protocol이 각각 다른 강도의 계약을 제공한다. 여기에 composition까지 놓고 보면 설계 선택이 훨씬 선명해진다.

상속은 is-a 관계와 대체 가능성에서 시작한다

DogAnimal의 한 종류라는 문장만 자연스럽다고 충분하지는 않다. 부모 타입을 기대하는 코드에 자식 객체를 넣어도 기존 약속이 깨지지 않아야 한다. 이를 흔히 Liskov substitution principle의 관점으로 설명한다.

class Rectangle:
    def __init__(self, width: int, height: int) -> None:
        self.width = width
        self.height = height

    def area(self) -> int:
        return self.width * self.height


class FramedRectangle(Rectangle):
    def __init__(self, width: int, height: int, frame: int) -> None:
        super().__init__(width, height)
        self.frame = frame

    def outer_area(self) -> int:
        return (self.width + 2 * self.frame) * (self.height + 2 * self.frame)

FramedRectangleRectanglewidth, height, area() 계약을 바꾸지 않고 기능을 더한다. 반대로 자식이 부모 method의 입력 범위를 좁히거나 예상한 결과 의미를 바꾼다면 문법상 상속은 가능해도 대체 가능한 설계는 아니다.

상속을 고르기 전에 다음을 확인한다.

  • 호출자가 부모와 자식을 같은 계약으로 다룰 수 있는가
  • 부모의 public behavior를 자식이 약화하지 않는가
  • 재사용하려는 것이 구현 몇 줄뿐인지, 실제 type relationship인지
  • 부모 변경의 영향을 모든 subclass가 감당할 수 있는가

super()는 “부모 호출”보다 MRO의 다음 호출이다

단일 상속에서는 super()가 직접 부모를 부르는 것처럼 보인다. 하지만 Python에서 super()는 현재 클래스와 객체를 기준으로 method resolution order(MRO)의 다음 구현을 찾는다.

class Base:
    def run(self) -> list[str]:
        return ["base"]


class AuditMixin(Base):
    def run(self) -> list[str]:
        return ["audit", *super().run()]


class MetricsMixin(Base):
    def run(self) -> list[str]:
        return ["metrics", *super().run()]


class Service(AuditMixin, MetricsMixin):
    pass


print(Service.mro())
print(Service().run())

Service().run()AuditMixin → MetricsMixin → Base 순서로 이어진다. 협력적 다중 상속에서는 같은 method name과 compatible signature를 사용하고, chain 중간에서 super()를 끊지 않아야 한다. MRO는 ClassName.mro() 또는 ClassName.__mro__로 먼저 확인한다.

Python의 interface 세 가지

Duck typing

호출에 필요한 method가 있으면 concrete class의 계보를 묻지 않는다. 작은 내부 코드에는 가장 단순하지만, 계약이 문서와 test에만 머물 수 있다.

ABC

abc.ABC@abstractmethod는 명시적 상속 관계를 만들고, 구현되지 않은 abstract method가 남은 class의 인스턴스 생성을 막는다. framework가 공통 구현이나 lifecycle을 소유할 때 유용하다. 정확한 동작은 Python 추상 클래스와 추상 메서드에서 예제로 확인할 수 있다.

Protocol

typing.Protocol은 필요한 attribute와 method의 구조를 type checker에 알려 준다. 구현체가 Protocol을 명시적으로 상속하지 않아도 구조가 맞으면 호환되는 것으로 검사할 수 있다.

from dataclasses import dataclass
from typing import Protocol


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


class ConsoleNotifier:
    def send(self, message: str) -> None:
        print(message)


@dataclass
class OrderService:
    notifier: Notifier

    def complete(self, order_id: str) -> None:
        self.notifier.send(f"order {order_id} completed")


service = OrderService(ConsoleNotifier())
service.complete("A-1024")

OrderService는 특정 notifier class를 상속하거나 생성하지 않는다. 필요한 계약만 받고, 실제 구현은 바깥에서 주입받는다. 이 구조에서는 email, message queue, test double로 바꿀 때 service 자체의 계층을 수정할 필요가 없다.

Protocol type annotation은 실행 시점에 자동으로 검증되지 않는다. @runtime_checkable을 붙이면 isinstance()에 제한적으로 사용할 수 있지만, runtime 검사는 필요한 attribute의 존재를 중심으로 볼 뿐 signature와 type까지 static checker처럼 검사하지 않는다.

재사용만 원한다면 composition을 먼저 본다

상속은 부모의 public·protected behavior와 lifecycle에 자식을 결합한다. 반면 composition은 필요한 객체를 field로 가지고 그 기능을 위임한다. “is-a”보다 “has-a”가 자연스러우면 composition 쪽이 대개 경계를 드러내기 쉽다.

상황 우선 검토할 선택
동일한 type으로 대체 가능하고 공통 lifecycle이 있음 상속 또는 ABC
필요한 method shape만 호출자에게 표현 Protocol
runtime 계보 없이 관례로 충분한 작은 코드 Duck typing
구현을 교체하거나 test double을 주입해야 함 Composition + Protocol
일부 기능만 재사용하고 type 관계는 없음 Composition

상속과 composition은 양자택일도 아니다. framework 내부에서는 좁은 ABC 계층을 쓰고, application service는 Protocol을 통해 그 객체를 composition할 수 있다. 중요한 것은 계층을 먼저 만드는 것이 아니라 변경 책임이 어디에 있는지 정하는 일이다.

상속이 문제를 키우는 신호

  • subclass가 부모 method 대부분을 override한다.
  • 부모의 protected state를 알아야만 정상 동작한다.
  • 순서가 다른 mixin을 추가할 때 behavior가 달라진다.
  • 자식이 부모 method 일부를 “지원하지 않음”으로 막는다.
  • unit test에서 긴 inheritance tree를 함께 준비해야 한다.
  • 단지 utility method 하나를 쓰려고 상속한다.

이 신호가 보이면 작은 collaborator로 기능을 분리하고 Protocol로 필요한 경계만 표현할 수 있는지 살펴본다.

자주 묻는 질문

Python에는 interface keyword가 없나

Java의 interface와 같은 별도 keyword는 없다. 실행 시점 계약이 필요하면 ABC, 정적 구조 계약이 필요하면 Protocol, 유연한 관례면 duck typing을 사용할 수 있다.

super()는 바로 위 부모를 호출하나

항상 그렇지는 않다. 현재 class 뒤의 MRO를 따라 다음 구현을 호출한다. 다중 상속에서는 이 차이가 핵심이다.

Protocol을 쓰면 runtime에도 안전한가

Protocol의 주된 효용은 static type checking이다. Python 실행기는 annotation을 자동 강제하지 않는다. runtime validation이 필요하면 별도의 경계 검사와 test를 둔다.

코드 재사용에는 상속이 가장 좋은가

재사용만이 목적이라면 결합 범위가 작은 composition이 더 적합한 경우가 많다. 상속은 실제 subtype 관계와 대체 가능성이 있을 때 선택한다.

참고 자료

반응형
KEEP READING
카테고리 전체 보기 →

댓글