목차
요청을 저장하고, 취소하고, 기록하고 싶다
텍스트 에디터에서 글자를 입력한다고 하자. 가장 단순하게 짜면 메서드를 그 자리에서 부른다.
document.insert("안녕", 0); // 0번 위치에 "안녕" 삽입동작은 한다. 그런데 요구가 붙기 시작한다. **되돌리기(Ctrl+Z)**를 넣고 싶다. 방금 무슨 일을 했는지 어딘가에 남아 있어야 되돌릴 수 있는데, 메서드 호출은 부르는 순간 끝나고 사라진다. 무엇을 했는지 남지 않으니 되돌릴 근거가 없다.
기록도 마찬가지다. “어떤 요청이 언제 실행됐는지” 로그로 남기거나, 요청을 큐에 쌓아 나중에 실행하고 싶어도, 호출은 저장할 수 있는 물건이 아니다. 동사는 저장이 안 된다.
커맨드 = 요청을 객체로
커맨드 패턴이 이걸 푼다.
요청(무엇을 하라) 자체를 객체로 캡슐화한다. 그러면 요청을 변수에 담고, 큐에 쌓고, 스택에 넣어 되돌리고, 로그로 남길 수 있다.
“삽입해라”라는 동작을 그 자리에서 실행하는 대신, “삽입 요청”이라는 객체를 만든다. 이제 요청은 저장할 수 있는 물건이 된다. 담아 두었다가 나중에 실행하고, 스택에 넣었다가 되돌리고, 파일에 적어 기록한다. 동사를 명사로 바꾸는 것 - 이게 커맨드의 핵심 발상이다.
Command 인터페이스
모든 요청이 따를 규격을 세운다.
public interface Command {
void execute(); // 실행한다
void undo(); // 되돌린다 (선택)
}execute는 “이 요청을 실행하라”, undo는 “방금 한 걸 되돌려라”다. 되돌리기가 필요 없는 요청이라면 undo는 없어도 된다. 중요한 건 모든 요청이 같은 모양(execute)을 갖는다는 점이다. 무슨 요청이든 겉모습이 똑같으니, 그걸 다루는 쪽은 내용물을 몰라도 실행할 수 있다.
Receiver에 위임한다
커맨드 구현은 스스로 일하지 않는다. **실제 동작을 아는 객체(Receiver)**에 위임한다. 여기선 Document가 Receiver다.
public class InsertTextCommand implements Command {
private final Document document; // Receiver: 실제로 일하는 쪽
private final String text;
private final int position;
public InsertTextCommand(Document document, String text, int position) {
this.document = document;
this.text = text;
this.position = position;
}
public void execute() {
document.insert(text, position); // 실제 삽입은 Document가
}
public void undo() {
document.delete(position, text.length()); // 삽입을 되돌리면 삭제
}
}InsertTextCommand는 무엇을·어디에 넣을지(text, position)를 자기 안에 품고 있다. 그래서 만들어 둔 뒤 나중에 실행해도 그때의 요청 그대로 재현된다. 요청에 필요한 정보가 객체 안에 다 들어 있으니, 요청이 시점에서 독립한다.
Invoker는 커맨드만 안다
커맨드를 실행하는 쪽을 **인보커(Invoker)**라 부른다. 인보커는 커맨드를 받아 execute를 부를 뿐, 그게 무슨 일인지 모른다.
public class Editor {
private final Deque<Command> history = new ArrayDeque<>();
public void run(Command command) {
command.execute(); // 무슨 요청인지 모른 채 실행
history.push(command); // 되돌리기용으로 쌓아 둔다
}
public void undo() {
if (!history.isEmpty()) {
history.pop().undo(); // 가장 최근 것을 되돌린다
}
}
}Editor는 삽입인지 삭제인지 서식 변경인지 관심 없다. Command라는 규격만 알고, execute와 undo만 부른다. 그래서 새 요청(붙여넣기, 서식 변경)이 생겨도 Editor는 안 고친다 - 커맨드 구현만 하나 추가하면 된다. 요청을 내는 쪽, 실행하는 쪽, 실제로 일하는 쪽이 이렇게 셋으로 갈린다.
요청이 카드가 되면, 들고 있는 쪽은 내용을 몰라도 실행할 수 있고 실행한 것은 사라지지 않고 쌓인다.
undo로 되돌린다
커맨드가 객체로 남으니, 실행한 것들을 스택에 쌓아 두면 되돌리기가 자연스럽게 나온다.
Editor editor = new Editor();
editor.run(new InsertTextCommand(doc, "안녕", 0));
editor.run(new InsertTextCommand(doc, "하세요", 2));
editor.undo(); // "하세요" 삽입을 되돌림 (가장 최근 것)
editor.undo(); // "안녕" 삽입을 되돌림각 커맨드가 자기를 어떻게 되돌리는지(undo)를 스스로 안다. 삽입의 반대는 삭제, 삭제의 반대는 복원. 인보커는 스택에서 가장 최근 커맨드를 꺼내 undo만 부르면 된다. 되돌린 것을 다른 스택에 옮겨 두면 **다시 실행(redo)**도 같은 방식으로 나온다. 요청을 객체로 만든 순간, 되돌리기는 거의 공짜로 따라온다.
큐잉과 로깅
되돌리기 말고도, 요청이 객체가 되면 할 수 있는 게 많아진다.
- 큐잉(지연 실행) - 커맨드를 큐에 쌓아 두었다가 나중에, 혹은 다른 스레드에서 꺼내 실행한다. 요청을 내는 시점과 실행하는 시점을 분리하는 것이다.
- 로깅 - 실행한 커맨드를 순서대로 기록해 두면, 시스템이 죽었다 살아났을 때 그 기록을 되짚어 상태를 복원할 수 있다.
Queue<Command> queue = new LinkedList<>();
queue.offer(new InsertTextCommand(doc, "나중에", 0)); // 지금은 담아만 둔다
// 다른 시점 / 다른 곳에서
while (!queue.isEmpty()) {
queue.poll().execute(); // 이제 실행
}담고, 쌓고, 미루고, 적는다. 전부 요청이 객체라서 가능한 일이다.
실무: 작업 큐와 트랜잭션적 작업
커맨드는 실무에 넓게 깔려 있다. 자바의 **Runnable**이 사실상 커맨드다. “해야 할 일”을 run() 하나로 감싼 객체이고, 이걸 ExecutorService에 넘기면 스레드 풀이 큐에 쌓아 두었다가 실행한다.
Runnable task = () -> report.generate(); // 요청을 객체로
executor.submit(task); // 큐에 넣어 나중에 실행ExecutorService는 인보커다. 무슨 일인지 모른 채 큐에서 꺼내 실행한다. 작업 큐, undo 스택, 매크로(여러 커맨드를 묶어 한 번에 실행), 트랜잭션적 작업(실패 시 지금까지 실행한 커맨드를 역순으로 undo해 되감기)이 모두 이 패턴 위에 선다. GUI 프레임워크에서 버튼·메뉴·단축키가 같은 동작을 공유하는 것도, 그 동작을 하나의 커맨드 객체로 만들어 여기저기 바인딩하기 때문이다.
정리
| 커맨드란 | 요청 자체를 객체로 캡슐화한다 |
| 문제 | 메서드 호출은 그 자리에서 사라져 저장·취소·기록이 안 된다 |
| 고침 | 요청을 Command 구현으로, execute/undo를 규격으로 세운다 |
| 역할 분리 | Invoker는 커맨드만 알고, Receiver가 실제 동작을 한다 |
| 효과 | 큐잉(지연 실행), undo 스택, 로깅, 매크로가 자연히 따라온다 |
| 실무 | 작업 큐(Runnable), undo 스택, 트랜잭션적 되감기 |
동사인 요청을 명사인 객체로 바꿔, 저장하고 되돌리고 기록할 수 있게 만드는 것 - 그게 커맨드다.