디자인 패턴 공부 - 커맨드 패턴

이혁진·2023년 1월 14일

커맨드 패턴

어떤 하나의 객체를 통해서 여러 객체에 명령(요청을 전달=함수를 호출)을 하는 경우, 요청을 캡슐화하여 호출자와 수신자를 분리한다. 이에 따라서 요청 처리 방식이 바뀌더라도 호출자의 코드는 앵간 바뀌지 않는다. 따라서, 호출자가 많거나 호출자를 수정하기 어려운 경우, 이 패턴이 적용되면 큰 효과를 볼 수 있다.

구현

구현은 단순히 호출자에서 수행되던 로직을 캡슐화하는것 뿐이다. 가령 버튼을 눌러 조명(수신자)을 끄고 킨다(요청)고 하면

public class Button {
	private final Light light;
    public Button(Light light) {this.light = light;}
    public void pressOdd() {light.turnOn();}
    public void pressEven() {light.turnOff();}
}

이렇게 될 텐데, light가 호출되어 수행되는 곳을 Command의 구현체로 캡슐화한다. 이때 다형성을 활용하기 위해서 Command를 인터페이스로 선언한다.

public interface Command {
	public void execute();
}

public class LightTurnOnCommand implements Command {
	private final Light light;
    public LightTurnOnCommand(Light light) {this.light = light;}
    public void execute() {light.turnOn();}
}

public class LightTurnOffCommand implements Command {
	public final Light light;
    public LightTurnOffCommand(Light light) {this.light = light;}
    public void execute() {light.turnOff();}
}

그러면 호출자인 쪽에서는 이렇게 쓰면 된다.

public class Button {
	private Command oddCommand;
    private Command evenCommand;
    
    public Button(Command oddCommand, Command evenCommand) {
    	this.oddCommand = oddCommand;
        this.evenCommand = evenCommand;
    }
    
    public void pressOdd() {oddCommand.execute();}
    public void pressEven() {evenCommand.execute();}
}

이렇게 하면 좋은 점은 확장성과, 요구사항 변화 시 호출자를 수정할 필요가 없다는 것, 그것으로 인해 호출자가 많은 경우에 변경의 전파가 상대적으로 적어진다.

또한 커맨드를 활용해서 다양한 기능을 만들 수 있다. 대표적으로 undo(반대 기능들을 수행)기능이 그것이다.

public interface Command {
	public void execute();
    public void undo();
}

public class LightTurnOnCommand implements Command {
	private final Light light;
	public LightTurnOnCommand(Light light) {this.light = light;}
	public void execute() {light.turnOn();}
    public void undo() {light.turnOff();}
}

public class LightTurnOffCommand implements Command {
	public final Light light;
	public LightTurnOffCommand(Light light) {this.light = light;}
	public void execute() {light.turnOff();}
    public void undo() {light.turnOn();}
}

public class Button {
	private Command oddCommand;
	private Command evenCommand;
    
    private Stack<Command> commands;
    
	public Button(Command oddCommand, Command evenCommand) {
		this.oddCommand = oddCommand;
		this.evenCommand = evenCommand;
	}
    
	public void pressOdd() {
    	oddCommand.execute();
        commands.push(command);
    }
    
	public void pressEven() {
    	evenCommand.execute();
        commands.push(command);
    }
    
    public void undo() {
    	if (!commands.isEmpty()) {
        	commands.pop().undo();
        }
    }
}

다른 예시로 적용 전과 후 요구사항 변화에 따른 영향을 살펴보자.

public class ButtonA {
	private final Light light;
    public ButtonA(Light light) {this.light = light;}
    public void press() {light.turnOn();}
}

public class ButtonB {
	private final Light light;
    public ButtonB(Light light) {this.light = light;}
    public void press() {light.turnOn();}
}

public class ButtonC {
	private final Door door;
    public ButtonC(Door door) {this.door = door;}
    public void press() {door.open();}
}

public class Main {
	public static void main(String[] args) {
    	ButtonA buttonA = new ButtonA(new Light());
        ButtonB buttonB = new ButtonB(new Light());
        ButtonC buttonC = new ButtonC(new Door());
        buttonA.press();
        buttonB.press();
        buttonC.press();
    }
}

이렇게 되어있다고 해보자. 다음은 위에 요구 변화를 적용했을 때, 어떻게 그것에 대응하는지를 정리한 것이다.

  1. light 에 끄기 기능을 추가하라, 모든 버튼에 해당 기능 넣기
    • Light 클래스에 turnOff 메소드 추가
    • ButtonA 클래스에 turnOn 메소드 turnOff 로 바꾸기
    • ButtonB 클래스에 turnOn 메소드 turnOff 로 바꾸기
  1. 새로운 가구인 컴퓨터를 추가하라, ButtonC 에 추가.
    • Computer 클래스 만들기
    • ButtonC 클래스 필드 수정
    • ButtonC 클래스 생성자 수정
    • ButtonC 클래스 메소드 수정
    • Main 에서 ButtonC 생성자 인자 수정
  1. ButtonA, B의 기능을 faucet 으로 바꾸어라
    • ButtonA 클래스 필드 수정
    • ButtonA 클래스 생성자 수정
    • ButtonA 클래스 메소드 수정
    • Main 에서 ButtonA 인자 수정
    • ButtonB 클래스 필드 수정
    • ButtonB 클래스 생성자 수정
    • ButtonB 클래스 메소드 수정
    • Main 에서 ButtonB 인자 수정
  1. faucet 의 turnOn에 세기 옵션을 추가
    • faucet 에서 메소드 시그니처 변경
    • ButtonB 의 수정
    • ButtonA 의 수정

이렇게 된다.

public class ButtonA {
	private final Faucet faucet;
	public ButtonA(Faucet faucet) {this.faucet = faucet;}
	public void press() {faucet.turnOn(10);}
}

public class ButtonB {
	private final Faucet faucet;
    public ButtonB(Faucet faucet) {this.faucet = faucet;}
    public void press() {faucet.turnOn(10);}
}

public class ButtonC {
	private final Computer computer;
    public ButtonC(Computer computer) {this.computer = computer;}
    public void press() {computer.turnOn();}
}

public class Main {
	public static void main(String[] args) {
    	ButtonA buttonA = new ButtonA(new Faucet());
        ButtonB buttonB = new ButtonB(new Faucet());
        ButtonC buttonC = new ButtonC(new Computer());
        buttonA.press();
        buttonB.press();
        buttonC.press();
    }
}

이따 보면 패턴 적용 전후의 차이가 크게 느껴질 것이다. SRP의 위배로 인해서 변경의 전파가 매우 크게 일어나고, 확장 시에도 기존 코드의 수정을 많이 필요로 한다.

커맨드 패턴을 적용해보자.

public interface Command {
	public abstract void execute();
}

public DoorOpenCommand implements Command {
	private final Door door;
    public DoorOpenCommand(Door door) {this.door = door;}
    public void execute() {door.open();}
}

public FaucetTurnOnCommand implements Command {
	private final Faucet faucet;
    public FaucetTurnOnCommand(Faucet faucet) {this.faucet = faucet;}
    public void execute() {faucet.turnOn();}
}

public LightTurnOnCommand implements Command {
	private final Light light;
    public LightTurnOnCommand(Light light) {this.light = light;}
    public void execute() {light.turnOn();}
}

public class ButtonA {
	private final Command command;
    public ButtonA(Command command) {this.command = command;}
    public void press() {command.execute();}
}

public class ButtonB {
	private final Command command;
    public ButtonB(Command command) {this.command = command;}
    public void press() {command.execute();}
}

public class ButtonC {
	private final Command command;
    public ButtonC(Command command) {this.command = command;}
    public void press() {command.execute();}
}

요구 사항에 맞추어 바꾸어라

  1. light 에 끄기 기능을 추가하라, 해당되는 모든 버튼에 해당 기능 넣기
    • Light 에 끄기 메소드 추가
    • 끄기 커맨드 클래스 추가
    • Main 에서 커맨드들 인자 바꾸기
  1. 새로운 가구인 컴퓨터를 추가하라, ButtonC 에 추가.
    • Computer 클래스 추가
    • 컴퓨터에 대한 커맨드 클래스 추가
    • Main 에서 해당 커맨드 인자로 넣기
  1. ButtonA, B의 기능을 faucet 으로 바꾸어라
    • Main 에서 ButtonA 인자 수정
    • Main 에서 ButtonB 인자 수정
  1. faucet 의 turnOn에 세기 옵션을 추가
    • faucet 에서 메소드 시그니처 변경
    • FaucetTurnOnCommand 수정

고치면 이렇게 된다.

public class ComputerTurnOnCommand implements Command {
	private final Computer computer;
    public ComputerTurnOnCommand(Computer computer) {this.computer = computer;}
    public void execute() {computer.turnOn();}
}

public class DoorOpenCommand implements Command {
	private final Door door;
    public DoorOpenCommand(Door door) {this.door = door;}
    public void execute() {door.open();}
}

public class FaucetTurnOnCommand implements Command {
	private final Faucet faucet;
    public FaucetTurnOnCommand(Faucet faucet) {this.faucet = faucet;}
    public void execute() {faucet.turnOn(10);}
}

public class LightTurnOffCommand implements Command {
	private final Light light;
    public LightTurnOffCommand(Light light) {this.light = light;}
    public void execute() {light.turnOff();}
}

public class LightTurnOnCommand implements Command {
	private final Light light;
    public LightTurnOnCommand(Light light) {this.light = light;}
    public void execute() {light.turnOn();}
}

public class ButtonA {
	private final Command command;
    public ButtonA(Command command) {this.command = command;}
    public void press() {command.execute();}
}

public class ButtonB {
	private final Command command;
    public ButtonB(Command command) {this.command = command;}
    public void press() {command.execute();}
}

public class ButtonC {
	private final Command command;
    public ButtonC(Command command) {this.command = command;}
    public void press() {command.execute();}
}

public class Main {
	public static void main(String[] args) {
    	ButtonA buttonA = new ButtonA(new FaucetTurnOnCommand(new Faucet()));
        ButtonB buttonB = new ButtonB(new FaucetTurnOnCommand(new Faucet()));
        ButtonC buttonC = new ButtonC(new ComputerTurnOnCommand(new Computer()));
        buttonA.press();
        buttonB.press();
        buttonC.press();
    }
}

딱 봐도 바뀌는 게 적다. 1, 3, 4 같은 경우에는 응집도와 SRP와 관련된 개선이다. 원래는 수신자의 수정이 여러 호출자에게 전파되었는데, 책임을 Command에 따로 부여하여 수신자의 수정이 해당되는 Command에만 전파된다. 물론 특정 수신자에 의존하는 커멘드 구현체가 많으면 수정의 전파가 많이 일어나겠지만, 그래도 한 단계 캡슐화를 한 것이 훨씬 덜할 것이다.

또한, 2번은 OCP와 유연한 확장성 이야기이다. 원래는 확장을 위해서 기존 클래스의 수정이 필요했다. 다만 이제는 단순히 클래스를 추가하고 Main 부분에서 생성자 주입 부분만 손봐주면 끝난다. 이전에도 말했었지만 이러한 확장 방향으로의 작업이 중요한 이유는 수정의 전파 때문이다. 확장은 상관 없지만, 어떤 클래스를 수정하는 것은 그것을 의존하는 클래스에도 영향을 미치기 때문이다. 이렇게 요구변화에 대응 시 수정의 전파가 안될 뿐더러, 수정해야 하는 코드의 양 자체도 매우 적은 편이다.

장점과 단점

위에서 말했듯이 요청을 캡슐화하여 수신자와 호출자를 분리했다. 이에 따라서 수신자에 변경이 생기더라도 호출자에 영향이 가지 않는다. 그래서 수신자의 변경이 어렵거나 수신자가 많은 경우 효과가 좋다.(SRP)
또한 그러한 분리로 인해서 기능 확장이 용이하다. 인터페이스 기반 설계로 인해서 새로운 기능이 수신자에게 추가되면 해당되는 커맨드 구현 클래스를 만들고 호출자에게 주입만 해주면 된다.(OCP)
이러한 객체지향적 관점 뿐만 아니라 기능적으로 다양한 방법으로 활용될 수 있다. 커맨드 객체를 로깅, DB에 저장, 네트워크 전송, undo 등이 있다.
물론 단점은 모든 디자인 패턴이 가지고 있는, 구조가 복잡하다는 점을 꼽을 수 있다.

예시1 - Java Runnable

자바 쓰레드 쓸 때 저거 쓴다. 어떻게 되냐면

Light light = new Light();
ExecutorService executorService = Executors.newFixedThreadPool(nThreads:4);
executorService.submit(new Runnable() {
	@Override
    public void run() {
    	light.on();
    }
});
executerService.shutdown();

이렇게 하거나

public class RunnableImpl implements Runnable {
	private final Light light;
    
    public RunnableImpl(Light light) {
    	this.light = light;
    }

	@Override
    public void run() {
    	light.on();
    }
}

Light light = new Light();
ExecutorService executorService = Executors.newFixedThreadPool(nThreads:4);
executorService.submit(new RunnableImpl(new Light()));
executerService.shutdown();

이렇게 넣어줄 수 있다. Runnable과 그 구현체가 커멘드이고, ExecuterService가 호출자, Light가 수신자라고 볼 수 있겠다. 아마 쓰레드라는 기능 자체가 굉장히 범용적인 기능이고, 여러 곳에서 수신자를 호출하며 수행될 것이다. 그러면 수신자가 터지면 여러 곳에 흩어져있는 쓰레드 로직이 다 터질 것이다. 그래서 Runnable로 캡슐화, 수정 사항을 Runnable로 응집시키는 것이다. 물론 확장성 등 다른 장점도 있겠지만.

예시2 - Spring SimpleJdbcInsert

jdbc template에서도 SQL을 보다 편리하게 사용할 수 있도록 커맨드 패턴을 적용한 부분이 있다.

public void add() {
	SimpleJdbcInsert insert = new SimpleJdbcInsert(dataSource)
    	.withTableName("command")
        .usingGeneratedKeyColumns("id");
    ...
    insert.execute(data);
}

여기에서 SimpleJdbcInsert가 커맨드의 구현체라고 보면 되겠다. 실제로 해당 클래스가 다음과 같이 여러 곳에서 사용될 가능성이 높다. jdbc는 굉장히 범용성이 큰 기능이기 때문이다.

// jdbc 연결
// 수신자A 활용
// 수신자A 활용
// 쿼리 쏨
// 작업...

여러 곳에서 의존되면 수신자의 변화 시 해당 모든 부분이 수정되어야 하나, 커맨드로 캡슐화하면 범용성이 크고 여러 곳에 있는 jdbc 로직을 바꾸는 것이 아니라 커맨드 부분인 insert를 다르게 구성하고 그 객체를 넘기면 되는 것이다. 그 이외에도 장점이 더 있긴 하겠지?

profile
한양대학교 정보시스템학과 22학번 이혁진입니다. 컴퓨터 아키텍처와 시스템 등 다양한 주제로 공부한 내용을 기록합니다.

0개의 댓글