커맨드 패턴(Command Pattern)
어떠한 동작을 요청하는 객체와 그 요청을 처리하는 객체를 분리하는 패턴입니다. 이러한 요청을 큐에 저장하거나 로그로 기록하거나 작업 취소 기능을 사용할 수 있습니다. 이러한 요청을 관리하고 추적할 필요가 있을 때 유용합니다.
📱 리모컨 API 제작을 수주하다
어느 날 당신은 홈 오토메이션이라는 리모 전문 업체로부터 리모컨 API를 제작하는 일을 수주하게 되며, 홈 오토메이션으로부터 전달받은 장비와 주요 요구 사항은 아래와 같습니다.
- 리모컨에 장착된 7개의 각 슬롯에 여러 협력 업체로부터 받은 클래스의 동작을 저장해야 함
- 각 슬롯에는 ON/OFF 버튼이 있고, 작업을 취소할 수 있는 버튼이 있음
- 향후 다른 협력 업체들로부터 공급받게 될 장비도 제어할 수 있어야 하므로, 특정 슬롯이 다른 기능으로 변경되거나 기능을 확장하는 데 문제가 없어야 함
흠.. 그런데 무려 이 최첨단 리모컨은 어느 하나의 기기에 한정되지 않고, 조명을 켜고 끄거나 차고의 문을 열고 닫는 등의 다양한 기능이 동작하도록 만들어야 한다고 하네요.😲
그래서 당신은 홈 오토메이션의 협력 업체들로부터 받은 클래스들을 살펴보게 됩니다.
조명, 차고 문 클래스만 봐도 두 클래스가 서로 ON/OFF 사용되는 메소드의 이름도 일관적이지 않고, 이 두 클래스 말고도 수많은 클래스들이 있다는 걸 알게 됩니다.
단순히 이 클래스들을 리모컨 코드에 적용하면, 슬롯에 들어갈 클래스가 더 추가되거나 수정될 때마다 사용 중인 리모컨들의 코드를 전부 수정해줘야 하는 문제가 생기겠네요..😞
💡 고민해보니 리모컨 코드에선 슬롯에 세팅된 클래스가 뭘 하는지는 모르겠지만 그 클래스가 execute()와 같은 동작을 실행하는 메소드를 가지고 있고, ON/OFF 버튼이 눌렸을 때 그 execute()만 실행하도록 구현하면, 리모컨 코드가 세팅된 클래스에 의존하지 않게 되고, 문제가 해결될 것이라는 아이디어가 떠오릅니다
그런데 검색해보니 이런 상황에선 커맨드 패턴이 사용되고 있다는군요.
그래서 커맨드 패턴에 대해 알아보기 시작합니다..
🍴 식당의 음식 주문 과정 살펴보기
커맨드 패턴은 식당의 주문 과정을 살펴보면 좀 더 쉽게 이해할 수 있습니다.
- 고객: 고객은 메뉴를 보고, 주문할 음식을 주문서에 적고(createOrder()), 이 주문서를 종업원에게 전달합니다(takeOrder())
- 종업원: 종업원은 받은 주문서를 주방에 전달함과 동시에 주문이 들어왔음을 알려줍니다(orderUp())
- 주방장: 주방장은 주문서를 보고 음식을 만들어서 제공합니다(makeBurger(), makeShake())
주문서는 주문 내용을 캡슐화 합니다.
종업원은 이 주문서를 받으면, 주문서에 적힌 것이 뭔지는 몰라도 주방장에게 주문서를 주면서 주문이 들어왔다는 것만 알리면 됩니다.
주방장은 실제로 음식을 조리하는 방법을 알고 있으므로, 주문서에 적힌 음식을 만들면 됩니다.
이번엔 실제 커맨드 패턴 구조를 살펴볼까요?
- Client: Client는 실행할 동작 내용이 담긴 커맨드 객체를 생성하고(createCommandObject()), 이 커맨드 객체를 Invoker 객체에 세팅합니다(setCommand())
- Invoker: Invoker는 이 커맨드 객체의 execute() 메소드를 실행해서 Receiver가 구체적인 처리를 하도록 지시합니다.
- Receiver: Receiver는 커맨드 객체의 execute() 안의 동작을 처리합니다(action1(), action2())
이전에 살펴봤던 음식 주문 과정과 매우 유사하네요.
각 요소들을 연결해볼까요?
고객 | Client |
createOrder() | createCommandObject() |
주문서 | 커맨드 객체 |
takeOrder() | setCommand() |
종업원 | Invoker |
orderUp() | execute() |
makeBurger(), ... | action1(), ... |
🤟 커맨드 객체를 만들고 사용해봅시다
우선 커맨드 인터페이스를 만들고, 이를 구현하는 불을 켜는 객체와 차고 문을 여는 객체를 만들어 볼까요?
public interface Command {
public void execute();
}
class LightOnCommand implements Command {
Light light;
public LightOnCommand(Light light) {
this.light = light;
}
public void execute() {
light.on();
}
}
class GarageDoorOpenCommand implements Command {
GarageDoor garageDoor;
public GarageDoorOpenCommand(GarageDoor garageDoor) {
this.garageDoor = garageDoor;
}
public void execute() {
garageDoor.open();
}
}
- 커맨드 인터페이스에는 execute()라는 메소드 구현이 필수이며, 이 메소드에 실제 처리할 로직을 넣어주면 됩니다.
그리고 이 커맨드 객체를 중개해줄 Invoker인 리모컨 객체를 만들어 줍니다.
public class SimpleRemoteControl {
Command slot;
public SimpleRemoteControl() {}
public void setCommand(Command command) {
slot = command;
}
public void buttonWasPressed() {
slot.execute();
}
}
이제 리모컨을 테스트를 해볼까요?
public class Main {
public static void main(String[] args) {
SimpleRemoteControl remote = new SimpleRemoteControl();
LightOnCommand lightOn = new LightOnCommand(new Light());
GarageDoorOpenCommand garageOpen = new GarageDoorOpenCommand(new GarageDoor());
remote.setCommand(lightOn);
remote.buttonWasPressed();
remote.setCommand(garageOpen);
remote.buttonWasPressed();
}
}
- Client는 커맨드 객체를 생성하고, 이를 setCommand()를 사용해서 Invoker인 리모컨에 커맨드 객체를 세팅합니다.
- 이제 리모컨의 버튼을 누르면, 세팅된 커맨드의 execute()를 실행합니다.
- Receiver인 Light 객체는 불을 켜는 on() 메소드를 실제로 처리합니다.
이제 리모컨은 슬롯에 어떤 커맨드가 세팅되어 있든지, 어떤 일을 하는지 몰라도, 버튼이 눌리면 그냥 execute()만 실행하면 됩니다. 이제 슬롯에 들어갈 클래스들이 추가되거나 수정되어도 리모콘 코드를 변경하지 않아도 되겠군요 🤗
이제 홈 오토메이션이 요구했던, 여러 슬롯을 가진 리모컨 코드를 만들어 볼까요?
public class RemoteControl {
Command[] onCommands;
Command[] offCommands;
public RemoteControl() {
onCommands = new Command[7];
offCommands = new Command[7];
// NoCommand 클래스는 아무것도 하지 않는 클래스이며,
// 모든 배열에 넣어주는 이유는 slot이 비어 있는지 매번 확인하는 것이 번거롭기 때문에 미리 넣었습니다.
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();
}
}
이번엔 조명을 끄고, 차고 문을 닫는 커맨드 객체를 만들어 보죠.
class LightOffCommand implements Command {
Light light;
public LightOffCommand(Light light) {
this.light = light;
}
public void execute() {
light.off();
}
}
class GarageDoorCloseCommand implements Command {
GarageDoor garageDoor;
public GarageDoorCloseCommand(GarageDoor garageDoor) {
this.garageDoor = garageDoor;
}
public void execute() {
garageDoor.close();
}
}
마지막으로 리모컨을 테스트 해봅시다!
public class Main {
public static void main(String[] args) {
RemoteControl remote = new RemoteControl();
LightOnCommand lightOnCommand = new LightOnCommand(new Light());
LightOffCommand lightOffCommand = new LightOffCommand(new Light());
GarageDoorOpenCommand garageOpenCommand = new GarageDoorOpenCommand(new GarageDoor());
GarageDoorCloseCommand garageCloseCommand = new GarageDoorCloseCommand(new GarageDoor());
remote.setCommand(0, lightOnCommand, lightOffCommand);
remote.onButtonWasPushed(0);
remote.offButtonWasPushed(0);
remote.setCommand(1, garageOpenCommand, garageCloseCommand);
remote.onButtonWasPushed(1);
remote.offButtonWasPushed(1);
}
}
그러고 보니 작업을 취소하는 기능을 깜빡했네요..
우선 각 커맨드 객체에 undo() 함수를 추가합니다.
public interface Command {
public void execute();
public void undo(); // 모든 구현체들이 undo를 구현해야 합니다.
}
class LightOnCommand implements Command {
...
public void undo() {
light.off();
}
}
class LightOffCommand implements Command {
...
public void undo() {
light.on();
}
}
class GarageDoorOpenCommand implements Command {
...
public void undo() {
garageDoor.close();
}
}
class GarageDoorCloseCommand implements Command {
...
public void undo() {
garageDoor.open();
}
}
이제 리모컨 코드에 작업 취소 기능을 추가합니다.
public class RemoteControl {
...
Command undoCommand; // 직전 커맨드의 undo 객체만 가지고 있으면 되므로 단일 변수여도 됩니다.
...
public void onButtonWasPushed(int slot) {
onCommands[slot].execute();
undoCommand = onCommands[slot]; // 버튼이 눌릴 때마다 해당 커맨드의 작업 취소 객체를 저장합니다.
}
public void offButtonWasPushed(int slot) {
offCommands[slot].execute();
undoCommand = offCommands[slot]; // off 버튼이 눌렸을 때에도 마찬가지입니다.
}
// 작업을 취소하는 기능을 새로 추가했습니다.
public void undoButtonWasPushed() {
undoCommand.undo();
}
}
이제 작업 취소 버튼을 테스트 해볼까요?
public class Main {
public static void main(String[] args) {
RemoteControl remote = new RemoteControl();
LightOnCommand lightOnCommand = new LightOnCommand(new Light());
LightOffCommand lightOffCommand = new LightOffCommand(new Light());
GarageDoorOpenCommand garageOpenCommand = new GarageDoorOpenCommand(new GarageDoor());
GarageDoorCloseCommand garageCloseCommand = new GarageDoorCloseCommand(new GarageDoor());
remote.setCommand(0, lightOnCommand, lightOffCommand);
remote.onButtonWasPushed(0);
remote.offButtonWasPushed(0);
remote.undoButtonWasPushed(); // 불이 다시 켜집니다!
remote.setCommand(1, garageOpenCommand, garageCloseCommand);
remote.onButtonWasPushed(1);
remote.offButtonWasPushed(1);
remote.undoButtonWasPushed(); // 차고의 문이 다시 열립니다!
}
}
위 예제의 경우엔 Receiver 클래스가 켜고 끄는 등 두 가지 기능만 가지고 있을 때이지만, 켜고 끄는 것 말고도 여러가지 기능을 가지고 있을 때 작업을 취소를 해야 하는 경우가 있습니다.
예를 들어, 선풍기의 세기 단계가 있고, 이전 세기로 돌아가야 하는 경우엔, 이전 세기의 상태를 저장해야 합니다.
협력 업체에서 제공한 선풍기 클래스는 아래와 같이 구현되어 있네요.
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;
int speed; // 현재 선풍기의 세기 정보를 저장합니다.
public CeilingFan() {
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;
}
}
다음으로 커맨드 객체를 만들 때, execute()에서 이전 상태를 저장합니다.
class CeilingFanHighCommand implements Command {
CeilingFan ceilingFan;
int prevSpeed; // 커맨드를 실행하기 전의 세기 정보를 저장합니다.
public CeilingFanHighCommand(CeilingFan ceilingFan) {
this.ceilingFan = ceilingFan;
}
public void execute() {
prevSpeed = ceilingFan.getSpeed(); // 속도를 3으로 바꾸기 전에, 선풍기의 이전 세기를 저장합니다.
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();
}
}
}
// ...Medium, Low Command Classes...
class CeilingFanOffCommand implements Command {
CeilingFan ceilingFan;
int prevSpeed;
public CeilingFanOffCommand(CeilingFan ceilingFan) {
this.ceilingFan = ceilingFan;
}
public void execute() {
prevSpeed = ceilingFan.getSpeed();
ceilingFan.off();
}
public void undo() {
if (prevSpeed == CeilingFan.HIGH) {
ceilingFan.high();
} else if (prevSpeed == CeilingFan.MEDIUM) {
ceilingFan.medium();
} else if (prevSpeed == CeilingFan.LOW) {
ceilingFan.low();
}
}
}
마지막으로 리모컨을 테스트 해보면...
public class Main {
public static void main(String[] args) {
RemoteControl remote = new RemoteControl();
CeilingFan fan = new CeilingFan();
CeilingFanHighCommand ceilingFanHighCommand = new CeilingFanHighCommand(fan);
CeilingFanOffCommand ceilingFanOffCommand = new CeilingFanOffCommand(fan);
remote.setCommand(0, ceilingFanHighCommand, ceilingFanOffCommand);
remote.onButtonWasPushed(0);
remote.offButtonWasPushed(0);
remote.undoButtonWasPushed(); // 선풍기가 다시 3의 속도로 돌아갑니다!
}
}
좀 더 활용한다면, 리모컨 코드에서 커맨드 배열에 저장된 커맨드들을 반복문으로 돌려서 한 번에 실행할 수 있는 기능도 만들 수 있겠네요.
위 예제에선 각 슬롯에 커맨드 객체를 저장해두고 계속 사용했지만, execute()를 호출한 뒤엔 그 커맨드 객체를 버리고 새로운 커맨드 객체를 가져오는 등의 스케줄러, 스레드 풀, 작업 큐와 같은 다양한 작업에 활용할 수 있습니다.
커맨드 패턴(Command Pattern)
어떠한 동작을 요청하는 객체와 그 요청을 처리하는 객체를 분리하는 패턴입니다. 이러한 요청을 큐에 저장하거나 로그로 기록하거나 작업 취소 기능을 사용할 수 있습니다. 이러한 요청을 관리하고 추적할 필요가 있을 때 유용합니다.
'Fundamental > Design Pattern' 카테고리의 다른 글
[디자인 패턴] 8. 템플릿 메소드 패턴 (1) | 2024.10.26 |
---|---|
[디자인 패턴] 7. 어댑터 패턴, 퍼사드 패턴 (1) | 2024.10.04 |
[디자인 패턴] 5. 싱글턴 패턴(Singleton Pattern) (0) | 2024.09.20 |
[디자인 패턴] 4. 팩토리 패턴(Factory Pattern) (0) | 2024.09.10 |
[디자인 패턴] 3. 데코레이터 패턴(Decorator Pattern) (0) | 2024.09.04 |