트렌드브리프
커맨드 패턴, 요청을 객체로 만들어 실행 취소 구현하기

커맨드 패턴, 요청을 객체로 만들어 실행 취소 구현하기

· 게시 · 약 5분 분량

커맨드 패턴은 요청을 객체로 감싸 호출자와 실행자를 분리하는 GoF 행동 패턴이다. Command·Receiver·Invoker 구조와 스택 기반 실행 취소, 자바 Runnable·닷넷 ICommand 사례를 코드로 정리했다.

목차
  1. 커맨드 패턴은 어떤 문제를 푸는가
  2. 구성 요소 네 가지: Command, Receiver, Invoker, Client
  3. 코드로 보는 최소 구현
  4. 실행 취소는 어떻게 만드나
  5. 실무에서 어디에 쓰이나

커맨드 패턴은 실행하려는 요청 자체를 하나의 객체로 감싸는 객체지향 설계 기법이다. 요청을 객체로 만들면 그것을 변수에 담고, 목록에 쌓고, 실행 시점을 미루고, 나중에 되돌릴 수 있다. GoF(Gang of Four)가 1994년 책 『Design Patterns』에서 정리한 23개 패턴 중 하나로, 객체의 행동 방식을 다루는 행동(behavioral) 패턴에 속한다. 이 글은 특정 언어 튜토리얼이 아니라 객체지향 언어에 공통으로 통하는 구조와 실행 취소 구현 방식, 그리고 실무 사례를 다룬다.

커맨드 패턴은 어떤 문제를 푸는가

요청을 보내는 쪽과 그 요청을 실제로 처리하는 쪽 사이의 강한 결합을 끊는 것이 핵심 목적이다. 화면의 저장 버튼이 파일 저장 클래스를 직접 호출하는 코드를 떠올려 보자. 버튼은 저장 클래스의 이름과 메서드를 알아야 하고, 인쇄 버튼은 인쇄 클래스를, 내보내기 버튼은 또 다른 클래스를 알아야 한다. 여기에 '저장한 뒤 알림 보내기' 같은 동작이 추가되면 버튼 코드를 다시 열어 고쳐야 한다. 버튼과 메뉴가 늘어날수록 이 의존이 사방으로 번진다.

커맨드 패턴은 요청을 보내는 쪽과 받는 쪽 사이에 객체 하나를 끼워 넣는다. 버튼은 '무엇을 하는지' 모른 채 건네받은 커맨드의 execute()만 부르고, 실제 작업은 커맨드가 안에 감춰 둔 대상에게 넘긴다. 디자인 패턴 참고서로 널리 쓰이는 리팩터링 구루는 이 구조를 식당 주문에 빗댄다. 손님은 요리사를 직접 몰라도 되고, 주문서만 주방에 넘기면 된다. 여기서 주문서가 곧 커맨드다.

구성 요소 네 가지: Command, Receiver, Invoker, Client

커맨드 패턴은 역할이 분명한 몇 개의 조각으로 이뤄진다. 각자 맡는 일은 이렇게 나뉜다.

여기에 커맨드를 만들어 수신자와 연결한 뒤 발신자에게 건네는 Client가 더해진다. 발신자는 자기가 실행할 커맨드가 구체적으로 무엇을 하는지 끝까지 알 필요가 없다. 이 무지가 곧 유연함이다.

요청이 티켓(커맨드 객체)으로 만들어져 실행되고, 아래 히스토리 스택에서 되돌리기 화살표로 취소되는 선화 일러스트
요청을 티켓(객체)으로 만들면 실행할 수 있고, 히스토리 스택에 쌓아 되돌릴 수도 있다.

코드로 보는 최소 구현

불을 켜고 끄는 예시로 네 조각이 어떻게 맞물리는지 보자. 자바 문법을 썼지만 구조는 다른 객체지향 언어에도 그대로 옮겨진다.

// Command 인터페이스
interface Command {
 void execute();
 void undo();
}

// Receiver: 실제 일을 하는 객체
class Light {
 void on() { System.out.println("불 켜짐"); }
 void off() { System.out.println("불 꺼짐"); }
}

// ConcreteCommand: 요청을 객체로 포장
class LightOnCommand implements Command {
 private final Light light;
 LightOnCommand(Light light) { this.light = light; }
 public void execute() { light.on(); }
 public void undo() { light.off(); }
}

// Invoker: 커맨드를 실행하고 이력을 쌓는다
class Button {
 private final Deque<Command> history = new ArrayDeque<>();
 void press(Command command) {
  command.execute();
  history.push(command);
 }
 void undoLast() {
  if (!history.isEmpty()) history.pop().undo();
 }
}

Button은 Light라는 이름조차 모른다. 그저 건네받은 커맨드의 execute()를 부를 뿐이다. 새 동작을 추가하고 싶으면 Command를 구현한 클래스를 하나 더 만들면 되고, Button은 손대지 않는다.

실행 취소는 어떻게 만드나

각 커맨드에 execute()의 반대 동작을 하는 undo()를 정의하고, 실행한 커맨드를 스택에 쌓으면 된다. 위 예시에서 Button이 이력을 Deque에 push했다가 되돌릴 때 pop해서 undo()를 부르는 부분이 바로 그것이다.

왜 스택이냐면, 실행 취소는 항상 가장 최근 동작부터 되돌아가기 때문이다. 스택은 마지막에 넣은 것이 먼저 나오는 LIFO 구조라 이 순서와 정확히 맞는다. 스택과 큐의 차이가 헷갈린다면 LIFO와 FIFO를 비교한 글을 먼저 보면 이 대목이 한결 쉽게 읽힌다. 다시 실행(redo)까지 지원하려면 취소한 커맨드를 담는 스택을 하나 더 두고, redo할 때 그쪽에서 꺼내 execute()를 다시 부르면 된다.

실무에서 어디에 쓰이나

GUI의 메뉴와 버튼, 작업 큐, 매크로 기록, 트랜잭션 로그처럼 요청을 나중에, 순서대로, 혹은 되돌려 다뤄야 하는 곳이 커맨드 패턴의 자리다. 편집기의 Ctrl+Z가 대표적이다. 편집 동작 하나하나를 커맨드로 만들어 쌓아 두었다가 역순으로 되돌린다.

자바 표준 라이브러리의 java.lang.Runnable과 javax.swing.Action은 요청을 객체로 감싸 실행을 미루는 이 구조를 그대로 보여 준다. 스레드 풀에 Runnable을 넣으면 풀이 발신자 역할을 맡아 큐에 쌓인 작업을 하나씩 run()으로 실행한다. 할 일을 객체로 만들어 큐에 담고 실행 시점을 미루는 흐름 그 자체다.

마이크로소프트 .NET에도 같은 발상이 들어 있다. WPF에서 쓰는 System.Windows.Input.ICommand 인터페이스는 실행을 담당하는 Execute 메서드, 지금 실행이 가능한지 알려 주는 CanExecute 메서드, 실행 가능 여부가 바뀌었음을 알리는 CanExecuteChanged 이벤트로 이뤄진다. 버튼이 눌러도 되는 상태인지를 커맨드가 스스로 판단하게 만드는 구조다.

여러 커맨드를 하나로 묶는 것도 가능하다. 커맨드 목록을 품은 커맨드를 만들면 execute() 한 번에 여러 동작이 순서대로 실행되는 매크로가 된다. 각 커맨드가 undo()를 갖고 있으니 매크로 전체를 되돌리는 일도 목록을 역순으로 훑으면 끝난다.

한 가지만 기억하면 된다. 커맨드 패턴은 실행 취소·큐잉·로그·매크로가 필요할 때 제값을 한다. 반대로 한 번 부르고 끝나는 단순한 호출까지 전부 커맨드로 감싸면 클래스 수만 늘고 코드는 오히려 읽기 어려워진다. 요청을 저장하거나, 미루거나, 되돌릴 이유가 있는가. 이 질문에 그렇다고 답할 수 있을 때 꺼내 쓰는 도구다.

더 많은 글 보기 RSS 구독