서비스
교육 철학

명령 패턴 (Command)

게임이 아니여도 명령패턴은 정말 다양한 분야에서 사용되고 있다.
명령 패턴은 메서드(함수) 호출을 실체화 한 것이다.

입력키 변경 및 실행

A버튼은 무기를 바꾸는 동작
B 점프를 하는 동작을 실행
X는 총아을 발사하는 동작
Y 방패를 올리는 동작
위 패턴을 정말 간단하게 구현 예를 보자
class InputHandler { public: void HandleInput() { if (isPressed(BUTTON_X)) Fire(); else if (isPressed(BUTTON_Y)) Defence(); else if (isPressed(BUTTON_A)) SwapWeapon(); else if (isPressed(BUTTON_X)) Jump(); } //... }
Python
복사
그래서 간단하게는 단순한 함수의 호출로 구현이 가능하다.
여기서 우리는 디자인패턴을 조금 더 깊게 공부하기 위해서 해당 함수에 실행되는 동작 하나하나를 하나의 클래스를 이용해서 객체로 바꾸어보자.
class Command { public: virtual ~Command() {} virtual void Execute() = 0; }; class JumpCommand : public Command { public: void Execute() override { std::cout << "Jump" << std::endl; } }; class FireCommand : public Command { public: void Execute() override { std::cout << "Fire" << std::endl; } }; class InputHandler { public: void HandleInput() { if (isPressed(BUTTON_X)) mButtonX->Execute(); else if (isPressed(BUTTON_Y)) mButtonY->Execute(); else if (isPressed(BUTTON_A)) mButtonA->Execute(); else if (isPressed(BUTTON_B)) mButtonB->Execute(); } private: Command* mButtonX; Command* mButtonY; Command* mButtonA; Command* mButtonB; };
Python
복사

게임 오브젝터(액터) 에게 명령하기

방금 정의한 command 클래스는 잘동작하긴 하겠지만 한계가 존재한다. jump() 나 fire()같은 함수가 플레이어 객체를 찾아서 실행을 해야한다.
이렇게 해당 로직 함수들이 플레이어와 긴밀하게 연결되어 있어 (커플링)
그러다 보니 유연성, 유용성이 떨어지게 된다. 현재 JumpCommand는 오직 플레이어 캐릭터만 점프 할 수 있도록 실행된다고 볼수 있다.
플레이어 외에도 몬스터, 여러가지 객체들 다 점프를 할수 있다면 코드를 새로 작성하지 않고 JumpCommand클래스를 재사용 하면 굉장히 편리해 질수 있다.
커맨드 클래스들이 플레이나 특정객체를 변수로 들고 있어서 실행할 수 있게 하지말고 외부에서 점프시킬 객체를 인자로 받아서 점프를 실행시키자.
class Command { public: virtual ~Command() {} virtual void Execute(GameObject* actor) = 0; }; class JumpCommand : public Command { public: void Execute(GameObject* actor) override { actor->Jump(); } };
Python
복사
이제 jumpCammand 클래스 하나로 게임이 등장하는 여러가지 캐릭터들이 다 점프가 가능하게 되었다. 남은것은 입력 핸들러에서 받아 적당한 객체의 메서드를 호출하는 명령 객체를 연결하는 작업뿐이다.
Command* InputHandler::handleInput() { if (isPreseed(BUTTON_X)) return buttonX; if (isPreseed(BUTTON_Y)) return buttonY; if (isPreseed(BUTTON_A)) return buttonA; if (isPreseed(BUTTON_B)) return buttonB; //아무것도 누르지 않았다면, 아무것도 하지 않느다. return nullptr; }
Python
복사
어떤 오브젝트를 매개변수로 넘겨줘야 할지 모르기 떄문에 handleInput()에서는 명령을 실행할수 없다.
그렇기 때문에 여기에서는 함수 호출시점을 지연시켜 주는 것이다.
다음 명령으로 객체를 받아서 플레이어를 대표하는 GameObject 객체에 적용하는 코드가 필요하다.
Command* command = inputHandler.handleInput(); if (command) { command->execute(actor); }
Python
복사
GameObject가 플레이어 캐릭터라면 유저 입력에 따라 동작하기 때문에 처음 예제와 기능상 다를 게 없다. 하지만 명령과 오브젝트 사이에 추상 계층 한 단계 더 둔 덕분에, 소소한 기능이 하나 추가 되었다. 명령을 실행할 때 actor(gameObject)만 바꿔주면 게임에 있는 어떤 액터라도 제어 할수 있게 되었다.
사실 플레이어가 다른 엑터를 제어하는 일은 일반적이지 않다. 하지만 비슷하면서도 자주 사용 되는 기능이 있다. 예를들어 AI가 제어하는 캐릭터를 생각해보자. 다양한 AI가 존재한다고 했을때 … (npc, monster….) 각각 다른 동작을 실행시킬수 있다.
command객체를 선택하는 AI와 이를 실행하는 엑터를 디커플링 시키면서, 액터마다 AI모듈을 다 다르게 적용 할 수 있고, 기존 AI를 변형시켜 새로운 성향의 AI를 만들수도 있다. 더 나아가 플레이어 캐릭터에 AI를 연결해서 자동으로 실행되는 데모를 만들어 볼수도 있다.
엑터를 제어하는 command를 일급 객체로 만든 덕분에, 메서드를 직접 호출하는 형태가 아니라 한단계 추상화를 거쳐서 디커플링을 제거 할 수 있다.
입력 핸들러나 AI 같은 코드에서는 명령 객체를 만들어 스트림에 밀어 넣는다. 결론적으로 엑터에서는 명령 객체를 받아서 동작을 실행한다.

실행취소와 재실행

명령 패턴 중에서도 가장 잘 알려진 패턴이다. 명령 객체가 어떤 작업을 실행 할 수 있다면, 이를 실행취소 할수 있게 만드는 것도 어렵지 않다.
실행 취소 기능은 원치 않는 행동을 되돌릴 수 있는 전략게임 자주 볼 수 있다. 특히 게임개발 툴을 만들 때 필수적으로 사용된다.
실행 취소기능을 구현하려면 굉장히 어렵지만, 명령패턴을 사용하면 쉽게 만들수 있다. 싱글플레이 턴제 게임에서 유저가 어림짐작해서 전략을 실행해볼수 있는 이동 취소기능 만들어 봅시다.
명령 객체에서 이를 이용해서 입력처리를 추상화 해두었다. 플레이어 이동도 캡슐화 되어있다. 유닛을 옮기는 명령을 만들어보자
class MoveUnitCommand : public Command { public: MoveUnitCommand(Unit* unit, int x, int y) : unit_(unit), x_(x), y_(y) {} virtual void execute() { unit_->moveTo(x_, y_); } private: Unit* unit_; int x_, y_; };
Python
복사
MoveUnitCommand 클래슨 이전 예제와 다르다. 이전 예제에서는 명령에서 변경하려는 액터를 인자로 받아서 명령과 액터사이를 추상화 시켜 격리했습니다. 이번에는 이동하려는 유닛을 위치값을 생성자에서 받아서
MoveUnitCommand 와 바인딩( Unit* 객체의 주소를 알고 있음) 시켰다. 이 커맨드는 보편적인(인자로 받는 경우)작업이 아니라 게임에서 구체적으로 실질적인 이동을 담당하고 있다.
이는 명령패턴 구현을 어떻게 변형할 수 있는지를 보여준다. 처음 예제의 경우에는 어떤일을 하는지가 중요하고 명령 객체하나가 매번 재사용 된다.
이번에는 명령 클래스는 특정 시점에 발생될 일을 표현하다는 거에서 조금더 구체적이다. 이를 테면 입력핸들러 코드는 플레이어가 이동을 선택 할 때마다 명령 인스턴스를 생성해야한다.
Command* handleInput() { Unit* unit = getSelectedUnit(); if (isPressed(BUTTON_UP)) { // Move the unit up one. int destY = unit->y() - 1; return new MoveUnitCommand(unit, unit->x(), destY); } if (isPressed(BUTTON_DOWN)) { // Move the unit down one. int destY = unit->y() + 1; return new MoveUnitCommand(unit, unit->x(), destY); } // Other moves... return NULL; }
Python
복사
MoveUnitCommand클래스가 일회용이 된다. 이게 장점이 된다는 거에요. 명령을 취소 하루 있도록 순수가상함수 undo()를 정의한다.
class Command { public: virtual ~Command() {} virtual void execute() = 0; virtual void undo() = 0; };
Python
복사
undo()에서는 excute()에서 변경하는 게임상태로 바꿔주면 된다. 실행 취소 기능을 넣어보자
class MoveUnitCommand : public Command { public: MoveUnitCommand(Unit* unit, int x, int y) : unit_(unit), xBefore_(0), yBefore_(0), x_(x), y_(y) {} virtual void execute() { // Remember the unit's position before the move // so we can restore it. xBefore_ = unit_->x(); yBefore_ = unit_->y(); unit_->moveTo(x_, y_); } virtual void undo() { unit_->moveTo(xBefore_, yBefore_); } private: Unit* unit_; int xBefore_, yBefore_; int x_, y_; };
Python
복사
잘 보면 MoveUnitCommand의 클래스 상태가 몇개 추가 되었다. 유닛이 이동 한후에 이전 위치를 기억하기위해서 before변수가 추가되었다.
이동을 취소 할 수 있게 하려면 이전에 실행했던 명령을 저장 해둬야 한다. 우리가 ctrl+z를 막 누르고 있을때, 이전 명령어의 undo() 실행되는 거다.
가장 최근 명령만 기억하는 대신, 명령목록을 유지하고 현재명령이 무엇이지만 알고 있으면 된다. 유저가 명령을 실행하면, 새로 생성된 명령을 목록 맨 뒤에 추가하고, 이를 현재명령으로 기억하면 된다.
유저가 실행 취소를 선택하면 현재 명령을 실행취소하고 현재 명령을 가리큰 포인터를 한칸 뒤로 이동하다. 몇번 실행 취소 한 뒤에 새로운 명령을 실행하면 , 현재 명령 뒤에 새로운 명령어를 추가 하고 그 다음에 붙어 있는 명령들은 지워준다.

클래스만 좋고, 함수형은 별로인가?

위 예제에서는 처음에 간단하게 표현한 클래스를 계속 복잡하게 패턴화시켜 함수형 객체로 표현하였다. 자 그러면 과연 패턴을 사용하는 경우와 간단하게 함수만을 이용해서 해당 패턴을 표현하는 경우 어떤것이 더 좋은가?
상황에 따라서 내가 만들고자 하는 기능이 간단한 경우라면 함수형이 더 좋은 성과를 낼 수 있다. 작업시간도 훨씬 줄어든다. 다만 유지보수가 추가적으로 들어가야하고 또한 추가적인 컨텐츠가 생기는 경우라면 클래스를 이용해서 패턴을 사용하는가 더 좋을 수 있다.
만약에 javascript같은 함수형 프로그래밍에 익숙한 경우라면 조금더 효율적으로 명령패턴을 사용할수 있을 것이다.
function makeMoveUnitCommand(unit, x, y) { // This function here is the command object: return function() { unit.moveTo(x, y); } } // 클로저 여러개를 사용 할 경우 function makeMoveUnitCommand(unit, x, y) { var xBefore, yBefore; return { execute: function() { xBefore = unit.x(); yBefore = unit.y(); unit.moveTo(x, y); }, undo: function() { unit.moveTo(xBefore, yBefore); } }; }
JavaScript
복사
💡
명령패턴 연습해보기 C++ 콘솔창을 이용하여 숙제를 해주세요.
플레이어와 그 플레이어의 행동 3가지에 맞는 커맨드 클래스(이동, 대쉬, 점프) 를 구현해라.
이동은 한칸식 이동하면된다. 좌우
대쉬는 앞으로(오른쪽)만 2칸씩
점프는 위로(1칸 이동)
(추가숙제 - 못해도 괜찮다.)그리고 추가적으로 z를누르면 이전 동작으로 돌아가게끔 undo를 구현하여 커맨드패턴을 활용해보라 2차원 std::vector<std::vector<char>>..의 맵을 만드시고 해당맵에서 플레이어가 커맨드 패턴을 이용해서 이동하는 기능을 구현 해주세요. 그리고 맵에 해당정보들이 화면에 그려줘리.
#include <iostream> #include <vector> #include <Windows.h> class Player { public: Player(char ch, int y, int x) : mCh(ch) , mY(y) , mX(x) { } int GetY() const { return mY; } int GetX() const { return mX; } char GetChar() const { return mCh; } private: char mCh; int mY; int mX; }; int main() { std::vector<std::vector<char>> map ( 20, std::vector<char>(20,'_') ); Player player('P', 10, 10); while (true) { char input = 0; std::cin >> input; // print player map[player.GetY()][player.GetX()] = player.GetChar(); //print map system("cls"); Sleep(100); for (size_t y = 0; y < 20; y++) { for (size_t x = 0; x < 20; x++) { std::cout << map[y][x]; } std::cout << std::endl; } } return 0; }
JavaScript
복사

원문 전체 해설·코드·도해

💡
위의 수업용 정리와 실습을 먼저 읽은 뒤, 아래에서 책의 원문 흐름·모든 코드·원본 도해·각주를 빠짐없이 확인할 수 있습니다. 보충 GIF는 실행 취소와 재실행의 상태 변화를 보여 줍니다.

2부 · 디자인 패턴 다시 보기

『GoF의 디자인 패턴』이 나온 지 20년이 넘었습니다. 원서 집필 시점을 기준으로, 사람이라면 술을 마셔도 될 만큼 나이를 먹은 셈입니다. 빠르게 변하는 소프트웨어 업계에서 20년은 긴 시간입니다. 그런데도 계속 읽힌다는 사실은 많은 프레임워크나 방법론과 달리 오래가는 설계의 가치가 얼마나 큰지 보여 줍니다.
그 책은 여전히 유용하지만, 그동안 개발자들도 많은 것을 배웠습니다. 이 부에서는 GoF의 여러 패턴을 되짚으며 유용하거나 재미있는 점을 살펴봅니다. 싱글턴은 남용되는 반면 명령 패턴은 과소평가되어 왔다고 저자는 봅니다. 경량과 관찰자는 게임 개발과의 관련성을 보기 위해, 프로토타입과 상태는 더 넓은 프로그래밍 분야에서 어떻게 얽히는지 살펴보기 위해 선택했습니다.
이 부의 순서는 2장 명령 → 3장 경량 → 4장 관찰자 → 5장 프로토타입 → 6장 싱글턴 → 7장 상태입니다.

명령 패턴(Command Pattern)

요청 자체를 캡슐화해 서로 다른 요청으로 사용자를 매개변수화하고, 요청을 대기시키거나 기록하며, 실행을 되돌릴 수 있게 합니다. — 『GoF의 디자인 패턴』, 한국어판 311쪽의 정의
명령 패턴은 저자가 특히 좋아하는 패턴입니다. 게임이 아니어도 어느 정도 규모가 있는 프로그램에서 늘 활용했고, 제대로 쓰면 지저분한 코드를 깔끔하게 정리할 수 있었습니다. 그런데 GoF의 정의는 상당히 난해하게 느껴집니다.
현실에서 ‘사용자(client)’는 함께 일하는 사람을 뜻합니다. 사람을 ‘매개변수화’한다는 비유는 잘 와닿지 않습니다. 정의의 나머지 부분도 적용할 수 있는 작업을 나열할 뿐이어서, 마침 우리가 그런 일을 하고 있지 않다면 이해에 큰 도움이 되지 않습니다.
저자는 이를 다음과 같이 표현합니다.
명령 패턴은 메서드 호출을 실체화(reify)한 것입니다.
이 설명도 너무 짧아 뜻이 불분명할 수 있습니다. 조금 풀어 보겠습니다. 실체화는 실제로 존재하는 것으로 만든다는 뜻이며, 프로그래밍에서는 무언가를 일급(first-class) 값으로 만든다는 뜻과도 통합니다.
개념을 변수에 저장하거나 함수에 전달할 수 있는 데이터, 즉 객체로 만드는 것입니다. 여기서 ‘메서드 호출을 실체화한다’는 것은 함수 호출을 객체로 감싼다는 뜻입니다.
원주 1·옮긴이: 책의 GoF 인용문과 쪽수는 『GoF의 디자인 패턴』 한국어판(피어슨에듀케이션코리아, 2015)을 기준으로 합니다.
원주 2: reify는 ‘사물’을 뜻하는 라틴어 res와 영어 접미사 -fy를 합친 말입니다. 영어식으로 하면 thingify인데, 저자는 그쪽이 더 재미있게 들린다고 합니다.
원주 3: 리플렉션(reflection)을 지원하는 언어에서는 실행 중에 자료형을 객체로 다룰 수 있습니다. 다른 객체의 클래스를 나타내는 객체를 얻거나, 어떤 자료형이 할 수 있는 일을 확인합니다. 즉 리플렉션은 자료형 체계를 실체화한 것으로 볼 수 있습니다.
익숙한 언어에 따라 콜백, 일급 함수, 함수 포인터, 클로저, 부분 적용 함수가 떠오를 것입니다. 실제로 비슷합니다. GoF도 뒤에서는 명령 패턴을 ‘콜백을 객체지향적으로 표현한 것’이라고 설명합니다.
처음부터 그렇게 설명했으면 조금 더 나았겠지만, 여전히 추상적입니다. 저자는 보통 구체적인 사례에서 출발하는 편인데 이번에는 그러지 못했다고 인정하며, 이제 명령 패턴을 잘 활용할 수 있는 예제로 들어갑니다.

2.1 입력키 변경

모든 게임에는 버튼, 키보드, 마우스 같은 입력을 읽는 코드가 있습니다. 그 코드는 입력을 게임에서 의미 있는 행동으로 바꿉니다.
그림 2-1. 버튼을 점프·공격·비틀거리기·무기 교체 함수에 직접 연결한 모습
가장 간단하게 구현하면 다음과 같습니다.
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(); }
C++
복사
원주 4: 고수의 조언이라며 B 버튼을 너무 자주 누르지 말라는 농담을 덧붙입니다. 이 버튼의 동작은 ‘제대로 하지 못하고 비틀거리기’입니다.
보통 이런 함수는 게임 루프(9장)에서 매 프레임 호출합니다. 코드는 쉽게 이해할 수 있습니다. 키를 바꾸지 못하게 할 생각이라면 이대로도 괜찮지만, 많은 게임은 입력키 변경을 지원합니다.
키를 바꾸려면 jump()나 fireGun()을 직접 호출하는 부분을 교체 가능한 무언가로 바꿔야 합니다. ‘교체’라는 말에서 게임 행동을 나타내는 객체를 변수에 할당하는 모습을 떠올릴 수 있습니다. 이제 명령 패턴이 등장할 차례입니다.
먼저 게임 행동을 실행할 공통 상위 클래스를 정의합니다.
class Command { public: virtual ~Command() {} virtual void execute() = 0; };
C++
복사
원주 5: 인터페이스에 반환값 없는 메서드 하나만 있다면 명령 패턴일 가능성이 높습니다.
이제 행동마다 하위 클래스를 만듭니다.
class JumpCommand : public Command { public: virtual void execute() { jump(); } }; class FireCommand : public Command { public: virtual void execute() { fireGun(); } }; // 나머지 행동도 같은 방식으로 정의합니다.
C++
복사
입력 핸들러에는 버튼별로 Command 포인터를 저장합니다.
class InputHandler { public: void handleInput(); // 명령을 바인드(bind)할 메서드들... private: Command* buttonX_; Command* buttonY_; Command* buttonA_; Command* buttonB_; };
C++
복사
입력 처리는 각 포인터가 가리키는 명령에 위임합니다.
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(); }
C++
복사
함수를 직접 호출하던 자리에 우회 계층이 한 겹 생겼습니다. 이제 X 버튼이 어떤 행동을 할지는 buttonX_에 어떤 명령을 연결하느냐로 정합니다.
그림 2-2. 버튼과 행동 사이에 교체 가능한 명령 객체를 둔 모습
원주 6: 예제는 모든 버튼에 명령 객체가 연결되어 있다고 가정하므로 NULL 검사를 생략합니다. 아무것도 하지 않는 버튼을 지원하면서 검사도 피하려면, execute()가 빈 명령을 만들어 NULL 대신 연결할 수 있습니다. 이를 널 객체(Null Object) 패턴이라고 합니다.
여기까지가 명령 패턴의 핵심입니다. 장점을 이해했다면 나머지는 더 활용하는 방법이라고 생각하며 읽어 보세요.
💡
키 설정 화면에서는 무엇을 바꾸면 될까요?
기존 코드는 X 버튼 처리 부분을 수정해야 점프를 다른 행동으로 바꿀 수 있었습니다. 이제는 X 버튼에 연결된 명령 객체만 교체하면 됩니다. 입력을 감지하는 코드와 행동을 수행하는 코드가 각자 한 가지 책임을 맡는 것입니다.

2.2 액터에게 지시하기

방금 만든 클래스도 한계가 있습니다. jump()와 fireGun() 같은 전역 함수가 플레이어 캐릭터를 암묵적으로 찾아 움직일 수 있다고 가정합니다. 이렇게 대상과 행동이 결합되어 있으므로 현재 JumpCommand는 플레이어만 점프하게 만듭니다.
제약을 줄이려면 명령이 대상을 직접 찾게 하지 말고, 제어할 객체를 바깥에서 전달합니다.
class Command { public: virtual ~Command() {} virtual void execute(GameActor& actor) = 0; };
C++
복사
GameActor는 게임 월드를 돌아다니는 캐릭터를 나타내는 게임 객체 클래스입니다. 명령의 execute()가 이 객체를 인수로 받으면 원하는 액터의 메서드를 호출할 수 있습니다.
class JumpCommand : public Command { public: virtual void execute(GameActor& actor) { actor.jump(); } };
C++
복사
이제 하나의 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; }
C++
복사
입력 핸들러는 어떤 액터를 전달해야 하는지 모르므로 여기서 명령을 실행할 수 없습니다. 함수 호출을 객체로 만들었기 때문에 호출 시점을 뒤로 미룰 수 있는 것입니다.
반환된 명령을 받아 플레이어 액터에 적용하는 코드는 다음과 같습니다.
Command* command = inputHandler.handleInput(); if (command) { command->execute(actor); }
C++
복사
actor가 플레이어라면 동작은 처음 예제와 같습니다. 하지만 명령과 액터 사이에 추상 계층이 하나 더 생겨, 액터만 바꾸면 플레이어가 다른 캐릭터를 제어할 수도 있게 됐습니다.
플레이어가 다른 액터를 제어하는 기능 자체가 흔하지는 않습니다. 그러나 비슷하면서 자주 필요한 기능이 있습니다. 지금까지는 플레이어 캐릭터만 생각했지만, 게임에는 AI가 제어하는 캐릭터도 많습니다. 같은 명령 인터페이스를 AI 엔진과 액터 사이에 놓으면 AI가 원하는 명령 객체를 선택할 수 있습니다.
명령을 선택하는 AI와 실행하는 액터를 디커플링하면 유연성이 높아집니다. 액터마다 다른 AI 모듈을 연결하거나, 기존 AI를 조합해 새로운 성향을 만들 수 있습니다. 적을 더 공격적으로 만들고 싶다면 공격 명령을 더 많이 내리는 AI를 적용하면 됩니다. 플레이어에게 AI를 연결해 자동으로 돌아가는 데모 모드를 만들 수도 있습니다.
액터를 제어하는 명령을 일급 객체로 만든 덕분에, 메서드를 직접 호출하던 강한 결합을 제거했습니다. 이제 명령을 큐(queue)나 스트림(stream)에 넣는 것도 생각할 수 있습니다.
그림 2-3. AI가 만든 명령이 스트림을 거쳐 액터로 전달되는 구조
입력 핸들러나 AI는 명령 객체를 만들어 스트림에 넣고, 디스패처나 액터는 명령을 받아 실행합니다. 둘 사이에 큐를 두면 명령의 생산자와 소비자를 디커플링할 수 있습니다.
원주 7: 큐로 할 수 있는 일을 더 알아보려면 15장 이벤트 큐 패턴을 참고하세요.
원주 8: 명령을 직렬화(serialization)할 수 있게 만들면 네트워크로 전송할 수도 있습니다. 플레이어의 입력을 명령으로 바꿔 상대에게 보내고 재현하는 방식으로, 네트워크 플레이에서 중요합니다.
💡
입력과 AI가 같은 길을 사용합니다.
사람이 오른쪽 키를 누르든 AI가 오른쪽 이동을 선택하든, 실행하는 쪽에는 같은 이동 명령이 도착하도록 만들 수 있습니다. 다만 명령을 전송하거나 나중에 실행한다면 대상의 유효기간과 식별 방법도 정해야 합니다. 객체의 메모리 주소 자체를 네트워크로 보내는 것과 명령을 직렬화하는 것은 다릅니다.

2.3 실행 취소와 재실행

명령 패턴의 가장 잘 알려진 활용 중 하나입니다. 어떤 작업을 명령으로 실행할 수 있다면 그 작업을 되돌리는 기능도 만들기 쉬워집니다. 전략 게임의 이동 취소나 게임 개발 도구에서 자주 사용합니다. 레벨 에디터에 실행 취소를 제공하지 않는 것만큼 기획자가 개발자를 미워하게 만드는 확실한 방법도 없다고 저자는 말합니다.
원주 9·옮긴이: 여기서 레벨은 게임 속 지역이나 단계를 말합니다.
원주 10: 저자는 실제로 실행 취소 없는 도구를 만들어 봐서 안다고 덧붙입니다.
아무 구조 없이 실행 취소를 붙이기는 어렵지만, 명령 패턴을 이용하면 수월합니다. 싱글플레이 턴제 게임에서 플레이어가 어림짐작보다는 전략에 집중할 수 있도록 이동 취소 기능을 추가한다고 해 봅시다.
입력은 이미 명령으로 추상화했고, 이동도 명령에 캡슐화할 수 있습니다. 유닛을 옮기는 명령은 다음과 같습니다.
class MoveUnitCommand : public Command { public: MoveUnitCommand(Unit* unit, int x, int y) : unit_(unit), x_(x), y_(y) {} virtual void execute() { unit_->moveTo(x_, y_); } private: Unit* unit_; int x_, y_; };
C++
복사
이 명령은 앞의 예와 조금 다릅니다. 이전에는 액터와 명령을 분리해 실행할 때 액터를 전달했지만, 이번에는 이동할 유닛과 위치를 생성자에서 받아 명령에 명시적으로 연결했습니다. 이 인스턴스는 ‘무언가를 움직인다’는 보편적인 행동이 아니라 특정 유닛의 구체적인 이동 한 번을 담습니다.
이 차이는 명령 패턴을 어떻게 변형할 수 있는지 보여 줍니다. 처음 예제는 어떤 일을 하는지 정의한 명령 하나를 재사용했습니다. 버튼이 눌릴 때마다 같은 명령의 execute()를 호출했습니다.
지금의 명령은 특정 시점에 발생할 일을 나타냅니다. 따라서 입력 핸들러가 이동을 선택할 때마다 새 명령 인스턴스를 만듭니다.
Command* handleInput() { Unit* unit = getSelectedUnit(); if (isPressed(BUTTON_UP)) { // 유닛을 한 칸 위로 이동합니다. int destY = unit->y() - 1; return new MoveUnitCommand(unit, unit->x(), destY); } if (isPressed(BUTTON_DOWN)) { // 유닛을 한 칸 아래로 이동합니다. int destY = unit->y() + 1; return new MoveUnitCommand(unit, unit->x(), destY); } // 다른 이동들... return NULL; }
C++
복사
원주 11: C++처럼 가비지 컬렉션이 없는 언어에서는 명령을 실행하는 쪽이 명령 객체의 메모리 해제까지 관리해야 합니다.
곧 이 명령이 일회용이라는 점이 장점이 됩니다. 취소할 수 있도록 순수 가상 함수 undo()를 추가합니다.
class Command { public: virtual ~Command() {} virtual void execute() = 0; virtual void undo() = 0; };
C++
복사
undo()는 execute()가 바꾼 상태를 되돌립니다. 이동 명령에 실행 취소를 넣어 보겠습니다.
class MoveUnitCommand : public Command { public: MoveUnitCommand(Unit* unit, int x, int y) : unit_(unit), xBefore_(0), yBefore_(0), x_(x), y_(y) {} 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_; };
C++
복사
상태가 몇 개 늘었습니다. 유닛을 옮긴 다음에는 이전 위치를 알 수 없으므로, 이동 전에 xBefore_, yBefore_에 저장합니다.
플레이어가 이동을 취소하려면 실행한 명령 자체도 보관해야 합니다. Ctrl+Z를 여러 번 누르면 이전 명령의 undo()가 계속 호출됩니다. 이미 취소한 명령을 다시 적용할 때는 재실행합니다. 본문은 이 동작을 redo()라고 부릅니다.
여러 단계의 취소도 어렵지 않습니다. 마지막 명령 하나만 기억하는 대신 목록을 유지하고, ‘현재 명령’을 가리키는 위치를 기억하면 됩니다. 새 명령을 실행하면 목록 끝에 추가하고 현재 명령으로 삼습니다.
그림 2-4. 명령 목록에서 현재 위치를 옮기며 실행 취소와 재실행을 하는 모습
사용자가 취소하면 현재 명령을 되돌린 뒤 포인터를 이전으로 옮깁니다. 재실행을 선택하면 포인터를 다음 명령으로 옮겨 실행합니다. 여러 번 취소한 뒤 새 명령을 실행했다면 현재 위치 뒤에 새 명령을 추가하고, 그 뒤에 남아 있던 예전 명령들은 버립니다.
원주 12: 메멘토(Memento) 패턴도 있지만 저자는 제대로 활용하는 모습을 보지 못했다고 말합니다. 객체의 상태를 전부 저장하기보다 명령이 변경한 데이터만 저장하는 편이 메모리 효율이 좋다고 봅니다. 지속 자료구조(persistent data structure)도 고려할 수 있습니다. 이 자료구조는 변경 시 원본을 그대로 두고 새 객체를 반환하며, 변경되지 않은 데이터를 이전 객체와 공유하면 전체 복사보다 메모리를 적게 씁니다. 명령이 실행 전 객체를 참조해 두었다가 취소할 때 그 객체로 되돌릴 수 있습니다.
원주 13: 재실행은 게임에서 흔하지 않을 수 있지만 리플레이는 자주 사용합니다. 매 프레임 전체 게임 상태를 저장하면 메모리를 많이 쓰므로, 많은 게임은 개체들이 수행한 명령을 프레임별로 기록합니다. 리플레이할 때는 명령을 순서대로 실행해 게임을 다시 시뮬레이션합니다.
저자는 처음 레벨 에디터에 다중 실행 취소를 붙인 날 천재가 된 것처럼 느꼈다고 합니다. 명령 패턴이 그만큼 쉽고 잘 동작했기 때문입니다. 모든 데이터 변경이 명령을 거치게 만드는 데는 수고가 들지만, 그 기반을 갖춘 뒤에는 나머지가 어렵지 않습니다.
💡
왜 ‘반대 방향으로 이동’만 하면 안 될까요?
이동 중 충돌 때문에 실제 이동량이 달라졌거나, 다른 규칙이 위치를 조정했다면 반대 방향 명령만으로 원래 상태가 복원되지 않을 수 있습니다. 예제처럼 변경 전 위치를 저장하면 ‘원래 어디에 있었는가’를 기준으로 복원합니다. 리플레이에서도 같은 명령만으로 같은 결과가 나오는지 확인해야 합니다. 난수, 시간 간격, 외부 입력 등 결과에 영향을 주는 조건을 함께 통제해야 합니다.
보충 GIF · 명령 기록을 따라 이동을 실행하고, 역순으로 취소한 뒤 재실행하는 과정
파란 점은 유닛의 위치이고, 초록색 명령은 현재 적용된 기록입니다. 실행 취소는 저장해 둔 이동 전 위치로 돌아가며, 재실행은 기록된 이동을 다시 적용합니다.

2.4 클래스만 좋고 함수형은 별로일까요?

앞에서는 명령이 일급 함수나 클로저와 비슷하다고 했는데, 예제는 모두 클래스를 사용했습니다. 함수형 언어에 익숙하다면 함수는 왜 쓰지 않는지 궁금할 수 있습니다.
저자는 당시 C++의 일급 함수 지원이 충분하지 않다고 보고 클래스를 사용했습니다. 함수 포인터에는 상태를 저장할 수 없고, 펑터(functor)는 결국 클래스를 정의해야 합니다. C++11의 람다도 메모리를 직접 관리하는 문제 때문에 까다롭다고 설명합니다.
그렇다고 다른 언어에서도 함수를 사용하면 안 된다는 뜻은 아닙니다. 언어가 클로저를 잘 지원한다면 쓰지 않을 이유가 없습니다. 어떤 관점에서는 명령 패턴이 클로저를 지원하지 않는 언어에서 클로저를 흉내 내는 방식이기도 합니다.
자바스크립트로 유닛 이동 명령을 만들면 다음과 같습니다.
function makeMoveUnitCommand(unit, x, y) { // 반환하는 함수가 명령 객체입니다. return function() { unit.moveTo(x, y); }; }
JavaScript
복사
클로저를 여러 개 사용하면 취소도 지원할 수 있습니다.
function makeMoveUnitCommand(unit, x, y) { var xBefore, yBefore; return { execute: function() { xBefore = unit.x(); yBefore = unit.y(); unit.moveTo(x, y); }, undo: function() { unit.moveTo(xBefore, yBefore); } }; }
JavaScript
복사
함수형 프로그래밍에 익숙하다면 이미 알고 있는 이야기겠지만, 그렇지 않다면 도움이 될 것입니다. 저자는 명령 패턴의 유용성 자체가 함수형 패러다임이 얼마나 많은 문제를 효과적으로 다루는지 보여 주는 사례라고 봅니다.
원주 14: ‘어떤 관점에서는’이라고 단서를 붙인 이유는, 클로저가 있어도 클래스나 구조체로 명령을 표현하는 편이 좋은 경우가 있기 때문입니다. 실행과 취소처럼 여러 기능을 함께 담아야 하면 함수 하나로 바꾸기 어렵습니다. 또한 멤버 변수가 명시된 클래스는 어떤 데이터를 들고 있는지 읽기 쉽습니다. 클로저는 상태를 편리하게 감싸지만 너무 자동으로 처리해 어떤 상태가 캡처되었는지 알아보기 어려울 수도 있습니다.
💡
현재 C++ 코드에 적용할 때
위 평가는 원서 집필 당시의 설명입니다. 람다나 함수 객체를 써도 ‘무엇을 언제 실행할지 데이터로 보관한다’는 개념은 같습니다. 구현을 선택할 때는 캡처한 객체의 수명, 취소에 필요한 데이터, 명령을 저장하거나 직렬화해야 하는지를 함께 생각하세요. 단순 콜백 하나와 실행·취소·기록을 함께 가진 명령은 필요한 표현이 다를 수 있습니다.

2.5 관련 자료

•
명령 패턴을 쓰다 보면 많은 Command 하위 클래스가 생길 수 있습니다. 구체 상위 클래스(concrete base class)에 편의용 상위 수준 메서드를 제공하고, 하위 클래스가 원하는 동작을 재정의하게 만들 수 있습니다. 이렇게 되면 execute()는 12장의 하위 클래스 샌드박스 패턴으로 발전합니다.
•
예제에서는 명령을 처리할 액터를 명시적으로 지정했습니다. 하지만 계층적인 객체 모델에서는 대상이 그렇게 명확하지 않을 수도 있습니다. 객체가 명령에 반응하거나 종속 객체에 처리를 넘길 수 있다면 책임 연쇄(Chain of Responsibility) 패턴으로 볼 수 있습니다.
•
첫 JumpCommand처럼 상태 없이 행동만 정의하는 명령도 있습니다. 이런 클래스의 인스턴스는 모두 같으므로 여러 개 만들면 메모리만 낭비합니다. 3장의 경량(Flyweight) 패턴으로 해결할 수 있습니다.
원주 15·옮긴이: 책임 연쇄는 『GoF의 디자인 패턴』 한국어판 298쪽을 참고합니다.
원주 16: 싱글턴으로 만들어도 되지만, 진정한 친구라면 싱글턴을 권하지 않을 것이라는 농담입니다. 6장에서 다룹니다.
💡
명령 패턴 연습해 보기
문자로 된 2차원 맵과 플레이어를 만든 뒤 이동·대시·점프를 각각 명령으로 표현해 보세요. 먼저 키와 명령 연결을 바꿔 보고, 다음으로 이동 전 위치를 저장해 실행 취소를 구현해 보세요. 여러 번 취소한 뒤 새 이동을 선택했을 때 예전 재실행 목록이 남지 않는지도 확인합니다.
출처: 사용자가 제공한 『게임 프로그래밍 패턴』 한국어판 2장(인쇄 43~55쪽). 원문 그림 2-1~2-4, 모든 코드와 원주 1~16을 포함했습니다. 회색 💡 콜아웃은 추가 설명입니다.