[디자인 패턴] 6. the Command Pattern

StandingAsh·2024년 10월 19일
3

참고: Head First Design Patterns

개요


아래과 같은 원격 조정 장치를 개발중이다.

  • 왼쪽의 7개의 슬롯에는 각각 원격 조정 할 가전 기기를 등록할 수 있다.
  • 슬롯 오른쪽에는 각각 ON, OFF 기능을 하는 두개의 버튼이 있다. 이 버튼으로 슬롯에 등록된 가전 기기를 켜고 끌 수 있다.
  • 오른쪽 맨 하단에는 UNDO 버튼이 있는데, 마지막 실행된 버튼취소할 수 있다.

이제 슬롯에 넣을 수 있는 가전 기기들의 클래스를 살펴보자. 그런데, 이런! 이들 사이에 공통된 인터페이스없어 보인다. 뿐만 아니라, 미래에 이런 서로 다른 메소드를 가진 클래스들이 더 추가될 수도 있겠다. 이런 경우에는 어떻게 접근해야 할까?

커맨드 패턴


우리는 이미 아래와 같은 구현은 좋지 않다고 배웠다.

if(slot1 == light)
	light.on()
else if(slot1 == hottub)
	hottub.jetsOn();
else if ...

이유야 뻔하다. 유지보수. OCP(Open-Closed Principle) 원칙에 크게 어긋나기 때문이다.

버튼을 누르면, 우리의 원격 조정 장치가 가전 기기에게 행동을 요구할 것이고, 그 행동을 실제로 하는 것은 가전 기기일 것이다. 커맨트 패턴은 이용한다면, 이 둘 사이에 커맨드 객체(Command Object)를 둠으로써 행동의 요청자와 실제 행동을 하는 객체를 분리시킬 수 있다.

  • 백문이 불여일견이니, 코드를 보면서 살펴보자.

커맨드 객체

public interface Command {
	public void execute();
}

커맨드 객체를 위한 인터페이스이다. execute() 라는 하나의 메소드만을 갖는다.
위 인터페이스를 이용해 형광등을 위한 커맨드 객체를 만들어보자.

public class LightOnCommand implements Command {
	Light light;
    
    public LightOnCommand(Light light) {
    	this.light = light;
    }
    
    public void execute() {
    	light.on();
    }
}

light 객체를 주입받아 .on()을 실행하는 간단한 커맨드이다. 이 커맨드가 실제로 어떻게 사용되는지 살펴보자.

public class SimpleRemoteControl {
	Command slot;
    
    public void setCommand(Command command) { slot = command; }
    
    public void buttonWasPresseed() {
    	slot.execute();
    }
}

SimpleRemoteControl은 행동을 할 객체에 대한 정보를 전혀 알지 못한다. 즉, 버튼이 눌렸을 때 .execute() 하는 커맨드가 light의 커맨드인지 아닌지조차 모른다. 위에서 설명한대로 요청자 SimpleRemoteControl과 실제 행동을 하는 light를 성공적으로 분리시켰다.

정의

커맨드 패턴은 아래와 같이 정의한다.

요청 자체를 객체로 캡슐화하는 디자인 패턴

또한, 아래와 같은 구조를 가진다.

  • 커맨드 객체(Command): 모든 요청들을 위한 인터페이스이다.
  • Concrete Command): 커맨드의 구현체이다.
  • 인보커(Invoker): 커맨드를 멤버로 가지며 execute()를 실행하는 객체. 우리의 SimpleRemoteControl이 여기에 해당한다.
  • 리시버(Receiver): 실제로 요청에 따라 행동 할 객체. 우리의 프로그램에서는 light등의 가전 기기가 여기에 해당한다.

이들 간의 관계를 클래스 다이어그램으로 나타내면 위와 같다.

적용

public class RemoteControl {
    Command[] onCommands;
    Command[] offCommands;
 
    public RemoteControl() {
        onCommands = new Command[7];
        offCommands = new Command[7];
 
        Command noCommand = new NoCommand();
        for (int i = 0; i < 7; i++) {
            onCommands[i] = noCommand;
            offCommands[i] = noCommand;
        }
    }
  
    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();
    }
}

위에서 하나의 커맨드를 사용한 SimpleRemoteControl을 살펴보았으니, 이를 발전시켜 커맨드 패턴을 적용한 RemoteControl을 구현해보았다.

  • 코드를 분석해보자.

RemoteControl은 7개의 슬롯을 가지고, 한개의 슬롯은 각각 ON 버튼과 OFF 버튼을 가진다. 따라서, 7개짜리 Command 배열 두개를 선언한다. 전원을 켤 커맨드들을 onCommands에, 전원을 끌 커맨드들을offCommands에 보관할 것이다.

setCommand() 메소드는 슬롯 번호, 켜기 커맨드, 끄기 커맨드를 입력 받아 onCommands, offCommands에 저장해준다.

buttonWasPushed() 메소드를 ON 버튼OFF 버튼에 해당하는 두 개의 메소드로 나누고, 슬롯 번호를 인자로 받아 해당 커맨드의 .execute() 메소드를 호출한다.

  • 잠깐, 다른 부분은 이해하겠는데, 생성자의 NoCommand는 대체 뭐지?

참고: null objects

public RemoteControl() {
    onCommands = new Command[7];
    offCommands = new Command[7];
 
    Command noCommand = new NoCommand();
    for (int i = 0; i < 7; i++) {
        onCommands[i] = noCommand;
        offCommands[i] = noCommand;
    }
}

우리의 생성자는 반복문을 이용하여 모든 커맨드를 NoCommand로 초기화해주고 있다. 먼저 코드를 보자.

public class NoCommand implements Command {
	public void execute() {}
}
  • 이게 뭐야, 아무것도 하지 않는 커맨드 객체잖아!

바로 그 목적으로 만든 클래스이다. 만약에 초기화가 되지 않은 커맨드의 execute() 메소드를 호출하려 한다면, 널 포인터 예외가 발생할 것이다. 이를 방지하기 위해서는 buttonWasPushed() 메소드에 commands[slot] != null임을 검사하는 조건문을 추가해야 할 것이다.

그러나, 생성자 단계에서 이러한 아무것도 안하는 객체로 미리 초기화를 해준다면, 버튼 메소드는 커맨드의 널 여부를 검사할 필요가 없다. 만약 커맨드의 널에 대한 핸들링이 필요하다면, NoCommand 내부에서 처리가 가능하다. 이런 객체를 널 객체(Null Objects)라고 한다

널 객체null에 대한 핸들링 책임을 클라이언트가 갖지 않도록 하고싶을 때 유용하다.

Undo


이제 어느정도 프로그램의 구조는 다 갖춰간다. 남은 것은 UNDO 버튼이다.
우선, 인터페이스에 메소드를 하나 추가해보자.

public interface Command {
	public void execute();
    public void undo();
}

자, 그렇다면 이를 구현한 커맨드 역시 수정해줘야 할 것이다.

public class LightOnCommand implements Command {
    Light light;
 
    public LightOnCommand(Light light) {
        this.light = light;
    }
 
    public void execute() { light.on(); }
    public void undo() { light.off(); }
 }

생각보다 단순하다. light는 불을 켜고 끄는 것이 전부이므로, 불 켜기의 undo()는 당연히 불 끄기겠다.
똑같은 원리로 LightOffCommand도 구현할 수 있겠다. on과 off만 반대로 해주면 된다.

public class LightOffCommand implements Command {
    Light light;
 
    public LightOffCommand(Light light) {
        this.light = light;
    }
 
    public void execute() { light.off(); }
    public void undo() { light.on(); }
 }

마지막으로, RemoteControlUNDO 기능을 넣어보자.

Command[] onCommands;
Command[] offCommands;
Command undoCommand;

마지막으로 누른 커맨드는 곧 UNDO 대상이므로, undoCommand 변수를 만들어서 마지막 커맨드를 저장한다.

  • 당연히 undoCommand를 널 객체로 초기화해주는 문장을 생성자에 추가해줘야 한다.
    undoCommand = noCommand;

다음으로는 ON, OFF 버튼 메소드에 undoCommand를 업데이트하는 문장을 넣어준다.

public void onButtonWasPushed(int slot) {
    onCommands[slot].execute();
    undoCommand = onCommands[slot];
}
 
public void offButtonWasPushed(int slot) {
    offCommands[slot].execute();
    undoCommand = offCommands[slot];
}

UNDO 버튼 메소드는 undoCommand.undo()를 호출해준다.

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

더 나아가..


더 많은 기능을 구현해보며 커맨드 패턴의 응용 연습을 해보자.

상태(State)에 대한 UNDO

불을 켜고 끄는 것에 대한 UNDO는 단순했다. 그런데 만약, 예를 들어 3가지 강도를 가진 실링팬(Ceiling Fan)이라면 어떨까?

public class CeilingFan {
    public static final int HIGH = 3;
    public static final int MEDIUM = 2;
    public static final int LOW = 1;
    public static final int OFF = 0;
    String location
    int speed;
 
    public CeilingFan(String location) {
    	this.location = location;
        speed = OFF;
    }
  
    public void high() { speed = HIGH; } 
    public void medium() { speed = MEDIUM; }
    public void low() { speed = LOW; }
    public void off() { speed = OFF; }
    public int getSpeed() { return speed; }
 }

CeilingFan이 위와 같이 구현되어있다고 생각해보자. HIGH, MEDIUM, LOW의 3가지 강도를 가지며 강도 0을 OFF라고 한다. 이제 CeilingFan의 커맨드를 구현할 차레인데, 총 4가지 단계가 있으므로 4개의 커맨드가 필요할 것이다.

public class CeilingFanHighCommand implements Command {
    CeilingFan ceilingFan;
    int prevSpeed;
  
    public CeilingFanHighCommand(CeilingFan ceilingFan) {
        this.ceilingFan = ceilingFan;
    }
 
    public void execute() {
        prevSpeed = ceilingFan.getSpeed();
        ceilingFan.high();
    }
 
    public void undo() {
        if (prevSpeed == CeilingFan.HIGH)
            ceilingFan.high();
        else if (prevSpeed == CeilingFan.MEDIUM) {
            ceilingFan.medium();
        else if (prevSpeed == CeilingFan.LOW)
            ceilingFan.low();
        else if (prevSpeed == CeilingFan.OFF)
            ceilingFan.off();
    }
}

prevSpeed라는 변수를 만들어, execute하기 전의 강도를 저장한다. undo() 메소드는 이 변수에 저장된 직전 강도대로 CeilingFan의 상태를 바꿔준다.

  • CeilingFanMediumCommand, CeilingFanLowCommandCeilingFanOffCommand는 위 코드에서 딱 한 줄만 수정해주면 된다.

execute() 메소드의

ceilingFan.high();

만 각각 .medium(), .low(), .off()로 바꿔주자.

위와 같이 커맨드를 구현한다면 상태를 갖는 커맨드의 undo()도 다룰 수 있다.

매크로 커맨드

이번에는 한 번의 버튼 클릭으로 여러 커맨드를 execute하도록 구현해보자. 우리의 RemoteControl은 버튼 하나 당 하나의 커맨드만이 매핑되어있는데, 그럼 RemoteControl을 뜯어 고쳐야 되나?

놀랍게도, 여러 커맨드를 실행 시키기 위한 하나의 커맨드를 만들 수 있다. 이를 매크로 커맨드라고 한다.

public class MacroCommand implements Command {
    Command[] commands;
 
    public MacroCommand(Command[] commands) {
        this.commands = commands;
    }
 
    public void execute() {
        for (int i = 0; i < commands.length; i++)
            commands[i].execute();
    }
 }

원리는 무척 단순하다. 커맨드들을 담을 자료구조와, 이들을 실행시킬 반복문을 가진 execute() 메소드면 된다.
생성자를 통해 실행시킬 커맨드들을 주입만 시켜준다면, 우리는 macroCommand.execute() 한 번만으로 원하는 만큼의 커맨드 실행을 얼마든지 할 수 있다.

남은 건 undo() 메소드 뿐이다. 근데 당연하게도, 반복문으로 커맨드들의 .undo()를 호출해주면 그만이다.

public void undo() {
	for (int i = 0; i < commands.length; i++
    	commands[i].undo();
}

참고

undo() 메소드를 응용하면 최근 커맨드 취소 뿐만 아니라 커맨드 히스토리를 관리하며 취소할 수도 있다. 어떻게?
간단하다. Command undoCommand 대신 적절한 자료구조, 이를테면 스택을 이용하여 더 이상 취소할 게 없을 때까지 undo() 할 수 있도록 만들어 줄 수 있다.

정리


지금까지 커맨드 패턴과 여러가지 활용 방법들을 알아보았다. 마지막으로 커맨드 패턴의 몇가지 사용 예시를 살펴보면서 마무리하겠다.

  • 요청 큐(Queuing Requests)
    잡 큐(Job Queue)에 커맨드 객체를 넣어 순서대로 execute 하도록 구현할 수 있겠다.

  • 요청 로그(Logging Requests)
    커맨드 인터페이스에 store(), load() 두 가지 메소드를 추가해서 execute 할 때 마다 디스크에 히스토리를 store한다. 이런 식의 구현은 크래시가 발생했을 때, 디스크에 저장된 커맨드를 다시 load하여 실행할 수 있도록 해준다. 일종의 체크포인트라고 볼 수 있다.

profile
우당탕탕 백엔드 생존기

1개의 댓글

comment-user-thumbnail
2024년 10월 19일

오빠 너무 멋있어용 잘보고 있어용

답글 달기