6. 커맨드 패턴 (Command Pattern)

Kim Dong Kyun·2023년 7월 9일
1
post-thumbnail

이미지 출처

커맨드 패턴?

매서드 호출을 캡슐화한다! Command Pattern!

요청 내역을 객체로 캡슐화해서, 객체를 서로 다른 요청 내역에 따라 매개변수화 할 수 있다. 이로 인해서 요청을 Queue에 저장하거나, 로그로 기록하거나 "요청"에 대한 작업 취소(롤백) 기능을 사용할 수 있다.

어떻게 이렇게 되는가? -> 실행될 기능의 변경에도 호출자 클래스를 수정 없이 그대로 사용 할 수 있도록 해주기 때문

들어가기 전에

의존관계 역전 원칙, (DIP) 는 다음과 같이 정의됩니다.

"상위 모듈은 하위 모듈의 구현 내용에 의존하면 안 된다. 상위 모듈과 하위 모듈 모두 추상화된 내용에 의존해야 한다."


리모콘 만들어주세요!

차세대 홈 오토메이션 장비를 제어할 리모콘을 만들어 달라는 요청을 받았다. 이번에도 거절하기엔 너무 큰 액수를 제안받은 우리, 리모콘을 깔끔 야물딱지게 만들어봅시다!

리모콘이 하는 기능들

  1. Light

조명을 on/off 해야합니다

  1. Thermostat

setTemperature() 매서드를 통해서 온도를 조절할 수 있어야 합니다

  1. Stereo

on, off, setCd, setDvd ... 여러 매서드가 존재합니다.

  1. else

on/off 기반의 녀석들과, 또 다른 매서드를 가지는 여러 녀석들이 존재합니다.

어떻게 시작해야 할까요?

이 행동들이 공통된 추상을 가지면 좋겠습니다. 그런데 문제는, 이 녀석들이 가지고 있는 매서드들이 전부 다 다르다는 것입니다.

  • 더불어서, 추가적으로 새 제품이 추가되면 리모컨에 새 매서드를 추가해야 한다고 합니다.

만약, 이렇게 짠다면?

  • 1번 슬롯에 조명이 연결되면 light.on() 매서드를 호출하고, Thermostat(온도 조절기)가 연결되면 setTemperature() 매서드를 연결하면 되지 않을까요?

이렇게 만들면, 새로운 클래스가 추가될 때마다 리모컨에 있는 코드를 고쳐야 하므로 좋지 않습니다.

DIP에 위배되는군요!


커맨드 패턴을 한번 써봅시다.

커맨드 패턴이 뭘까요?

예시를 들어보겠습니다.

  • 식당에서 음식을 주문하고 있습니다.

  • 다음과 같은 플로우로 진행될 듯 합니다.

  1. 음식 주문 : createOrder()

  2. 종업원이 음식 받기 : takeOrder()

  3. 주문서 : orderUp()

  4. 실제 음식을 만드는 부분 : makeFood()

위와 같이 캡슐화 한다면, 어떤 음식이 들어오던간에 주문서 부분에 호출만 적절히 해주면 클라이언트가 어떤 요청을 하던간에 상고나 없을 듯 합니다.

그렇다면, 이런 캡슐화가 코드단에서는 어떤 모습을 가질까요?


커맨드 패턴의 구조

0. Client

  • 클라이언트는 커맨드 객체를 생성해야 합니다.

  • 이 커맨드 객체는 리시버에 전달할 일련의 행동으로 구성됩니다!

1. Command 인터페이스

public interface Command {
    void execute();
}
  • 이 인터페이스에는 execute 매서드만 존재합니다.

이 매서드는 행동을 "캡슐화" 하며, 리시버에 있는 특정 행동읅 처리합니다.

2. Command 구현체

public class TurnOnCommand implements Command{
    private final Light light;

    public TurnOnCommand(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        light.turnOn();
    }
}
  • 커맨드 객체의 실제 구현체입니다.

  • execute(), receiver의 정보가 같이 들어있습니다.

3. Invoker

public class Invoker {
    private Command command;

    public void setCommand(Command command) { 
    // 생성자에서 Command 를 특정, 이 생성자에는 Receiver 를 특정해야 합니다.
        this.command = command;
    }

    public void executeCommand() {
        command.execute();
    }
}
  • 클라이언트는 인보커 객체의 setCommand() 매소드를 호출합니다.
  • 이 때, 커맨드 객체를 넘겨줍니다.
  • 이 커맨드 객체는 나중에 쓰이기 전까지 인보커 객체에 보관됩니다.

4. Receiver

public class Light {
    public void turnOn() {
        System.out.println("불을 켭니다.");
    }

    public void turnOff() {
        System.out.println("불을 끕니다.");
    }
}
  • 리시버에 있는 행동 매서드가 호출됩니다.

다시 정리!

인보커 로딩 순서?

  1. 클라이언트에서 커맨드 객체 생성

  2. setCommand() 호출해서 인보커에 커맨드 객체를 저장

public void setCommand(Command command) { 
    // 생성자에서 Command 를 특정, 이 생성자에는 Receiver 를 특정해야 합니다.
        this.command = command;
    }
  1. 나중에 클라이언트에서 인보커에게 그 명령을 실행하라고 요청.

테스트!

위와 같이 테스트 가능합니다.


그럼 이제, 기능들을 더 할당해봅시다!

1. Receiver

public class Stereo {
    int volume;
    public void on(){
        System.out.println("스테레오를 켭니다");
    }

    public void setCD(){
        System.out.println("CD를 설정합니다.");
    }
    public void setVolume(int volume){
        this.volume = volume;
        System.out.println("볼륨이 " + volume + "으로 설정되었습니다.");
    }
}
  • 새로운 리시버인 스트레오를 만들었습니다.
  • 스테레오는 위와 같은 기능이 있으면 좋겠군요!

2. Command (구현체)

public class StereoOnWithCDCommand implements Command{
    private final Stereo stereo;

    public StereoOnWithCDCommand(Stereo stereo) {
        this.stereo = stereo;
    }

    @Override
    public void execute() {
        stereo.on();
        stereo.setCD();
        stereo.setVolume(11); // 여기서는 그냥 맞춰줬습니다.
    }
}
  • 스트레오의 온버튼이 눌렸을 대 실행할 녀석을 정의해서, Command의 인터페이스인 execute()에 담아줍니다. 켜면 저녀석들이 켜집니다!
public class StereoOffCommand implements Command{
    private final Stereo stereo;

    public StereoOffCommand(Stereo stereo) {
        this.stereo = stereo;
    }

    public void stereoOff(){
        System.out.println("스테레오를 끕니다.");
    }
    @Override
    public void execute() {
        stereoOff();
    }
}
  • 꺼주는 매서드입니다!

3. Invoker

public class Invoker {
    private Command[] onCommands;
    private Command[] offCommands;

    public Invoker() {
        onCommands = new Command[7]; // 버튼은 7개라고 가정합니다
        offCommands = new Command[7];

        Command noCommand = new NoCommand();
        for (int i = 0; i < 7; i++) {
            // 생성자이므로, 커맨드는 noCommand 로 초기화 합니다. (할당되지 않은 커맨드)
            onCommands[i] = noCommand;
            offCommands[i] = noCommand;
        }
    }

    public Invoker(Command[] onCommands, Command[] offCommands) {
        this.onCommands = onCommands;
        this.offCommands = offCommands;
    }

    public void setCommand(int slot, Command onCommand, Command offCommand){
        onCommands[slot] = onCommand; // 해당 슬롯에
        offCommands[slot] = offCommand; // 온커맨드, 오프커맨드를 할당합니다.
    }

    public void onButtonWasPushed(int slot){
        onCommands[slot].execute();
    }

    public void offButtonWasPushed(int slot){
        offCommands[slot].execute();
    }
}
  • 인보커 클래스입니다.

  • Command[]을 통해서, onCommand 와 offCommand 를 각각 배열로 정의해서 별개의 슬롯에 꽂아줄 생각입니다.

  • 리모컨에 실제 버튼을 할당한다고 생각하면 편합니다.

  • setCommand() 매서드가 변했습니다. 이제 슬롯을 할당해서, command[] 배열의 몇번 칸에 구현체 커맨들를 넣을지 결정 할 수 있습니다.

public Invoker() {
        onCommands = new Command[7]; // 버튼은 7개라고 가정합니다
        offCommands = new Command[7];

        Command noCommand = new NoCommand();
        for (int i = 0; i < 7; i++) {
            // 생성자이므로, 커맨드는 noCommand 로 초기화 합니다. (할당되지 않은 커맨드)
            onCommands[i] = noCommand;
            offCommands[i] = noCommand;
        }
    }
  • 따라서, "아직 할당되지 않은 버튼"의 커맨드를 정의해야 하니, NoCommand 도 정리했습니다.

  • 위 코드 부분, 즉 생성자 부분에서의 초기화를 위해서입니다.

public class NoCommand implements Command{
    @Override
    public void execute() {

    }
}
  • 초기화만을 위한 것이므로, 아무 기능이 없습니다.

4. Client 및 테스트

public class InvokerAsRemoteController {
    public static void main(String[] args) {
        Invoker invoker = new Invoker();

        Light light = new Light();
        LightOnCommand turnLightOn = new LightOnCommand(light);
        LightOffCommand turnLightOff = new LightOffCommand(light);

        Stereo stereo = new Stereo();
        StereoOnWithCDCommand stereoOnWithCD = new StereoOnWithCDCommand(stereo);
        StereoOffCommand stereoOff = new StereoOffCommand(stereo);


        invoker.setCommand(1, turnLightOn, turnLightOff); // 1번 슬롯을 라이트 온오프로 설정
        invoker.setCommand(2, stereoOnWithCD, stereoOff); // 2번 슬롯을 스테레오 온오프로 설정

        invoker.onButtonWasPushed(1);
        invoker.offButtonWasPushed(1);
    }
}

  • 테스트가 잘 되는 모습을 확인 할 수 있습니다.

생각해보니, undo 기능도 추가하고 싶어요!

한번 해보죠!

1. Command 인터페이스 수정하기

public interface Command {
    void execute();
    void undo();
}
  • 위와 같이 undo() 추상 매서드를 선언했습니다.

2. Light(리시버) 에 해당하는 구현체 커맨드들에 오버라이드 해주기

public class LightOnCommand implements Command{
    private final Light light;

    public LightOnCommand(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        light.turnOn();
    }

    @Override
    public void undo() {
        light.turnOff(); // on 의 undo 는 turnOff 이므로!
    }
}
  • on 의 undo() 는 off니까. 해주죠! 반대도 같은 방식으로 합니다.

3. Invoker 수정

바꾼 부분?

public class Invoker {
    ...
    private Command undoCommand;

    public Invoker() {
   		 ...
       
        undoCommand = noCommand; // noCommand 로 초기화 해줍니다.
    }

	...

    public void onButtonWasPushed(int slot){
        onCommands[slot].execute();
        undoCommand = onCommands[slot]; // undoCommand 에 마지막으로 누른 녀석을 할당합니다.
    }

    public void offButtonWasPushed(int slot){
        offCommands[slot].execute();
        undoCommand = offCommands[slot]; // undoCommand 에 마지막으로 누른 녀석을 할당합니다.
    }

    public void undoButtonWasPushed(){
        undoCommand.undo();
    }
}
  • 생성자는 마찬가지로 noCommand 로 초기화합니다 (사용자가 아무것도 하지 않고 언두만 누르면 noCommand가 되게 합니다)

  • 버튼이 눌렸을 때, execute() 매서드를 호출 한 후 그 객체의 레퍼런스를 undoCommand 변수에 할당합니다.

  • 이를 통해서 마지막으로 실행된 기능이 undo에 저장되므로, undoButtonWasPushed() 매서드를 통해서 작업 취소가 가능합니다.

전체 코드

public class Invoker {
    private Command[] onCommands;
    private Command[] offCommands;
    private Command undoCommand;

    public Invoker() {
        onCommands = new Command[7]; // 버튼은 7개라고 가정합니다
        offCommands = new Command[7];

        Command noCommand = new NoCommand();
        for (int i = 0; i < 7; i++) {
            // 생성자이므로, 커맨드는 noCommand 로 초기화 합니다. (할당되지 않은 커맨드)
            onCommands[i] = noCommand;
            offCommands[i] = noCommand;
        }
        undoCommand = noCommand; // noCommand 로 초기화 해줍니다.
    }

    public Invoker(Command[] onCommands, Command[] offCommands) {
        this.onCommands = onCommands;
        this.offCommands = offCommands;
    }

    public void setCommand(int slot, Command onCommand, Command offCommand){
        onCommands[slot] = onCommand; // 해당 슬롯에
        offCommands[slot] = offCommand; // 온커맨드, 오프커맨드를 할당합니다.
    }

    public void onButtonWasPushed(int slot){
        onCommands[slot].execute();
        undoCommand = onCommands[slot]; // undoCommand 에 마지막으로 누른 녀석을 할당합니다.
    }

    public void offButtonWasPushed(int slot){
        offCommands[slot].execute();
        undoCommand = onCommands[slot]; // undoCommand 에 마지막으로 누른 녀석을 할당합니다.
    }

    public void undoButtonWasPushed(){
        undoCommand.undo();
    }
}

테스트!

  • 기능이 잘 작동하는군요

그런데 말입니다

Invoker 말고, Command 구현체에서 상태를 가지는 방법이 더 낫지 않을까요?

당장 해봅시다

1. Receiver

public class Fan { // 선풍기!
    public static final int HIGH = 3;
    public static final int MEDIUM = 2;
    public static final int LOW = 1;
    public static final int OFF = 0;
    int speed;
    public Fan() {
        this.speed = OFF; // 초기에는 꺼진 상태로 시작합시다
    }
    
    public void setHigh(){
        this.speed = HIGH;
    }
    public void setMedium(){
        this.speed = MEDIUM;
    }
    public void setLow(){
        this.speed = LOW;
    }
    public void setOff(){
        this.speed = OFF;
    }
    
    public int getSpeed(){
        return this.speed;
    }
}
  • 선풍기를 하나 만들었습니다.

2. Command 구현체

High

public class FanHighCommand implements Command{
    private Fan fan;
    int prevSpeed; // 상태를 저장해줄 생각입니다.

    public FanHighCommand(Fan fan) {
        this.fan = fan;
    }

    @Override
    public void execute() {
        prevSpeed = fan.getSpeed(); // 팬 객체에 에 이미 저장되어있는, 이전 스피드를 가져옵니다.
        fan.setHigh();
        System.out.println("High 커맨드 선택되었습니다.");
        System.out.println("이전 스피드는 " + prevSpeed + "이며, 속도가 " + fan.getSpeed() + " 로 변경되었습니다.");
    }

    @Override
    public void undo() {
        System.out.println("undo 커맨드 선택되었습니다.");
        if (prevSpeed == Fan.OFF) { // 작업 취소 부분입니다. 이런 느낌으로 쭉쭉 추가 가능합니다.
            fan.setOff();
            System.out.println("이전 스피드는 " + prevSpeed + "이며, 속도가 " + fan.getSpeed() + " 로 변경되었습니다.");
        } else if (prevSpeed == Fan.HIGH){
            fan.setHigh();
        }
    }
}

off

public class FanOffCommand implements Command{
    private final Fan fan;
    private int prevSpeed;

    public FanOffCommand(Fan fan) {
        this.fan = fan;
    }

    @Override
    public void execute() {
        prevSpeed = fan.getSpeed();
        fan.setOff();
        System.out.println("off 커맨드 선택되었습니다.");
        System.out.println("이전 스피드는 " + prevSpeed + "이며, 속도가 " + fan.getSpeed() + " 로 변경되었습니다.");
    }

    @Override
    public void undo() {
        System.out.println("undo 커맨드 선택되었습니다.");
        if (prevSpeed == Fan.HIGH) { // 작업 취소 부분입니다. 이런 느낌으로 쭉쭉 추가 가능합니다.
            fan.setHigh();
            System.out.println("이전 스피드는 " + prevSpeed + "이며, 속도가 " + fan.getSpeed() + " 로 변경되었습니다.");
        } else if (prevSpeed == Fan.MEDIUM){
            fan.setMedium();
            System.out.println("이전 스피드는 " + prevSpeed + "이며, 속도가 " + fan.getSpeed() + " 로 변경되었습니다.");
        }
    }
}

이런 식으로, 커맨드 구현체 자체에 prevSpeed; 라는 상태를 가지는 필드를 선언할 수 있습니다.


모든 코드는 깃허브에 있습니다.


헤드 퍼스트 디자인 패턴 책을 참고했습니다.

0개의 댓글