명령 패턴 (Command)
명령 패턴이란 요청 자체를 캡슐화하는 것입니다. 이를 통해 요청이 서로 다른 사용자(client)를 매개변수로 만들고 요청을 대기 시키거나 로깅하며, 되돌릴 수 있는 연산을 지원합니다.
간단하게 표현하자면 명령 패턴은 메서드 호출을 실체화한 것입니다.
여기서 말하는 '실체화'는 '실제하는 것으로 만든다' 라는 뜻. 즉, 명령 패턴은 매서드 호출(함수 호출)이라는 행위를 데이터화(객체화) 한다는 의미입니다. 메서드를 호출하는 행위를 하나의 객체로 감싸서, 이 객체를 변수에 담거나, 전달하거나, 저장하거나, 할 수 있게 만드는것 입니다.
명령 패턴을 사용할 수 있는 예제를 한번 살펴보겠습니다.
입력키 변경
모든 게임에는 버튼이나 키보드, 마우스를 누르는 등의 유저 입력을 읽는 코드가 있습니다.
간단하게 예제 코드를 만들어보자면
void InputHandler: :handleInput()
{
if (isPressed(BUTTON_X)) jump();
else if (isPressed(BUTTON_Y)) fireGun();
else if (isPressed(BUTTON_A)) swapWeapon();
else if (isPressed(BUTTON_B)) lurchIneffectively();
}
해당 코드와 같은 코드가 있고 각각의 X 키는 점프, Y는 총을 발사하고 A와 B는 각각 무기 교체와 은신기능을 하는 코드를 작성해두었습니다. 이대로 진행해도 상관 없겠지만 많은 게임들을 각 키에 대해서 플레이어가 편하도록 맵핑(변경)할 수 있게 기능을 구현해두었습니다.
키를 변경하려면 jump()나 fireGun() 같은 함수를 직접 호출하지 말고 런타임중 교체 가능한 무엇인가로 바뀌어야합니다. 이럴때 사용하는것이 명령 패턴입니다. 먼저 게임에서 할 수 있는 행동을 실행할 수 있는 공통 상위 클래스 부터 정의해보겠습니다.
class Command
{
public:
virtual ~Command() {}
virtual void execute() = 0;
};
이제 각 행동별로 하위 클래스를 생성해줍니다.
class JumpCommand : public Command
{
public:
virtual void execute() { jump(); }
};
class FireCommand : public Command
{
public:
virtual void execute() { fireGun(); }
};
// ...
입력 핸들러 코드에는 각 버튼별로 Command 클래스 포인터를 저장한다.
class InputHandler
{
public:
void HandleInput();
// 명령을 바인드(Bind)할 메서드들
private:
Command* buttonX;
Command* buttonY;
Command* buttonA;
Command* buttonB;
};
이제 입력 처리는 다음 코드로 위임된다.
void InputHandler: :handleInput() {
if (isPressed(BUTTON_X)) buttonX->execute();
else if (isPressed(BUTTON_Y)) buttonY->execute();
else if (isPressed(BUTTON_A)) buttonA->execute();
else if (isPressed(BUTTON_B)) buttonB->execute();
}
직접 함수를 호출하던 코드 대산에, 한 겹 우회하는 계층이 생겼다. 자세한 설명을 하기위해 코드를 뜯어보자면
1. Command 클래스는 공통의 `execute()`메서드를 정의하고 있습니다.
class Command {
public:
virtual ~Command() {}
virtual void execute() = 0; // 공통의 execute()
};
2. 각 행동은 Command의 하위 클래스로 구현되어 있습니다.
class JumpCommand : public Command {
public:
virtual void execute() { jump(); }
};
class FireCommand : public Command {
public:
virtual void execute() { fireGun(); }
};
- 여기서 `execute()`를 호출하면 각각의 행동을 실행합니다.
3. InputHandler가 Command 포인터를 가지고 있습니다.
class InputHandler {
public:
void handleInput();
private:
Command* buttonX;
Command* buttonY;
Command* buttonA;
Command* buttonB;
};
- `buttonX`,`buttonY`등에 어떤 Command 객체를 연결하느냐에 따라 실행되는 행동이 달라지는 구조입니다.
buttonX = new JumpCommand();
buttonY = new FireCommand();
이런 식으로 하면 BUTTON_X를 눌렀을 때는 점프, BUTTON_Y를 눌렀을 때는 총을 발사하게 됩니다.
또한 입력키의 변동을 원한다면
buttonX = new FireCommand(); // 점프 대신 발사로 변경!
이런식의 `buttonX`가 가리키는 함수를 다르게 해주면 `execute()`시에 점프가 아닌 발사 행동이 실행 됩니다.
4. 입력 처리
void InputHandler::handleInput() {
if (isPressed(BUTTON_X)) buttonX->execute();
else if (isPressed(BUTTON_Y)) buttonY->execute();
// ...
}
- 버튼과 명령 객체를 분리하였기 때문에 명령을 쉽게 바꿔 끼울 수 있는 구조가 되었습니다.
- 이제 사용자가 원하는대로 원하는 행동을 할당 할 수 있습니다.
액터에게 지시하기
방금 정의한 Command 클래스는 잘 작동하지만 유용성이 떨어진다는 단점이 있습니다. 현재 Command 클래스는 플레이어 캐릭터 전용으로만 사용할 목적으로 정의된 클래스로 볼 수 가 있습니다. 즉, 현재 위의 에제 코드들은 다른 캐릭터나 객체가 점프하는 상황에서는 사용할 수가 없습니다. 그렇기에 현재 Command 클래스는 재사용성이나 확장성이 떨어진다고 볼 수 있습니다.
이러한 제약들을 유연하게 만들기 위해서 제어하려는 객체를 함수에서 직접 찾게 하지 말고 밖에서 전달해주는 방법을 선택할 수 있습니다.
class Command
{
public:
virtual ~Command() {}
virtual void execute(Actor &actor) = 0; // 전의 Command 클래스와의 차이점
};
여기서 `Actor`는 게임 월드를 돌아다니는 캐릭터를 대표하는 '게임 객체' 클래스입니다.
Command를 상속받은 클래스는 `execute()`가 호출될 때 이제 `Actor`객체를 인수로 받기 때문에 원하는 액터의 메서드를 호출할 수 있게되었습니다.
class JumpCommand : public Command
{
public:
virtual void execute(Actor &actor)
{
actor.jump();
}
이제 `JumpCommand` 클래스 하나로 게임에 등장하는 어떤 캐릭터라도 점프를 호출할 수 있게되었습니다.
남은 것은 입력 핸들러에서 입력을 받아 적당한 객체의 메서드를 호출하는 명령 객체를 연결하는 코드뿐입니다. 먼저 `handleInput()`에서 명령 객체를 반환하도록 변경하겠습니다.
Command* InputHandler::handleInput()
{
if (isPressed(BUTTON_X)) return buttonX;
if (isPressed(BUTTON_Y)) return buttonY;
if (isPressed(BUTTON_A)) return buttonA;
if (isPressed(BUTTON_B)) return buttonB;
// 아무것도 누르지 않았다면, 아무것도 하지 않는다.
return NULL;
}
어떤 액터를 매개변수로 넘겨줘야 할지 모르기 때문에 `handleInput()`에서는 명령을 실행 할 수가 없습니다. 기존에 코드에서는 `if (isPressed(BUTTON_X)) buttonX->execute();` 구문을 보면 현재 `buttonX->execute()` 에서 `execute()`에게 전달해줄 인자를 알 수 없기때문에 `buttonX`를 반환하도록 변경한 것 입니다. 즉, 명령이 실체화된 함수 호출이라는 점을 활용해서, 함수 호출 시점을 지연하고 있습니다.
다음으로 명령 객체를 받아서 플레이어를 객체에 적용하는 코드가 필요합니다.
Command* command = InputHandler.handleInput();
if (command)
{
command->execute(actor);
}
보통 게임의 입력 기능은 실시간 받아와야하기때문에 Tick과 같은 함수에서 호출되는 경우가 많습니다. 따라서 위의 코드 구문은 X 버튼이 눌려서 `handleInput()`메서드 에서`buttonX`가 반환된다고 가정했을때 `if(command)`가 참(true)가 되어서 `buttonX`의 `execute(actor)`를 호출하게 됩니다.
이제 해당 구문들을 통해서, 명령을 실행할 때 액터만 바꾸면 플레이어가 게임에 있는 어떤 액터라도 제어 할 수 있게 되었습니다.
플레이어가 다른 액터를 제어하는 기능은 일반적으로 많지 않지만 비슷하면서도 자주 사용되는 기능이 있습니다. 플레이어 캐릭터 외에 캐릭터는 AI가 제어하게 되는데 같은 명령 패턴을 AI 엔진과 액터 사이에 인터페이스용으로 사용 할 수가 있습니다. AI코드에서 원하는 Command의 객체를 이용하는 방식입니다.
액터를 자체를 제어하는 Command를 만들었기에 메서드를 직접 호출하는 형태의 강한 커플링(결합도, Coupling)를 제거할 수 있었습니다. 다음에는 명령을 큐(queue)나 스트림(stream)으로 만드는 것도 고려할 수 있습니다.
입력 핸들러나 AI같은 코드에서는 명령 객체를 만들어 스트림에 밀어 넣습니다. 디스패처(dispatcher)나 액터에서는 명령 객체를 받아서 호출하며, 큐를 둘 사이에 끼워 넣음으로써, 생상자(producer)와 소비자(consumer)를 디커플링 할 수 있게 되었습니다.
실행취소와 재실행
명령 객체가 어떤 작업을 실행할 수 있다면, 실행취소(Undo)할 수 있게 만드는 것도 어렵지 않습니다. 그냥 실행취소 기능을 구현하려면 굉장히 어렵지만, 명령 패턴을 이용하면 쉽게 만들 수 있습니다. 싱글플레이어 턴제 게임에서 이동 취소 기능을 추가한다고 가정해보겠습니다.
이미 명령 객체를 이용해서 입력 처리를 추상화해둔 덕분에, 플레이어 이동도 명령에 캡슐화되어 있습니다. 이제 어떤 유닛을 옮기는지 명령에 대해서 고민해봅시다.
class MoveCommand : public Command // 위의 Command 클래스를 상속
{
public:
MoveCommand(Unit* unit, int x, int y)
{
unit_ = unit;
x_ = x;
y_ = y;
}
virtual void execute()
{
unit_->moveTo(x_,y_);
}
private:
Unit* unit_;
int x_;
int y_;
}
새로 작성한 `MoveCommand` 는 이전 예제와는 약간 다른 모습을 하고 있습니다. 이전 예제에서는 명령에서 변경하려는 액터와 명령 사이를 추상화로격시켰지만 이번에는 이동하려는 유닛과 위치 값을 생성자에서 받아서 명령과 명시적으로 바인드했습니다. `MoveCommand` 명령 인스턴스는 '무엇인가를 움직이는' 보편적인 작업이 아니라 게임에서의 구체적인 실제 이동을 담고 있습니다.
이는 명령 패턴 구현을 어떻게 변형할 수 있는지 잘 보여주는 모습입니다. 처음 예제 같은 경우, 어떤 일을 하는지를 정의한 명령 객체 하나가 매번 재사용 됩니다. 입력 핸들러 코드에서는 특정 버튼이 눌릴 때마다 여기에 연결된 명령 객체의 `execute()`를 호출했었습니다.
이번에 만든 명령 클래스는 특정 시점에 발생될 일을 표현하다는 점에서 좀 더 구체적이다. 예를 들면, 입력 핸들러 코드는 플레이어가 이동을 선택할 때마다 명령 인스턴스를 생성해야한다.
Command* handleInput()
{
Unit* unit = getSeletedUnit();
if(isPressed(BUTTON_UP))
{
// 유닛을 한 칸 위로 이동한다.
int destY = unit->y() - 1;
return new MoveCommand(unit, unit->x(), destY);
}
if(isPressed(BUTTON_DOWN))
{
// 유닛을 한 칸 아래로 이동한다.
int destY = unit->y() + 1;
return new MoveCommand(unit, unit->x(), destY);
}
// 다른 이동들
return NULL;
`Command` 클래스가 일회용이라는 게 장점이라는 걸 곧 알게 될 것입니다. 명령을 취소할 수 있도록 순수 가상 함수 `undo()`를 정의 하겠습니다.
class Command
{
public:
virtual ~Command() {}
virtual void execute() = 0;
virtual void undo() = 0;
}
`undo()`에서는 `execute()`에서 변경하는 게임 상태를 반대로 바꿔주기만 하면 됩니다.`MoveCommand` 클래스에서 실행 취소 기능을 넣어보겠습니다.
class MoveUnitCommand : public Command
{
public:
MoveUnitCommand(Unit* unit, int x, int y)
: unit_(unit), x_(x), y_(y), xBefore_(0), yBefore_(0) {}
virtual void execute() {
// 나중에 이동을 취소할 수 있도록 원래 유닛 위치를 저장
xBefore_ = unit_->x();
yBefore_ = unit_->y();
unit_->moveTo(x_, y_);
}
virtual void undo() {
unit_->moveTo(xBefore_, yBefore_);
}
private:
Unit* unit_;
int x_, y_;
int xBefore_, yBefore_;
};
`MoveCommand` 클래스에 몇가지의 변수가 추가로 선언되었습니다. 유닛이 이동한 후에는 이전 위치를 알 수 없기 때문에, 이동을 취소할 수 있도록 이전 위치를 `xBefore`,`yBefore` 멤버 변수에 따로 저장하도록 두었습니다.
이전 명령에 대한 내용은 저장해두지 않기에 따로 플레이어가 이동을 취소할 수 있게 하려면 이전에 실행했던 명령을 저장해야 합니다.
여러 단계의 실행취소를 지원하는 것도 그다지 어렵지 않습니다. 가장 최근 명령만 기억하는 대신, 명령 목록을 유지하고 "현재" 명령이 무엇인지만 알고 있으면 가능합니다. 유저가 명령을 실행하면, 새로 생성된 명령을 목록 맨 뒤에 추가하고, 이를 "현재" 명령으로 기억하면 됩니다.
유저가 '실행취소'를 선택하면 현재 명령을 실행취소하고 현재 명령을 가리키는 포인터를 뒤로 이동한다.
'재실행'을 선택하면, 포인터를 다음으로 이동시킨 후에 해당 포인터를 실행한다. 유저가 몇 번 '실행취소'한 뒤에 새로운 명령을 실행한다면, 현재 명령 뒤에 새로운 명령을 추가하고 그 다음에 붙어 있는 명령들은 버린다.
'Software Engineering > Design Pattern' 카테고리의 다른 글
관찰자 패턴(Observer Pattern) (0) | 2025.03.02 |
---|---|
경량 패턴 (0) | 2025.02.24 |