Command 시스템 구조
목차
1. 시스템 요구 사항
건축 시스템에서는 구조물 배치, 제거, 교체와 같은 다양한 작업이 지속적으로 발생한다.
이러한 작업은 단순히 한 번 실행되는 것으로 끝나지 않고, 플레이어가 언제든지 이전 상태로 되돌릴 수 있어야 한다.
특히 Grid 기반 건축 시스템에서는 하나의 동작이 GameObject 생성이나 삭제만 일으키는 것이 아니라, 동시에 Grid 데이터까지 바꾸기 때문에, 되돌리기 기능도 단순한 Transform 복원이 아니라 실행 당시의 논리 상태와 시각 상태를 함께 되돌리는 기능이어야 한다.
이 문제를 해결하려면 각 동작을 단순 함수 호출로 흩어 두는 것이 아니라, 하나의 명령 단위로 캡슐화해서 관리해야 한다.
그래야 실행된 작업을 순서대로 기억할 수 있고, 마지막 작업부터 반대로 되돌릴 수도 있다.
또한 배치, 제거, 교체처럼 내용은 다르지만 실행할 수 있어야 하고, 되돌릴 수 있어야 한다는 공통 규약을 공유해야 시스템이 일관되게 유지된다.
2. 흐름도
PlacementManager
↓
ICommand 구현체 생성
↓
CommandManager.Invoke(command)
↓
CanExecute() 검사
├─ false → 종료
└─ true
↓
Stack<ICommand>에 저장
↓
Execute() 실행
Undo 요청
↓
CommandManager.Undo()
↓
Stack 비어있는지 검사
├─ 비어 있음 → false 반환
└─ 명령 존재
↓
Pop()
↓
Undo() 실행
이 구조의 핵심은 CommandManager가 배치 명령, 제거 명령, 교체 명령의 내부 내용을 전혀 알지 않는다는 점이다.
어떤 명령이 들어오든 CanExecute, Execute, Undo라는 같은 인터페이스로만 다룬다.
즉 무엇을 실행하는가는 명령 객체가 알고 있고, 언제 실행하고 언제 되돌리는가는 CommandManager가 관리한다.
이 역할 분리 덕분에 시스템이 훨씬 단순해진다.
3. 구현
3.1. CommandSystem 네임스페이스 설계 및 using CommandSystem의 의미
CommandSystem 네임스페이스 설계
namespace CommandSystem
{
public interface ICommand
...
}Command 관련 클래스들은 모두 CommandSystem 네임스페이스 내부에 정의되어 있다.
이 구조는 단순한 코드 정리가 아니라, Command 패턴을 하나의 독립적인 시스템으로 분리하기 위한 설계이다.
Unity 프로젝트에서는 스크립트가 많아질수록 클래스 간 결합도가 쉽게 증가한다.
특히 PlacementManager처럼 여러 기능을 담당하는 상위 컨트롤러 내부에 Command 로직까지 포함시키면, 배치, 제거, Undo, 이동 로직이 하나의 클래스에 뒤섞이게 되어 유지보수가 매우 어려워진다.
이를 방지하기 위해 Command 관련 클래스들을 별도의 네임스페이스로 분리하였다.
이렇게 하면 Command는 건축 시스템에서 사용하는 하위 기능이 아니라, 독립적인 실행/Undo 관리 시스템으로 취급된다.
또한 네임스페이스를 분리하면 코드 탐색성과 가독성이 크게 개선된다.
Command 관련 클래스들은 모두 CommandSystem 내부에 존재하기 때문에, 구조를 파악할 때도 해당 영역만 집중해서 보면 된다.
using CommandSystem의 의미
using CommandSystem;
public class PlacementManager : MonoBehaviour{}
public class StructurePlacementCommand : ICommand{}
public class StructureRemoveCommand : ICommand{}
public class StructureSwapCommand : ICommand{}PlacementManager 및 각 Command 사용 클래스에서는 using CommandSystem을 통해 Command 관련 기능을 가져와 사용한다.
이 구조의 핵심은 의존 방향이다.
PlacementManager는 Command를 포함하지 않고, CommandSystem에 정의된 ICommand 인터페이스와 CommandManager를 사용만 한다.
즉, CommandSystem은 독립적인 실행/Undo 시스템이고, PlacementManager는 CommandSystem을 사용하는 상위 계층이다.
이 방식의 장점은 명확하다.
PlacementManager는 Command 내부 구현을 전혀 알 필요 없이, 단순히 Invoke, Undo 같은 인터페이스만 사용하면 된다.
따라서 새로운 명령(예: 이동, 회전, 복제 등)이 추가되더라도 PlacementManager 코드를 수정할 필요 없이 Command만 추가하면 된다.
이 구조는 객체지향 설계에서 말하는 의존성 역전 원칙(DIP)과 개방-폐쇄 원칙(OCP)을 자연스럽게 만족한다.
한마디로, CommandSystem을 namespace로 분리하고, using으로 가져다 쓰는 구조는 PlacementManager가 Command에 의존하지 않고 인터페이스만 사용하도록 만든 설계이다.
3.2. ICommand 인터페이스
public interface ICommand
{
void Execute();
bool CanExecute();
void Undo();
}이 코드는 Command 시스템의 가장 바닥에 있는 규약을 정의한다.
C#의 interface는 클래스가 반드시 구현해야 하는 함수 목록만 선언하는 구조다.
즉 인터페이스는 데이터를 직접 가지지 않고, 동작의 형식만 강제한다.
여기서 ICommand는 건축 시스템 안에서 명령으로 취급되려면 어떤 함수를 반드시 가져야 하는지를 정의한다.
가장 먼저 Execute()는 명령의 실제 실행 함수다.
배치 명령이라면 구조물을 배치하고 Grid 데이터를 기록하는 작업이 들어가고, 제거 명령이라면 구조물을 삭제하고 Grid 데이터를 비우는 작업이 들어간다.
중요한 점은 이 함수가 단순한 콜백이 아니라, 이 명령이 시스템에 변화를 일으키는 진입점이라는 것이다.
그 다음 CanExecute 함수는 이 명령이 실행 가능한 상태인지 먼저 검사하는 함수다.
이 함수가 왜 필요한지를 코드 관점에서 보면 더 명확하다.
CommandManager는 명령을 실행하기 전에 이 함수를 먼저 호출하고, false면 아예 스택에 넣지도 않고 실행도 하지 않는다.
즉 이 함수는 예외 방지용 보조 함수가 아니라, 실행되지 말아야 할 명령이 기록까지 되는 상황을 막는 필터 역할을 한다.
예를 들어 제거 명령에서 실제로 제거할 대상이 없는 경우, Execute를 호출하지 않는 것만으로는 충분하지 않다.
그 명령이 Undo 스택에 들어가 버리면 나중에 되돌리기 순서가 꼬일 수 있기 때문이다.
그래서 CanExecute는 실행 가능 여부 검사이면서 동시에 스택 기록 여부 판단 기준이다.
마지막 Undo()는 Execute의 반대 동작을 수행하는 함수다.
이때 중요한 것은 Undo가 단순히 이전 값을 되돌리는 마법 같은 기능이 아니라, 각 명령이 자기 실행 내용을 알고 있어야만 구현할 수 있는 반대 작업이라는 점이다.
배치 명령의 Undo는 제거이고, 제거 명령의 Undo는 복원이며, 교체 명령의 Undo는 원래 구조물로 되돌리는 동작이 된다.
즉 이 인터페이스는 실행과 복원을 한 쌍으로 가진 객체만 명령으로 인정하는 구조다.
이 인터페이스가 좋은 이유는 CommandManager가 구체적인 클래스 종류를 몰라도 된다는 점이다.
배치든 제거든 교체든 상관없이 ICommand 타입으로만 받으면 동일한 방식으로 다룰 수 있다.
반대로 단점은 인터페이스 자체는 공통 로직을 가질 수 없다는 점이다.
예를 들어 모든 명령이 공통으로 저장해야 할 데이터가 있다면 인터페이스만으로는 해결이 어렵다.
하지만 현재 구조에서는 공통 데이터보다 공통 규약이 더 중요하므로, 인터페이스가 적절한 선택이다.
3.3. CommandManager 구조
public class CommandManager
{
private Stack<ICommand> commands = new Stack<ICommand>();이 코드는 Command 시스템의 가장 바닥에 있는 규약을 정의한다.
C#의 interface는 클래스가 반드시 구현해야 하는 함수 목록만 선언하는 구조다.
인터페이스는 데이터를 직접 가지지 않고, 동작의 형식만 강제한다.
여기서 ICommand는 건축 시스템 안에서 명령으로 취급되려면 어떤 함수를 반드시 가져야 하는지를 정의한다.
가장 먼저 Execute는 명령의 실제 실행 함수다.
배치 명령이라면 구조물을 배치하고 Grid 데이터를 기록하는 작업이 들어가고, 제거 명령이라면 구조물을 삭제하고 Grid 데이터를 비우는 작업이 들어간다.
중요한 점은 이 함수가 단순한 콜백이 아니라, 이 명령이 시스템에 변화를 일으키는 진입점이라는 것이다.
그 다음 CanExecute는 이 명령이 실행 가능한 상태인지 먼저 검사하는 함수다.
이 함수가 왜 필요한지를 코드 관점에서 보면 더 명확하다.
CommandManager는 명령을 실행하기 전에 이 함수를 먼저 호출하고, false면 아예 스택에 넣지도 않고 실행도 하지 않는다.
이 함수는 예외 방지용 보조 함수가 아니라, 실행되지 말아야 할 명령이 기록까지 되는 상황을 막는 필터 역할을 한다.
예를 들어 제거 명령에서 실제로 제거할 대상이 없는 경우, Execute를 호출하지 않는 것만으로는 충분하지 않다.
그 명령이 Undo 스택에 들어가 버리면 나중에 되돌리기 순서가 꼬일 수 있기 때문이다.
그래서 CanExecute는 실행 가능 여부 검사이면서 동시에 스택 기록 여부 판단 기준이다.
마지막 Undo는 Execute의 반대 동작을 수행하는 함수다.
이때 중요한 것은 Undo가 단순히 이전 값을 되돌리는 마법 같은 기능이 아니라, 각 명령이 자기 실행 내용을 알고 있어야만 구현할 수 있는 반대 작업이라는 점이다.
배치 명령의 Undo는 제거이고, 제거 명령의 Undo는 복원이며, 교체 명령의 Undo는 원래 구조물로 되돌리는 동작이 된다.
이 인터페이스는 실행과 복원을 한 쌍으로 가진 객체만 명령으로 인정하는 구조다.
이 인터페이스가 좋은 이유는 CommandManager가 구체적인 클래스 종류를 몰라도 된다는 점이다.
배치든 제거든 교체든 상관없이 ICommand 타입으로만 받으면 동일한 방식으로 다룰 수 있다.
반대로 단점은 인터페이스 자체는 공통 로직을 가질 수 없다는 점이다.
예를 들어 모든 명령이 공통으로 저장해야 할 데이터가 있다면 인터페이스만으로는 해결이 어렵다.
하지만 현재 구조에서는 공통 데이터보다 공통 규약이 더 중요하므로, 인터페이스가 적절한 선택이다.
3.4. 명령 실행
public void Invoke(ICommand commandToExecute)
{
if (commandToExecute.CanExecute() == false)
return;
commands.Push(commandToExecute);
commandToExecute.Execute();
}이 함수는 새로운 명령을 실행할 때 호출되는 함수다.
겉보기에는 짧지만, Command 시스템의 실제 실행 흐름이 모두 들어 있다.
함수 첫 줄의 매개변수 ICommand commandToExecute는 실행할 명령 객체를 받는다.
여기서 타입이 구체 클래스가 아니라 인터페이스인 이유는, CommandManager가 배치 명령인지 제거 명령인지 몰라도 되기 때문이다.
이 함수는 어떤 명령이 들어와도 같은 방식으로만 처리한다. 이게 바로 Command 패턴이 제공하는 추상화의 장점이다.
첫 번째 조건문은 실행 가능 여부 검사다.
if (commandToExecute.CanExecute() == false)
return;이 코드는 단순한 예외 방지가 아니다.
이 시점에서 false가 나오면, 그 명령은 실행도 하지 않고, 스택에도 저장하지 않는다.
이게 중요하다.
만약 CanExecute가 false인데도 스택에 push해 버리면, 실제로 아무 일도 안 했던 명령이 Undo 대상에 들어가게 된다.
그렇게 되면 사용자가 Undo를 눌렀을 때 논리적으로 아무 의미 없는 명령이 먼저 꺼내져서 전체 흐름이 어그러질 수 있다.
그래서 이 if문은 실행 여부 판단이면서 동시에 Undo 히스토리 오염 방지 역할을 한다.
그 다음 줄이 흥미롭다.
commands.Push(commandToExecute);
commandToExecute.Execute();이 코드는 명령을 먼저 스택에 넣고, 그 다음 Execute를 호출한다.
표면적으로 보면 실행 후 저장이 더 자연스러워 보일 수도 있다.
그런데 현재 구조에서는 Execute 전에 Push해도 문제없다.
왜냐하면 CanExecute 검사를 이미 통과했기 때문에, 이 명령은 실행될 자격이 있는 명령이고, 실행되는 순간 곧바로 Undo 대상이 되어야 하기 때문이다.
다만 이 순서에는 설계적 의미도 있다.
이 코드는 CommandManager가 실행된 작업을 히스토리에 기록하고, 그 명령의 실제 수행은 명령 객체에게 맡긴다는 구조를 보여준다.
Stack에 넣는 건 관리자 책임이고, Execute를 실제로 하는 건 명령 객체 책임이다.
이 역할이 분리되어 있기 때문에 CommandManager는 내부 로직을 전혀 몰라도 된다.
즉 이 함수는 짧지만, 관리자와 명령 객체의 책임 분리를 가장 잘 보여주는 부분이다.
3.5. Undo 처리
public bool Undo()
{
if (commands.Count <= 0)
return false;
ICommand command = commands.Pop();
command.Undo();
return true;
}이 함수는 마지막으로 실행된 명령을 되돌릴 때 호출되는 함수다.
Undo 시스템의 핵심이므로, 코드 한 줄씩 의미를 보면 더 분명해진다.
첫 번째 if문은 스택이 비어 있는지 검사한다.
if (commands.Count <= 0)
return false;Count는 C# 컬렉션이 현재 몇 개의 원소를 가지고 있는지 알려주는 속성이다.
이 코드는 되돌릴 명령이 하나도 없는 상태를 먼저 처리한다.
여기서 false를 반환하는 이유는 단순하다.
Undo를 시도했지만 실제로는 아무 작업도 하지 않았다는 사실을 호출부에 알려주기 위해서다.
PlacementManager 쪽에서는 이 false를 보고 되돌릴 수 있는 명령이 없습니다 같은 처리를 할 수 있다.
이 함수의 반환형이 void가 아니라 bool인 이유는, Undo가 실제로 성공했는지 실패했는지를 외부가 알 수 있게 하기 위함이다.
그 다음 줄은 가장 최근 명령을 꺼내는 부분이다.
ICommand command = commands.Pop();Pop 함수는 Stack의 핵심 함수로, 가장 마지막에 들어간 원소를 꺼내면서 동시에 Stack에서 제거한다.
이 동작이 바로 Undo 규칙과 일치한다.
가장 최근 작업을 되돌리고, 그 작업은 더 이상 되돌리지 않은 상태 목록에 남아 있지 않게 되는 것이다.
만약 Peek처럼 꺼내기만 하고 제거하지 않으면 같은 명령을 여러 번 Undo하는 문제가 생길 수 있다.
그래서 Pop을 쓰는 것은 논리적으로도 정확하다.
그 다음 줄에서 실제 Undo를 수행한다.
command.Undo();여기서 CommandManager는 Undo의 내용 자체를 전혀 모른다.
배치 명령이면 제거가 일어나고, 제거 명령이면 복원이 일어나고, 교체 명령이면 원상복구가 일어난다.
하지만 CommandManager는 그런 차이를 구분하지 않고, 그저 ICommand의 Undo 함수를 호출할 뿐이다.
이게 이 구조의 가장 큰 장점이다.
Undo의 구체적인 구현은 명령 객체에 숨겨져 있고, 관리자는 순서만 통제한다.
이런 역할 분리 덕분에 새로운 명령 클래스가 추가되어도 Undo 흐름은 바뀌지 않는다.
마지막의 return true는 Undo가 실제로 수행되었다는 사실을 호출부에 알리는 값이다.
3.6. 명령 기록 초기화
public void ClearCommandsList()
=> commands.Clear();
public int GetCommandsCount() => commands.Count;이 함수는 현재 스택에 쌓여 있는 모든 명령 기록을 삭제하는 함수다.
코드는 한 줄이지만 건축 시스템에서는 꽤 중요하다.
예를 들어 건축 모드를 종료하거나, 다른 상태로 완전히 전환할 때 이전 Undo 기록까지 유지하면 논리적으로 어색해질 수 있다.
그럴 때 CommandManager는 이 함수를 통해 현재 히스토리를 모두 비운다.
commands.Clear()는 C# 컬렉션에서 내부 원소를 전부 제거하는 함수다.
이 함수의 장점은 구현이 단순하고 의미가 명확하다는 점이다.
Stack을 새로 생성하는 방식으로도 비슷한 효과를 낼 수 있지만, 현재 컬렉션을 그대로 유지하면서 내부 데이터만 비우는 Clear 함수가 더 직관적이다.
여기서 표현식 본문 메서드(=>)를 사용한 이유도 코드 관점에서 설명할 수 있다.
이 함수는 내부 동작이 하나뿐이므로, 중괄호를 풀어서 길게 쓰는 것보다 이 문법이 더 간결하다.
장점은 짧고 읽기 쉽다는 점이고, 단점은 함수가 조금만 복잡해져도 가독성이 떨어질 수 있다는 점이다.
현재처럼 한 줄 호출만 하는 경우에는 매우 적절한 문법이다.
3.7. 현재 명령 개수 조회
public int GetCommandsCount() => commands.Count;이 함수는 현재 스택에 몇 개의 명령이 쌓여 있는지 반환한다.
겉보기에는 단순한 보조 함수지만, 실제로는 UI와 시스템 상태를 연결하는 데 사용될 수 있다.
예를 들어 Undo 버튼을 비활성화할지, 활성화할지 판단하려면 현재 Undo 가능한 명령이 존재하는지 알아야 하는데, 그때 commands.Count를 외부에 직접 노출하는 대신 이 함수 하나로 접근하게 만든 것이다.
반환형이 int인 이유는 현재 명령 개수가 단순 존재 여부를 넘어, 몇 단계까지 Undo 가능한지 같은 추가 정보로도 활용될 수 있기 때문이다.
만약 단순히 true/false만 반환했다면 활용 범위가 더 좁았을 것이다.
이 함수는 지금 당장은 Undo 버튼 활성화 여부 같은 용도로 쓰더라도, 더 넓은 확장 가능성을 가진 조회 함수라고 볼 수 있다.
이 함수 역시 표현식 본문 메서드를 사용한다.
동작이 commands.Count 반환 하나뿐이기 때문에 코드가 매우 간결하다.
이 한 줄은 단순한 유틸 함수처럼 보여도, CommandManager 내부 상태를 외부에 안전하게 제공하는 작은 인터페이스 역할을 한다.
4. 개발 의도
ICommand와 CommandManager의 핵심 설계 의도는 건축 시스템의 모든 상태 변경을 명령 객체라는 같은 단위로 다루는 것이다.
배치, 제거, 교체는 각각 내용이 다르지만, 시스템 입장에서는 모두 실행할 수 있어야 하고, 되돌릴 수 있어야 하며, 실행 가능한지 먼저 검사할 수 있어야 하는 작업이다.
그래서 ICommand는 그 공통 규약을 정의하고, CommandManager는 그 규약을 만족하는 객체들을 동일한 방식으로 관리한다.
특히 이 구조의 강점은 선택 로직이나 배치 로직을 건드리지 않고도 Undo 시스템을 일관되게 붙일 수 있다는 점이다.
CommandManager는 내부 동작을 모르고도 CanExecute, Execute, Undo만으로 명령을 관리할 수 있고, 각 명령 클래스는 자신이 무엇을 실행하고 어떻게 되돌릴지만 책임지면 된다.
그 결과 배치, 제거, 교체처럼 성격이 다른 동작도 모두 같은 Undo 파이프라인에 들어갈 수 있게 된다.
결과적으로 이 코드는 짧지만, 건축 시스템의 실행과 되돌리기를 하나의 아키텍처로 묶는 핵심 기반 구조다.
ICommand는 명령 객체가 갖춰야 할 최소 규약이고, CommandManager는 그 명령들을 시간 순서대로 관리하는 실행/복원 컨트롤러라고 볼 수 있다.
