Backend/Design Pattern

[CS스터디-헤드퍼스트 디자인 패턴] 2. 커맨드 패턴

Emil :) 2022. 12. 17. 20:06
728x90
반응형

목차

이 글은 Notion에서 작성 후 재편집한 포스트입니다.


서론


저번 시간에 이어서 이번 시간엔 커맨드 패턴에 대해서 공부해보도록 하겠습니다.

 

참고


https://www.coupang.com/vp/products/6403382250?itemId=13700178772&vendorItemId=80951605437&src=1042503&spec=10304982&addtag=400&ctag=6403382250&lptag=10304982I13700178772&itime=20221210115503&pageType=PRODUCT&pageValue=6403382250&wPcid=15730553344490846187221&wRef=&wTime=20221210115503&redirect=landing&gclid=Cj0KCQiA1sucBhDgARIsAFoytUsR3Vik_OevxodcLNtgECFd34jaUngo-H_vE0iSiNhcnK5-8bdglOUaAhjOEALw_wcB&campaignid=18626086777&adgroupid=&isAddedCart= \

 

헤드 퍼스트 디자인 패턴:14가지 GoF 필살 패턴!

COUPANG

www.coupang.com

https://kotlinworld.com/370

 

[Design Pattern] 커맨드 패턴이란 무엇인가?

커맨드 패턴 커멘드 패턴은 하나의 객체를 통해 여러 객체들에 명령(Command)을 해야 할 때 사용되는 패턴이다. 커멘드 패턴을 사용하면 요청을 캡슐화해서 커멘드 객체가 명령을 해야하는 객체들

kotlinworld.com

 

본론


커맨드 패턴의 정의


커맨드 패턴의 사전적 정의는 다음과 같습니다.

커맨드 패턴(Command pattern)이란 요청을 객체의 형태로 캡슐화 하여 사용자가 보낸 요청을 나중에 이용할 수 있도록 매서드 이름, 매개변수 등 요청에 필요한 정보를 저장 또는 로깅, 취소할 수 있게 하는 패턴이다.

쉽게 말해, 하나의 객체를 통해, 여러개의 명령을 해야될 때 사용하는 패턴입니다.
커맨드 패턴을 사용하면 요청을 캡슐화하여 커맨드 객체가 명령을 해야 하는 객체들에 대한 의존성을 느슨하게 만들 수 있다는 장점이 있습니다. 또한 요청을 큐에 저장하거나 로그로 기록하거나, 작업 취소 기능을 사용할 수도 있습니다.

 

만능 IOT 리모컨 예제를 통한 커맨드 패턴 이해하기


오늘도 책에 있는 내용을 각색하여 정리해보겠습니다.

요즘 지어지는 아파트나 주택에선 리모콘 하나로 집안의 각종 가구 및 장비를 제어할 수 있는 환경이 주어집니다.
리모콘 하나로 에어컨을 켜고 끌 수 있거나, 욕조에 물이 받아지게 한다거나, 창문을 열 수 있게 해준다거나 말이죠.
(아래 사진에서 리모콘과 같은 역할이라고 생각하시면 편합니다.)

이미지 출처 : http://m.boannews.com/html/detail.html?idx=73700


우리는 협력업체로부터 홈 IOT 리모콘을 제공받았고, 리모콘을 통해 내 집을 컨트롤해줄 API를 제작해야 하는 업무를 받았다고 가정해봅시다. 리모콘의 모양은 다음과 같습니다.

열심히 그려봤어요

협력업체는 각 장비를 컨트롤 할 수 있는 자바 클래스들도 동봉해서 보내줬습니다.
다음과 같습니다.

public class CeilingLight {
    on()
    off()
    dim()
}

public class OutdoorLight {
    on()
    off()
}

public class TV {
    on()
    off()
    setInputChannel()
    setVolume()
}

...그밖의 수많은 장비 컨트롤 클래스들..

3개 뿐만 아니라, 수십 개의 가구를 컨트롤 할 수 있는 클래스들이 있습니다.
장비가 한두가지가 아니라 너무 많은데.. 이 클래스들과의 접점이나, 공통 인터페이스가 하나도 없습니다.
더 큰 문제는 앞으로도 이런 문제가 추가될 수 있다는겁니다.

 

문제점 파악하기


위 상황을 통해 우리는 다음과 같은 문제점을 도출해낼 수 있습니다.

  • 전달받은 클래스는 너무 많아, 변경사항이 있다면 일일이 하나씩 작업해줘야됨
  • 집에 장비가 추가된다면, 같은 작업을 반복해서 코드를 짜고 재적용시켜야 하는 유지보수 측면에서의 단점

별도의 디자인패턴없이 장비별 클래스에서 어떻게 동작하는지를 전부 구현해준다면, on()이나 off()같은 필수적으로 들어가야하는 기능은 같은 코드인데 또 코드를 작성해야 해서 추가 노동력이 발생하게 됩니다.
이와 같이 전반적으로 생산성 하락과 관련된 문제가 발생합니다.

 

해결방안 모색하기


이와 같은 상황에 어울리는 것이 커맨드 패턴입니다.
마침 우리는 리모콘이라는 하나의 물건(객체)여러가지 가구나 장비(여러 객체)컨트롤해줘야 하는 상황이기 때문입니다.

이 예제에서 리모콘은 사용자가 버튼을 눌렀을 때 어떤 명령을 내렸는지 알 수도, 알 필요도 없게 만들어버립니다.
이렇게 한다면 리모콘과 내부 객체를 완전히 분리시키는게 가능합니다.
아주 간단한 음식 주문 예제로 이를 설명해보도록 하겠습니다. 식당에 가서 음식을 주문하면 보통 다음과 같이 진행됩니다.

  1. 손님이 음식을 주문한다
  2. 종업원이 주문을 받아 주문지에 적는다
  3. 종업원이 주방장에게 주문지를 전달한다.
  4. 주방장은 주문지에 적힌 메뉴를 만든다.

여기서 종업원은 손님이 어떤 메뉴를 말했는지 전혀 알 수도, 알지 못해도 기능 수행엔 전혀 이상이 없습니다.
물론 현실에선 당연히 알 수 밖에 없겠지만.. 컴퓨팅적인 사고로 생각해본다면 그렇다는 뜻입니다.
종업원은 말 그대로 손님의 주문을 받아 주방장에게 전달만하는 역할이기 때문입니다.
그렇다면 여기서 메소드를 이용해 조금 더 이해해보도록 합시다.

  1. 주문서는 orderUp() 이라고 하는 식사 준비에 필요한 행동을 캡슐화한 메소드가 있습니다. 이 안에는 그 식사를 주문해야 하는 주방장(객체)의 레퍼런스도 들어있습니다.
  2. 종업원은 주문서를 받을 때 takeOrder()라는 메소드를 호출하고, 주방장에게 가서 orderUp() 메소드를 호출합니다.
  3. 종업원이 orderUp() 메소드를 호출하면 주방장은 음식을 만들 때 필요한 메소드를 전부 처리합니다.

이 과정에서 알 수 있는 것은 종업원(리모콘)은 이 사람들이 무슨 주문을 했고, 무슨 행동을 해야하는지 전혀 알 필요가 없습니다. 전달만 해준다는게 이번에 공부하는 커맨드 패턴의 핵심입니다.

 

1) 설계 시작


이제 커맨드 패턴의 흐름은 파악하셨으리라 생각합니다.
위에서 설명한 내용들을 개발스러운(?) 용어와 더불어 설계를 해 볼 차례입니다.
기본적으로 커맨드 패턴에는 명령(command), 수신자(receiver), 발동자(invoker), 클라이언트(client)의 네개의 용어가 항상 따르게 됩니다. 일련의 순서는 다음과 같습니다.

클라이언트(client)가
수신자(receiver)에게
명령(command)을 내리는데
발동자(invoker)가 명령에 대한 처리를 수행해준다.

라고 이해하시면 편하실겁니다.
이를 길게 풀어쓰면 다음과 같습니다.

  1. 클라이언트는 createCommantObject() 메소드를 통해 커맨드 객체를 생성합니다. 커맨드 객체는 리시버에 전달할 일련의 행동으로 구성됩니다. 생성된 커맨드 객체에는 행동과 리시버의 정보가 같이 들어있습니다.
  2. 커맨드가 execute() 메소드를 가지고있습니다. 커맨드 객체에서 제공하는 메소드는 execute() 하나뿐입니다. 이 메소드는 행동을 캡슐화하여, 리시버에 있는 특정 행동을 처리합니다.
  3. 클라이언트는 인보커 객체의 setCommand() 메소드를 호출합니다. 이 때, 커맨드 객체를 넙겨줍니다. 이 커맨드 객체는 나중에 쓰이기 전까지 인보커 객체에 보관됩니다.
  4. 인보커에서 커맨드 객체의 execute() 메소드를 호출합니다.
  5. 리시버에 있는 행동 메소드가 호출됩니다. 이 때, 어떤 행동을 할 지는 커맨드 객체에 담겨있습니다.

이제 이를 코드로 구현해보도록 합시다.

 

2) 코드로 구현하기


가장 먼저 할 일은 커맨드 객체를 만드는 것입니다. 커맨드 인터페이스를 구현합니다.

public interface Command {
    public void execute();
}

조명을 켤 때 필요한 커맨드 클래스도 구현해줍니다. 

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

생성자에 커맨드 객체로 제어할 특정 조명의 정보가 전달됩니다. ex) 거실 조명, 욕실 조명...
이 때 light라는 인스턴스 변수에 저장이 되고, execute() 메소드가 호출되면 light 객체가 바로 그 요청의 리시버가 됩니다.
execute() 메소드는 리시버 객체(light)에 있는 on() 메소드를 호출합니다.

우리가 제공받은 리모콘은 on/off 기능만 있는 리모콘입니다. 처음은 간단하게 이런 기능이 가능하도록 해보겠습니다.

pulbic class SimpleRemoteControl {
    // 커맨드를 저장할 슬롯 1개, 이 슬롯으로 1개의 기기를 제어
    Command slot;
    public SimpleRemoteControl() {}
    
    // 슬롯을 가지고 제어할 명령을 설정하는 메소드.
    // 리모콘 버튼의 기능을 바꾸고 싶다면 이 메소드로 얼마든지 변경이 가능
    public void setCommand(Command command) {
    	slot = command;
    }
    
    // 버튼을 누르면 이 메소드가 호출, 지금 슬롯에 연결된 커맨드 객체의 execute() 메소드만 호출하면 됨.
    public void ButtonWasPressed() {
    	slot.execute();
    }
}

위와 같이 SimpleRemoteControl() 이라는 리모콘 컨트롤 클래스를 구현해줬습니다.
리모콘은 setCommand() 메소드를 통해 제어할 명령을 설정해줄 수 있습니다.

이제 간단한 테스트코드를 작성해보도록 합니다.

// 커맨드 패턴의 클라이언트에 해당
public class RemoteControlTest {
    public static void main(String[] args) {
    	// remote 변수가 인보커 역할
    	SimpleRemoteControl remote = new SimpleRemoteControl();
        
        // 요청을 받아서 처리할 리시버인 Light 객체
        Light light = new Light();
        
        // 커맨드 객체를 생성 및 리시버(light) 객체 전달
        LightOnCommand lightOn = new LightOnCommand(light);
        
        // 커멘드 객체를 인보커(리모콘)에게 전달
        remote.setCommand(lightOn);
        
        // 버튼 누르기
        remote.buttonWasPressed();
    }
}

/*
테스트 실행 결과:
RemoteControlTest()
조명이 켜졌습니다.
*/

이렇게 해서 간단한 테스트코드를 작성했습니다.
일련의 과정들을 순서도를 통해 확인해봅시다.

  1. 클라이언트는 ConcreteCommand 를 생성하고 Receiver를 설정합니다.
  2. 인보커에는 명령이 들어있으며, execute() 메소드를 호출함으로써 커맨드 객체엑 특정 작업을 수행해 달라는 요구를 하게 됩니다.
  3. Command에는 모든 커맨드 객체에서 구현해야 하는 인터페이스 입니다.
    모든 명령은 execute() 메소드 호출로 수행되며, 이 메소드를 리시버에 특정 작업을 처리하라는 지시를 전달합니다.
    이 인터페이스를 보면 undo() 메소드도 들어있는데, 이는 잠시 뒤에 설명하겠습니다.
  4. 리시버는 요구 사항을 수행할 때 어떤 일을 처리해야 하는지 알고 있는 객체입니다.
  5. ConcreteCommand는 특정 행동과 리시버를 연결해줍니다. execute() 호출로 요청하면 concreteCommand 객체에서 리시버에 있는 메소드를 호출해서 그 작업을 처리합니다.
  6. execute() 메소드에서는 리시버에 있는 메소드를 호출해서 요청된 작업을 수행합니다.

그런데, 여기서 중요한 점이 있습니다. 커맨드 패턴을 사용했을 때, 작업을 요구한 인보커와 작업을 처리하는 리시버의 분리 방법입니다.
예를들면 Light객체를 쓸 때, 거실에 있는 조명과 부엌에 있는 조명을 어떻게 구분하는지 입니다.

하지만 이는 단순하게 리모콘에 로드할 커맨드 객체를 만들 떄, LightCommand를 거실 조명용과 부엌 조명용으로 하나씩 만들어두면 그만입니다. 버튼은 어떤 조명이 켜질지 신경 쓰지 않아도 되기 때문입니다.

이제 지금까지 작성된 내용을 바탕으로 리모컨 코드를 작성해볼 차례입니다. 조명과 오디오 등 여러가지 장비를 컨트롤하고, 취소하는 기능까지 작성해보도록 합시다.

public class RemoteControl {
    // 이 리모컨 코드는 7개의 ON/OFF 명령을 처리할 수 있으며, 각 명령은 배열에 저장됨
    Command[] onCommands;
    Command[] offCommands;
    
    public RemoteControl() {
    	// 생성자는 각 ON/OFF 배열의 인스턴스를 만들고 초기화하기만 하면됨
    	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;
        }
    }
    
    // setCOmmand() 메소드는 슬롯 번호와 그 슬롯에 저장할 ON/OFF 커맨드 객체를 파라미터로 전달받음
    // 각 커맨드 객체는 나중에 사용하기 편하게 onCommand와 offCommand 배열에 저장
    public void setCommand(int slot, Command onCommand, Command offCommand) {
    	onCommands[slot] = onCommand;
        offCOmmands[slot] = offCommand;
    }
    
    // 사용자가 ON/OFF 버튼을 누르면 리모콘 하드웨어에서 각 버튼에 대응하는 onButtonWasPushed() 나 offuttonWasPushed()를 호출
    public void onButtonWasPushed(int slot) {
    	onCommands[slot].execute();
    }
    
    public void offButtonWasPushed(int slot) {
    	offCOmmands[slot].execute();
    }
    
    // toString을 오버라이드하여 슬롯별 명령을 출력하도록 수정, 리모콘 테스트 시 사용
    public String toString() {
    	StringBuffer stringBuff = new StringBuffer();
        stringBuff.append("\n====== 리모콘 =======\n");
        for (int i = 0; i < onCOmmands.length; i++) {
        	stringBuff.append("[slot " + i + "] " + onCommands[i].getClass().getName() + "  " + offCommands[i].getClass().getName() + "\n");
        }
        
        return stringBuff.toString();
    }
}

 앞에서 LightOnCommand라는 커맨드 클래스를 만들었는데, 조명을 끌 때 사용될 LightOffCommand에도 동일하게 그대로 사용이 가능합니다.

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

이런식으로 다양한 장비별 기능을 만들어주면 됩니다.
이제 UNDO 기능을 만들어봅시다.
정말정말정말 간단합니다. 다음과 같습니다.

public class LightOffCommand implements Command{
    Light light;
    
    public LightOffCommand(Light light){
    	this.light = light;
    }
    
    public void execute(){
    	light.off();
    }
    
    // 불을 켜고 끄는 부분은 그냥 반대 상황으로 만들어버리면 끝.
    public void undo() {
    	light.on();
    }
}

이제 작동이 잘 되는지 테스트코드를 작성해봅시다.

public class RemoteLoader {
    public static void main(String[] args) {
    	RemotControl 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);
        
        // 차고와 오디오도 동일하게 생성
        
        // 준비된 커맨드를 리모콘 슬롯에 커맨드 로드
        remoteControl.setCommand(0, livingRoomLightOn, livingRoomLightOff);
        remoteControl.setCommand(1. kitchenLightOn, kitchenLightOff);
        remoteControl.setCommand(2, ceilingFanON, ceilingFanOff);
        
        System.out.println(remoteControl);
        
        remoteControl.onButtonWasPushed(0);
        remoteControl.offButtonWasPushed(0);
        // .... 슬롯 껐다 켜보기
    
    }
}

이렇게 해서 커맨드 패턴의 학습을 마쳤습니다. 사실 실 상황에서 고려되는 여러가지 변수를 감안한다면 위와 같은 간단한 코드로 끝나지는 않습니다만, 이번 학습을 통해 커맨드 패턴의 흐름은 확실하게 파악되셨으면 좋겠습니다.

 

결론


오늘은 커맨드 패턴에 대해서 알아봤습니다.
다음 포스팅에선 오늘 코드에서 좀 더 구체적인 기능 요건이 추가된다면 어떻게 될지에 대해 포스팅하도록 하겠습니다.

 

구독 및 하트는 정보 포스팅 제작에 큰 힘이됩니다♡

728x90
반응형