커맨드 패턴
요청을 객체로 캡슐화하여 사용자가 발생시킨 요청을 기록, 큐에 저장, 실행 취소 등을 가능하게 하는 디자인 패턴.
요청하는 객체와 요청을 수행하는 객체 사이의 결합도를 낮추고, 요청을 객체로 만들어 다양한 요구, 큐 또는 로그 요구에 쉽게 맞출 수 있다.
구조
- Command - 실행될 모든 명령에 대한 인터페이스를 정의한다.
- ConcreteCommand - Command 인터페이스를 구현하며, Receiver 객체에 정의된 연산을 호출한다.
- Receiver -명령이 수행될 때 실제로 연산을 수행하는 객체.ConcreteCommand는 이 Receiver 객체의 특정 연산을 호출하여 요청을 수행한다.
- Invoker -명령을 요청하는 역할을 하는 객체.명령 객체를 저장하고, 명령을 실행하기 위한 메소드를 호출한다
- Client -ConcreteCommand 객체를 생성하고 수신자와 연결한다.
기본 인터페이스
// Command 인터페이스
public interface ICommand
{
void Execute();
}
// ConcreteCommand 클래스
public class ConcreteCommand : ICommand
{
private readonly Receiver _receiver;
public ConcreteCommand(Receiver receiver)
{
_receiver = receiver;
}
public void Execute()
{
_receiver.Action();
}
}
// Receiver 클래스
public class Receiver
{
public void Action1()
{
Console.WriteLine("Receiver: Performing an action.");
}
}
// Invoker 클래스
public class Invoker
{
private ICommand _command;
public void SetCommand(ICommand command)
{
_command = command;
}
public void ExecuteCommand()
{
_command.Execute();
}
}
// 클라이언트 코드
class Client
{
static void Main(string[] args)
{
var receiver = new Receiver();
var command = new ConcreteCommand(receiver);
var invoker = new Invoker();
invoker.SetCommand(command);
invoker.ExecuteCommand();
}
}
왜 사용해야 할까?
커맨드 패턴의 핵심은 모든 기능들의 객체화가 가능하다는 것이다. 객체화된 기능, 즉 커맨드 객체를 필드로 저장하는 객체(Invoker)들은 필요할 때마다 커맨드 객체를 변경할 수 있다. 기능을 추가해야 할 경우 Invoker의 코드 수정이 필요없다. 추가로 ConcreteCommand를 구현하고 Invoker에 추가하면 된다.
아래 하나의 예시를 작성해보았다. 아래는 버튼의 동작 방식을 구현하려고 한다. 기능을 객체화하지 않을 경우 각 기능들을 사용하려면 클라이언트에서 어떤 명령이 들어오느냐에 따라 어떤 동작을 하도록 분기점을 만들어야 하고, 추가 기능을 구현해야 할 경우 기존 ButtonClick 클래스를 수정해야 한다.
class Client
{
static void ButtonClick(string action)
{
if (action == "save")
{
SaveDocument();
}
else if (action == "load")
{
LoadDocument();
}
// 새로운 동작을 추가하려면 여기에 또 다른 else if문을 추가해야 함
// 이는 OCP(Open-Closed Principle)를 위반하며, 코드를 확장하기 어렵게 만듦
}
static void SaveDocument()
{
Console.WriteLine("Document saved.");
}
static void LoadDocument()
{
Console.WriteLine("Document loaded.");
}
static void Main(string[] args)
{
ButtonClick("save");
ButtonClick("load");
// 새로운 기능을 추가하려면, ButtonClick 메소드를 수정해야 함
}
}
커맨드 패턴을 적용하여 위 문제를 해결할 수 있다. 각각의 동작(예: 저장, 로드)을 별도의 커맨드 객체로 캡슐화하고, 이를 호출자와 분리한다.
이를 통해 새로운 동작을 추가할 때 기존 코드를 수정하지 않고도 새로운 커맨드 클래스를 추가하는 방식으로 확장할 수 있다.
// 커맨드 인터페이스
public interface ICommand
{
void Execute();
}
// 구체적인 커맨드: Save
public class SaveCommand : ICommand
{
public void Execute()
{
Console.WriteLine("Document saved.");
}
}
// 구체적인 커맨드: Load
public class LoadCommand : ICommand
{
public void Execute()
{
Console.WriteLine("Document loaded.");
}
}
// 호출자 (Invoker)
public class Button
{
private ICommand _command;
public Button(ICommand command)
{
_command = command;
}
public void Click()
{
_command.Execute();
}
}
class Program
{
static void Main(string[] args)
{
ICommand saveCommand = new SaveCommand();
ICommand loadCommand = new LoadCommand();
Button saveButton = new Button(saveCommand);
Button loadButton = new Button(loadCommand);
saveButton.Click(); // Document saved.
loadButton.Click(); // Document loaded.
// 새로운 기능을 추가하려면 새로운 커맨드 클래스를 작성하고 Button 객체에 연결하기만 하면 됨
}
}
또한 커맨드 패턴은 명령 자체가 객체로 캡슐화되어 있기 때문에 기능의 직렬화에 유리해진다.
커맨드 패턴을 사용하지 않고 직렬화를 수행하는 것은 가능하지만, 이 경우 직렬화해야 할 행동(예: 메서드 호출)과 관련 데이터를 별도로 처리해야 한다.
반면, 커맨드 패턴을 사용하면 명령 자체를 객체로 표현하고, 이 객체는 실행할 작업과 필요한 모든 데이터를 포함한다. 이를 통해 명령의 상태를 저장할 수 있고, 명령을 순차적으로 저장하고 불러와 재생할 수 있다.
직렬화에 수원하다는 건 서버간 통신에도 유리하다는 것이다. 서버간 통신은 데이터를 직렬화하여 전송하고 이를 역직렬화하여 데이터를 읽는 과정이 필요하다. 커맨드 패턴을 통해 작성한 객체는 명령과 그 상태를 저장한 채로 전송할 수 있다.
public class Program
{
public static void Main()
{
Button saveButton = new Button(new saveCommand());
// 커맨드 실행
saveButton.Click();
// 커맨드 직렬화
byte[] serializedButton = CommandSerializer.Serialize(saveButton);
// 커맨드 역직렬화
Button deserializedButton = CommandSerializer.Deserialize(serializedButton);
// 역직렬화된 커맨드 실행. 같은 명령을 실행한다.
deserializedCommand.Execute();
}
}
장점
- 기존 코드를 변경하지 않고 새로운 커맨드(명령)을 만들 수 있다.
- 커맨드 객체를 재사용할 수 있다.
- 수신자의 코드가 변경되어도 호출자의 코드는 변경되지 않는다.
- 커맨드 객체를 직렬화할 수 있기 때문에 로깅, DB 저장, 네트워크 전송 등 다양한 방법으로 활용할 수 있다.
단점
- 코드가 복잡해진다.
커맨드 패턴과 delegate
C#의 delegate는 커맨드 패턴의 역할을 수행할 수 있다.
delegate는 메소드를 참조할 수 있는 타입으로, 이를 사용하면 메소드를 변수처럼 전달하고 저장할 수 있다. 이는 커맨드 패턴의 핵심 개념인 '명령을 객체로 캡슐화한다'와 유사하다.
커맨드 패턴에서는 명령을 실행하는 호출자와 명령을 수행하는 수신자를 분리한다. delegate를 사용하면 명령(메소드)을 delegate 객체에 할당하고, 이 객체를 통해 명령을 실행할 수 있다. 이렇게 하면 실행할 명령을 변경하거나 새로운 명령을 추가하는 것이 유연하게 가능해지며, 이는 커맨드 패턴의 주요 장점 중 하나다.
예를 들어, 버튼 클릭 이벤트에 대한 콜백 함수를 지정할 때 delegate를 사용하면, 버튼 클릭에 대한 다양한 행동을 유연하게 정의하고 변경할 수 있다. 이는 커맨드 패턴의 일종으로 볼 수 있다.
public class Program
{
// 버튼 클릭 이벤트에 대한 델리게이트 선언
public delegate void ButtonClickHandler();
// 버튼 클래스 정의
public class Button
{
// 버튼 클릭 이벤트 핸들러
public ButtonClickHandler OnClick { get; set; }
// 버튼 클릭 시뮬레이션 메서드
public void Click()
{
// 이벤트 핸들러가 설정되어 있으면 실행
OnClick?.Invoke();
}
}
// 메인 프로그램
public static void Main()
{
// 버튼 인스턴스 생성
Button button = new Button();
// 버튼 클릭 이벤트에 대한 다양한 행동 정의
button.OnClick = SayHello;
button.Click(); // "Hello" 출력
}
}
하지만 delegate는 단순히 메소드 참조에 불과하므로, 커맨드 패턴의 모든 특징을 완전히 구현하지는 못한다. 예를 들어, 커맨드의 상태를 저장하거나, 실행 취소(undo) 기능을 구현하기 위해서는 별도의 클래스를 정의해야 한다. 따라서 delegate를 커맨드 패턴의 "가벼운" 형태로 볼 수 있다.
참고자료
'프로그래밍 이론 > 디자인 패턴' 카테고리의 다른 글
16. [Design Pattern] 이터레이터(Iterator) 패턴 (0) | 2024.01.14 |
---|---|
15. [Design Pattern] 인터프리터(interpreter) 패턴 (0) | 2024.01.14 |
13. [Design Pattern] 책임 연쇄(Chain of Responsibility) 패턴 (1) | 2024.01.06 |
12. [Design Pattern] 프록시(Proxy) 패턴 (1) | 2024.01.06 |
11. [Design Pattern] 플라이웨이트(Flyweight) 패턴 (0) | 2023.12.27 |