SOLID with Python - Dependency Inversion Principle

3 minute read

일반적으로 객체 지향 프로그래밍(OOP)에서 상위 계층(혹은 모듈)은 하위 계층의 구현에 의존적입니다. 계층 간의 의존성이 높아질수록(tight coupling) 코드의 유지, 변경에 어려움이 생기는데, 의존관계 역전 원칙(DIP)을 적용하여 결합을 느슨하게(loosely coupling) 만들 수 있습니다. 예시와 함께 공부해보겠습니다.

의존관계 역전 원칙

  1. 상위 모듈은 하위 모듈에 의존하지 않습니다. 둘 다 추상화(인터페이스 등)에 의존합니다.
  2. 추상화는 세부사항에 의존하지 않습니다. 세부 사항이 추상화에 의존해야 합니다.

이게 무슨 말이냐..를 이해해보자면, 만약 위 원칙을 지키지 않고서 외부 라이브러리(하위 모듈)를 이용해서 개발을 완료했다고 가정합시다. 그런데 버전 업데이트 등으로 사용한 라이브러리 모듈의 구체적인 세부 사항이 변경되는 경우가 생깁니다. 그러면 그 모듈을 사용하는 내가 짠 main 코드(상위 모듈)가 변경되야 할 수 있습니다. 그러니 외부 라이브러리가 변경이 된다 할지라도 추상화된 interface를 이용해서 내 main 코드는 변경할 필요가 없도록 하자(의존성을 역전시키자)는 말입니다.

DIP example

전기 자동차와 충전소 개념을 차용해서 예시 코드를 만들어 봤습니다.

일반적인 구현

Car의 규약을 따르는 테슬라 전기차가 출시되었습니다. 그리고 테슬라를 충전하기 위한 슈퍼차저가 생겼습니다.

from dataclasses import dataclass
from abc import ABCMeta

@dataclass
class Car:
    model: str

@dataclass
class Tesla(Car):
    def __init__(self, model) -> None:
        self.model = model
        self.tesla_battery = 0

class SuperCharger:
    def charge(self, tesla: Tesla):
        assert Tesla.__instancecheck__(tesla), "Not a Tesla model"
        tesla.tesla_battery = 100

tesla = Tesla("model s")
super_charger = SuperCharger()
super_charger.charge(tesla)

이후에 현대차도 전기차를 출시했습니다. 테슬라와 달리 hyundai_battery를 쓰기 때문에 슈퍼차저에서는 충전할 수 없습니다.

@dataclass
class Hyundai(Car):
    def __init__(self, model) -> None:
        self.model = model
        self.hyundai_battery = 0

hyundai = Hyundai("ionic 5")
super_charger.charge(hyundai)

# Traceback (most recent call last):
#     assert Tesla.__instancecheck__(tesla), "Not a Tesla model"
# AssertionError: Not a Tesla model

그래서 현대차를 위한 새로운 충전기가 만들어졌고, hyundai_battery를 사용하는 차만 충전할 수 있습니다.

class HyundaiCharger:
    def charge(self, hyundai: Hyundai):
        assert Hyundai.__instancecheck__(hyundai), "Not a hyundai model"
        hyundai.hyundai_battery = 100

hyundai_charger = HyundaiCharger()
hyundai_charger.charge(hyundai)
  • SuperCharger는 Tesla에, HyundaiCharger는 Hyundai에 의존하고 있습니다.
    • charge 메서드가 특정한 클래스를 요구합니다.

이런 식으로 충전소가 세워지면, 제조사별 충전소를 세워야하고, 이용자는 제조사의 충전소만 이용해야하는 비효율이 발생합니다. 충전소가 만들고 유지하기 어렵다고 가정하면, 더 효율적인 방식이 필요합니다.

DIP 적용

그래서 제 3자 입장(국가, 혹은 한전)에서 모든 전기차를 충전할 수 있는 충전소를 만들고 싶습니다. 어떻게 해야할까요? 제조사가 배터리 규격을 통일하게 하면 좋겠지만, 이미 만들어진 생산설비를 변경하게 할 수는 없습니다. 그래서 제조사가 만든 자동차와 충전기는 그대로 두고, GeneralChargingStation추상화된 Adaptor 규약을 만들었습니다. 그리고 전기차 사용자들이 GeneralChargingStation을 이용할 수 있도록 제조사들에게 Adaptor 규약을 따르는 개별 어댑터를 만들도록 했습니다. 이러면 차량에 변경이 있을 때에도(하위 모듈 변경) 충전소(상위 모듈)보다는 비교적 변경하기 손쉬운 어댑터(인터페이스)만 대응해주면 됩니다.

from abc import ABCMeta, abstractmethod

class Adaptor(metaclass=ABCMeta): # 추상화된 Interface
    @abstractmethod # 이 규약을 따르는(상속받은 클래스는) 이 메서드를 꼭 구현해야합니다. 
    def send_energe(self, car: Car):
        ...

class TeslaAdaptor(Adaptor):
    def send_energe(self, car: Tesla):
        print(f"charge battery {car}")
        car.tesla_battery = 100
        return car

class HyundaiAdaptor(Adaptor):
    def send_energe(self, car: Hyundai):
        print(f"charge battery {car}")
        car.hyundai_battery = 100
        return car


class GeneralChargingStation:
    def __init__(self, adaptor: Adaptor): # interface를 요구합니다.
        self.adaptor = adaptor
        assert isinstance(adaptor, Adaptor), "error"

    def charge(self, car: Car):
        return self.adaptor.send_energe(car)


model3 = Tesla("model3")
tesla_adaptor = TeslaAdaptor()

ionic5 = Hyundai("ionic5")
hyundai_adaptor = HyundaiAdaptor()

GeneralChargingStation(tesla_adaptor).charge(model3)
print(model3.tesla_battery)
GeneralChargingStation(hyundai_adaptor).charge(ionic5)
print(ionic5.hyundai_battery)

# charge battery Tesla(model='model3')
# Tesla(model='model3')
# 100

# charge battery Hyundai(model='ionic5')
# Hyundai(model='ionic5')
# 100

이전의 Charger는 TeslaHyundai(로 만들어진 instance)와 직접 소통했습니다. 새로 만들어진 GeneralCharingStationAdaptor라는 interface와 소통하므로 느슨한 결합이 되었습니다. 만약 테슬라가 tesla_batterytesla_battery_v2로 변경한다고 해도 GeneralCharingStation은 수정할 필요가 없고, TeslaAdaptor만 변경하면 됩니다.


다만, 파이썬은 아래 예시처럼 인터페이스(추상 기본 클래스)를 사용하지 않고도 GeneralCharingStationHyundaiAdaptor를 넘길 수 있습니다. (에러가 나지 않습니다.)

class HyundaiAdaptor: # Adaptor를 상속받지 않음
    def send_energe(self, car: Hyundai):
        print(f"charge battery {car}")
        car.hyundai_battery = 100
        return car

class GeneralChargingStation:
    def __init__(self, adaptor: Adaptor): # Adaptor Class를 요구함
        self.adaptor = adaptor
        # 강제로 instance 확인하는 코드를 주석처리
        # assert isinstance(adaptor, Adaptor), "error" 

    def charge(self, car: Car):
        return self.adaptor.send_energe(car)

ionic5 = Hyundai("ionic5")
hyundai_adaptor = HyundaiAdaptor()

GeneralChargingStation(hyundai_adaptor).charge(ionic5) # Adaptor 클래스가 아니어도 동작.
print(ionic5.hyundai_battery)

이유는 파이썬이 유연한 동적 타입의 언어이기 때문에 그렇다고 합니다. 그럼에도 불구하고 추상 기본 클래스 사용을 권장하는 이유는 1.코드의 가독성 향상 2.너무 유연한 파이썬으로 인한 실수 방지, 그리고 3. duck typing: 덕 타이핑 이 있습니다.

Leave a comment