Command 패턴으로 게임 내 되돌리기(Undo) 기능 만들기

Command 패턴으로 게임 내 되돌리기(Undo) 기능 만들기

게임 플레이 중 발생한 변경을 Command 객체로 기록하면 되돌리기와 다시 실행을 일관된 방식으로 구현할 수 있다. C++ 예제로 Undo/Redo 스택과 실패 처리, 복합 명령 설계 시 주의점을 정리한다.

Undo는 상태를 저장하는 기능이 아니라 행동을 기록하는 기능이다

게임에서 되돌리기를 구현할 때 가장 먼저 떠오르는 방법은 매 프레임 전체 상태를 복사해 두는 방식이다. 작은 프로토타입에서는 가능하지만 오브젝트 수가 늘고 상태가 복잡해질수록 메모리 사용량과 복원 비용이 빠르게 커진다.

Command 패턴은 상태 전체가 아니라 플레이어 또는 시스템이 수행한 행동을 객체로 표현한다. 각 명령은 실행과 취소 방법을 함께 가지므로 히스토리 관리자는 명령을 순서대로 보관했다가 마지막 명령부터 취소할 수 있다.

flowchart LR
    A[입력 또는 게임 이벤트] --> B[Command 생성]
    B --> C{Execute 성공?}
    C -- 예 --> D[Undo 스택에 저장]
    D --> E[Redo 스택 비우기]
    C -- 아니오 --> F[히스토리에 기록하지 않음]
    G[Undo 요청] --> H[Undo 스택의 마지막 명령 취소]
    H --> I[Redo 스택으로 이동]
    J[Redo 요청] --> K[Redo 스택의 마지막 명령 실행]
    K --> L[Undo 스택으로 이동]

핵심 규칙은 단순하다. 새 명령을 성공적으로 실행한 뒤에는 Redo 스택을 비운다. 과거로 돌아간 상태에서 새로운 행동을 하면 이전에 취소했던 미래의 행동은 더 이상 현재 상태와 연결되지 않기 때문이다.

명령 인터페이스 정의하기

C++에서는 실행 성공 여부를 반환하는 인터페이스가 실용적이다. 예를 들어 대상 오브젝트가 이미 제거되었거나 이동이 금지된 상태라면 명령을 히스토리에 넣으면 안 된다.

class ICommand
{
public:
    virtual ~ICommand() = default;

    // 상태를 앞으로 진행한다. 성공한 명령만 히스토리에 저장한다.
    virtual bool Execute() = 0;

    // Execute가 성공한 뒤에만 호출한다.
    virtual void Undo() = 0;
};

명령 객체는 필요한 최소한의 정보만 보관한다. 이동 명령이라면 대상과 이동 전·후 위치면 충분하다. 전체 월드를 복제하는 것보다 훨씬 작고 무엇이 바뀌었는지도 코드에 명확히 드러난다.

이동 명령 구현하기

아래 예제는 위치를 직접 가진 간단한 게임 오브젝트를 대상으로 한다. 실제 엔진에서는 Transform, Actor, Entity 같은 타입으로 바꾸면 된다.

struct Vec3
{
    float x;
    float y;
    float z;
};

struct GameObject
{
    Vec3 position;
    bool canMove = true;
};

class MoveCommand final : public ICommand
{
public:
    MoveCommand(GameObject& target, Vec3 destination)
        : target_(target), destination_(destination)
    {
    }

    bool Execute() override
    {
        if (!target_.canMove)
        {
            return false;
        }

        // 처음 실행할 때만 이전 위치를 기록해야 Redo 시에도 같은 위치로 되돌릴 수 있다.
        if (!hasSourcePosition_)
        {
            sourcePosition_ = target_.position;
            hasSourcePosition_ = true;
        }

        target_.position = destination_;
        return true;
    }

    void Undo() override
    {
        target_.position = sourcePosition_;
    }

private:
    GameObject& target_;
    Vec3 sourcePosition_{};
    Vec3 destination_{};
    bool hasSourcePosition_ = false;
};

처음 Execute()에서 이전 위치를 저장하는 이유가 중요하다. Redo는 새 이동의 시작 위치를 다시 계산하는 작업이 아니라 과거에 성공했던 명령을 동일하게 재적용하는 작업이다. Redo 때 이전 위치를 덮어쓰면 이후 Undo의 결과가 달라질 수 있다.

Undo/Redo 히스토리 관리자 만들기

명령의 소유권은 히스토리 관리자가 갖는 편이 안전하다. std::unique_ptr를 사용하면 명령이 어느 스택에 있든 하나의 소유자만 존재한다.

#include <memory>
#include <utility>
#include <vector>

class CommandHistory
{
public:
    bool Execute(std::unique_ptr<ICommand> command)
    {
        if (!command || !command->Execute())
        {
            return false;
        }

        undoStack_.push_back(std::move(command));
        redoStack_.clear();
        return true;
    }

    bool Undo()
    {
        if (undoStack_.empty())
        {
            return false;
        }

        auto command = std::move(undoStack_.back());
        undoStack_.pop_back();

        command->Undo();
        redoStack_.push_back(std::move(command));
        return true;
    }

    bool Redo()
    {
        if (redoStack_.empty())
        {
            return false;
        }

        auto command = std::move(redoStack_.back());
        redoStack_.pop_back();

        if (!command->Execute())
        {
            // 다시 실행할 수 없는 명령은 Redo 히스토리에 남겨 두지 않는다.
            return false;
        }

        undoStack_.push_back(std::move(command));
        return true;
    }

    void Clear()
    {
        undoStack_.clear();
        redoStack_.clear();
    }

private:
    std::vector<std::unique_ptr<ICommand>> undoStack_;
    std::vector<std::unique_ptr<ICommand>> redoStack_;
};

사용하는 쪽에서는 입력 처리 코드가 구체적인 변경을 직접 수행하지 않고 명령을 만들어 히스토리에 전달한다.

CommandHistory history;
GameObject player{};

history.Execute(std::make_unique<MoveCommand>(
    player,
    Vec3{10.0f, 0.0f, 5.0f}
));

history.Undo();
history.Redo();

여러 변경을 하나로 되돌리기

문 하나를 여는 행동이 애니메이션 상태 변경, 충돌 판정 비활성화, 퀘스트 진행도 갱신을 함께 일으킨다고 해 보자. 사용자는 이 과정을 세 번의 Undo가 아니라 한 번의 Undo로 되돌릴 것으로 기대한다.

이때는 여러 명령을 묶는 복합 명령을 만든다. 실행 중 하나라도 실패하면 이미 실행한 명령을 역순으로 취소해 부분 적용 상태가 남지 않도록 해야 한다.

class CompositeCommand final : public ICommand
{
public:
    explicit CompositeCommand(std::vector<std::unique_ptr<ICommand>> commands)
        : commands_(std::move(commands))
    {
    }

    bool Execute() override
    {
        executedCount_ = 0;

        for (auto& command : commands_)
        {
            if (!command->Execute())
            {
                while (executedCount_ > 0)
                {
                    commands_[--executedCount_]->Undo();
                }
                return false;
            }
            ++executedCount_;
        }

        return true;
    }

    void Undo() override
    {
        while (executedCount_ > 0)
        {
            commands_[--executedCount_]->Undo();
        }
    }

private:
    std::vector<std::unique_ptr<ICommand>> commands_;
    std::size_t executedCount_ = 0;
};

실제 게임에서 결정해야 할 기준

대상 참조가 여전히 유효한가

예제는 이해를 위해 참조를 사용했지만 실제 게임에서는 Undo 대상이 삭제되거나 씬이 전환될 수 있다. 장기 히스토리에는 엔티티 ID를 저장하고 실행 시 월드에서 다시 찾는 방식이 더 안전하다. 대상을 찾을 수 없다면 명령 실패를 어떻게 다룰지 정책을 정해야 한다.

  • 에디터 기능이라면 실패를 알리고 해당 히스토리 이후를 무효화할 수 있다.
  • 전투 중 일시적인 되돌리기라면 대상 삭제 자체를 명령으로 관리해 복원 가능하게 만들 수 있다.
  • 네트워크 게임에서는 클라이언트 Undo를 서버 권한 상태와 별도로 취급해야 한다.

무엇을 저장할 것인가

명령은 일반적으로 두 방식 중 하나를 사용한다.

  • 역연산 방식: 피해 적용 명령은 체력을 감소시키고 Undo는 같은 양만큼 회복한다. 구현은 간단하지만 중간에 체력 상한이나 다른 보정이 끼면 원래 상태를 정확히 복원하지 못할 수 있다.
  • 스냅샷 방식: 실행 전 값과 실행 후 값을 저장해 정확히 대입한다. 메모리는 조금 더 쓰지만 편집 기능이나 중요한 게임 상태에는 보통 더 안전하다.

되돌렸을 때 반드시 원래 상태가 되어야 한다면 역연산보다 필요한 범위의 이전 상태를 저장하는 쪽을 우선 검토하는 편이 좋다.

히스토리 크기를 제한하기

명령이 계속 쌓이면 메모리를 끝없이 사용한다. 최근 100개만 유지하는 식으로 상한을 두거나 드래그 중 발생한 연속 이동을 하나의 명령으로 합치는 전략이 필요하다. 특히 매 프레임 생성되는 명령을 그대로 기록하면 Undo 경험도 나빠지고 메모리도 빠르게 늘어난다.

정리

Command 패턴 기반 Undo는 입력 처리와 상태 변경, 히스토리 관리를 분리한다. 명령이 성공한 뒤에만 Undo 스택에 넣고 새 명령이 실행되면 Redo 스택을 비우며 여러 변경은 하나의 복합 명령으로 묶는 것이 기본이다.

처음에는 이동이나 아이템 사용처럼 작은 변경부터 적용해 보자. 이후 대상 수명 관리와 명령 병합, 스냅샷 범위를 프로젝트 요구에 맞춰 확장하면 에디터와 게임플레이 양쪽에서 재사용할 수 있는 되돌리기 구조가 된다.

#game-programming#command-pattern#undo-redo#c-plus-plus#architecture

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs