헤드 퍼스트 디자인 패턴을 읽고 정리한 글입니다.
💡 요청 내역을 객체로 캡슐화해서 객체를 서로 다른 요청 내역에 따라 매개변수화할 수 있다. 이러면 요청을 큐에 저장하거나 로그로 기록하거나 작업 취소 기능을 사용할 수 있다.커맨드 패턴을 이용하면 요구 사항을 객체로 캡슐화 할 수 있으며, 매개변수를 써서 여러 가지 다른 요구 사항을 집어넣을 수도 있다. 또한 요청 내역을 큐에 저장하거나 로그로 기록할 수도 있으며, 작업 취소 기능도 지원 가능하다.
- 호출 캡슐화
- 한 차원 높은 단계의 캡슐화인 메소드 호출을 캡슐화하는 것을 배워보자
- 메소드 호출을 캡슐화 하면 계산 과정의 각 부분들을 결정화시킬 수 있끼 때문에, 계산하는 코드를 호출한 객체에서는 어떤 식으로 일을 처리해야 하는지에 대해 전혀 신경쓰지 않아도 된다.
- 그 외에도 캡슐화된 메소드 호출을 로그 기록용으로 저장을 한다거나 취소 기능을 구현하기 위해 재사용하는 것과 같은 작업을 할 수 도 있다.
리모컨 API 디자인을 해보자. 해당 리모컨에는 일곱 가지 프로그래밍이 가능한 슬롯과 각 슬롯에 대한 온오프 스위치가 있다. 각 슬롯은 서로 다른 가정용 기기에 연결할 수 있다. 리모컨에는 작업 취소 버튼도 장착되어 있다.
조명, 팬, 욕조, 오디오를 비롯한 각종 홈 오토메이션 장비들을 제어하기 위한 용도로 다양한 업체에서 공급 받은 자바 클래스들을 같이 받았다.
각 슬롯을 한 가지 기기 또는 하나로 엮여 있는 일련의 기기들에 할당할 수 있도록 리모컨을 프로그래밍하기 위한 API를 제작해보자.
제공 받은 클래스들을 살펴보자 리모컨에서 제어해야 하는 객체의 인터페이스에 대한 정보를 얻을 수 있을 것이다.
공통적인 인터페이스가 있는 것 같진 않다. 리모컨에는 on, off 버튼만 있지만, 가전제품 클래스에는 여러 메서드가 존재하고 더 큰 문제는 앞으로 이런 클래스들이 더 추가될 수 있다는 점이다.
따라서 리모컨 버튼을 누르면 자동으로 해야할 일을 처리할 수 있도록 하고 리모컨에서 제품 업체에게 전달받은 클래스에 대해 자세히 알 필요가 없도록 디자인을 진행해야 할 것 같다.
이를 해결하기 위해 어떻게 해야할까??
커맨드 객체
를 추가하여 분리시킬수 있다.커맨드 패턴
이라고 한다.클라이언트
리시버
커맨드
인보커
이제 첫 커맨드 객체를 만들어 보자.
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();
}
}
리시버
가 된다.이제 커맨드 객체를 써서 가정요 기기를 조작하기 위해 버튼이 하나 밖에 없는 리모콘이 있다고 가정하고 코드를 작성해보자.
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();
}
}
인보커
역할을 한다.리시버
인 Light 객체를 생성한다.이제 커맨드 패턴의 정의를 알아보고 더 자세히 살펴보자.
💡 커맨드 패턴을 이용하면 요구 사항을 객체로 캡슐화 할 수 있으며, 매개변수를 써서 여러 가지 다른 요구 사항을 집어넣을 수도 있다. 또한 요청 내역을 큐에 저장하거나 로그로 기록할 수도 있으며, 작업 취소 기능도 지원 가능하다.커맨드는 캡슐화된 요구사항
이다.클라이언트
인보커
리시버
커맨드 인터페이스
구상 커맨드
이제 리모컨의 각 슬롯에 명령을 할당해보자. 이제 리모컨이 인보커가 되는 것이다.
사용자가 버튼을 누르면 그 버튼에 상응하는 커맨드 객체의 execute() 메소드가 호출되고, 그러면 리시버(vendor class)에서 특정 행동을 하는 메소드가 실행될 것이다.
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 객체는 일종의 널 객체이다. 딱히 리턴할 객체는 없지만 클라이언트 쪽에서 null을 처리하지 않아도 되도록 하고 싶을 때 널 객체를 활용하면 좋다. 특정 슬롯을 쓰려고 할 때 마다 거기에 뭔가가 로딩되어 있는지 확인하려면 좀 귀찮기 때문이다.
https://blog.yevgnenll.me/posts/what-is-command-pattern
// 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();
}
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
A) 일반적으로 리시버에 있는 행동을 호출하는 ‘더미’ 커맨드 객체를 만든다. 하지만 요구 사항의 전부는 아니더라도 대부분을 구현하는 ‘스마트’ 커맨드 객체를 만드는 경우도 자주 볼 수 있다. 물론 커맨드 객체에서 대부분의 행동을 처리해도 됩니다. 하지만 그러면 인보커와 리시버를 분리하기 어렵고, 리시버로 커맨드를 매개변수화할 수 없다는 점을 염두하자.
A) 사실 그리 어려운 일은 아니다. 앞에서는 마지막으로 실행한 커맨드의 레퍼런스만 저장했었는데, 그 대신 전에 실행한 커맨드 자체를 스택에 넣으면 됩니다. 그리고 나서 사용자가 undo 버튼을 누를 때마다 인보커에서 스택 맨 위에 있는 항목을 꺼내서 undo() 메소드를 호출하도록 만들면 된다.
A) 그렇게 해도 되지만, 그러면 PartyComman에 파티 모드 코드를 직접 넣어야 하는데, 나중에 문제가 생길 수도 있습니다. MacroCommand를 사용하면 PartyCommand에 넣을 커맨드를 동적으로 결정할 수 있기에 유연성이 훨씬 좋아진다. 일반적으로 MacroCommand 만들어서 쓰는 방법이 더 우아한 방법이며, 추가해야 할 코드를 줄이는데도 도움이 된다.
커맨드로 컴퓨테이션의 한 부분(리시버와 일련의 행동)을 패키지로 묶어서 일급 객체 형태로 전달할 수도 있다. 그러면 클라이언트 애플리케이션에서 커맨드 객체를 생성 한 뒤 오랜 시간이 지나도 그 컴퓨테이션을 호출할 수 있다. 심지어 다른 스레드에서 호출할 수도 있다. 이점을 활용해서 커맨드 패턴을 스케줄러나 스레드 풀, 작업 큐와 같은 다양한 작업에 적용할 수 있다.
작업 큐를 떠올려 보자. 큐 한 쪽 끝은 커맨드를 추가할 수 있도록 되어 있고, 다른 쪽 끝에는 커맨드를 처리하는 스레드들이 대기하고 있다. 각 스레드는 우선 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("해!")
}
}