Command 패턴: 요청을 객체로 캡슐화해 실행과 취소를 관리하는 법
Command 패턴으로 실행·취소·재실행을 캡슐화하는 원리와 Undo 전략별 트레이드오프, 히스토리 관리 방식을 Java 예제로 정리한다.
2026-08-13 · 최초 발행 2025-10-14
Command가 캡슐화하는 것
요청을 메서드 호출로 바로 실행하는 대신, 요청 자체를 객체로 감싸서 실행·취소·재실행을 다루는 방식이 Command 패턴이다. Command 객체는 execute와 undo 인터페이스를 제공하고, 호출자(Invoker)와 실제 작업을 수행하는 수신자(Receiver)를 분리한다.
역할은 분명하게 나뉜다. Client는 Command 객체를 조립하고, Invoker는 Command를 호출하며 실행 히스토리를 관리하고, Command(추상)와 ConcreteCommand(구체 동작)가 요청 자체를 표현하며, Receiver가 실제 작업을 수행한다. Undo를 구현하는 방식도 하나가 아니다 — 실행의 역연산(Compensation)을 정의하거나, 실행 전 상태를 스냅샷(Memento)으로 저장하거나, 이벤트 소싱(Event Sourcing) 기반으로 로그를 리플레이하는 방법 중에서 상황에 맞게 고른다.
느슨한 결합과 교체 가능한 구조
Invoker는 Command 인터페이스만 알면 되므로 Receiver를 교체하거나 확장하기 쉽다. 런타임에 바인딩을 바꿔 기능을 스위즐할 수 있고, 명령 큐·재시도·스케줄링 같은 인프라 레이어로 추상화하기도 좋아 운영 자동화에 유리하다.
Undo와 Redo의 구현
실행 히스토리 스택(undoStack)과 재실행 스택(redoStack)을 운영한다. execute를 호출하면 undoStack에 push하고, undo를 호출하면 pop한 뒤 해당 Command의 undo를 호출한다. 에러가 나면 보상 트랜잭션을 호출해 히스토리 정합성을 유지해야 하고, 외부 I/O가 포함된 Command는 멱등성과 재시도 정책이 필수다.
여러 명령을 묶어서 다루기
여러 Command를 Composite로 묶으면 일괄 실행·취소가 가능하다. 부분 실패가 나면 역순으로 보상해 일관성을 확보한다. 배치·워크플로우·마이그레이션 같은 절차적 작업에 이 구조가 맞는다.
감사와 테스트를 쉽게 만드는 설계
Command를 불변·직렬화 가능하게 설계하면 감사 로그·재현·리플레이가 가능해지고, 입력 검증이나 권한 검증을 끼워 넣기도 쉬워진다. 명령 단위로 단위 테스트·계약 테스트를 작성하면 회귀도 줄어든다.
실행에서 취소까지, 그리고 다시 실행까지
입력 단계에서는 Receiver 참조와 불변 파라미터를 주입해 Command를 생성하고, 스키마 검증·권한 체크·멱등 키 확인 같은 사전 검증을 거친다. 처리 단계에서 Invoker.execute는 히스토리에 push한 뒤 execute를 호출하고 결과나 예외를 수집한다. 예외가 발생하면 히스토리에서 pop하고 undo로 보상한 뒤 에러를 로깅·알림한다. 출력은 두 갈래다 — 정상 실행이면 결과를 반환하고 undoStack에 적재하며 redoStack을 비우고, 취소라면 undo 호출 결과를 반환하고 정합성 메트릭을 갱신한다.
리모컨으로 보는 실행과 취소
전제조건은 JDK 17 이상, 표준 자바 컴파일러다.
// Command.java
public interface Command {
void execute();
void undo();
String name();
}
// Receiver: 실제 동작
class Light {
private boolean on = false;
public void on() { on = true; System.out.println("Light ON"); }
public void off() { on = false; System.out.println("Light OFF"); }
public boolean isOn() { return on; }
}
// Concrete Commands
class LightOnCommand implements Command {
private final Light light;
public LightOnCommand(Light light) { this.light = light; }
public void execute() { light.on(); }
public void undo() { light.off(); }
public String name() { return "LightOn"; }
}
class LightOffCommand implements Command {
private final Light light;
public LightOffCommand(Light light) { this.light = light; }
public void execute() { light.off(); }
public void undo() { light.on(); }
public String name() { return "LightOff"; }
}
// Invoker: 실행/히스토리 관리
import java.util.ArrayDeque;
import java.util.Deque;
class RemoteControl {
private final Deque<Command> undoStack = new ArrayDeque<>();
private final Deque<Command> redoStack = new ArrayDeque<>();
private final int maxHistory;
public RemoteControl(int maxHistory) { this.maxHistory = maxHistory; }
public void execute(Command cmd) {
try {
cmd.execute();
undoStack.push(cmd);
redoStack.clear();
if (undoStack.size() > maxHistory) undoStack.removeLast();
System.out.println("Executed: " + cmd.name());
} catch (RuntimeException ex) {
// 보상 처리 시나리오
safeUndo(cmd);
throw ex;
}
}
public boolean undo() {
if (undoStack.isEmpty()) return false;
Command cmd = undoStack.pop();
cmd.undo();
redoStack.push(cmd);
System.out.println("Undone: " + cmd.name());
return true;
}
public boolean redo() {
if (redoStack.isEmpty()) return false;
Command cmd = redoStack.pop();
cmd.execute();
undoStack.push(cmd);
System.out.println("Redone: " + cmd.name());
return true;
}
private void safeUndo(Command cmd) {
try { cmd.undo(); } catch (Exception ignore) { /* 보상 실패 로깅 대상 */ }
}
}
// Demo
public class Main {
public static void main(String[] args) {
Light light = new Light();
RemoteControl rc = new RemoteControl(100);
rc.execute(new LightOnCommand(light)); // ON
rc.execute(new LightOffCommand(light)); // OFF
rc.undo(); // ON
rc.redo(); // OFF
}
}
MacroCommand로 확장하려면 List를 순서대로 실행하고 undo는 역순으로 실행하면 된다. 직렬화가 필요하면 Command payload를 DTO로 분리하고 핸들러 매핑으로 복원하는 방식을 쓴다.
직접 호출을 대신할 때 오가는 비용
| 항목 | 직접 호출 | Command 패턴 |
|---|---|---|
| 성능 | 호출 오버헤드 최소 | 객체 생성·히스토리 관리 오버헤드 소폭 증가 |
| 확장성 | 호출부-피호출부 결합도 높음 | 인터페이스 기반 확장 용이, 기능 스위즐 가능 |
| 일관성 | Undo/Redo 부재, 로깅 산발 | 표준화된 히스토리·재현·리플레이 가능 |
| 안정성 | 예외 전파·부분 실패 취약 | 보상·재시도·멱등 정책으로 복원력 개선 |
| 운영 편의 | 트레이싱·감사 한계 | 감사 로그·권한·검증·스케줄링 일원화 |
어디에 쓰이는가
데스크톱·모바일 편집기는 텍스트·도형 편집의 세밀한 Undo/Redo에 Command를 쓴다. 스냅샷과 역연산을 섞어 쓰고, 배치 편집은 MacroCommand로 한꺼번에 처리한다. 게임 엔진의 입력 처리도 마찬가지다 — 입력을 커맨드 큐에 적재해 재생·디버깅·리플레이를 지원하고, 네트워크 지연을 보정할 때는 멱등성과 타임스탬프 기반 재정렬을 적용한다. 서버사이드 관리자 기능에서는 사용자 정지·권한 변경·리소스 할당을 Command로 모델링해 보상 트랜잭션 기반 롤백과 감사 로그를 제공한다. 인프라 자동화·데브옵스 영역에서는 배포·마이그레이션을 Command로 정의해 장애 시 역순 롤백을 수행하고, 변경 이력을 승인 워크플로우와 묶는다.
실무에 적용할 때 고려할 것들
명령 설계에서는 파라미터를 불변으로 두고, 이름·id를 명시하며, 생성자에서 검증을 끝낸다. 명령 데이터(DTO)와 핸들러(실행 로직)를 분리하고 직렬화 포맷(JSON/Avro)을 표준화한다.
Undo 전략은 상황에 따라 고른다. 역연산이 가능하면 단순하고 가볍지만 외부 부작용을 제한해야 한다. 스냅샷(Memento) 방식은 메모리·스토리지 비용이 늘지만 상태 일관성이 높다. 이벤트 소싱은 영속 로그 기반으로 리플레이할 수 있지만 모델이 복잡해진다.
트랜잭션 경계도 신경 써야 한다. 단일 DB라면 트랜잭션 경계 안에서 execute와 로그를 동시에 커밋하면 되지만, 분산 환경이라면 사가 패턴·아웃박스 패턴·멱등키로 일관성을 확보하고 재시도 정책을 명시해야 한다. 보안 측면에서는 Invoker 전후로 권한을 체크하고, 입력을 정규화·검증하며, 감사 로그에 사용자·명령·결과·코릴레이션ID를 남긴다. PII는 마스킹하고, 리플레이 공격을 막기 위해 명령 재실행에 nonce/TTL을 건다. 운영 측면에서는 히스토리 크기 한도·TTL·압축(동일 명령 합치기)을 두고 성공률·지연·보상률 같은 메트릭을 수집한다. 실패 주입 테스트로 보상 경로를 검증하고, 혼합 워크로드에서는 큐 길이를 모니터링한다.
이렇게 호출부와 구현부의 결합도를 낮추면 기능을 추가하거나 교체하는 시간을 20~40% 절감할 수 있다는 사내 도입 사례가 있다.