Concepts/Design Pattern

행동 패턴 - 명령(Command)

ocwokocw 2021. 6. 6. 11:45

https://refactoring.guru/design-patterns/command

- GoF의 디자인 패턴

 

- 명령(Command)

다른 이름으로 작동(Action), 트랜잭션(Transaction) 이라고도 불리우는 패턴이다. 요청을 단일 객체로 캡슐화 해서 메소드 인자로 만들고, 지연시키거나, 되돌릴 수 있는 연산을 지원한다.


- 문제점

Text 에디터 app 이 있다고 가정해보자. 에디터의 다양한 기능을 수행하는 버튼과 그 버튼을 이용한 툴바를 만드는 작업을 하고 있다. 다양한 dialog들의 버튼과 툴바에서 사용되는 Button 클래스를 만들었다. 이런 버튼들은 다 비슷하게 생겼지만 모두 다른 버튼이다. 이런 버튼들의 클릭 핸들러들을 위한 코드를 어디에 위치시켜야 할까?

 

가장 직관적인 해결책은 버튼이 사용되는곳에 서브클래스를 만드는것이다. 이런 서브클래스들은 버튼을 클릭할 때 수행되야 하는 코드를 포함하고 있을것이다.

 

이렇게 구현하면 문제가 발생한다. 서브클래스들이 너무 많아진다. 물론 기본 Button 클래스를 수정할 때, 이런 서브클래스들의 코드가 망가지지 않으면 괜찮긴하다. 하지만 이렇게 구현하면 GUI 코드가 비즈니스 로직의 코드와 너무 강하게 결합된다.

 

Text를 복사하거나 붙여넣는 기능들은 다양한곳에서 사용된다. 예를 들어 툴바에서 "Copy" 버튼을 누르거나, 컨텍스트 메뉴에 있을 수도 있고, Ctrl+C 같은 단축키에서도 사용된다.

 

초기버전의 App에서 툴바만 있었을때에는 버튼의 서브클래스 방식으로 다양한 기능들을 구현해도 괜찮다. 즉, "CopyButton" 서브 클래스안에 text를 복사하는 기능의 코드가 있어도 괜찮다. 하지만 컨텍스트 메뉴기능이나 단축키등 다른 UI에서 비슷한 기능들이 추가되면 코드가중복된다.


- 해결책

좋은 소프트웨어 디자인은 관심사 분리의 원칙에 기반을 두는데, 이를 적용하면 어플리케이션의 계층이 분리된다. 보통 어플리케이션에서는 GUI와 비즈니스로직을 위한 계층을 분리한다. GUI 계층은 화면에 요소들을 그리는것을 담당하며 계산과 같은 부분은 비즈니스 계층으로 위임한다.

 

GUI 객체는 인자들과 함께 비즈니스 로직 객체의 메소드를 호출한다. 일반적으로 GUI 객체들이 이런 인자들을 직접적으로 넘기지는 않는다. 대신 요청의 세부사항들을(ex - 호출되는 객체, 메소드 이름, 인자 목록들) 1개의 메소드를 갖는 command 클래스로 분리한다.

 

Command 객체들은 GUI와 비즈니스 로직 객체들간의 연결을 제공하며 GUI 객체는 비즈니스 로직 객체가 받아야하는 요청이나 프로세스들을 알 필요가 없다. GUI 객체는 command 일으키기만 하면 되며, command 객체는 모든 세부사항을 다룬다.

 

Command 들은 같은 인터페이스를 구현해야 한다. 대게는 파라미터 없이 단일 수행 메소드를 갖는다. 이런 인터페이스는 command 들의 상세 클래스들과 결합성 없이, 동일한 요청을 보내는 sender 들에게 다양한 command를 제공한다. 또한 sender에게 연결된 command를 변경하면 동적으로 sender의 행동을 변경할 수 있다.

 

위의 말만 보면 파라미터와 관련해 한 가지 의문점이 들 수 있다. GUI 객체가 비즈니스 계층 객체에 파라미터를 제공할 수도 있는데 어떻게 command 수행 메소드는 아무런 파라미터를 가지고 있지 않으면서 요청을 전달할 수 있을까? 이런 데이터를 미리 설정 하거나 자신이 가지고 방식으로 구현할 수 있다.

 

다시 Text 에디터로 돌아가보자. 커맨드 패턴을 적용하고나면 클릭에 대한 행동을 구현하기 위한 버튼 서브클래스들은 더이상 필요하지 않게 된다. 기본 버튼 클래스에 커맨드 객체를 참조할 필드와 클릭 시 수행할 커맨드만 있으면 된다. 모든 기능에 해당하는 커맨드 클래스를 구현하고, 커맨드 클래스를 버튼들과 연결하면 된다.

 

메뉴들이나 단축키, 전체 dialog 와 같이 서로 다른 GUI 요소들도 같은 방법으로 구현될 수 있다. 유저가 GUI 요소와 상호작용할 때, 해당 GUI 요소들을 수행되는 커맨드들과 연결하면 된다. 지금까지 설명으로 짐작했겠지만, 동작이 같은 해당 요소들은 코드 중복을 피하기 위해 같은 커맨드와 연결된다. 그 결과 커맨드들은 비즈니스 로직 계층과 GUI 계층의 결합도를 줄이기 위한 중간 계층이 된다.


- 실생활 예제

손님이 근사한 식당에 가서 창가옆 테이블에 앉았다고 생각해보자. 친절한 웨이터가 다가와서 빠르게 주문을 받아 종이에 적는다. 웨이터는 주방으로 가서 벽면에 오더가 적힌 종이쪽지를 매단다. 잠시 후에 주문이 셰프에게 들어가고, 셰프들은 주문을 읽고 곧바로 음식을 만든다. 쟁반위에 음식과 해당 오더쪽지가 놓인다. 웨이터는 쟁반에 있는 오더쪽지를 보고 주문한바가 맞는지 확인한 후 음식을 가져간다.

 

위의 예제에서 오더가 적힌 쪽지는 일종의 커맨드이다. 오더 쪽지는 셰프가 음식을 차릴 준비가 될 때 까지 계속 사용된다. 오더에는 음식을 요리하기 위해 필요한 정보가 적혀있다. 오더 쪽지 덕분에 셰프는 손님을 직접 시중들지 않고 음식을 곧바로 만들 수 있다.


- 시나리오

이번 예제에서는 커맨드패턴을 이용하여 수행된 동작들을 추적하고 필요시 동작을 되돌리는 텍스트 에디터를 생각해보자. 에디터의 상태를 변경할 수 있는 커맨드(잘라내기 와 붙여넣기)는 해당 명령에 대한 동작을 수행하기 전에 에디터의 상태를 복사해서 백업해둔다. 명령이 수행되고 나면 명령 이력에 들어가게 된다. 추후 만약 유저가 동작을 되돌려야할 일이 생기면, 앱은 이력에서 가장 최근 명령을 꺼내서 에디터 상태의 연관된 백업본을 읽고 복구 시킨다.

 

클라이언트 코드(GUI 요소, 명령 이력)은 커맨드 인터페이스를 통해 명령을 수행하기 때문에 콘크리트 명령 클래스들과 결합되어있진 않다. 이런 방식은 기존 코드를 해치지 않고 앱에 새로운 명령을 추가할 수 있게 해준다.


- 참여자

아래는 커맨드 패턴을 이용한 텍스트 에디터 다이어그램이다. 요소가 많아서 다이어그램이 복잡해보이니 참여자를 살펴보면서 천천히 이해해보자.

 

  • Invoker(Application): 요청을 초기화하며 또 다른 이름으로 Sender 라고도 한다. Invoker는 command 객체를 참조하는 필드를 가지고 있어야 한다. 요청을 Receiver 에게 직접 보내는 대신 커맨드를 일으킨다. Invoker는 커맨드 객체를 생성하는게 아니라 Client로 부터 생성자를 이용해 미리 정의된 Command를 이용한다.
  • Command(Command): 커맨드는 명령을 수행하는 단일 메소드를 정의한 인터페이스이다.
  • ConcreteCommand(CopyCommand, CutCommand, PasteCommand, UndoCommand): ConcreteComamnd 는 다양한 요청을 구현한다. 스스로 행동을 수행하는게 아니라 요청을 비즈니스 로직 객체중 하나에게 전달한다.
  • Receiver(Editor): Receiver 는 비즈니스 로직을 포함한다. 어떤 객체라도 Receiver 로서 동작할 수 있다.
  • Client(Buttons, Shortcuts, CommandHistory ): ConcreteCommand 객체를 생성하고 처리 객체로 정의한다.

- Java 예제

시나리오의 기능이 복잡해서 코드가 길다. 주석으로 설명을 추가하였고, 중간중간 설명도 첨부하였으니 천천히 이해해보자.

 

먼저 Receiver에 해당하는 Editor 클래스부터 살펴보자. Editor 클래스는 에디터의 기능을 수행하는 핵심 클래스이다. 비즈니스 계층에 해당하며 실제로 text를 조작한다. 에디터에서는 block 지정해서 지우거나 하는 행위를 할 수 있지만 우리는 간단한 예제 클래스를 만들것이기 때문에, block을 지정하면 무조건 전체 text가 지정된다고 가정한다. 삭제도 마찬가지로 전체 삭제로 가정한다.

 

public class Editor {

	public String text;
	
	public Editor() {
		super();
		text = "";
	}

	//Drag UI가 없으므로 editor에서 선택은 전체 선택만 할 수 있도록 기능 제한
	public String getSelection() {
		return text;
	}
	
	//Drag UI가 없으므로 삭제는 전체 삭제
	public void deleteSelection() {
		text = "";
	}
	
	public void replaceSelection(String newText) {
		text += newText;
	}
}

 

그 다음은 Command 클래스이다. 우리가 흔히 사용하는 에디터에는 되돌리기 기능이 있다. 되돌리기 기능을 사용하려면 되돌리기전 text를 백업해두고 있어야 한다. 그리고 잘라내기, 복사, 붙여넣기 커맨드가 구현해야 할 execute 메소드 시그니처를 정의하였다.

 

반환형이 boolean 인점에 주목할 필요가 있다. 에디터를 사용할 때 생각해보면 복사나 되돌리기 기능을 다시 되돌리기를 하진 않는다. 모든 Command를 History에 넣는게 아니라 선택적으로 저장을 해야 되돌리기 명령 수행시 적절한 동작을 수행하게 된다. 

 

예를 들어 내가 복사(1)-붙여넣기(2)-붙여넣기(3)-복사(4) 를 수행할 때, 되돌리기 기능을 수행하면 붙여넣기(3) 에서 했던 동작을 되돌려야 한다. 그런데 모든 명령을 history에 넣으면 복사(4)를 수행하는 기능을 되돌리는 로직을 수행하게 되므로 이 구별을 boolean 반환으로 판단한다.

 

public abstract class Command {

	protected Application app;
	
	protected Editor editor;
	
	protected String backup;

	public Command(Application app, Editor editor) {
		super();
		this.app = app;
		this.editor = editor;
	}
	
	//기능 수행 전 현재 text를 저장한다.
	//Copy나 잘라내기 수행 시 되돌리기 기능을 지원하려면 해당 text를 저장해놓아야 한다.
	public void saveBackup() {
		backup = editor.text;
	}
	
	//되돌리기를 수행하면 현재 editor의 text를
	//기능 수행 전의 text로 변경한다.
	public void undo() {
		editor.text = backup;
	}
	
	//각 command 에서 구현해야할 기능
	//반환형 boolean은 해당 기능을 Command 이력을에 넣을지 말지를 결정하는 용도이다.
	//ex - 복사, 되돌리기 Command를 해당 이력에 넣어버리면
	//Undo를 했을 때 복사나, 되돌리기를 다시 수행하기 때문에
	//의도된 대로 동작하지 않는다.
	public abstract boolean execute();
}

 

아래 Class들은 우리가 제공할 기능에 대해 Command 추상 클래스를 상속받아 구현한 클래스들이다. 아주 어렵진 않지만, 어떤 명령시 clipboard에 text를 할당하는지, 그리고 되돌리기 기능 지원을 위해 text를 백업하는지, 그리고 되돌리기 기능을 잘 수행시키기 위해 boolean 반환을 어떤 명령에 true로 반환했는지 천천히 살펴보길 바란다.

 

public class CopyCommand extends Command{

	public CopyCommand(Application app, Editor editor) {
		super(app, editor);
	}

	@Override
	public boolean execute() {
		app.clipboard = editor.getSelection();
		
		return false;
	}
}

public class CutCommand extends Command{

	public CutCommand(Application app, Editor editor) {
		super(app, editor);
	}

	@Override
	public boolean execute() {
		saveBackup();
		app.clipboard = editor.getSelection();
		editor.deleteSelection();
		
		return true;
	}
}

public class PasteCommand extends Command{

	public PasteCommand(Application app, Editor editor) {
		super(app, editor);
	}

	@Override
	public boolean execute() {
		saveBackup();
		editor.replaceSelection(app.clipboard);
		
		return true;
	}
}

public class UndoCommand extends Command{

	public UndoCommand(Application app, Editor editor) {
		super(app, editor);
	}

	@Override
	public boolean execute() {
		app.undo();
		
		return false;
	}
}

 

마지막으로 Application을 살펴보자. 우선 shortcut Map 자료형에서 <String, Command>를 사용하지 않고 함수를 사용한점을 생각해보아야 한다. Command는 수행을 할 때 마다 신규로 생성되어야 한다. 만약 같은 Command를 참조하게 되면 clipboard나 backup text 동작이 원하는대로 수행되지 않게 된다. 이점은 예제 test 코드에서 살펴보겠다.

 

createUI 메소드는, 단축키 기능 정의시 Editor 클래스로 곧바로 조작하지 않고 Command를 연결시키고 있다. 이렇게 해당기능을 곧바로 구현하지 않고 Command를 연결시키면 코드 중복을 피할 수 있다.

 

executeCommand와 undo는 해당 커맨드를 수행하거나 마지막 커맨드 수행을 되돌리는 작업을 한다. 그리고 이를 위해 CommandHistory 클래스를 이용한다.

 

public class Application {

	//Excel의 여러 Sheet가 있는것과 같이 
	//1 프로그램 안에 여러 editor가 있음을 표현하였다.
	//예제에서는 사용하지 않는다.
	public List<Editor> editors;
	
	//복사나 잘라내기같은 기능 수행 시
	//해당 text가 저장될 장소
	public String clipboard;
	
	public CommandHistory history;
	
	public Editor activeEditor;
	
	public Map<String, Consumer<Void>> shortcut;
	
	public Application() {
		super();
		editors = new ArrayList<>();
		clipboard = "";
		history = new CommandHistory();
		activeEditor = new Editor();
		shortcut = new HashMap<>();
	}

	public void createUI() {
		
		shortcut.put("copy", (Void) 
				-> executeCommand(new CopyCommand(this, activeEditor)));
		shortcut.put("paste", (Void) 
				-> executeCommand(new PasteCommand(this, activeEditor)));
		shortcut.put("cut", (Void) 
				-> executeCommand(new CutCommand(this, activeEditor)));
		shortcut.put("undo", (Void) 
				-> executeCommand(new UndoCommand(this, activeEditor)));
	}
	
	public void executeCommand(Command command) {
		if(command.execute()) {
			history.push(command);
		}
	}
	
	public void undo() {
		Command command = history.pop();
		if(command != null) {
			command.undo();
		}
	}
}

 

CommandHistory는 자료구조를 아는 사람이라면 이미 Stack 자료구조를 예상했을 것이다. 되돌리기 기능 수행 시 마지막 작업의 명령을 되돌려야 하기 때문에 history를 Stack 형으로 선언하였다. Stack 자료구조는 인터넷을 참조하길 바란다.

 

public class CommandHistory {

	private Stack<Command> history;
	
	public CommandHistory() {
		super();
		history = new Stack<>();
	}

	public void push(Command c) {
		history.push(c);
	}
	
	public Command pop() {
		return history.pop();
	}
	
}

 

이제 Application test를 해보자. 이 예제에서 Application의 shorcut Map을 <String, Command> 자료형이 아닌 함수형으로 Command를 생성하게 만든 이유를 알 수 있다.

 

맨 마지막 부분에서 붙여넣기를 2번 한 뒤에 undo를 2번 수행하는 로직을 살펴보자. 만약 Command를 수행할 때 마다 생성하지 않고 같은 Command를 참조하게 되면 마지막 undo를 2번 수행해도 abcdefg가 순차 적으로 모두 지워지지 않고 남아 있게 된다. 이는 같은 Command 를 참조하면서 backup text를 참조하기 때문이다. 궁금한 사람은 해보길 바란다.

 

public class DesignPatternTest {

	public static void main(String[] args) {

		Application app = new Application();
		
		app.createUI();
		
		System.out.println("====Editor start====");
		//editor에 abcd를 타이핑한다.
		app.activeEditor.replaceSelection("abcd");
		System.out.println(app.activeEditor.getSelection());
		
		//이어서 efg를 타이핑한다.
		app.activeEditor.replaceSelection("efg");
		System.out.println(app.activeEditor.getSelection());
		
		//복사 기능으로 clipboard에 있는 text를 저장한다.
		Consumer<Void> copyCommand = app.shortcut.get("copy");
		Consumer<Void> pasteCommand = app.shortcut.get("paste");
		Consumer<Void> cutCommand = app.shortcut.get("cut");
		
		copyCommand.accept(null);
		System.out.println(app.activeEditor.getSelection());
		
		//붙여넣기 기능으로 clipboard의 text를 붙여넣기 한다.
		pasteCommand.accept(null);
		System.out.println(app.activeEditor.getSelection());
		
		//되돌리기 기능을 수행한다.
		app.undo();
		System.out.println(app.activeEditor.getSelection());
		
		//잘라내기 기능 수행
		cutCommand.accept(null);
		System.out.println(app.activeEditor.getSelection());

		//붙여넣기 2번 수행
		pasteCommand.accept(null);
		pasteCommand.accept(null);
		System.out.println(app.activeEditor.getSelection());
		
		app.undo();
		System.out.println(app.activeEditor.getSelection());
		app.undo();
		System.out.println(app.activeEditor.getSelection());
		System.out.println("====Editor end====");
	}
}

====Editor start====
abcd
abcdefg
abcdefg
abcdefgabcdefg
abcdefg

abcdefgabcdefg
abcdefg

====Editor end====