커맨드 패턴
개요
버튼을 누르는 순간 바로 편집하지 않고, 작업을 큐에 쌓았다가 순서대로 실행하고 싶다고 하자. 메서드 이름뿐 아니라 어떤 대상에 어떤 값을 보낼지도 함께 보관해야 한다.
커맨드(Command)는 요청을 객체로 표현한다. 실행할 대상을 수신자(Receiver), 명령의 Execute()를 부르는 쪽을 호출자(Invoker)라고 하며, 여기서는 편집 버퍼와 명령 큐가 그 역할을 맡는다.
UML
CommandQueue는 ICommand만 보관한다. AppendText는 TextBuffer와 추가할 문자열을 가지고, Execute()에서 버퍼의 Append()를 호출한다.
역사
GoF는 1994년 Design Patterns에서 Command를 행위 패턴으로 정리했다. 요청을 객체로 만들어 대기열이나 실행 이력 같은 구조에서 다룰 수 있게 한다.1
왜 사용하는가?
큐는 명령이 어떤 편집을 하는지 몰라도 실행 순서를 관리한다. 호출부도 작업 객체를 만드는 시점과 실제로 실행하는 시점을 나눌 수 있다.
명령 객체에 담았다고 실행 취소나 중복 방지가 자동으로 생기지는 않는다. 실행 취소에는 되돌릴 상태나 역연산이 필요하고, 외부에 효과를 내는 명령은 재실행 정책도 따로 정해야 한다.
예제
텍스트를 추가하는 요청을 객체 하나로 만들고 실행한다.
using System;
using System.Collections.Generic;
using System.Linq;
var buffer = new TextBuffer();
ICommand command = new AppendText(buffer, "A");
command.Execute();
Console.WriteLine(buffer.Text);
sealed class TextBuffer
{
public string Text { get; private set; } = "";
public void Append(string value) => Text += value;
}
interface ICommand { void Execute(); }
// 나중에 실행할 수 있도록 실제 대상과 인자를 명령 객체에 함께 보관한다.
sealed class AppendText(TextBuffer buffer, string value) : ICommand
{
public void Execute() => buffer.Append(value);
}
// 큐는 편집 내용을 몰라도 공통 execute 약속으로 실행 순서를 관리한다.
sealed class CommandQueue
{
private readonly Queue<ICommand> commands = new();
public void Add(ICommand command) => commands.Enqueue(command);
public void RunAll()
{
while (commands.TryDequeue(out var command)) command.Execute();
}
}
AAppendText가 실행할 버퍼와 문자열을 함께 가지고 있다.
범위 넓힌 예제
명령 두 개를 큐에 넣는다. 넣기만 했을 때는 버퍼가 비어 있고, RunAll() 뒤에 순서대로 반영된다.
using System;
using System.Collections.Generic;
using System.Linq;
var buffer = new TextBuffer();
var queue = new CommandQueue();
queue.Add(new AppendText(buffer, "A"));
queue.Add(new AppendText(buffer, "B"));
Console.WriteLine("before: [" + buffer.Text + "]");
queue.RunAll();
Console.WriteLine("after: [" + buffer.Text + "]");
sealed class TextBuffer
{
public string Text { get; private set; } = "";
public void Append(string value) => Text += value;
}
interface ICommand { void Execute(); }
// 나중에 실행할 수 있도록 실제 대상과 인자를 명령 객체에 함께 보관한다.
sealed class AppendText(TextBuffer buffer, string value) : ICommand
{
public void Execute() => buffer.Append(value);
}
// 큐는 편집 내용을 몰라도 공통 execute 약속으로 실행 순서를 관리한다.
sealed class CommandQueue
{
private readonly Queue<ICommand> commands = new();
public void Add(ICommand command) => commands.Enqueue(command);
public void RunAll()
{
while (commands.TryDequeue(out var command)) command.Execute();
}
}
before: []
after: [AB]이 큐는 메모리 안에서 동기적으로 실행한다. 저장·장애 복구·명령 실패 뒤의 재개는 구현하지 않았으며, 같은 AppendText를 다시 실행하면 문자열도 다시 추가된다.
TypeScript 예제
버퍼는 실제 편집을, AppendText는 대상과 인자의 보관을, 큐는 실행 순서를 맡는다. 이렇게 나누면 요청을 만드는 시점과 편집이 일어나는 시점을 서로 다르게 둘 수 있다.
class TextBuffer {
#text = "";
get text(): string { return this.#text; }
append(value: string): void { this.#text += value; }
}
interface ICommand { execute(): void; }
// 나중에 실행할 수 있도록 실제 대상과 인자를 명령 객체에 함께 보관한다.
class AppendText implements ICommand {
constructor(private readonly buffer: TextBuffer, private readonly value: string) {}
execute(): void { this.buffer.append(this.value); }
}
// 큐는 편집 내용을 몰라도 공통 execute 약속으로 실행 순서를 관리한다.
class CommandQueue {
private readonly commands: ICommand[] = [];
add(command: ICommand): void { this.commands.push(command); }
runAll(): void {
for (;;) {
const command = this.commands.shift();
if (command === undefined) return;
command.execute();
}
}
}
const buffer = new TextBuffer();
const queue = new CommandQueue();
queue.add(new AppendText(buffer, "A"));
queue.add(new AppendText(buffer, "B"));
console.log("before: " + buffer.text);
queue.runAll();
console.log("after: " + buffer.text);before:
after: AB등록만으로 버퍼가 바뀌지는 않는다. 메모리 안의 동기 큐이며 실행 취소나 장애 뒤 재개·중복 실행 방지는 구현하지 않는다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.