호출을 객체로 만들면 저장할 수 있게 된다
레벨 에디터를 만들고 있습니다. 오브젝트를 옮기고, 지우고, 색을 바꿉니다. 그리고 되돌리기를 붙여야 합니다.
selected.transform.position = newPos;
이 한 줄을 실행한 뒤에 되돌릴 방법이 있을까요. 없습니다. 실행이 끝나면 아무것도 남지 않기 때문입니다. 무엇을 했는지도, 하기 전에 어땠는지도 어디에도 적혀 있지 않습니다.
되돌리려면 두 가지가 남아 있어야 합니다.
- 무엇을 했는가
- 하기 전에 어땠는가
메서드 호출은 둘 다 남기지 않습니다. 그래서 호출 자체를 물건으로 만듭니다.
커맨드는 이렇게 생겼다
public interface ICommand
{
void Execute();
void Undo();
}
public class MoveCommand : ICommand
{
readonly Transform target;
readonly Vector3 from; // 하기 전에 어땠는가
readonly Vector3 to; // 무엇을 할 것인가
public MoveCommand(Transform target, Vector3 to)
{
this.target = target;
this.from = target.position; // 만들 때 지금 상태를 적어 둔다
this.to = to;
}
public void Execute() => target.position = to;
public void Undo() => target.position = from;
}
핵심은 생성자입니다. 명령을 만드는 시점에 “지금 어땠는지” 를 자기 안에 적어 둡니다. 이게 있어야 나중에 되돌릴 수 있습니다.
부르는 쪽은 명령을 만들고, 실행하고, 쌓아 둡니다.
public class CommandInvoker
{
readonly Stack<ICommand> done = new();
readonly Stack<ICommand> undone = new();
public void Execute(ICommand command)
{
command.Execute();
done.Push(command);
undone.Clear(); // 새 작업을 하면 다시하기 기록은 버린다
}
public void Undo()
{
if (done.Count == 0) return;
var c = done.Pop();
c.Undo();
undone.Push(c);
}
public void Redo()
{
if (undone.Count == 0) return;
var c = undone.Pop();
c.Execute();
done.Push(c);
}
}
Ctrl+Z 를 여러 번 누르면 스택에서 계속 꺼내면 됩니다.
쌓는 순서와 되돌리는 순서가 반대라서 스택이 정확히 맞는 자료구조입니다.
되돌리기만 딸려 오는 게 아닙니다
한 일이 물건으로 남기 시작하면 되는 일이 몇 가지 더 생깁니다.
기록과 리플레이. 스택 대신 리스트에 담아 두면 그게 그대로 재생 목록입니다.
처음부터 순서대로 Execute 를 부르면 같은 일이 다시 일어납니다.
foreach (var c in recorded)
{
c.Execute();
yield return new WaitForSeconds(0.1f);
}
전송. 온라인 게임에서 위치를 보내는 대신 “앞으로 이동” 이라는 명령을 보냅니다. 결과가 아니라 의도를 보내는 것이라 데이터가 훨씬 작고, 서버가 그 명령이 정당한지 검사할 수 있습니다. 결과만 받으면 클라이언트가 보낸 좌표를 그대로 믿어야 합니다.
입력과 동작을 떼어 놓기. 키 설정을 바꾸는 기능이 필요할 때 이게 답입니다.
Dictionary<KeyCode, ICommand> bindings = new()
{
[KeyCode.W] = new MoveCommand(player, Vector3.forward),
[KeyCode.Space] = new JumpCommand(player),
};
void Update()
{
foreach (var (key, command) in bindings)
if (Input.GetKeyDown(key)) invoker.Execute(command);
}
키를 바꾸는 것이 딕셔너리의 열쇠 하나를 바꾸는 일이 됩니다.
if (Input.GetKeyDown(KeyCode.W)) Move(); 로 짜여 있으면 이게 안 됩니다.
밟기 쉬운 것 넷
1. Undo 는 Execute 의 반대 계산이 아닙니다.
위치를 +5 했으니 -5 하면 될 것 같지만, 그 사이에 다른 힘이 작용했다면 안 맞습니다.
하기 전 상태를 저장해 두고 그대로 되돌리는 것이 안전합니다.
그래서 명령 객체는 결과를 만들 재료가 아니라 이전 상태의 사본을 들고 있습니다.
2. 그 사본이 커질 수 있습니다. 위치 하나면 12바이트지만, “모든 타일을 지웠다” 를 되돌리려면 지운 타일 전체를 들고 있어야 합니다. 되돌리기 스택에 그런 명령이 백 개 쌓이면 메모리가 눈에 띄게 늘어납니다. 개수를 제한하는 것이 기본입니다. 대부분의 에디터가 되돌리기 횟수에 한계를 두는 이유입니다.
3. 리플레이는 결정론이 필요합니다. 명령만 다시 실행한다고 같은 결과가 나오지 않습니다. 난수를 쓰면 시드를 같이 저장해야 하고, 물리 연산이 끼면 프레임 시간까지 맞아야 합니다. 명령 기록은 리플레이의 필요조건이지 충분조건이 아닙니다.
4. 명령이 참조를 붙잡고 있습니다. MoveCommand 는 Transform 을 들고 있습니다.
그 오브젝트를 파괴해도 되돌리기 스택이 살아 있는 한 참조가 남습니다.
파괴된 오브젝트를 되돌리려 하면 예외가 납니다.
오브젝트를 지울 때 관련 명령을 같이 정리하거나, 스택 자체를 비워야 합니다.
언제 쓰지 않는가
되돌리기도, 기록도, 전송도 필요 없다면 그냥 메서드를 부르면 됩니다.
명령 하나를 만드는 데 클래스 하나가 듭니다. 동작이 스무 개면 클래스가 스무 개입니다. “나중에 되돌리기를 붙일지도 모르니까” 로 시작하면 붙이지도 않을 기능 때문에 파일만 스무 개 늘어납니다.
기준은 이렇게 잡습니다.
한 일이 남아 있어야 하는가. 남아 있어야 하면 커맨드, 아니면 메서드 호출.
레벨 에디터, 턴제 게임, 리플레이가 있는 게임, 키 설정을 바꿀 수 있는 게임. 이런 것들은 남아 있어야 합니다.
정리
- 메서드 호출은 실행되면 사라진다. 무엇을 했는지도 이전이 어땠는지도 남지 않는다.
- 커맨드는 호출을 객체로 만든다. 만드는 시점에 지금 상태를 자기 안에 적어 둔다.
- 실행한 명령을 스택에 쌓으면 되돌리기가 된다. 쌓는 순서와 되돌리는 순서가 반대라 스택이 맞다.
- 리스트에 담으면 리플레이, 보내면 네트워크 명령, 딕셔너리에 담으면 키 리매핑이 된다.
Undo는 반대 계산이 아니라 저장해 둔 상태로 되돌리는 것이다.- 그 저장이 커진다. 되돌리기 개수는 제한한다.
- 명령 기록만으로 리플레이가 되지 않는다. 난수와 물리까지 결정론이어야 한다.
- 남길 이유가 없으면 그냥 메서드를 부른다. 클래스 하나가 공짜가 아니다.