본문 바로가기

취업과 기본기 튼튼/빽 투더 기본기

[디자인 패턴 16편] 행동 패턴, 커맨드 (Command)

1. 개념

커맨드 패턴은 특정 객체에 대한 커맨드를 객체화하여
이 커맨드 객체를 필요에 따라 처리하는 패턴이다.

가장 주의 깊게 볼 사항은 커맨드 자체를 객체화한다는 점이다.
보통 OOP 에서는 주체 객체와 대상 객체가 존재하고, 대상 객체에 대한 액션은 주체 객체에서의 메쏘드로 처리하는데,
이 액션까지 객체로 만든 뒤에 처리하겠다는 말이다.

처음엔 이렇게만 봐서 이해가 절대 안 간다. 자세한 내용은 차차 설명하겠다.

1.1. 첫 번째 예시

아무래도 커맨드 패턴은 조금 복잡해서 처음에 적절한 예시가 필요한 거 같다.
가장 많이 사용되는 '손님 - 웨이터 - 주방장' 예시를 들어보려고 한다.

다음과 같이 가게에 손님이 음식을 요청하는 상황을 생각해보자.

어떤 식당에는 주방장웨이터가 있다.
이 식당에 어떤 손님이 들어와, 주문서에 스파게티를 작성하고 이를 웨이터에게 전달했다.
웨이터는 이 주문서를 읽고 주방장에게 스파게티 요리를 요청했다.

상황을 단계별로 하나하나 구체적으로 적어보았다.
먼저 상황에 참여하는 주체를 살펴보면 다음과 같다. 이는 모두 객체로 추상화시킬 수 있는 개념들이다.

  • 손님 (Client)
    • 어떤 음식을 요청한다.
  • 웨이터 (Invoker)
    • 음식 요청을 주방장에게 전달한다.
  • 주방장 (Recevier)
    • 요청받은 것을 수행한다.

주체는 아니지만, 객체로 추상화 시킬 수 있는 개념이 하나 더 있다.

  • 주문 (Command)
    • 어떤 주문을 할 것인지에 대한 정보가 있다.

이제 이를 코드로 하나씩 구현해보자.

먼저, 식당에는 주방장과 웨이터가 있다고 했다.
주방장과 웨이터를 추상화하면 다음과 같다.

class Chef:
    """요리에 대한 액션을 하는 주체"""
    def __init__(self, name):
        self.name = name

    def cook_spaghetti(self):
        print(f"{self.name}가 스파게티를 요리합니다.")
        pass

    def cook_pizza(self):
        print(f"{self.name}가 피자를 요리합니다.")
        pass


class Waiter:
    """주문을 받거나 주문을 실행하는 주체"""
    def __init__(self):
        self.orders = []

    def add_order(self, order: Order) -> None:
        """고객의 주문을 주문서에 적는다."""
        self.orders.append(order)

    def execute_orders(self):
        """주문서에 적힌 주문을 실행한다."""
        for order in self.orders:
            order.execute()
        self.orders = []

그다음으로, 주문을 추상화해보자.
구체적인 주문에는 어떤 "셰프" 가 어떤 "음식" 을 해야 하는지 적혀있다고 하자.
다음과 같이 추상 클래스를 정의하고, 구체적인 클래스를 정의할 수 있다.

class Order(metaclass=ABCMeta):
    @abstractmethod
    def execute(self):
        pass


class SpaghettiOrder(Order):
    def __init__(self, chef: Chef) -> None:
        self.chef = chef

    def execute(self):
        self.chef.cook_spaghetti()


class PizzaOrder(Order):
    def __init__(self, chef: Chef) -> None:
        self.chef = chef

    def execute(self):
        self.chef.cook_pizza()

주문서 안에는 execute(...) 라는 주문에 대한 액션도 포함한다.
또 스파게티에 대한 주문인지, 피자에 대한 주문인지 명확하게 분리해놓았다.
각 주문이 실행될 때 실행되는 로직도 조금 다르다.
왜 이렇게 짰는지 의문을 갖지 말고 일단은 이 플로우를 따라가 보자.

이제 손님이 들어와서 요리를 주문하는 상황을 코드로 구현해보자.

# 처음에 가게에 있는 요리사와 웨이터
chef = Chef("흠시")
waiter = Waiter()

# 고객(클라이언트)는 주문서를 작성하고, 웨이터에게 건낸다.
order = SpaghettiOrder(chef=chef)  # 흠시 주방장에게 스파게티를 요청하는 주문서
waiter.add_order(order) 

# 주문을 다 받은 웨이터는 주문을 실행한다.
waiter.execute_orders()

# 이후 더 배가 고파진 고객은 주문서를 또 작성하고, 웨이터에게 건낸다.
order = PizzaOrder(chef=chef)  # 흠시 주방장에게 피자를 요청하는 주문서
waiter.add_order(order)

# 주문을 다 받은 웨이터는 주문을 실행한다.
waiter.execute_orders()

실행 결과는 다음과 같다.

흠시가 스파게티를 요리합니다.
흠시가 피자를 요리합니다.

이제, 각 객체의 역할을 정리하면 다음과 같다.

  • 요청을 하는 사람 (Client) = 손님
  • 그 요청을 받아서 요청을 수행시키는 사람 (Invoker) = 웨이터
  • 요청을 수행하는 사람 (Receiver) = 주방장
  • 요청 그 자체 (Command) = 주문서

이와 같이 역할을 나누어 설계한 패턴을 커맨드 패턴이라고 한다.
특히 요청 그 자체 (Command) 를 주체 객체 마냥 설계해놓았기 때문에, 좀 더 고도화된 캡슐화라고 할 수 있겠다.

1.2. 구조

이번엔 전반적인 구조를 통해 다시 한번 살펴보자.

출처 :  https://1.bp.blogspot.com/

 

  • Client
    • 말 그대로 명령을 내릴 사용자다.
    • 사용자는 Recevier 와 ConcreteCommand 만 알고 있으면 된다.
      • 즉, 누가 무엇을 할지에 대해서만 알고 있으면 된다.
      • 이때 '누가' 는 Recevier, '무엇을 할지' 는 Command 가 된다.
  • Recevier
    • 실제로 Client 의 원하는 요청을 수행하는 객체다.
    • 누가 언제 요청해왔는지 몰라도 된다. 액션에 대해서만 관심(구현)을 가지면 된다.
    • 위 Client 에서 '누가' 에 해당하는 객체다.
  • Invoker
    • Client 의 요청을 받아 실제로 Receiver 의 액션을 호출하는 객체다.
    • 흐름 상 Client 의 요청을 Receiver 에게 전달해준다.
    • Client 는 Invoker 를 거쳐 Receiver 에게 요청하기 때문에,
      이 Invoker 에서 Receiver 에 도달하기 전, 요청에 대한 이런저런 설정들을 할 수가 있다.
  • Command
    • ConcreteCommand 의 추상 클래스
    • execute() 를 공통 인터페이스로 둔다.
  • ConcreteCommand
    • 실제 구체적인 요청 내용을 담는 클래스
    • 누가(Receiver) 가 무엇을 해야하는지(execute() 내 로직) 를 담는다.

1.3. 두 번째 예시

이 패턴을 왜 쓸까?
위에는 간단한 예시라, 이 패턴을 이해하기는 좋았지만 왜 쓰는지에 대한 유용성은 잘 못 느껴졌을 거 같다.
그래서 예시 하나를 더 들어보려고 한다.
goodGid님 블로그를 참고했다.

내 방 책상 위에는 전등과 음악 플레이어가 있다.
전등과 음악 플레이어 각각의 ON / OFF 명령을 하나의 리모콘에 담고 싶다.
1~9 까지의 버튼을 가지는 리모콘에 이러한 기능을 설계해보자

실제로 ON, OFF 를 수행하는 주체는 전등과 음악 플레이어다. 이들은 리시버의 역할을 하게 된다.
이러한 ON, OFF 명령어를 입력받아 전달하는 주체는 리모콘이다. 인보커의 역할을 하게 된다.
각 주체에 대한 ON, OFF 명령은 그 자체로 객체가 된다. 커맨드의 역할을 하게 된다.
이제 이를 코드로 구현해보자.

먼저, 전등과 음악 플레이어를 객체화하면 다음과 같다.

class Light:
    def __init__(self, location: str):
        self.location = location

    def on(self):
        print(f"{self.location} 전등 켜짐")

    def off(self):
        print(f"{self.location} 전등 꺼짐")


class MusicPlayer:
    def __init__(self, location: str):
        self.location = location

    def on(self):
        print(f"{self.location} 음악 플레이어 켜짐")

    def off(self):
        print(f"{self.location} 음악 플레이어 꺼짐")

이번엔 각 리시버들에게 전달되는 명령을 객체화해보자.

class Command(metaclass=ABCMeta):
    @abstractmethod
    def execute(self):
        pass


class LightOnCommand(Command):
    def __init__(self, light: Light):
        self.light = light

    def execute(self):
        self.light.on()

    def undo(self):
        self.music_player.off()


class LightOffCommand(Command):
    def __init__(self, light: Light):
        self.light = light

    def execute(self):
        self.light.off()

    def undo(self):
        self.music_player.off()


class MusicPlayerOnCommand(Command):
    def __init__(self, music_player: MusicPlayer):
        self.music_player = music_player

    def execute(self):
        self.music_player.on()

    def undo(self):
        self.music_player.off()


class MusicPlayerOffCommand(Command):
    def __init__(self, music_player: MusicPlayer):
        self.music_player = music_player

    def execute(self):
        self.music_player.off()

    def undo(self):
        self.music_player.on()

이번에는 execute() 뿐만 아니라, 실행 취소의 기능인 undo() 도 추가하였다.
이제 인보커 역할을 하는 리모콘을 객체화해보자.

class RemoteControl:
    def __init__(self):
        self.buttons = [None] * 10
        self.last_command = None

    def setCommand(self, index: int, command: Command):
        self.buttons[index] = command

    def pressButton(self, index):
        self.buttons[index].execute()
        self.last_command = self.buttons[index]

    def pressUndoButton(self):
        self.last_command.undo()

이제 클라이언트는 다음과 같이 사용할 수 있다.

# 책상 위에 전등과 음악 플레이어가 있다.
light = Light("책상 위")
music_player = MusicPlayer("책상 위")

# 리모콘에 명령어 세팅을 한다.
remote_control = RemoteControl()
remote_control.setCommand(0, LightOnCommand(light))
remote_control.setCommand(1, LightOffCommand(light))
remote_control.setCommand(2, MusicPlayerOnCommand(music_player))
remote_control.setCommand(3, MusicPlayerOffCommand(music_player))

# 사용자가 각 버튼을 순서대로 누른다.
remote_control.pressButton(0) # 책상 위 전등 켜짐
remote_control.pressButton(2) # 책상 위 음악 플레이어 켜짐
remote_control.pressButton(1) # 책상 위 전등 꺼짐
remote_control.pressButton(3) # 책상 위 음악 플레이어 꺼짐

# 마지막 버튼 실행 취소를 한다.
remote_control.pressUndoButton() # 책상 위 음악 플레이어 켜짐

이번 예시를 통해 커맨드 패턴을 좀 더 잘 이해했을 거라고 생각한다.
당장 느껴지는 유용함이라면

  • 명령어를 인보커에 언제든지 등록할 수 있고, 원하는 언제든지 인보커로 간단하게 실행시킬 수 있다는 것이다.
  • 또한, 추후 명령이나 리시버가 더 추가될 경우 기존 코드에 수정 없이, 두 객체를 만들고 setCommand() 로 추가해주면 된다. 즉 OCP 원칙을 잘 지켜낸다.

1.4. 장점

  • 작업을 수행하는 객체(리시버)와 작업을 요청하는 객체를 분리하기 때문에 SRP 원칙을 잘 지켜낸다.
  • 기존 코드 수정 없이 새로운 리시버, 명령어 추가가 가능하기 때문에 OCP 원칙을 잘 지켜낸다.
  • 커맨드 단위의 별도의 액션(undo, redo) 등이 가능하고, 커맨드 상속 및 조합을 통해 더 정교한 커맨드를 구현할 수 있다.

1.5. 단점

  • 전체적으로 이해가 필요하고 복잡한 설계구조를 가진다.

1.6. 활용 상황

  • 커스텀 리모콘의 예시처럼, 커맨드 발생 시점을 사용자가 커스터마이징 해야 하는 경우
  • 여러 커맨드를 조합하여 하나의 커맨드처럼 사용할 필요가 있는 경우
  • 커맨드 실행 취소, 재 실행 등의 기능을 구현해야 하는 경우

2. 구현

클라이언트는 다음과 같이 사용할 수 있다.

recevier = Receiver()
recevier_operation_a = RecevierOperationA(recevier)
recevier_operation_b = RecevierOperationB(recevier)

invoker = Invoker()
invoker.setCommand(recevier_operation_a)
invoker.doCommand()
invoker.setCommand(recevier_operation_b)
invoker.doCommand()

Receiver 는 다음과 같이 구현할 수 있다.

class Recevier:
    def operation_A(self):
        pass

    def operation_B(self):
        pass

Command 는 다음과 같이 구현할 수 있다.

class Command(metaclass=ABCMeta):
    @abstractmethod
    def execute(self):
        pass


class RecevierOperationA(Command):
    def __init__(self, recevier: Recevier):
        self.recevier = recevier

    def execute(self):
        self.recevier.operation_A()


class RecevierOperationB(Command):
    def __init__(self, recevier: Recevier):
        self.recevier = recevier

    def execute(self):
        self.recevier.operation_B()

Invoker 는 다음과 같이 구현할 수 있다.

class Invoker:
    def __init__(self):
        self.commands = []

    def setCommand(self, command:Command):
        self.commands.append(command)

    def doCommand(self, index: int = -1):
        self.commands[index].execute()

3. 참고