상태와 전략은 둘 다 기본 골자는 비슷하다.


상태와 전략의 기본 골자






상태와 전략 둘 다 Context에서 InterfaceParent.Execute()를 실행한다는 것이 포인트이다. 객체지향적인 설계이기 때문인데, ConcreteSon의 종류가 현재는 2개 뿐이지만 3, 4, 5.....로 늘어나더라도 Context는 InterfaceParent.Execute()를 실행할 뿐이므로, OCP(Open-Closed Principle : 확장에는 열리고, 수정에는 닫혀야 한다는 객체지향의 원리)를 지킬 수 있다. 만약 Context에서 ConcreteSon1.Execute(), ConcreteSon2.Execute()...... 이런식으로 실제 객체의 함수를 직접호출 했다면, ConcreteSon이 늘어날 때마다 Context 내부를 수정해야 하기 때문에 지저분한 코드가 될 것이다.



기본 골자가 비슷하다면 전략패턴과 상태패턴의 차이는 무엇인가?


바로 SetConcreteSon을 어디서 호출할 것인가 이다.


이 블로그의 전략패턴 글에서, SetState함수(SetConcreteSon함수에 해당)를 키보드 입력에 따라서 Player(Client에 해당)에서 호출하고 있다.

이 블로그의 상태패턴 글에서, SetState함수(SetConcreteSon함수에 해당)를 StateOpen과 StateClose(ConcreteSon1, 2에 해당)에서 호출하고 있다.


즉, 외부에서 인위적으로 ConcreteSon 객체를 바꿔준 후 Execute()함수를 호출하고 싶다면 전략패턴을 사용해야한다.

반면, Client는 단순히 Context.Operate()함수를 호출해줄 뿐이고, 그때마다 내부적으로 알아서 ConcreteSon 객체가 바뀌게 하고 싶다면 상태패턴을 사용하면 된다.


구조의 골자는 비슷하지만 목적이 다른 두 패턴이므로 적재적소에 써주면 된다.

게임에서 어떤 레버를 당기면 문이 열리고, 그 레버를 또 당기면 문이 닫힌다고 해보자. 내가 어떤 동작을 수행했을 때 내부적으로 현재 상태를 알아서 바꾸는 경우, 상태패턴을 사용하면 객체지향적으로 간단히 만들 수 있다.



플레이어가 레버를 클릭한다 -> 닫힌 상태였다면 열림 / 열린 상태였다면 닫힘.



클래스 구조를 그림으로 보면 아래와 같이 된다.


클래스 구조




함수의 호출 순서를 글로 풀어보면 아래와 같다.


1. 플레이어가 레버 앞에서 액션키를 누름 -> Lever의 Handle() 함수 실행


2. Handle()함수 안에는 context.Operate()함수가 실행됨.


3. Operate()함수 안에는 curState.Execute(DoorStateContext context, Door door)함수가 실행됨.


4. Execute(...) 함수 안에는 매개변수 door의 Open() 혹은 Close() 함수가 실행되고, 그 후에 매개변수 context의 SetState(...) 함수를 이용해서 현 상태를 바꿔줌.



플레이어는 레버를 누를 뿐이고, 그 레버는 context의 Operate()함수를 실행할 뿐이다. 그럼 하위 State 객체들이 알아서 상태를 바꿔준다.





본격적인 코드는 아래와 같다.



1
2
3
4
5
6
7
8
9
10
public class Lever : MonoBehaviour
{
    public DoorStateContext context;
 
    //플레이어가 레버를 당겼을 때 실행되는 함수
    public void Handle()
    {
        context.Operate();
    }
}
cs




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class DoorStateContext : MonoBehaviour
{
    //레버에 반응할 문 게임오브젝트.
    //유니티 인스펙터에서 드래그해서 넣어주기로 하자.
    public Door door;
 
    //열리거나 닫히거나, 현재 상태.
    public IState curState;
 
    void Start()
    {
        //기본은 닫힌 상태.
        curState = new StateClose();
    }
 
    //현재 상태의 동작을 호출해주는 함수.
    public void Operate()
    {
        curState.Execute(this, door);
    }
 
    //새로운 상태를 세팅해줄때 사용하는 함수.
    public void SetState(IState newState)
    {
        curState = newState;
    }
}
cs



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public interface IState
{
    //상태의 행동 선언.
    void Execute(DoorStateContext context, Door door);
}
 
 
public class StateOpen : IState
{
    //상태의 행동 정의.
    public void Execute(DoorStateContext context, Door door)
    {
        //열려있다면 닫아준다.
        door.Close();
 
        //닫힘으로 상태 전환.
        context.SetState(new StateClose());
    }
}
 
 
public class StateClose : IState
{
    //상태의 행동 정의.
    public void Execute(DoorStateContext context, Door door)
    {
        //문이 닫혀있을 땐 열어준다.
        door.Open();
 
        //상태를 열림상태로 전환.
        context.SetState(new StateOpen());
    }
}
cs




1
2
3
4
5
6
7
8
9
10
11
12
public class Door : MonoBehaviour
{
    public void Open()
    {
        Debug.Log("열림");
    }
 
    public void Close()
    {
        Debug.Log("닫힘");
    }
}
cs


횡스크롤 러닝 게임이 있다고 해보자. 사용자가 입력할 수 있는 버튼은 점프와 슬라이딩이 있다. 그럼 캐릭터가 가질 수 있는 상태는 아래와 같을 것이다.


달리기(평소)

점프

슬라이딩

죽음


만약 게임이 업데이트가 되어서 상태들이 늘어날 수도 있을 것이다. 기획자에 따라서 점프 도중 한 번 더 점프를 할 수 있는 이중점프, 점프시 빠르게 땅으로 내려오는 급하강, 무적아이템을 먹었을 때의 무적상태 등등을 추가할 수도 있다. 그 때마다 다른 클래스에서 이 상태들을 사용할 때 이중점프.Do() 혹은 급하강.Do() 이런 식으로 직접 써준다면, 상태가 늘어날 때마다 그 상태들을 사용하는 클래스를 수정해야 할 것이다. 그것은 객체지향의 Open-Closed Principle(확장에는 열리게 하고, 수정에는 닫히게 해야 한다는 객체지향 원칙)을 어기는 것이다. 그래서 각 상태들을 IState라는 인터페이스로 묶어주고, IState.Do()로 실행하게 한다면, 상태가 아무리 추가가 되어도 IState 변수에 들어갈 상태 객체만 바꿔주면 되기 때문에 더이상 상태를 사용해주는 클래스를 수정하지 않아도 될 것이다.


이런식으로 객체들을 인터페이스로 묶어주고, 객체들의 멤버함수를 실행할 때 직접실행하는게 아니라 인터페이스.Do() 이런식으로 실행한다. 이렇게 OCP를 지치고 좀 더 유연하게 구조를 짜는 것이 전략패턴의 핵심이다. 그리고 Set함수를 만들어서 인터페이스 변수에 내가 원하는 객체를 넣어줄 수 있게 한다.


보통 게임프로그래밍을 본격적으로 시작할 때 가장 먼저 배우는 것 중 하나가 유한상태기계(FSM, Finite State Machine)일 것이다. 키보드 마우스의 입력에 따라서 캐릭터의 상태가 바뀌게 하고, 현재 상태에 따라 다른 행동이나 반응을 하게 하는 것, 그것이 캐릭터의 유한상태기계이다. 전략 패턴을 사용하면 유한상태기계를 객체지향적으로 만들 수 있다.


위의 횡스크롤 러닝 게임의 유한상태기계를 전략 패턴을 이용해서 간단하게 만들면 아래와 같다.






클래스 구조 설명 : 

Player는 유저가 조작하는 캐릭터이고,

StateMachine은 키보드 입력에 따라 상태를 바꿔주고 각 상태의 Operate 함수를 실행시켜주는, 상태들을 총괄해주는 역할이다.

IState 및 세부 상태들은 맨 위에서 말했듯이 각 상태들을 객체로 만들어준 것이다.



게임 플레이 시에 상태끼리 연결되는 과정 : 

Run 상태일 때 : z를 누르면 점프. X를 누르면 슬라이딩. 적에게 닿으면 죽음.

Jump 상태일 때 : 적에게 닿으면 죽음

Sliding 상태일 때 : z를 누르면 점프, 적에게 닿으면 죽음.

dead 상태일 때 : 키 입력 받지 않음. 마우스로 다시하기 버튼을 클릭하면 게임 리셋(여기서는 구현하지 않겠음).



OperateEnter, OperateUpdate, OperateExit는 무엇 ? :

간략하게 말하자면, Enter 함수에는 상태가 바뀌자마자 하고 싶은 행동을 넣고, Update는 매 프레임마다 하고 싶은 행동을, Exit에는 다른 상태로 바뀌기 직전에 하고 싶은 행동을 넣으면 된다. 보통 객체의 라이프 사이클을 짜다보면 Enter, Update, Exit 구조로 짜는 경우가 많다. 유니티에도 이러한 경우가 많은데, 충돌처리의 OnCollision 함수 시리즈에도 OnCollisionEnter, OnCollisionStay, OnCollisionExit 로 이루어져있고, 유니티의 MonoBehaviour에도 Start, Update OnDestroy 등등의 함수가 존재하는 걸 알 수 있다.



왜 Enter, Update, Exit가 필요? : 

예를 들어 Dead의 경우에는 상태가 바뀌자마자 캐릭터의 모습을 Dead.Png로 바꾸고, 다른 키 입력이 안되게만 하면 되므로 Enter만 필요하게 된다.

반면 Sliding에 경우에는 상태가 바뀌자마자 엎드려있는 모습으로 바꾸고, x버튼에서 손을 떼면 슬라이딩을 끝내고 다시 일어서는 모습으로 바꿔야하므로 Enter와 Exit가 둘 다 필요하다.

이렇게 상태별로 Enter, Exit, Update가 필요한 경우가 있으므로 원활한 게임 제작을 위해서 이런식으로 구조를 짜는 것이다. 




상세한 코드는 아래와 같다.



//플레이어블 캐릭터
public class Player : MonoBehaviour
{
    private enum PlayerState
    {
        Run,
        Sliding,
        Jump,
        Dead
    }

    private StateMachine stateMachine;

    //스테이트들을 보관
    private Dictionary<PlayerState, Istate> dicState = new Dictionary<PlayerState, Istate>();

    // Start is called before the first frame update
    void Start()
    {
        //상태 생성
        IState run = new StateRun();
        IState sliding = new StateSliding();
        IState jump = new StateJump();
        IState dead = new StateDead();

        //키입력 등에 따라서 언제나 상태를 꺼내 쓸 수 있게 딕셔너리에 보관
        dicState.Add(PlayerState.Run, run);
        dicState.Add(PlayerState.Sliding, sliding);
        dicState.Add(PlayerState.Jump, jump);
        dicState.Add(PlayerState.Dead, dead);

        //기본상태는 달리기로 설정.
        stateMachine = new StateMachine(run);
    }

    // Update is called once per frame
    void Update()
    {
        //키입력 받기
        KeyboardInput();

        //매프레임 실행해야하는 동작 호출.
        stateMachine.DoOperateUpdate();
    }

    //키보드 입력
    void KeyboardInput()
    {
        if(Input.GetKeyDown(KeyCode.Z))
        {
            //달리기, 슬라이딩 중일 때만 점프 가능
            if (stateMachine.CurrentState == dicState[PlayerState.Run] || stateMachine.CurrentState == dicState[PlayerState.Sliding])
            {
                stateMachine.SetState(dicState[PlayerState.Jump]);
            }
        }

        if(Input.GetKeyDown(KeyCode.X))
        {
            //달리기 중에만 슬라이딩 가능.
            if(stateMachine.CurrentState == dicState[PlayerState.Run])
            {
                stateMachine.SetState(dicState[PlayerState.Sliding]);
            }
        }
    }

    public void OnTriggerEnter(Collider other)
    {
        if(other.tag == "Enemy")
        {
            stateMachine.SetState(dicState[PlayerState.Dead]);
        }
    }
}



public class StateMachine
{
    //현재 상태를 담는 프로퍼티.
    public IState CurrentState { get; private set; }

    //기본 상태를 생성시에 설정하게 생성자 만들기.
    public StateMachine(IState defaultState)
    {
        CurrentState = defaultState;
    }

    //외부에서 현재상태를 바꿔주는 부분.
    public void SetState(IState state)
    {
        //같은 행동을 연이어서 세팅하지 못하도록 예외처리.
        //예를 들어, 지금 점프중인데 또 점프를 하는 무한점프 버그를 예방할수도 있다.
        if(CurrentState == state)
        {
            Debug.Log("현재 이미 해당 상태입니다.");
            return;
        }

        //상태가 바뀌기 전에, 이전 상태의 Exit를 호출한다.
        CurrentState.OperateExit();

        //상태 교체.
        CurrentState = state;
        
        //새 상태의 Enter를 호출한다.
        CurrentState.OperateEnter();
    }
    
    //매프레임마다 호출되는 함수.
    public void DoOperateUpdate()
    {
        CurrentState.OperateUpdate();
    }
}



//상태들의 최상위 인터페이스.
public interface IState
{
    void OperateEnter();
    void OperateUpdate();
    void OperateExit();
}



//각 세부 상태들은 굳이 정의하지 않겠다.
public class StateSliding : IState
{
    public void OperateEnter()
    {
        
    }

    public void OperateExit()
    {
        
    }

    public void OperateUpdate()
    {
        
    }
}


빌더라는 디자인패턴이 있다. 그런데 Effective Java의 빌더와 GoF 말하는 빌더패턴의 의미가 다르다. 이 글에서는 두 패턴의 차이를 아주 간단한 예제를 통해 알아보도록 하겠다. 설명을 위해 유니티의 특성을 덜 사용하게 되는데 그래도 유니티상에서 코딩을 한 것이기 때문에 제목에 유니티를 넣어주었다.





1. Java에서의 빌더 패턴


일단 Java에서의 빌더 패턴은 쉽게 말하자면 객체를 만들 때 private 변수를 각각 개별 함수로 세팅하고, 변수설정이 끝났다면 객체를 만들어 반환하는 방법이다. 이때 만들어진 객체는 멤버변수를 다시는 수정할 수 없도록 해서 안전하게 보호할 수 있다.


만약 세이브 데이터를 빌더 패턴으로 만든다면 다음과 같은 코드로 만들게 된다.



public class SaveData { public string name{ get; private set; } public int level{ get; private set; } public string location{ get; private set; } public SaveData(string tName, int tLevel, string tLocation) { this.name = tName; this.level = tLevel; this.location = tLocation; } //세이브 데이터를 생성해주는 빌더 클래스. public class SaveDataBuilder { private string name = "defaultName"; private int level = -1; private string location = "시작의 마을 여관"; //개발자가 판단하기에 가장 중요한 변수는 생성자에 넣어서 반드시 세팅되도록 해준다. public SaveDataBuilder(string tName) { this.name = tName; } //이하는 Setter함수들. 리턴값으로 this를 해주는게 바로 포인트. public SaveDataBuilder SetLevel(int tLevel) { this.level = tLevel; return this; } public SaveDataBuilder SetLocation(string tLocationh) { this.location = tLocationh; return this; } //원하는 값들이 세팅되면 최종적으로 이 함수를 실행해서 세이브 데이터 객체를 하나 만들어준다. public SaveData Build() { return new SaveData(name, level, location); } } }



public class AppMain : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        //각 Set 함수를 실행하면 SaveDataBuilder가 리턴되기 때문에 걔속해서 .Set 이런식으로 멤버변수 세팅 가능. 
        //최종적으로 Build()를 실행하면 설정한 값에 따라 SaveData객체가 만들어짐.
        //이후엔 save1의 멤버변수를 수정할 방도가 없어서, 데이터를 보호할 수 있음.
        //만약 내가 설정하기 싫은 변수가 있으면 그 변수에 대한 Set함수를 사용 안하면 된다.
        SaveData save1 = new SaveData.SaveDataBuilder("글릭").SetLevel(1).SetLocation("발라마을 여관").Build();

    }
}





2. GoF의 빌더 패턴



GoF의 빌더 패턴은 새로운 객체를 만드는 방법론이라는 점은 Java의 빌더 패턴과 같지만, 전혀 관점이 다르다. 


Java의 빌더패턴은 Set함수를 여러개 써서 내가 원하는 변수를 세팅하고, Build() 후에는 멤버변수를 수정할 수 없게 해서 안전하게 보호하는 데에 목적이 있다.


반면 GoF의 빌더 패턴은 객체지향적인 방식인데, 예시를 통해 설명하겠다.



생과일 주스를 만드는 타이쿤 게임이 있다고 해보자. 손님이 원하는대로 포도주스, 딸기주스를 만들어서 줘야한다. 기계에 과일을 넣고, 버튼을 눌러 즙을 짜서 손님에게 주는 것은 동일하지만, 재료가 포도이냐 딸기냐가 다르다.


그럼 빌더패턴에 따라서 과일만들기 기능을 설계해보겠다.


 클래스 명

 설명

빌더 패턴 내에서의 명칭

 Juice

 손님에게 줄 주스. 멤버변수 type에 따라 포도주스 혹은 딸기주스가 된다.

product 

 JuiceBuilder

 주스 객체를 만들고 세팅하는데에 필요한 멤버함수나 멤버변수를 선언해주는 추상 클래스.

 Builder

 StrawberryJuiceBuilder

GrapeJuiceBuilder

 JuiceBuilder를 상속받아 딸기주스 혹은 포도주스를 만드는 기능을 정의할 수 있는 실질적인 객체들.

Concrete Builder 

 JuiceMachine

 JuiceBuilder의 함수들을 사용해서 때로는 딸기주스빌더, 때로는 포도주스빌더를 이용해 딸기주스와 포도주스를 만들어내는 과일가계의 주스기계.

 Director


위의 표를 토대로 만든 코드는 아래와 같다.



//주스.
public class Juice
{
    public enum JuiceType
    {
        None,
        Grape,
        Strawberry
    }

    public JuiceType type = JuiceType.None;

    public void SetType(JuiceType type)
    {
        this.type = type;
    }
}



//주스 빌더 추상클래스.
public abstract class JuiceBuilder
{
    //만들어질 주스를 담을 변수
    protected Juice juice;

    //주스를 리턴
    public Juice CreateJuice()
    {
        return juice;
    }

    //새로운 주스를 만들 준비
    public void PrepareNewJuice()
    {
        juice = new Juice();
    }

    //주스를 만들기 전에 과일 세팅을 하는 추상함수. 내용물 정의는 하위 클래스에서 해줄 것이다.
    public abstract void SetFruit();
}



//딸기주스 빌더
public class StrawberryJuiceBuilder : JuiceBuilder
{
    public override void SetFruit()
    {
        juice.SetType(Juice.JuiceType.Strawberry);
    }
}

//포도주스 빌더
public class GrapeJuiceBuilder : JuiceBuilder
{
    public override void SetFruit()
    {
        juice.SetType(Juice.JuiceType.Grape);
    }
}



//주스 빌더를 사용할 주스 머신
public class JuiceMachine
{
    //딸기주스빌더 혹은 포도주스빌더를 담을 변수.
    private JuiceBuilder juiceBuilder;

    //딸기주스빌더 혹은 포도주스빌더를 세팅하는 함수.
    public void SetJuiceBuilder(JuiceBuilder builder)
    {
        juiceBuilder = builder;
    }

    //빌더가 세팅되었다면 본격적으로 주스를 만들 준비를 한다.
    public void MakeJuice()
    {
        juiceBuilder.PrepareNewJuice();
        juiceBuilder.SetFruit();
    }

    //주스 만들 준비가 끝났다면 주스를 만들어서 손님에게 준다.
    public Juice GetJuice()
    {
        return juiceBuilder.CreateJuice();
    }
}



public class AppMain : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        JuiceMachine machine = new JuiceMachine();
        GrapeJuiceBuilder grapeJuiceBuilder = new GrapeJuiceBuilder();
        StrawberryJuiceBuilder strawberryJuiceBuilder = new StrawberryJuiceBuilder();

        //포도주스 제조.
        machine.SetJuiceBuilder(grapeJuiceBuilder);
        machine.MakeJuice();
        Juice grapeJuice = machine.GetJuice();

        //딸기주스 제조.
        machine.SetJuiceBuilder(strawberryJuiceBuilder);
        machine.MakeJuice();
        Juice strawberryJuice = machine.GetJuice();

    }
}


컴포지트 패턴은 예시를 통해 설명하겠다.




위의 그림과 같은 인벤토리 창이 있는 RPG게임이 있다고 해보자. 아이템 슬롯에는 포션이나 무기 같은 아이템을 보관할 수 있는데, 아이템 중에 4차원 가방이라는 아이템이 있다. 그 가방은 자신도 아이템이라서 아이템 슬롯에 위치하는데, 그 가방을 더블클릭하면 또다른 아이템 슬롯 창이 뜨고, 거기에도 아이템을 보관할 수 있다고 해보자. 이런 구조를 컴포지트 패턴을 이용해 설계한다면 아래와 같이 만들 수 있다.






위의 클래스 구조를 간략히 설명하면 다음과 같다.


클래스 명

 설명

 컴포지트 패턴 내에서의 용어

 Item

 모든 아이템은 이 추상클래스를 상속받아 만들어진다.

 Component

 ItemLeaf

 4차원 가방을 제외한 다른 아이템들은 ItemLeaf를 상속받아 만들어진다. Leaf는 잎, 말단이란 뜻으로, 상위클래스인 Item과 구별해주기 위해 접미사로 붙여주었다.

 Leaf(단일객체)

 ItemBag

 4차원 가방 아이템. 이 아이템은 자신도 Item이면서 다른 아이템을 List에 넣어 관리할 수 있다. 자기 자신이 추가 인벤토리 아이템 슬롯인 셈.

 Composite(복합객체)



type은 물약같은 사용 아이템인지, 무기같은 장착 아이템인지 구별해주는 멤버변수인데, 뭔가 허전해서 넣어봤을 뿐이며 자신의 게임에 맞게 수정하거나 빼버리면 될 것이다.




자세한 코드는 아래와 같다.


//최상위 아이템 추상클래스
public abstract class Item
{
    public enum Type
    {
        Use,
        Equip,
        Own,
        Main
    }

    public string name;
    public Type type;

    //아이템이 수행할 동작을 여기에 정의하거나 실행.
    public abstract void Operate();

    //생성자
    public Item(string name, Type type)
    {
        this.name = name;
        this.type = type;
        Debug.Log("부모클래스 세팅 : " + name + " " + type.ToString());
    }
}


//4차원 가방 클래스
public class ItemBag : Item
{
    private List itemList = new List();

    public ItemBag(string name, Type type = Type.Use) : base(name, type)
    {
        
    }

    public override void Operate()
    {
        OpenSlotWindow();
    }

    //가방의 인벤토리 창을 여는 멤버함수. 본 예제에서는 내부를 정의하진 않겠다.
    private void OpenSlotWindow()
    {

    }

    //가방에 아이템 넣을때 사용하는 함수
    public void Add(Item item)
    {
        if(itemList.Contains(item))
        {
            Debug.Log(" 버그 : 이미 해당 아이템이 리스트에 존재함.");
            return;
        }
        itemList.Add(item);
    }

    //가방에서 아이템 뺄때 사용하는 함수
    public void Remove(Item item)
    {
        if(!itemList.Contains(item))
        {
            Debug.Log("버그 : 해당 아이템이 리스트에 존재하지 않음.");
            return;
        }
        itemList.Remove(item);
    }
}


//일반 아이템 클래스
public class ItemLeaf : Item
{
    //생성자
    public ItemLeaf(string name, Type type) : base(name, type)
    {
        
    }

    public override void Operate()
    {
        
    }
}


//추가 예시. 물약을 만들어보았다.
public class Potion : ItemLeaf
{
    public Potion(string name, Type type) : base(name, type)
    {
        
    }

    //또 오버라이딩 해준다. 그럼 ItemLeaf의 Operate함수는 실행되지 않을 것이다.
    public override void Operate()
    {
        UsePotion();
    }

    //체력을 채우고, 물약 갯수를 1 감소시켜주는 기능을 함수내부에 만들어주면 될 것이다.
    private void UsePotion()
    {

    }
}

주의사항
: 본 예제는 컴포지트 패턴의 설명을 돕기 위해 만든 설명용 예제일 뿐이다. 실제 게임에 적용하려면 많은 고민과 수정을 거쳐야 할 것이며, 아이템 구조를 짤 때 컴포지트 패턴을 사용하지 않을 수도 있다.


커맨드는 단어 그 자체로 본다면 명령, 요청이라는 뜻이다. 위키피디아를 보면 요청을 캡슐화한다고 써있는데, 쉽게 말하면 객체의 행동을 저장하고 필요할 때 꺼내 쓰는 방식이라고 보면 된다.


여기서는 FPS 총게임에서 무기를 사용하는 경우를 생각하겠다.


일단 유저가 키보드나 마우스로 할 수 있는 상황을 아래와 같이 정해보자.



[무기 사용 매커니즘]

좌클릭 : 총발사

R키 : 재장전

F키 : 빠른 칼 휘두르기




이를 토대로 커맨드 패턴에 입각해서 구조를 짜보도록 한다.

본 예제는 오직 커맨드 패턴의 설명을 위해서 만들었기 때문에 무기 교체, 캐릭터 이동 등의 기능은 제외했다.




우선 커맨드 패턴의 구조는 아래의 그림과 같다.

사진 출처 : https://ko.wikipedia.org/wiki/%EC%BB%A4%EB%A7%A8%EB%93%9C_%ED%8C%A8%ED%84%B4



클라이언트 : 인보커, 리시버, 커맨드를 생성하거나 세팅한다.


리시버 : 행동의 당사자. 예를 들어 '총을 발사한다'라는 상황에서 '총'이 바로 리시버이다.


커맨드 : execute()함수를 선언하는 인터페이스.


콘크리트 커맨드 : 커맨드를 상속받고 멤버변수로 리시버를 가지는 객체. 커맨드 인터페이스의 execute()함수를 여기서 정의한다. 예를 들어, '총을 발사하는 커맨드'라면 멤버변수로 Gun 객체를 가지고, execute()함수 내에서 Gun.Shoot()함수를 호출해준다.


인보커 : 클라이언트가 원하는 행동을 대신 호출해주는 객체. 예를 들어 총을 쏜다라는 행동이 있다면, 클라이언트에서 gun.shoot()을 직접 호출하는 것이 아닌, 인보커에게 명령을 내리면 인보커가 알맞은 커맨드의 execute()함수를 호출한다. 그 execute()함수 안에는 gun.shoot()이 호출되고 있을 것이다.




위의 그림을 토대로 커맨드 패턴에서 쓰는 용어들과 클래스들을 매칭시켜보겠다.


 클래스 명

 설명

 커맨드패턴 내의 명칭

WeaponSystem

 무기시스템을 관장하는 클래스.

커맨드,인보커,리시버를 생성하거나 세팅.

 클라이언트

Weapon

gun, knife의 부모클래스 

리시버

 CommandManager

커맨드를 관리 

인보커

IWeaponCommand

발사, 휘두르기 등 무기의 행동 인터페이스

커맨드

ShootGunCommand

ReloadGunCommand

StabKnifeCommand

실제동작 정의. 

콘크리트 커맨드 

 


부모자식 구조로 만들 클래스는 아래의 그림들과 같이 만들도록 하겠다.



커맨드 부모관계



참고로 Execute는 실행하다라는 뜻으로, 

ShootGunCommand에서는 쏘다,

ReloadGunCommand에서는 재장전하다,

StabKnifeCommand에서는 찌르다

라는 행동으로 정의될 것이다.







리시버 부모관계





코드는 아래와 같이 만들겠다.



//리시버
public class Weapon
{
    //모든 무기에 공통적으로 들어가는 변수 선언
    protected int damage;
    protected int coolTime;
}

public class Knife : Weapon
{
    //찌르기
    public void Stab()
    {
        Debug.Log("칼로 찌르기 동작 실행(상세내용은 생략).");
    }
}


public class Gun : Weapon
{
    //총에 공통적으로 필요한 기본 정보들
    protected int maxBulletCount;
    protected int curBulletCount;
    protected int reloadCoolTime;
    protected float maxReboundRange;

    //발사
    public virtual void Shoot()
    {
        Debug.Log("Shoot이 하위 클래스에서 정의되지 않은것으로 보입니다.");
    }

    //재장전
    public virtual void Reload()
    {
        Debug.Log("reload가 하위 클래스에서 정의되지 않은 것으로 보입니다.");
    }
}


public class M4A1 : Gun
{
    public override void Shoot()
    {
        Debug.Log("M4A1 발사");
    }

    public override void Reload()
    {
        Debug.Log("M4A1 재장전");
    }
}



//커맨드
public interface IWeaponCommand
{
    void Execute();
}


public class ReloadGunCommand : IWeaponCommand
{
    private Gun gun;

    public void Execute()
    {
        //총쏘기
        gun.Reload();
    }

    public ReloadGunCommand(Gun val)
    {
        gun = val;
    }
}


public class ShootGunCommand : IWeaponCommand
{
    private Gun gun;

    public void Execute()
    {
        //총쏘기
        gun.Shoot();
    }

    public ShootGunCommand(Gun val)
    {
        gun = val;
    }
}


public class StabKnifeCommand : IWeaponCommand
{
    private Knife knife;

    public void Execute()
    {
        //찌르기 실행
        knife.Stab();
    }

    public StabKnifeCommand(Knife val)
    {
        knife = val;
    }
}



//인보커. 커맨드 인스턴스들을 보유하고 관리.
public class CommandManager
{
    private Dictionary commandDic = new Dictionary();

    //커맨드를 세팅
    public void SetCommand(string name, IWeaponCommand command)
    {
        if(commandDic.ContainsValue(command))
        {
            Debug.Log("이미 커맨드가 리스트 포함되어있음.");
            return;
        }
        commandDic.Add(name, command);
    }

    //저장된 특정 커맨드를 실행
    public void InvokeExecute(string name)
    {
        commandDic[name].Execute();
    }
}



//커맨드와 리시버, 인보커를 세팅하고 원할 때 실행하는 클라이언트
public class WeaponSystem : MonoBehaviour
{
    CommandManager commandMgr = null;

    void Start()
    {
        //인보커 생성
        commandMgr = new CommandManager();
        
        //리시버 생성
        M4A1 m4a1 = new M4A1();
        Knife knife = new Knife();

        //커맨드를 생성하고 리시버와 연결
        ShootGunCommand shootM4a1Command = new ShootGunCommand(m4a1);
        ReloadGunCommand reloadM4a1Command = new ReloadGunCommand(m4a1);
        StabKnifeCommand stabKnifeCommand = new StabKnifeCommand(knife);

        //인보커에 커맨드를 세팅해서 인보커가 커맨드를 실행할 수 있게함.
        commandMgr.SetCommand("leftClick", shootM4a1Command);
        commandMgr.SetCommand("RKey", reloadM4a1Command);
        commandMgr.SetCommand("FKey", stabKnifeCommand);
    }

    void Update()
    {
        //좌클릭
        if(Input.GetMouseButton(0))
        {
            commandMgr.InvokeExecute("leftClick");
        }

        //재장전
        if(Input.GetKeyDown(KeyCode.R))
        {
            commandMgr.InvokeExecute("RKey");
        }

        //칼 휘두르기
        if (Input.GetKeyDown(KeyCode.F))
        {
            commandMgr.InvokeExecute("FKey");
        }
    }
}


메멘토는 라틴어로서 기억이라는 뜻을 가진다.

이전 데이터를 기억하고 때로는 복원하는 기능을 메멘토 패턴이라고 한다.


메멘토 패턴을 쓰는 경우는 많이 있지만, 몇가지 예시를 들겠다.

1. 캐릭터 장비를 새로 세팅했다가 [취소]버튼을 누르는 경우, 예전 세팅으로 돌아가기

2. 액션게임에서 캐릭터가 죽으면 이전 세이브 시점의 장소와 레벨로 돌아가기


이 외에도 많은 경우에 이 패턴을 쓸 수 있다. 요점은 데이터를 저장하고 필요시 그 데이터를 복원하는 것이다.




예제에서는 캐릭터가 죽으면 이전 세이브 시점의 데이터를 복원하는 것을 해보겠다.



using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//플레이어의 레벨과 자동저장된 곳의 이름을 담을 수 있는 데이터 클래스.
public class AutoSaveData
{
    public int level = -1;
    public string location = "시작의 마을";

    public AutoSaveData()
    {

    }

    public AutoSaveData(int level, string location)
    {
        this.level = level;
        this.location = location;
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//오토 세이브 데이터를 기억하고 복원해주는 관리 클래스
public class AutoSaveManager
{
    //AutoSaveManager는 자주 쓰이고 
    //게임 내에 하나만 존재할 것이므로 싱글톤으로 구현.
    private static AutoSaveManager instance = null;

    public static AutoSaveManager Instance
    {
        get
        {
            if(instance == null)
            {
                instance = new AutoSaveManager();
            }
            return instance;
        }
    }

    private List autoSaveDataList = new List();


    //오토 세이브 데이터를 리스트에 넣어 보존.
    public void PushAutoSaveData(AutoSaveData data)
    {
        if(autoSaveDataList == null)
        {
            autoSaveDataList = new List();
        }
        autoSaveDataList.Add(data);
    }

    //오토 세이브 데이터를 리스트에서 빼오고 리스트에서 삭제. 
    public AutoSaveData PopLastAutoSaveData()
    {
        AutoSaveData data = GetLastAutoSaveData();

        //최근 데이터가 존재한다면 list에서 삭제
        if (data != null)
        { 
            autoSaveDataList.Remove(data);
        }

        return data;
    }

    //가장 최근 오토 세이브 데이터 불러오기.
    public AutoSaveData GetLastAutoSaveData()
    {
        //최소한의 예외처리
        if(!CheckListUsable())
        {
            Debug.Log("데이터 불러오기 실패 : GetLastAutoSaveData");
            return null;
        }

        //프로그래밍 언어는 숫자를 셀 때 0부터 시작하므로, 마지막 세이브 데이터의 순번은 1을 빼준다.
        return autoSaveDataList[autoSaveDataList.Count - 1];
    }

    //특정위치의 오토 세이브 데이터 불러오기
    public AutoSaveData GetAutoSaveDataAt(int index)
    {
        //최소한의 예외처리
        if (!CheckListUsable())
        {
            Debug.Log("데이터 불러오기 실패 : GetAutoSaveDataAt.");
            return null;
        }
        else if (autoSaveDataList.Count <= index)
        {
            Debug.Log("요청한 인덱스가 리스트의 크기를 넘어섬.");
            return null;
        }

        return autoSaveDataList[index];
    }

    //autoSaveDataList가 정의되어있는지, 혹은 비어있는지 체크
    private bool CheckListUsable()
    {
        if (autoSaveDataList == null)
        {
            Debug.Log("오토 세이브 데이터 리스트가 정의되지 않음.");
        }
        else if (autoSaveDataList.Count == 0)
        {
            Debug.Log("오토 세이트 데이터 리스트에 저장된 데이터가 없음.");
        }
        else
        {
            return true;
        }

        return false;
    }

    //데이터 클리어
    public void ClearAutoSaveDataList()
    {
        autoSaveDataList.Clear();
    }  
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//실사용 예. 실제로는 자동저장 시점에 데이터를 저장해주고, 플레이어가 죽으면 복원하면 될 것이다.
public class SceneGame : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        AutoSaveData data = new AutoSaveData(1, "마을 입구");
        AutoSaveManager.Instance.PushAutoSaveData(data);

        AutoSaveData lastData = AutoSaveManager.Instance.GetLastAutoSaveData();
        Debug.Log(lastData.level + ", " + lastData.location);
    }
}




참고 - Pop과 Get

이전 데이터를 복원할 때 2가지 방법이 있다.

1. 이전 데이터를 복원하고 그 데이터를 더이상 기억하지 않는다(1회성 기억). = pop기능

2. 이전 데이터를 복원하고 그 데이터를 계속 남겨둔다. 한번 기억한 데이터는 원할 때 몇번이고 복원한다(지속성 기억). = get기능


pop과 get은 관용인 표현이며, 어느 때는 그 경계가 모호하다. 

보통 자료구조에서 stack이나 queue를 공부할 때 push와 pop이라는 이름을 사용하며, push는 데이터를 배열(또는 벡터)에 넣는 기능, pop은 데이터를 배열에서 빼내오는(그리고 배열에서 삭제함) 기능을 뜻한다.

get set은 유니티 프로그래머라면 property를 공부할 때 보게 될텐데, get은 property 들어있는 값을 가져오는 기능, set은 데이터를 property에 값을 넣어주는 기능이다.


push와 set이 비슷하고,

pop과 get이 비슷한 개념인 셈이다.


그래서 나는 pop을 사용하면 데이터를 빼오면서 배열에서 삭제해주고, get은 데이터를 배열에서 삭제하지 않는 식으로 함수의 이름을 지어준다. 이건 동료 프로그래머와의 소통을 위해 주석을 달아주어야 하는 문제라고 생각한다. 


자신의 게임에 어느 방식이 좋은지는 스스로 선택하도록 한다. 또는 pop과 get 2가지 함수를 모두 만들어줘도 상관 없을것이다.


흔한 프로그래밍 언어서 새로운 인스턴스를 만들 때  new를 사용한다. 

만약 내 게임의 스테이지1에서 그린고블린을 만든다면 아래와 같이 만들것이다.
GreenGoblin greenGoblin = new GreenGoblin();

만약 유니티 모노를 상속받았다면 아래와 같이 할것이다.
GreenGoblin greenGoblin = Instantiate(prefabGreenGoblin);

그런데 갑자기 기획자가 레드고블린, 블랙고블린을 추가한다고 한다면?

혹은 그린고블린을 아예 없앤다면?

그리고 새로운 몬스터로 오크도 만든다고 한다면?

 

수정사항이나 추가사항이 있을 수도 있는데 메인클래스에서 new를 해주는 것은 게임의 규모가 커지면 복잡해지거나 일일이 고쳐줘야해서 번거로워질 수 있다.

 

그래서 인스턴스를 생성하는 기능을 따로 빼주는 것이고, 그것을 팩토리 패턴이라고 말한다.

 

 

simple factory

 

규모가 크지 않을 때는 단순히 if문으로 만들어주기도 한다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SimpleFactory : MonoBehaviour
{
    public static SimpleFactory instance = null;

    public GreenGoblin prefab_greenGoblin;
    public RedGoblin prefab_redGoblin;

    void Awake()
    {
        //싱글톤을 간단한 모양으로 구현.
        instance = this;
    }

    public Goblin CreateGoblin(string type)
    {
        Goblin goblin = null;

        if(type.Equals("green"))
        {
            goblin = Instantiate(prefab_greenGoblin);
        }
        else if(type.Equals("red"))
        {
            goblin = Instantiate(prefab_redGoblin);
        }

        return goblin;
    }
}

 

SimpleFactory라는 클래스를 따로 만들었고, 고블린을 생성해주는 함수 CreateGoblin을 만들어줬다. 

이렇게 단순히 인스턴스 생성기능만 따로 빼준 것을 simple Factory라고 하며, 디자인패턴 급은 아니고 간단히 만들 때 종종 쓰는 주입 방식이다. 고블린을 생성할 때는 아래와 같이 사용해주면 될것이다.

 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Scene1 : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        Goblin greenGoblin = SimpleFactory.instance.CreateGoblin("green");

        greenGoblin.SetPosition();
    }
}

 

하지만 위의 심플 팩토리로 게임을 만든다면 문제가 있다. 지금은 레드고블린과 그린고블린밖에 없지만, 오크, 트롤, 코볼트, 다크엘프 등등의 수많은 몬스터를 만들어야 한다고 해보자. 몬스터를 추가할 때마다 함수를 고치거나 몬스터별로 만들어야할 수도 있다. 이는 객체지향의 OCP(클래스 만들 때 확장은 가능하게 하되, 한번 만들면 추후에 수정할 필요 없게 만들라는 원칙)를 위반하는데다가 그냥 프로그래머팀 팀장님한테 설계 잘못했다고 욕먹을 수도 있다.

 

그래서 나온 디자인패턴이 팩토리 메소드와 추상팩토리 패턴이다.

 

 

팩토리 메소드 패턴

 

 

 

 

각 몬스터별로 생성 함수를 따로 정의해주는 것이 팩토리 메소드 패턴의 요점이다.

 

팩토리 메소드 패턴을 만드는 방식은 아래와 같다.

1. 최상위 Factory를 abstract class로 만들어준다. 그 안에 CreateMonster라는 함수를 선언해준다.

2. 고블린은 GoblinFactory, 오크는 OrcFactory 이런식으로 몬스터별로 팩토리를 따로 만들어준다.

3. 각 몬스터 팩토리는 CreateMonster라는 함수를 각자 알아서 정의해준다.

4. 고블린을 만들어야 한다면 GoblinFactory를 하나 생성해주고 거기서 고블린을 만들어준다.

 

자세한 코드는 아래와 같다.

using System.Collections; 
using System.Collections.Generic; 
using UnityEngine; 

//최상위 팩토리 클래스. 각 몬스터별로 이 클래스를 상속받아 각자의 팩토리를 만들어준다.
public abstract class AbsMonsterFactory 
{ 
    public abstract void CreateMonster(); 
}

 

아래는 AbsMonsterFactory를 상속받은  GoblinFactory클래스이다. 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GoblinFactory : AbsMonsterFactory
{
    public GreenGoblin greenGoblin;
    public RedGoblin redGobline;

    public override Monster CreateMonster(string type)
    {
        Monster monster = null;

        if(type.Equals("green"))
        {
            monster = Instantiate(greenGoblin);
        }
        else if(type.Equals("red"))
        {
            monster = Instantiate(redGobline);
        }

        return monster;
    }
}

 OrcFactory역시 이런 방식으로 만들어주면 된다. override 받은 CreateMonster함수를 각 몬스터별로 따로 정의해주는게 포인트이다.

 

참고로 여기서는 예제이기 때문에 프리팹을 클래스의 멤버 변수로 선언해놓고서 내용물을 정의해주는 부분을 생략했다. 하지만 실전에서는 에셋번들에서 추출해서 넣어주거나, 에셋번들 자체를 멤버변수로 해주는 방식으로 해야할 것이다. 또는 프리팹이 들어있는 에셋번들을 관리해주는 클래스를 따로 만들어 참조할 수도 있을 것이다.

 

마지막으로 아래와 같이 사용해주면 된다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Scene1 : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        AbsMonsterFactory gf = new GoblinFactory();
        Monster monster = gf.CreateMonster("green");
    }
}

 

여기서 포인트는 Scene1클래스에서 고블린 팩토리를 하나 만들어줄 때, AbsMonsterFactory 타입으로 만들어주었다는 점이다. 신규 몬스터로 오크나 코볼트가 나오더라도, AbsMonsterFactory타입의 변수로 만들어준다면 코드가 일관성이 있게 되고 수정이 최소화 되게 된다.

 

 

추상 팩토리 패턴

일단 위의 게임과는 다른 게임을 만든다고 생각해보자.

 

이 게임은 보스몬스터들이 무기를 들고 있고, 몬스터의 종족과 무기종류에 따라 공략하는 맛이 있는 게임이라고 가정해보자. 보스의 패턴은 계속 랜덤으로 나온다고 가정해보자.

 

보스 종류 1 : 고블린 & 칼

보스 종류 2 : 오크 & 도끼

 

지금은 2종류의 보스 뿐이지만, 계속해서 보스와 무기가 추가될 예정이라면? 칼을든 고블린 뿐만 아니라 창을 든 고블린, 활을 든 고블린, 또는 오크가 칼을 들고 있다면? 이런식으로 부품을 끼워 맞추듯이 몬스터와 무기를 매칭시켜주는 방식이라면 추상팩토리 패턴을 사용하는게 좋다.

 

클래스 구조 설계는 아래 이미지들을 통해 설명하겠다.

 

팩토리를 통해 만들어지는 보스. 보스는 몬스터와 웨폰으로 이루어져있다.

 

 

 

 

고블린과 오크는 몬스터 클래스를 상속받고, 소드와 액스는 웨폰 클래스를 상속받는다.

 

 

 

 

외부에서 실질적으로 호출되는 함수는 CreateBoss함수 뿐이다. CreateMonster와 CreateWeapon 함수는 CreateBoss함수 내부에서 호출되는 함수이다.

 

실질적인 코드는 아래와 같다. 설명의 편의상 Instantiate를 사용하지 않고 new로 인스턴스를 만들어주도록 하겠다.

public abstract class AbsBossFactory
{
    //실제 보스를 만들어주는 부분. 
    public Boss CreateBoss()
    {
        Boss boss = new Boss
        {
            monster = CreateMonster(),
            weapon = CreateWeapon()
        };

        return boss;
    }

    public abstract Monster CreateMonster();
    public abstract Weapon CreateWeapon();
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//보스유형 1은 칼을 든 고블린.
public class BossFactory1 : AbsBossFactory
{
    public override Monster CreateMonster()
    {
        Monster monster = new Goblin();
        return monster;
    }

    public override Weapon CreateWeapon()
    {
        Weapon weapon = new Sword();
        return weapon;
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//보스유형 2는 도끼를 든 오크.
public class BossFactory2 : AbsBossFactory
{
    public override Monster CreateMonster()
    {
        Monster monster = new Orc();
        return monster;
    }

    public override Weapon CreateWeapon()
    {
        Weapon weapon = new Axe();
        return weapon;
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//실질적으로 팩토리를 써주는 부분
public class BattleGenerator : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        AbsBossFactory factory1 = new BossFactory1();
        Boss boss1 = factory1.CreateBoss();
    }
}

지금은 Start함수에서 1번 패턴의 보스를 대놓고 만들었지만, 이 팩토리들을 관리하는 클래스를 따로 만들거나 해서 Random.Range를 사용해서 무작위 보스가 계속 출현하게 만들면 될 것 같다.

 

 

 

추상팩토리 VS 팩토리 메소드 패턴 

그럼 추상팩토리와 팩토리 메소드 패턴은 뭐가 더 좋은걸까? 답은 없다. 상황에 따라 다르다. GoblinFactory처럼 하나의 카테고리 안의 완제품(레드고블린, 그린고블린)을 매개변수에 따라 만들어줄 때는 팩토리 메소드가 좋다. 하지만 2개 이상의 인스턴스를 조합하는 공정에서는(여기서는 몬스터와 무기를 골라서 보스를 만들때) 추상팩토리가 더 좋다. 경우에 따라서는 2개의 장점을 섞어서 만들거나, 둘 다 필요가 없는 아주 간단한 기능의 구현 때는 심플 팩토리로 시간을 절약하며 개발하는 경우도 있다. 또한 다른 디자인 패턴들과 섞어서 만들면 더욱 독특하고 효율적인 코드가 나올 수 있다. 지식을 많이 쌓아 지혜를 끌어올리도록 하자.

+ Recent posts