본문 바로가기

Python

[디자인패턴] 파이썬에서 Delegate Pattern (위임 패턴) 구현하기

 

이번 글에서는 객체 지향 디자인 패턴의 하나인 “위임 패턴”에 대해 정리해보고자 한다.

 

  언제 필요한가?

  • 코드 재사용이 필요할 때
  • 상속(inheritance)이나 합성(composition) 중 하나로만 구현하기에 부적절한 상황일 경우
    • 상속: 부모 클래스에서 상속받아 한 클래스의 구현을 정의
    • 합성: 다른 객체를 여러개 붙여서 새로운 객체를 구성
  • 상속시 부모 클래스에 종속되는 것을 원치 않는 경우
    • 부모 클래스의 구현에 변경이 생기면 서브 클래스도 변경해야함
  • 기존 구성 요소의 조합만으로는 목적을 달성하기 어려운 경우

 

  위임(delegation)이란?

  • 위키백과의 정의:
    • 위임 패턴은 객체 지향 디자인 패턴으로, 객체 구성을 통해 상속과 동일한 코드 재사용을 달성할 수 있다.
  • 어떠한 연산을 처리할때 객체는 연산의 처리를 위임자에게 보냄
    • 상속에서 서브클래스가 부모 클래스에게 요청을 전달하는 것과 같음

 

예시

Animal 클래스의 하위 클래스인(따라서 Animal 클래스의 기능을 상속하는) Dog 클래스가 있다고 가정해 보자.

Animal에 get_number_of_legs라는 메서드가 있다면 Dog 클래스의 모든 인스턴스화는get_number_of_legs 메서드를 호출할 수 있다.

class Animal:
  def __init__(self, name, num_of_legs):
    self.name = name
    self.num_of_legs = num_of_legs

  def get_number_of_legs(self):
    print(f"I have {self.num_of_legs} legs")

class Dog(Animal):
  def __init__(self, name, num_of_legs):
    super().__init__(name, num_of_legs)

dog = Dog('Fido', 4)
dog.get_number_of_legs()

>>>
"I have 4 legs"

Dog 클래스는 Animal 클래스를 상속하고 실제로 해당 메서드를 가지고 있기 때문에 Dog가 get_number_of_legs를 Animal에 위임한다고 말하는 것은 올바르지 않다.

 

  Composition의 예시 - 부엌

부엌을 예시로 들어보자. 실생활에서 부엌은 방으로서 어떤 기능도 없지만, 우리는 부엌의 기능을 부엌에 있는 가전제품의 구성으로 생각한다. 주방에 전자레인지가 있으면 주방에서 음식을 데울 수 있고, 식기세척기가 있으면 주방에서 설거지를 할 수 있다.

이제 객체 지향 디자인 관점에서 생각해보자. 음식을 데우고 설거지하는 것을 주방의 기능으로 해서 아래 의사코드와 같이 코드를 작성하고 싶다고 가정해보자.

# 의사코드
>>> kitchen = new Kitchen();
>>> kitchen.heat_up_food(); 
Food is being microwaved
>>> kitchen.wash_dishes();
Dishwasher starting

이를 위해 Kitchen 클래스 정의에서 MicrowaveDishwasher 클래스를 Kitchen 클래스의 속성으로 지정하여 Kitchen 클래스가 해당 메서드에 액세스할 수 있도록 할 수 있다. 여기까지는 composition이며, 아래와 같이 구현할 수 있다.

class Microwave:
  def __init__(self):
    pass

  def heat_up_food(self):
    print("Food is being microwaved")

class Dishwasher:
  def __init__(self):
    pass

  def wash_dishes(self):
    print("Dishwasher starting")

class Kitchen:
  def __init__(self):
    self.microwave = Microwave()
    self.dishwasher = Dishwasher()

그리고 아래와 같이 Kitchen에서 MicrowaveDishwasher 의 메서드가 실행되는 것을 기대하고 실행하지만, 에러가 발생한다.

>>> from kitchen import Kitchen
>>> kitchen = Kitchen()
>>> kitchen.heat_up_food()
Traceback (most recent call last):
  File ".\kitchen.py", line 21, in <module>
    kitchen.heat_up_food()
AttributeError: 'Kitchen' object has no attribute 'heat_up_food'

주방의 전자 레인지를 사용하려면 kitchen.microwave.heat_up_food()로 참조해야 하기 때문이다. 이것은 위임의 정의에서 말하는 코드 재사용이 아니다. 그렇다면 kitchen.heat_up_food()가 우리가 원하는 것을 제공하도록 하려면 어떻게 해야 할까?

 

  위임 구현하기

가장 확실한 방법은 상속을 사용하는 것이다. Kitchen 클래스를 MicrowaveDishwasher를 상속하는 것으로 구현하면 구문상 문제 없이 kitchen.heat_up_food()를 사용할 수 있다.

하지만 이 솔루션은 매우 잘못된 설계이다. 상속은 일반적으로 "is" 관계가 있는 클래스에 대해 수행되는 것이 맞다. 이전 예시에서는 DogAnimal이기 때문에 Animal 클래스로부터 상속받은 Dog 클래스를 만들었지만, Kitchen은 전자레인지나 식기 세척기가 아니므로 여기서 상속은 의미가 없다.

그래서 위임을 해야 한다. 간단한 위임 방법 중 하나는 다음과 같이 Kitchen 클래스에 래퍼 메서드를 생성하는 것이다.

# ... Microwave and Dishwasher truncated
class Kitchen:
  def __init__(self):
    self.microwave = Microwave()
    self.dishwasher = Dishwasher()

  def  heat_up_food(self):
    self.microwave.heat_up_food()

  def wash_dishes(self):
    self.dishwasher.wash_dishes()

이렇게하면 앞서 수행한 작업이 원하는 대로 작동한다.

>>> from kitchen import Kitchen
>>> kitchen = Kitchen()
>>> kitchen.heat_up_food()
Food is being microwaved
>>> kitchen.wash_dishes()
Dishwasher starting

이제 Kitchen 클래스에서 heat_up_food를 호출하면 실제로는 Microwave 클래스에 위임하는 것이다. 이 복합 클래스에 대한 '코드 재사용'에 가까워지고 있지만 아직은 그 정도에 이르지 못한다.

아직 '아직'이라는 표현을 쓴 이유는 Microwave와 Dishwasher 메서드를 실제로 재사용한 것이 아니라 Kitchen 클래스의 동일한 이름의 함수로 감싸기만 했기 때문이다.

 

문제점

복합 클래스에 위임할 메서드가 하나 또는 두 개만 있는 경우에는 괜찮지만, 전자레인지와 식기세척기 클래스를 더 많은 메서드로 확장하고 싶다면 어떻게 해야 할까?

이제 이 클래스에서 메서드를 작성할 때마다 Kitchen 클래스에서 해당 함수를 위임하기 위해 또 다른 래퍼를 작성해야 하며, 이것은 코드 재사용이 아니라 중복이다.

 

  __getattr__, getattr, dir

위의 문제를 해결하기 위해 파이썬의 세 가지 내장 메서드 __getattr__, getattr, dir를 사용할 수 있다.

  • __getattr__ : 모든 파이썬 클래스가 가지고 있는 함수
    • 이 메서드는 클래스에서 알 수 없거나 존재하지 않는 속성이 호출될 때마다 기본적으로 호출된다.
    • 예를 들어, 앞서 메서드를 위임하기 전에 kitchen.heat_up_food()를 호출했을 때 해당 속성 에러를 발생시킨 것은 내장된 __getattr__ 메서드이다.
  • getattr : 클래스 인스턴스와 어트리뷰트를 입력으로 받아 지정된 클래스의 지정된 어트리뷰트를 반환하는 내장 파이썬 함수
    • 끝에 ()를 추가하여 클래스 함수를 실행할 수도 있다.
    • ex) 이전의 Dog 클래스에서 getattr을 호출할 경우
>>> from animal import Dog
>>> dog = Dog('fido', 4)
>>> getattr(dog, 'name')
'fido'
>>> getattr(dog, 'get_number_of_legs')()
I have 4 legs
  • dir: 클래스 인스턴스의 모든 메서드 이름에 대한 문자열 배열을 반환하는 함수
>>> dishwasher = Dishwasher()
>>> dir(dishwasher)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'wash_dishes']

주방 클래스에서 이 세 가지 방법을 결합하여 모든 식기 세척기 및 전자 레인지 기능을 위임하도록 해보자.

 

  부엌 위임하기

알고있는 것:

  • dir 메서드는 클래스 내에서 사용 가능한 메서드 목록을 제공한다는 점
    • (밑줄로 시작하는 메서드는 파이썬 내장 메서드이므로 무시하도록 한다.)
  • __getattr__ 메서드는 클래스에서 존재하지 않는 어트리뷰트가 호출될 때 호출된다는 점

따라서 Kitchen의 __getattr__ 메서드를 덮어쓰고 존재하지 않는 속성이 Dishwasher 또는 Microwave 속성에 존재하는지 확인해야 한다. 속성이 존재하면 getattr 메서드를 사용하여 적절한 클래스에서 요청된 메서드를 호출할 수 있다. 방법은 다음과 같다.

class Kitchen:
  def __init__(self):
    self.microwave = Microwave()
    self.dishwasher = Dishwasher()
    self.microwave_methods = [f for f in dir(Microwave) if not f.startswith('_')]
    self.dishwasher_methods = [f for f in dir(Dishwasher) if not f.startswith('_')]


  def __getattr__(self, func):
    def method(*args):
      if func in self.microwave_methods:
        return getattr(self.microwave, func)(*args)
      elif func in self.dishwasher_methods:
        return getattr(self.dishwasher, func)(*args)
      else:
        raise AttributeError
    return method

 

순서

  1. Kitchen의 생성자에서는 전자레인지와 식기 세척기 속성을 설정할 뿐만 아니라 해당 클래스 내에서 사용 가능한 메서드 리스트도 설정한다. (밑줄로 시작하는 메서드는 Ptyhon에 내장된 메서드이므로 무시한다).
  2. 그런 다음 그 안에 다른 메서드를 정의하여 Kitchen__getattr__ 메서드를 덮어쓴다.
    (이 메서드는 Kitchen에 존재하지 않는 어트리뷰트가 호출될 때마다 호출되므로 위임을 수행하기에 이상적인 장소이다.)
  3. 호출된 메서드를 func 인수에서 포착한 다음, 해당 메서드가 전자레인지 또는 식기 세척기에서 사용 가능한 메서드 목록에 있는지 확인한다.
  4. 만약 그렇다면, (*args)를 사용하여 해당 함수를 호출한다.
>>> from kitchen import *
>>> kitchen = Kitchen()
>>> kitchen.heat_up_food()
Food is being microwaved
>>> kitchen.wash_dishes()
Dishwasher is starting

위의 코드를 다시 실행할 경우, Kitchen 인스턴스가 만들어지며 heat_up_food를 호출했을 때 heat_up_foodKitchen의 함수나 속성이 아니기 때문에 kitchen의 __getattr__ 메서드가 호출된다.

이 메서드 내부에서 호출된 메서드가 microwave_methods에 존재하는지 확인하고, 실제로 존재하므로 getattr을 사용하여 microwave 인스턴스에서 kitchenheat_up_food를 호출하고 해당 메서드를 반환한다. 따라서 kitchen.heat_up_food()가 우리가 원하는 대로 정확하게 작동한 것처럼 보인다.

참고: AttributeError를 발생시키는 줄은 반드시 필요하다. 이 줄이 없으면 존재하지 않는 메서드/속성 호출은 소리 없이 에러를 발생시킨다. 즉, kitchen.hey_look_at_me()와 같은 함수는 아무 것도 반환하지 않는다.

 

 

  왜 이 방법인가?

기능적으로는 이전과 동일해 보인다. 하지만 위임 패턴의 이전 구현과 달리, 코드의 중복없이 그리고 확장 가능하게 Microwave와 Dishwasher에 메서드를 추가할 수 있으며, 다른 작업 없이도 Kitchen 클래스에서 사용할 수 있다. 예시로 Microwave 클래스를 확장해 보자.

class Microwave:
  def __init__(self):
    pass

  def heat_up_food(self):
    print("Food is being microwaved")

  def timed_heat(self, minutes):
    print(f"Microwaving the food for {minutes} minutes")

이때, Kitchen 클래스에서 이 새로운 timed_heat 클래스를 사용하도록 허용하기 위해 다른 작업을 할 필요가 없다.

>>> from kitchen import *
>>> kitchen = Kitchen()
>>> kitchen.timed_heat(4)
Microwaving the food for 4 minutes

이것이 바로 상속을 실제로 사용하지 않고도 상속을 통해 얻을 수 있는 진정한 코드 재사용의 성과이다. 그러나 주의할 점이 있다.

 

  ⚠️ 주의할 점

이 특정 구현에서 한 가지 주의할 점은 Kitchen을 구성하는 클래스에 같은 이름의 attribute가 없다고 가정한다는 것이다. 예를 들어 Microwave 클래스에 color라는 속성이 있는 경우 Kitchen.color를 호출하면 오류가 반환된다.

또한 이 방식에도 몇 가지 단점이 있는데, 첫 번째는 위임된 메서드는 복합 클래스 내부에 명시적으로 정의되지 않기 때문에 IDE에서 위임된 메서드에 대한 코드 완성이 제공되지 않는다는 점이다. 실제로 정적으로 분석하면 해당 메서드가 존재하지 않는 것처럼 보이므로 위임된 메서드에 대한 호출이 잠재적 오류로 표시될 수도있다.

또한 메타프로그래밍(__getattr__ 메서드를 덮어썼을 때 사용한 것)을 사용하면 스크립트 속도가 느려질 수 있다. 그러나 실행 속도에서 잃는 것은 개발 속도에서 얻을 수 있으며, 모든 소프트웨어 설계 결정에는 trade-off가 있기 마련이다. 종합적으로 고려해 봤을 때 위임 패턴은 객체 지향 프로그래밍에서 코드를 가독성 있고 확장 가능하게 한다는 점에서 유용한 것 같다.