커맨드 패턴
헤드 퍼스트 디자인 패턴을 읽고 정리한 글입니다.
커맨드 패턴을 이용하면 요구 사항을 객체로 캡슐화 할 수 있으며, 매개변수를 써서 여러 가지 다른 요구 사항을 집어넣을 수도 있다. 또한 요청 내역을 큐에 저장하거나 로그로 기록할 수도 있으며, 작업 취소 기능도 지원 가능하다.
- 호출 캡슐화
- 한 차원 높은 단계의 캡슐화인 메소드 호출을 캡슐화하는 것을 배워보자
- 메소드 호출을 캡슐화 하면 계산 과정의 각 부분들을 결정화시킬 수 있끼 때문에, 계산하는 코드를 호출한 객체에서는 어떤 식으로 일을 처리해야 하는지에 대해 전혀 신경쓰지 않아도 된다.
- 그 외에도 캡슐화된 메소드 호출을 로그 기록용으로 저장을 한다거나 취소 기능을 구현하기 위해 재사용하는 것과 같은 작업을 할 수 도 있다.
- 요청하는 객체와 요청을 수행하는 객체를 분리하고 싶다면 커맨드 패턴을 사용하자
개요 - 홈 오토메이션 리모콘
리모컨 API 디자인을 해보자. 해당 리모컨에는 일곱 가지 프로그래밍이 가능한 슬롯과 각 슬롯에 대한 온오프 스위치가 있다. 각 슬롯은 서로 다른 가정용 기기에 연결할 수 있다. 리모컨에는 작업 취소 버튼도 장착되어 있다.
조명, 팬, 욕조, 오디오를 비롯한 각종 홈 오토메이션 장비들을 제어하기 위한 용도로 다양한 업체에서 공급 받은 자바 클래스들을 같이 받았다.
각 슬롯을 한 가지 기기 또는 하나로 엮여 있는 일련의 기기들에 할당할 수 있도록 리모컨을 프로그래밍하기 위한 API를 제작해보자.
제공 받은 클래스
제공 받은 클래스들을 살펴보자 리모컨에서 제어해야 하는 객체의 인터페이스에 대한 정보를 얻을 수 있을 것이다.
공통적인 인터페이스가 있는 것 같진 않다. 리모컨에는 on, off 버튼만 있지만, 가전제품 클래스에는 여러 메서드가 존재하고 더 큰 문제는 앞으로 이런 클래스들이 더 추가될 수 있다는 점이다.
따라서 리모컨 버튼을 누르면 자동으로 해야할 일을 처리할 수 있도록 하고 리모컨에서 제품 업체에게 전달받은 클래스에 대해 자세히 알 필요가 없도록 디자인을 진행해야 할 것 같다.
해결책 : 커맨드 패턴
이를 해결하기 위해 어떻게 해야할까??
- 작업을 요청한 쪽과 작업을 처리하는 쪽을 분리시킨다.
- 여기서는 리모컨이 작업을 요청하는 쪽, 업체에서 공급한 클래스의 인스턴스는 작업을 처리하는 쪽
- 이를 위해
커맨드 객체
를 추가하여 분리시킬수 있다.- 커맨드 객체는 특정 객체에 대한 특정 작업 요청을 캡슐화한다.
- 따라서 버튼마다 커맨드 객체를 저장해 두면 사용자가 버튼을 눌렀을 때 커맨드 객체를 통해서 작업을 처리하도록 만든다.
- 리모컨은 작업이 무엇인지 전혀 모르고 작업을 완료하기 위한 객체와 상호작용하는 방법을 알고 있는 커맨드 객체만 존재하므로, 리모컨은 벤더쪽 클래스와 분리된다.
- 이러한 패턴을
커맨드 패턴
이라고 한다.
커맨드 패턴 다이어그램
클라이언트
- 클라이언트는 커맨드 객체를 생성해야 한다.
- 커맨드 객체는 리시버에 전달할 일련의 행동으로 구성된다.
리시버
- 커맨드 객체에는 행동과 리시버에 대한 정보가 같이 들어 있다.
커맨드
- 커맨드 객체에서 제공하는 메소드는 excute() 하나 뿐이다.
- 이 메소드에는 행동을 캡슐화하며, 리시버에 있는 특정 행동을 처리하기 위한 메소드를 호출하기 위한 메서드이다.
인보커
- 클라이언트에서는 인보커 객체의 setCommand() 메소드를 호출하는데, 이 때 커맨드 객체를 넘겨줍니다.
- 그 커맨드 객체는 나중에 쓰으기 전까지 인보커 객체에 보관됩니다.
- 인보커에서 커맨드 객체의 excute() 메소드를 호출하면 리시버에 있는 특정 행동을 하는 메소드가 호출된다.
인보커 로딩
- 클라이언트에서 커맨드 객체 생성
- setCommand()를 호출하여 인보커에 커맨드 객체를 저장
- 나중에 클라이언트에서 인보커한테 그 명령을 실행시켜 달라는 요청을 함
커맨드 패턴과 식당 비유해보기
- 클라이언트 == 손님
- 주문서 == 커맨드 객체
- 주문받기(takeOrder()) == setCommand()
- 웨이트리스 == 인보커 객체
- 주문(orderUp()) == execute()
- 주방장 == 리시버 객체
커맨드 객체
이제 첫 커맨드 객체를 만들어 보자.
Command 인터페이스 만들기
- 커맨드 객체는 모두 같은 인터페이스를 구현해야 한다.
- 해당 인터페이스에는 메소드가 하나 밖에 없다.
- 일반적으로 execute() 이름을 많이 사용한다.
public interface Command {
void execute();
}
이제 전등을 켜기 위한 커맨드 클래스를 구현해보자. 벤더사에서 제공한 클래스를 보니 Light 클래스에는 on(), off() 두 개의 메소드가 있다.
public class LightOnCommand implements Command {
Light light;
public LightOnCommand(Light light) {
this.light = light;
}
@Override
public void execute() {
light.on();
}
}
- 생성자에 이 커맨드 객체로 제어할 특정 전등에 대한 정보가 전달된다.
- 해당 객체는 light라는 인스턴스 변수에 저장이 되며, execute() 메소드가 호출되면 light 객체가 바로 그 요청에 대한
리시버
가 된다. - execute() 메소드에서는 리시버 객체에 있는 on() 메서드를 호출한다.
커맨드 객체 사용하기
이제 커맨드 객체를 써서 가정요 기기를 조작하기 위해 버튼이 하나 밖에 없는 리모콘이 있다고 가정하고 코드를 작성해보자.
public class SimpleRemoteControl { // 인보커
Command slot;
public SimpleRemoteControl() {
}
public void setCommand(Command command) {
slot = command;
}
public void buttonWasPressed() {
slot.execute();
}
}
class SimpleRemoteControlTest { // 1
public static void main(String[] args) {
SimpleRemoteControl remoteControl = new SimpleRemoteControl(); // 2
Light light = new Light(); // 3
LightOnCommand lightOn = new LightOnCommand(light); // 4
remoteControl.setCommand(lightOn); // 5
remoteControl.buttonWasPressed();
}
}
- 1 : 클라이언트에 해당하는 부분(SimpleRemoteControlTest)
- 2 : remoteControl 변수가
인보커
역할을 한다.- 필요한 작업을 요청할 때 사용할 커맨드 객체를 인자로 전달받는다.
- 3 : 요청을 받아서 처리할
리시버
인 Light 객체를 생성한다. - 4 : 커맨드 객체를 생성한다.
- 이 때 리시버를 전달해 준다.
- 5 : 커맨드 객체를 인보커한테 전달해 준다.
커맨드 패턴의 정의
이제 커맨드 패턴의 정의를 알아보고 더 자세히 살펴보자.
- 커맨드 객체는 일련의 행동을 특정 리시버하고 연결시킴으로써 요구 사항을 캡슐화한 것이다.
- 이렇게 하기 위해서 행동과 리시버를 한 객체에 집어넣고 execute()라는 메소드 하나만 외부에 공개하는 방법을 쓴다.
- 이 메소드 호출에 의해서 리시버에서 일련의 작업이 처리된다.
- 외부에서 볼 때는 어떤 객체가 리시버 역할을 하는지, 그 리시버에서 실제로 어떤 일을 하는지 알 수 없다. 그냥 execute() 메소드를 호출하면 요구 사항이 처리된다는 것만 알 수 있을 뿐이다.
- 즉,
커맨드는 캡슐화된 요구사항
이다.- 리시버 : action
- execute() : recevier.action();
- 명령을 통해서 객체를 매개변수화하는 예도 몇 가지 볼 수 있었다.
- 리모컨 입장에서는 특정 인터페이수만 구현이 되어 있다면 그 커맨드 객체에서 실제로 어떤일을 하는지 신경 쓸 필요가 없다.
- 인보커(리모컨에 있는 슬롯 등)에 매개변수를 써서 여러가지 요구사항을 전달할 수도 있다.
- 커맨드 객체들을 써서 큐나 로그를 구현하거나 작업 취소를 할 수 있으며, 메타 커맨드 패턴이라는 것을 이용하여 명령들로 이루어진 매크로를 만들어서 여러 개의 명령을 한 번에 실행할 수도 있다.
커맨드 패턴 클래스 다이어그램
클라이언트
- ConcreteCommand를 생성하고 Receiver를 설정한다.
인보커
- 인보커에는 명령이 들어 있으며, execute() 메소드를 호출함으로써 커맨드 객체에게 특정 작업을 수행해 달라는 요구를 하게 된다.
리시버
- 리시버는 요구 사항을 수항해기 위해 어떤 일을 처리해야 하는지 알고 있는 객체이다.
커맨드 인터페이스
- 모든 커맨드 객체에서 구현해야 하는 인터페이스
- 모든 명령은 execute() 메소드 호출을 통해 수행되며, 이 메소드에서는 리시버에 특정 작업을 처리하라는 지시를 전달한다.
- 이 인터페이스를 보면 undo() 메소드도 들어있는데, 잠시 후에 알아보자
구상 커맨드
- 구상 커맨드는 특정 행동과 리시버 사이를 연결해 준다.
- 인보커에서 execute() 호출을 통해 요청을 하면 구상 커맨드 객체에서 리시버에 있는 메소드를 호출함으로써 그 작업을 처리한다.
- execute() → receiver.action();
슬롯에 명령 할당하기
이제 리모컨의 각 슬롯에 명령을 할당해보자. 이제 리모컨이 인보커가 되는 것이다.
사용자가 버튼을 누르면 그 버튼에 상응하는 커맨드 객체의 execute() 메소드가 호출되고, 그러면 리시버(vendor class)에서 특정 행동을 하는 메소드가 실행될 것이다.
- 각 슬롯마다 커맨드 객체가 할당된다.
- 사용자가 버튼을 누르면 해당 커맨드 객체의 execute() 메소드가 호출된다.
- execute() 메소드에서는 리시버로 하여금 특정 작업을 처리하도록 지시한다.
public class RemoteControl {
private static final int SLOT_SIZE = 7;
Command[] onCommands;
Command[] offCommands;
public RemoteControl() {
offCommands = new Command[SLOT_SIZE];
onCommands = new Command[SLOT_SIZE];
Command noCommand = new NoCommand();
for (int i = 0; i < SLOT_SIZE; 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();
}
public String toString() {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("\n------ Remote Control -------\n");
for (int i = 0; i < onCommands.length; i++) {
stringBuilder
.append("[slot ")
.append(i)
.append("] ")
.append(onCommands[i].getClass().getName())
.append(" ")
.append(offCommands[i].getClass().getName())
.append("\n");
}
return stringBuilder.toString();
}
}
public class RemoteLoader {
public static void main(String[] args) {
RemoteControl remoteControl = new RemoteControl();
Light livingRoomLight = new Light("Living Room");
Light kitchenLight = new Light("Kitchen");
CeilingFan ceilingFan = new CeilingFan("Living Room");
GarageDoor garageDoor = new GarageDoor("Garage");
Stereo stereo = new Stereo("Living Room");
LightOnCommand livingRoomLightOn = new LightOnCommand(livingRoomLight);
LightOffCommand livingRoomLightOff = new LightOffCommand(livingRoomLight);
LightOnCommand kitchenLightOn = new LightOnCommand(kitchenLight);
LightOffCommand kitchenLightOff = new LightOffCommand(kitchenLight);
CeilingFanOnCommand ceilingFanOn = new CeilingFanOnCommand(ceilingFan);
CeilingFanOffCommand ceilingFanOff = new CeilingFanOffCommand(ceilingFan);
GarageDoorUpCommand garageDoorUp = new GarageDoorUpCommand(garageDoor);
GarageDoorDownCommand garageDoorDown = new GarageDoorDownCommand(garageDoor);
StereoOnWithCDCommand stereoOnWithCD = new StereoOnWithCDCommand(stereo);
StereoOffCommand stereoOff = new StereoOffCommand(stereo);
remoteControl.setCommand(0, livingRoomLightOn, livingRoomLightOff);
remoteControl.setCommand(1, kitchenLightOn, kitchenLightOff);
remoteControl.setCommand(2, ceilingFanOn, ceilingFanOff);
remoteControl.setCommand(3, stereoOnWithCD, stereoOff);
System.out.println(remoteControl);
remoteControl.onButtonWasPushed(0);
remoteControl.offButtonWasPushed(0);
remoteControl.onButtonWasPushed(1);
remoteControl.offButtonWasPushed(1);
remoteControl.onButtonWasPushed(2);
remoteControl.offButtonWasPushed(2);
remoteControl.onButtonWasPushed(3);
remoteControl.offButtonWasPushed(3);
}
}
NoCommand 객체
NoCommand 객체는 일종의 널 객체이다. 딱히 리턴할 객체는 없지만 클라이언트 쪽에서 null을 처리하지 않아도 되도록 하고 싶을 때 널 객체를 활용하면 좋다. 특정 슬롯을 쓰려고 할 때 마다 거기에 뭔가가 로딩되어 있는지 확인하려면 좀 귀찮기 때문이다.
완성된 리모컨 api 디자인
https://blog.yevgnenll.me/posts/what-is-command-pattern
- 리모컨 코드를 최대한 단순하게 만들어서 협력 업체가 새로운 벤더 클래스를 공급하더라도 리모컨 코드를 고치지 않도록 하는 것에 중점을 두었다.
- 커맨트 패턴을 도입해서 RemoteControl 클래스와 협력 업체로부터 제공되는 클래스를 논리적으로 분리했다.
- 이러한 디자인은 리모컨의 유지보수 비용을 줄이는데 굉장히 도움이 된다.
- 커맨드 클래스는 람다 표현식을 사용하여 커맨드 객체를 생성하는 단계를 건너뛰고 인스턴스를 생성하는 대신 그 자리에 함수 객체를 사용할 수 있다.
- 물론 추상 메서드가 하나일 때만 가능
// remoteControl.setCommand(0, livingRoomLightOn, livingRoomLightOff);
remoteControl.setCommand(0, () -> livingRoomLight.on(), () -> livingRoomLight.off());
remoteControl.setCommand(1, kitchenLightOn, kitchenLightOff);
remoteControl.setCommand(2, ceilingFanOn, ceilingFanOff);
remoteControl.setCommand(3, stereoOnWithCD, stereoOff);
작업 취소 기능 추가하기
커맨드에서 작업 취소 기능을 지원하려면 execute() 메소드와 비슷한 undo() 메소드가 있어야 한다.
excute() 메소드에서 했던 작업과 정반대의 작업을 처리하면 된다. 커맨드 클래스에 작업 취소 기능을 추가하기 전에 우선 Command 인터페이스에 undo() 메소드를 추가해야 한다.
public interface Command {
void excete();
void undo();
}
- 그리고 RemoteControl 클래스에 사용자가 마지막으로 누른 버튼을 기록하고, UNDO 버튼을 눌렀을 때 필요한 작업을 처리하는 코드를 추가해야 한다.
package command.client;
import command.cmd.Command;
import command.cmd.NoCommand;
public class RemoteControl {
private static final int SLOT_SIZE = 7;
Command[] onCommands;
Command[] offCommands;
Command undoCommand;
public RemoteControl() {
offCommands = new Command[SLOT_SIZE];
onCommands = new Command[SLOT_SIZE];
Command noCommand = new NoCommand();
for (int i = 0; i < SLOT_SIZE; i++) {
onCommands[i] = noCommand;
offCommands[i] = noCommand;
}
// 다른 슬롯과 마찬가지로 사용자가 다른 버튼을 한 번도 누르지 않은 상태에서 undo 버튼을 누르더라도 별 문제가 없도록 한다.
undoCommand = noCommand;
}
public void setCommand(int slot, Command onCommand, Command offCommand) {
onCommands[slot] = onCommand;
offCommands[slot] = offCommand;
}
public void onButtonWasPushed(int slot) {
onCommands[slot].execute();
// 사용자가 버튼을 누르면 해당 커맨드 객체의 execute() 메서드를 호출한 다음
// 그 객체의 레퍼런스를 undoCommand 인스턴스 변수에 저장한다.
// on과 off 버튼을 처리할 때도 같은 방법 사용
undoCommand = onCommands[slot];
}
public void offButtonWasPushed(int slot) {
offCommands[slot].execute();
undoCommand = offCommands[slot];
}
public void undoButtonWasPushed() {
undoCommand.undo();
}
public String toString() {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("\n------ Remote Control -------\n");
for (int i = 0; i < onCommands.length; i++) {
stringBuilder
.append("[slot ")
.append(i)
.append("] ")
.append(onCommands[i].getClass().getSimpleName())
.append(" ")
.append(offCommands[i].getClass().getSimpleName())
.append("\n");
}
return stringBuilder
.append("[undo]")
.append(" ")
.append(undoCommand.getClass().getSimpleName())
.toString();
}
}
public class RemoteLoader {
public static void main(String[] args) {
RemoteControl remoteControl = new RemoteControl();
Light livingRoomLight = new Light("Living Room");
LightOnCommand livingRoomLightOn = new LightOnCommand(livingRoomLight);
LightOffCommand livingRoomLightOff = new LightOffCommand(livingRoomLight);
remoteControl.setCommand(0, livingRoomLightOn, livingRoomLightOff);
remoteControl.onButtonWasPushed(0);
remoteControl.offButtonWasPushed(0);
System.out.println(remoteControl);
remoteControl.undoButtonWasPushed();
remoteControl.offButtonWasPushed(0);
remoteControl.onButtonWasPushed(0);
System.out.println(remoteControl);
remoteControl.undoButtonWasPushed();
}
}
Living Room light is on
Living Room light is off
------ Remote Control -------
[slot 0] LightOnCommand LightOffCommand
[slot 1] LightOnCommand LightOffCommand
[slot 2] CeilingFanOnCommand CeilingFanOffCommand
[slot 3] StereoOnWithCDCommand StereoOffCommand
[slot 4] NoCommand NoCommand
[slot 5] NoCommand NoCommand
[slot 6] NoCommand NoCommand
[undo] LightOffCommand >>>>>> undoCmd에 마지막으로 호출되었던 커맨드 저장
Living Room light is on >>>>>> 사용자가 undo 버튼 클릭
Living Room light is off
Living Room light is on
------ Remote Control -------
[slot 0] LightOnCommand LightOffCommand
[slot 1] LightOnCommand LightOffCommand
[slot 2] CeilingFanOnCommand CeilingFanOffCommand
[slot 3] StereoOnWithCDCommand StereoOffCommand
[slot 4] NoCommand NoCommand
[slot 5] NoCommand NoCommand
[slot 6] NoCommand NoCommand
[undo] LightOnCommand >>>>>> undoCmd에 마지막으로 호출되었던 커맨드 저장
Living Room light is off >>>>>> 사용자가 undo 버튼 클릭
Process finished with exit code 0
작업 취소 기능을 구현할 때 상태를 사용하는 방법
작업 취소 기능을 구현하다 보면 간단한 상태를 저장해야 하는 상황도 종종 생긴다.
CeilingFan 클래스로 간단한 속도와 관련된 상태를 저장해보자.
public class CeilingFan {
String location;
int speed; // 속도를 나타내는 상태를 저장
public static final int HIGH = 3;
public static final int MEDIUM = 2;
public static final int LOW = 1;
public static final int OFF = 0;
public CeilingFan(String location) {
this.location = location;
speed = OFF;
}
public void high() {
// turns the ceiling fan on to high
speed = HIGH;
System.out.println(location + " ceiling fan is on high");
}
public void medium() {
// turns the ceiling fan on to medium
speed = MEDIUM;
System.out.println(location + " ceiling fan is on medium");
}
public void low() {
// turns the ceiling fan on to low
speed = LOW;
System.out.println(location + " ceiling fan is on low");
}
public void off() {
// turns the ceiling fan off
speed = OFF;
System.out.println(location + " ceiling fan is off");
}
public int getSpeed() {
return speed;
}
}
package command.cmd;
import command.vendor.CeilingFan;
public class CeilingFanHighCommand implements Command {
CeilingFan ceilingFan;
int prevSpeed; // 상태 지역 변수로 선풍기의 속도를 저장
public CeilingFanHighCommand(CeilingFan ceilingFan) {
this.ceilingFan = ceilingFan;
}
public void execute() {
// 속도를 변경하기 전에 작업을 취소해야 할 때를 대비해서 이전 속도를 저장
prevSpeed = ceilingFan.getSpeed();
ceilingFan.high();
}
@Override
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();
}
}
}
여러 동작을 한 번에 처리하기
package command.cmd;
public class MacroCommand implements Command {
Command[] commands;
public MacroCommand(Command[] commands) {
this.commands = commands;
}
@Override
public void execute() {
for (int i = 0; i < commands.length; i++) {
commands[i].execute();
}
}
@Override
public void undo() { // 역순으로 undo
for (int i = commands.length - 1; i >= 0; i--) {
commands[i].undo();
}
}
}
public class RemoteLoader {
public static void main(String[] args) {
RemoteControl remoteControl = new RemoteControl();
Light light = new Light("Living Room");
Stereo stereo = new Stereo("Living Room");
LightOnCommand lightOnCommand = new LightOnCommand(light);
LightOffCommand lightOffCommand = new LightOffCommand(light);
StereoOnCommand stereoOnCommand = new StereoOnCommand(stereo);
StereoOffCommand stereoOffCommand = new StereoOffCommand(stereo);
Command[] partyOn = {lightOnCommand, stereoOnCommand};
Command[] partyOff = {lightOffCommand, stereoOffCommand};
MacroCommand partyOnMacro = new MacroCommand(partyOn);
MacroCommand partyOffMacro = new MacroCommand(partyOff);
remoteControl.setCommand(0, partyOnMacro, partyOffMacro);
System.out.println(remoteControl);
System.out.println("---- macro on ------");
remoteControl.onButtonWasPushed(0);
System.out.println("---- macro off ------");
remoteControl.offButtonWasPushed(0);
}
}
------ Remote Control -------
[slot 0] MacroCommand MacroCommand
[slot 1] NoCommand NoCommand
[slot 2] NoCommand NoCommand
[slot 3] NoCommand NoCommand
[slot 4] NoCommand NoCommand
[slot 5] NoCommand NoCommand
[slot 6] NoCommand NoCommand
[undo] NoCommand
---- macro on ------
Living Room light is on
Living Room stereo is on
Living Room stereo is set for CD input
Living Room stereo volume set to 11
---- macro off ------
Living Room light is off
Living Room stereo is off
Process finished with exit code 0
Q) 항상 리시버가 필요할까?? 커맨드 객체에서 execute()를 구현하면 안될까??
A) 일반적으로 리시버에 있는 행동을 호출하는 ‘더미’ 커맨드 객체를 만든다. 하지만 요구 사항의 전부는 아니더라도 대부분을 구현하는 ‘스마트’ 커맨드 객체를 만드는 경우도 자주 볼 수 있다. 물론 커맨드 객체에서 대부분의 행동을 처리해도 됩니다. 하지만 그러면 인보커와 리시버를 분리하기 어렵고, 리시버로 커맨드를 매개변수화할 수 없다는 점을 염두하자.
Q) 작업 취소를 할 때 히스토리 기능은 어떻게 구현할 수 있을까? 즉, undo 버튼을 여러 번 누를 수 있도록 하려면 어떻게 해야 할까??
A) 사실 그리 어려운 일은 아니다. 앞에서는 마지막으로 실행한 커맨드의 레퍼런스만 저장했었는데, 그 대신 전에 실행한 커맨드 자체를 스택에 넣으면 됩니다. 그리고 나서 사용자가 undo 버튼을 누를 때마다 인보커에서 스택 맨 위에 있는 항목을 꺼내서 undo() 메소드를 호출하도록 만들면 된다.
Q) 파티 모드를 구현할 때 PartyCommand의 execute() 메소드에서 다른 커맨드 객체의 excute()를 호출하는 방법을 써도 될까?
A) 그렇게 해도 되지만, 그러면 PartyComman에 파티 모드 코드를 직접 넣어야 하는데, 나중에 문제가 생길 수도 있습니다. MacroCommand를 사용하면 PartyCommand에 넣을 커맨드를 동적으로 결정할 수 있기에 유연성이 훨씬 좋아진다. 일반적으로 MacroCommand 만들어서 쓰는 방법이 더 우아한 방법이며, 추가해야 할 코드를 줄이는데도 도움이 된다.
커맨트 패턴 활용하기
커맨드로 컴퓨테이션의 한 부분(리시버와 일련의 행동)을 패키지로 묶어서 일급 객체 형태로 전달할 수도 있다. 그러면 클라이언트 애플리케이션에서 커맨드 객체를 생성 한 뒤 오랜 시간이 지나도 그 컴퓨테이션을 호출할 수 있다. 심지어 다른 스레드에서 호출할 수도 있다. 이점을 활용해서 커맨드 패턴을 스케줄러나 스레드 풀, 작업 큐와 같은 다양한 작업에 적용할 수 있다.
작업 큐를 떠올려 보자. 큐 한 쪽 끝은 커맨드를 추가할 수 있도록 되어 있고, 다른 쪽 끝에는 커맨드를 처리하는 스레드들이 대기하고 있다. 각 스레드는 우선 execute() 메소드를 호출하고 호출이 완료되면 커맨드 객체를 버리고 새로운 커맨드 객체를 가져옵니다.
작업 큐
- 커맨드 인터페이스를 구현하는 객체를 큐에 추가한다.
컴퓨테이션(?)
- 컴퓨테이션을 고정된 개수의 스레드로 제한할 수 있다.
작업 처리 스레드
- 스레드는 큐에서 커맨드를 하나씩 제겋면서 커맨드의 execute() 메소드를 호출한다.
- 메소드 실행이 끝나면 다시 큐에서 새로운 커맨드 객체를 가져간다.
작업 큐 클래스는 계산 작업을 하는 객체들과 완전히 분리되어 있다. 한 스레드가 한동안 금융 관련 계산을 하다가 잠시 후에는 네트워크로 뭔가를 내려받을 수도 있다. 작업 큐 객체는 전혀 신경쓸 필요가 없다. 큐에 커맨드 패턴을 구현하는 객체를 넣으면 그 객체를 처리하는 스레드가 생기고 자동으로 execute() 메소드가 호출된다.
커맨드 패턴 더 활용하기
어떤 애플리케이션은 모든 행동을 기록해 두었다가 애플리케이션이 다운되었을 때 그 행동을 다시 호출해서 복구할 수 있어야 한다. 커맨드 패턴을 사용하면 store()
와 load()
메소드를 추가해서 이런 기능을 구현할 수 있다. 자바에서는 이런 메소드를 객체 직렬화로 구현할 수도 있지만, 직렬화와 관련된 제약 조건 때문에 쉽지 않다.
로그 기록은 어떤 명령을 실행하면서 디스크에 실행 히스토리를 기록하고, 애플리케이션이 다운되면 커맨드 객체를 다시 로딩해서 execute() 메소드를 자동으로 순서대로 실행하는 방식으로 작동한다.
지금까지 예로 든 리모컨에는 이런 로그 기록이 무의미하다. 하지만 데이터가 변경될 때마다 매번 저장할 수 없는 방대한 자료구조를 다루는 애플리케이션에 로그를 사용해서 마지막 체크 포인트 이후로 진행한 모든 작업을 저장한 다음 시스템이 다운되었을 때 최근 수행된 작업을 다시 적용하는 방법으로 사용할 수 있다.
스프레드시트 애플리케이션을 예를 들어 볼까요? 매번 데이터가 변경될 때마다 디스크에 저장하지 않고, 특정 체크 포인트 이후의 모든 행동을 로그에 기록하는 방식으로 복구 시스템을 구축할 수 있다. 더 복잡한 애플리케이션에는 이런 테크닉을 확장해서 일련의 작업에 트랜잭션을 활용해서 모든 작업이 완변하게 처리되도록 하거나, 아무것도 처리되지 않게 롤백되도록 할 수 있다.
실전 커맨드 패턴
자바의 스윙 라이브러리에는 사용자 인터페이스 구성 요소에서 발생하는 이벤트에 귀를 기울이는 ActionListener 형태의 옵저버가 어마어마하게 많다는 걸 배웠습니다. 그런데 ActionListener 는 Observer 인터페이스이자 Command 인터페이스이기도 하며, AngelListener와 DevilListenr 클래스는 그냥 Observer가 아니라 구상 Command 클래스이다. 즉, 두 패턴이 한꺼번에 들어가 있는 예제이다.
public class SwingObserverEx { // 클라이언트
JButton button = new JButton("할까 말까"); // 인보커
button.addActionListener(new AngelListener());
button.addActionListener(new DevilListener());
}
class AngelListener implements ActionListenr { // ActionListenr 커맨드 인터페이스
public void actionPerformed(ActionEvent event) {
System.out.println("하지마!") // System 리시버
}
}
class DevlilListener implements ActionListenr { // Angel, DevlilListener 구상 커맨드
public void actionPerformed(ActionEvent event) {
System.out.println("해!")
}
}
핵심 정리
- 커맨드 패턴을 사용하면 요청하는 객체와 요청을 수행하는 객체를 분리할 수 있다.
- 이렇게 분리하는 과정의 중심에는 커맨드 객체가 있으며, 이 객체가 행동이 들어있는 리시버를 캡슐화한다.
- 인보커는 무언가 요청할 때 커맨드 객체의 execute() 메소드를 호출하면 된다.
- 커맨드는 인보커를 매개변수화할 수 있다. 실행 중에 동적으로 매개변수화를 설정할 수도 있다.
- execute() 메소드가 마지막으로 호출되기 전의 상태로 되돌리는 작업 취소 메소드를 구현하면 커맨드 패턴으로 작업 취소 기능을 구현할 수도 있다.
- 매크로 커맨드는 커맨드를 확장해서 여러 개의 커맨드를 한 번에 호출할 수 있게 해주는 가장 간편한 방법이다. 매크로 커맨드로도 어렵지 않게 작업 취소 기능을 구현할 수 있다.
- 프로그래밍을 하다 보면 요청을 스스로 처리하는 ‘스마트’ 커맨드 객체를 사용하는 경우도 종종 있다.
- 커맨드 패턴을 활용해서 로그 및 트랜잭션 시스템을 구현할 수 있다.
객체지향 도구 상자
- 객체지향의 기초(4요소)
- 캡슐화
- 상속
- 추상화
- 다형성
- 객체지향 원칙
- 바뀌는 부분을 캡슐화한다.
- 상속보다는 구성을 활용한다.
- 구현이 아닌 인터페이스(super type)에 맞춰서 프로그래밍한다.
- 서로 상호작용을 하는 객체 사이에서는 가능하면 느슨하게 결합하는 디자인을 사용해야 한다.
- 클래스는 확장에 대해서는 열려 있지만 변경에 대해서는 닫혀 있어야 한다. (OCP)
- 추상화된 것에 의존하라. 구상 클래스에 의존하지 않도록 한다.
- 객체지향 패턴
- 스트레지티 패턴 : 알고리즘군을 정의하고 각각의 알고리즘을 정의하고 각각을 캡슐화하여 교환해서 사용할 수 있도록 만든다. 전략을 사용하면 알고리즘을 사용하는 클라이언트와는 독립적으로 알고리즘을 변경할 수 있다.
- 옵저버 패턴 : 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들한테 연락이 가고 자동으로 내용이 갱신되는 방식으로 일대다 의존성을 정의한다.
- 데코레이터 패턴: 객체에 추가 요소를 동적으로 더할 수 있습니다. 데코레이터를 사용하면 서브 클래스를 만드는 경우에 비해 훨씬 유연하게 기능을 확장할 수 있습니다.
- 추상 팩토리 패턴 : 서로 연관된, 또는 의존적인 객체들로 이루어진 제품군을 생성하기 위한 인터페이스를 제공한다. 구상 클래스는 서브 클래스에 의해 만들어진다.
- 팩토리 메소드 패턴 : 객체를 생성하기 위한 인터페이스를 만든다. 어떤 클래스의 인스턴스를 만들지는 서브클래스에서 결정하도록 한다. 팩토리 메소드를 이용하면 인스턴스를 만드는 일을 서브클래스로 미룰 수 있다.
- 싱글턴 패턴 : 클래스 인스턴스가 하나만 만들어지도록 하고, 그 인스턴스에 대한 전역 접근을 제공한다.
- 커맨드 패턴 : 요청 내역을 객체로 캡슐화해서 객체를 서로 다른 요청 내역에 따라 매개변수화할 수 있다. 이러면 요청을 큐에 저장하거나 로그로 기록하거나 작업 취소 기능을 사용할 수 있다.
'design & development' 카테고리의 다른 글
[디자인 패턴] 템플릿 메소드 패턴 (0) | 2024.07.18 |
---|---|
[디자인 패턴] 어댑터, 퍼사드 패턴 (0) | 2024.07.18 |
[디자인 패턴] 싱글턴 패턴 (0) | 2024.07.18 |
[디자인 패턴] 팩토리 패턴 - 추상 팩토리 (0) | 2024.07.18 |
[디자인 패턴] 팩토리 패턴 - 팩토리 메서드 (0) | 2024.07.18 |