문서 편집기에 메뉴와 버튼과 단축키를 단다고 하자. Ctrl+C도, 메뉴의 [복사]도, 툴바의 복사 아이콘도 결국 같은 일을 시킨다. 자연스럽게 이런 코드를 짜게 된다 — 버튼 클릭 핸들러 안에 if (menu.equals("복사")) document.copy(); else if (menu.equals("붙여넣기")) document.paste();. 메뉴가 늘 때마다 이 분기가 자란다. 그런데 진짜 문제는 그 다음에 온다. “방금 한 작업을 되돌리고 싶다(undo)“는 요구가 붙는 순간, 이 구조는 손쓸 데가 없다. 무슨 일이 벌어졌는지가 코드 실행과 함께 증발해 버렸기 때문이다.
커맨드(Command) 패턴은 발상을 뒤집는다. “복사하라”는 요청 자체를 객체 하나로 만든다. 버튼은 자기가 무슨 일을 시키는지 몰라도 되고, 그저 손에 쥔 커맨드 객체에게 “실행해”라고만 말한다. 요청이 객체가 되어 값처럼 변수에 담기고 리스트에 쌓이는 순간, 방금 전까지 불가능하던 것들이 줄줄이 열린다 — 실행을 취소하고, 다시 실행하고, 큐에 줄 세우고, 로그로 남기고, 여러 개를 묶어 매크로로 만드는 일 전부가.
무엇이 문제인가 — 동기
버튼 하나를 놓고 보자. GUI 툴킷의 Button은 “눌리면 무언가를 한다”는 것까지만 안다. 그 무언가가 문서 저장인지, 창 닫기인지, 글자 복사인지는 버튼이 알 바가 아니다 — 알아서도 안 된다. 버튼이 document.save()를 직접 호출하도록 짜면, 이 버튼은 오직 저장 버튼으로만 살 수 있다. 다른 일을 시키려면 버튼 클래스를 새로 만들거나 버튼 코드를 고쳐야 한다. 호출하는 쪽(버튼)이 실제로 일하는 쪽(문서)에 못박혀 재사용이 막힌다.
그래서 흔히 거대한 분기로 도망친다. 하나의 핸들러가 이벤트의 출처를 보고 switch로 갈래를 친다. 이러면 메뉴·툴바·단축키가 새 명령을 하나 얻을 때마다 이 핸들러가 부풀고, 서로 무관한 명령들의 코드가 한 자리에 뒤엉킨다. 여기까지는 책임연쇄나 전략에서 본 “부푼 분기”와 같은 병이다.
커맨드가 정말로 갈라지는 지점은 undo다. 사용자가 글자를 지우고, 문단을 옮기고, 색을 바꾼 뒤 “방금 것 취소”를 누른다. 이걸 지원하려면 시스템은 “가장 최근에 무슨 일이 있었는가”를 기억하고, 그 일을 거꾸로 되돌리는 법을 알아야 한다. document.delete()를 직접 호출하는 절차적 코드에는 되돌릴 실마리가 없다 — 실행이 끝나면 무슨 일을 했는지가 흔적 없이 사라진다. 요청이 그저 함수 호출로 지나가 버리기 때문이다.
판단 기준: “이 요청을 나중에 되돌리거나, 저장했다 다시 실행하거나, 줄 세우거나, 기록해야 하는가”라는 물음에 하나라도 그렇다면 커맨드를 의심한다. 요청을 값으로 붙잡아 둬야 할 이유가 있을 때가 커맨드의 자리다. 함정: 요청이 즉시 실행되고 그걸로 끝, 되돌릴 일도 기록할 일도 없다면 커맨드는 과하다 — 그냥 메서드를 직접 호출하는 편이 낫다.
패턴의 구조 — 누가 무엇을 하나
커맨드는 다섯 배역이 요청 하나를 사이에 두고 협력한다. 핵심은 “요청을 시키는 자”와 “요청을 실제로 수행하는 자” 사이에 요청을 대신하는 객체를 하나 끼워 넣는 것이다.
Command(커맨드 인터페이스). “실행할 수 있다”는 계약 하나. 대개 execute() 하나이고, 되돌림이 필요하면 undo()가 짝으로 붙는다. 이 인터페이스가 모든 구체 명령이 공유하는 공통의 얼굴이고, 호출자는 오직 이 얼굴만 안다.
ConcreteCommand(구체 커맨드). execute()가 실제로 무엇을 하는지 아는 객체. PasteCommand, DeleteCommand 같은 것들. 중요한 건 이 객체가 일을 직접 하지 않는다는 점이다 — 자기 안에 **수신자(Receiver)**를 참조로 들고 있다가, execute()가 불리면 receiver.paste() 하고 수신자에게 넘긴다. 구체 커맨드는 “어떤 수신자의 어떤 연산을, 어떤 인자로 부를지”의 묶음을 저장한다.
Receiver(수신자). 실제로 일을 하는 객체. 문서, 조명, 계좌 같은 도메인 객체다. paste, delete가 진짜로 벌어지는 곳. 수신자는 자기가 커맨드를 통해 불렸다는 사실조차 모른다.
Invoker(호출자). 커맨드를 쥐고 있다가 때가 되면 execute()를 부르는 쪽. 버튼·메뉴·단축키·큐가 여기다. 호출자는 자기 커맨드가 무슨 일을 하는지 전혀 모른다 — 그저 command.execute()만 안다. 이 무지가 커맨드의 핵심 이득이다.
Client(클라이언트). 구체 커맨드를 만들고 수신자를 물려 호출자에게 쥐여 주는 조립자. “이 버튼에는 붙여넣기 커맨드를”이라고 배선하는 쪽이다.
협력의 결정적 지점은 커맨드가 수신자와 연산을 함께 품는다는 것이다. 전략(Strategy)이 “알고리즘 하나”를 객체로 뽑았다면, 커맨드는 “수신자 + 그 위에서 부를 연산 + 필요한 인자”를 통째로 한 객체에 봉인한다. 그래서 이 객체는 나중에, 다른 곳에서, 심지어 원래 요청한 사람이 사라진 뒤에도 실행될 수 있다. 요청이 시간과 장소로부터 풀려나는 것이다.
판단 기준: 커맨드에는 수신자를 향한 참조를 반드시 담아라 — execute() 안에서 일을 직접 하지 말고 수신자에게 위임해야, 명령은 “무엇을 언제 할지”만 책임지고 “어떻게 할지”는 수신자가 갖는 분리가 성립한다. 함정: 구체 커맨드 안에 도메인 로직을 다 때려넣으면(수신자 없이 execute()가 직접 파일을 쓰고 계산하면), 커맨드가 곧 God 객체가 되어 분리는 이름뿐이 된다.
코드로 — 패턴이 자라나는 과정
호출자에 동작을 하드코딩한 원본에서 출발해, 커맨드 인터페이스를 뽑고, undo를 붙이고, 실행 취소 스택까지 세우는 과정을 단계로 따라간다.
// 버튼이 자기가 무슨 일을 시키는지 직접 안다. 명령이 늘면 분기가 자라고,// "방금 한 일을 되돌린다"는 걸 붙일 자리가 어디에도 없다.public class Button { private final String action; private final Document document; public Button(String action, Document document) { this.action = action; this.document = document; } public void onClick() { switch (action) { case "COPY": document.copy(); break; case "PASTE": document.paste(); break; case "DELETE": document.delete(); break; // 명령이 하나 늘 때마다 case가 하나 는다. // Button이 Document의 어떤 메서드가 있는지까지 훤히 안다 — 강하게 묶였다. } }}
첫 스텝의 switch가 사라진 것이 핵심이 아니다 — 사라진 분기는 “어떤 커맨드를 버튼에 물릴까”라는 조립 시점의 선택으로 옮겨 갔을 뿐, 전략에서 봤던 것과 같은 이동이다. 커맨드가 진짜로 벌어 준 것은 요청이 값이 됐다는 사실이다. 함수 호출은 실행하는 순간 증발하지만, 커맨드 객체는 실행한 뒤에도 스택에 남아 되돌려지고, 리스트에 담겨 매크로가 되고, 파일로 직렬화돼 세션 사이를 건너간다. undo·redo·큐·매크로가 서로 다른 기능처럼 보이지만, 전부 “요청을 객체로 붙잡아 뒀다”는 하나의 결정에서 파생된 것이다.
판단 기준: execute()와 undo()가 대칭을 이루는지, 즉 undo가 execute의 상태 변화를 정확히 역전하는지 확인한다 — 실행이 저장한 스냅샷만으로 되돌아가면 대칭이 맞은 것이다. 함정: undo를 “반대 연산을 한 번 더 실행”으로 착각하는 것(지운 걸 되돌리려 아무 텍스트나 삽입). 되돌림은 실행 시점에 무엇이 있었는지를 기억해야만 정확하다 — 그 기억이 커맨드 안에 없으면 undo는 거짓말이 된다.
결과 — 무엇을 얻고 무엇을 내주나
커맨드가 사는 값은 호출자와 수신자의 분리, 그리고 그로부터 파생되는 요청의 조작 가능성이다. 버튼은 무슨 일을 시키는지 모르고, 큐는 무엇을 줄 세우는지 모르고, 히스토리는 무엇을 되돌리는지 모른다 — 전부 execute()라는 얼굴만 안다. 이 무지 덕에 트리거(버튼·메뉴·단축키)와 명령이 서로 독립으로 조합되고, 새 명령은 커맨드 클래스 하나를 추가하는 것으로 끝난다(기존 호출자 코드 0줄 수정). 그리고 요청이 값이 됐으므로 undo·redo(스택), 지연 실행·스케줄링(큐), 감사 추적(로그), 여러 요청의 묶음(매크로)이 전부 같은 뿌리에서 열린다. 트랜잭션 로그로 남긴 커맨드들을 시스템 재시작 뒤 재생(replay)하면 크래시 복구까지 된다.
대가는 명확하다. 첫째, 클래스가 우수수 는다. 명령 하나당 커맨드 클래스 하나다. 명령이 수십 개인 애플리케이션이라면 커맨드 클래스도 수십 개가 되고, “복사가 실제로 무슨 일을 하나”를 보려면 버튼에서 커맨드로, 커맨드에서 수신자로 두세 단계를 건너뛰어야 한다 — 직접 호출이 한 줄로 보여 주던 것을 간접성이 가린다. 둘째, undo를 제대로 하려면 상태 저장 비용이 붙는다. 각 커맨드가 되돌림에 필요한 스냅샷을 들고 있어야 하고, 스냅샷이 크면(문서 전체 상태 등) 메모리를 먹는다. 되돌릴 수 없는 연산(외부 결제, 메일 발송)은 애초에 undo 대칭이 성립하지 않아 보상 트랜잭션이라는 별도 설계를 요구한다. 셋째, 조립의 책임이 늘어난다. 어떤 커맨드에 어떤 수신자를 물려 어떤 호출자에 꽂을지를 누군가(클라이언트) 배선해야 하므로, 선택의 복잡성이 사라지지 않고 조립 코드로 옮겨 간다.
운영비용 렌즈로 보면 순이득의 조건이 분명하다. undo·큐·로그·매크로 중 하나라도 실제로 필요하면, 늘어난 클래스와 간접성의 대가보다 그 기능을 다른 방식으로 짜맞추는 고통이 훨씬 크다 — 커맨드 없이 undo를 붙이는 일은 사실상 커맨드를 재발명하는 일이다. 반대로 그중 아무것도 필요 없다면, 커맨드의 간접성은 값을 못 치른 채 읽는 비용만 더한다.
판단 기준: 얻는 것(호출자-수신자 분리·undo·큐·로그·매크로) 중 지금 실제로 쓸 것을 세고, 내주는 것(클래스 증가·간접성·상태 저장)과 저울질해 순이득이 양수일 때만 앉힌다. 함정: “언젠가 undo가 필요할지도”라는 이유만으로 모든 요청을 커맨드로 감싸는 것 — 오지 않을 기능에 거는 보험이며, 그동안 코드는 간접성의 이자만 문다.
언제 쓰고, 언제 과용인가
커맨드가 정확히 맞는 자리는 이렇다. 실행 취소·재실행이 필요한 편집기·그래픽 도구. 요청을 큐에 넣어 나중에·다른 스레드에서 실행하는 작업 스케줄러·스레드 풀(자바의 Runnable이 사실상 커맨드다 — execute()가 run()으로 이름만 바뀐 인자 없는 커맨드다). 요청을 로그로 남겨 크래시 후 재생해야 하는 트랜잭션 시스템. GUI에서 하나의 명령을 메뉴·툴바·단축키 여러 트리거에 공유해야 할 때. 여러 연산을 하나로 묶어 트랜잭션처럼 다뤄야 할 때(매크로).
과용의 냄새는 반대편에서 난다. 되돌릴 일도, 줄 세울 일도, 기록할 일도 없이 그냥 즉시 실행하고 끝인 요청을 커맨드로 감싸면, 얻는 기능은 하나도 안 쓰이는데 클래스와 간접성만 떠안는다. 커맨드 인터페이스에 execute() 하나뿐이고 상태도 없다면, 자바에서는 클래스 대신 람다나 메서드 참조로 족한 경우가 많다(Runnable task = document::save) — undo가 필요해지는 순간에야 정식 클래스로 승격하면 된다. 커맨드를 도입했는데 클라이언트가 다시 거대한 if로 어떤 커맨드를 만들지 고르고 있다면, 분기를 없앤 게 아니라 조립 코드로 한 칸 민 것이니 그 선택을 팩토리나 맵으로 밀어내야 한다.
이웃 패턴과의 경계를 그어 둔다. [메멘토]는 커맨드의 짝꿍이다 — undo를 위해 상태 스냅샷을 저장할 때, 그 스냅샷을 커맨드가 직접 들면 캡슐화가 새어 나가므로, 메멘토에게 “저장·복원”을 떼어 주고 커맨드는 그 메멘토를 보관만 한다. 커맨드가 “무엇을 되돌릴지”를 안다면, 메멘토는 “무엇을 저장했는지”를 캡슐 안에 숨긴다. 책임연쇄(Chain of Responsibility)와는 요청을 객체로 본다는 발상은 닮았으나 목적이 갈린다 — 책임 연쇄는 “누가 이 요청을 처리할지”를 사슬로 찾고, 커맨드는 “이 요청을 언제·몇 번·되돌려 실행할지”를 다룬다. 전자는 수신자를 찾는 문제, 후자는 요청을 붙잡아 조작하는 문제다. [프로토타입]은 로그를 남길 때 손을 잡는다 — 실행한 커맨드를 로그에 기록하려면 그 시점의 커맨드를 **복제(clone)**해 둬야 원본이 나중에 바뀌어도 기록이 오염되지 않는다.
판단 기준: 요청을 실행한 그 순간 이후에도 그 요청을 다뤄야 할 일(되돌리기·다시 하기·줄 세우기·기록·묶기)이 있으면 커맨드, 요청이 실행과 함께 끝나면 직접 호출이다. 함정: 상태 없는 일회성 콜백까지 정식 커맨드 클래스로 만드는 것 — “요청의 객체화”라는 큰 이름에 홀려, 람다 한 줄이면 될 자리에 인터페이스·클래스·조립 배선을 세우는 낭비다.
다음으로 인터프리터