이번 포스트에서는 Unity 게임 제작에 많이 쓰이는 Game State를 알아보고 리팩토링하는 과정을 함께하는 시간을 갖겠습니다.
1. Game State Machine
게임에는 다양한 등장인물이 있습니다. 그리고 각 인물들은 자신들의 고유한 동작을 합니다. 프로그래머는 이러한 동작을 코드로 구현하게 되는데요. 직접 게임을 만들어 본 독자들은 알겠지만 등장인물의 수와 동작들이 늘어날 수록 프로젝트는 엉망이 되기 쉽습니다.예를 들어, 괴물을 무찌르는 용사 게임을 만든다고 생각해봅시다.
적어도 용사는 무찌를 괴물을 만나러 갈 능력은 있어야 합니다. 용사의 위치가 실제로 이동해야함은 물론이고, 게이머가 재미 비슷한 것을 느끼려면 팔다리를 앞뒤로 휘두르는 애니메이션과 저벅저벅 소리정도는 나줘야 합니다. 좀 더 현실적인 게임을 구현하고자 한다면 걷기/뛰기 기능을 만들 수도 있는데요. 게이머가 걷기 기능의 존재 이유를 궁굼해하지 않도록 하려면, 용사가 일정 시간만 뛸 수 있도록 스테미너 기능도 만들어야 할 것입니다. 물론 걷는 애니메이션과 뛰는 애니메이션은 달라야하고, 소리도 달라야 하겠지요.
역사상 최고의 노잼 게임 "ET" 마저도 주인공이 이동할 때 다리가 움직인다.
https://youtu.be/EFt-La3UUu0
비슷한 기능은 용사뿐만 아니라 괴물에게도 필요합니다. 괴물의 종류가 한 가지가 아니라면 종류별로 따로 구현해줘야겠지요. 이렇게까지 했는데도 용사와 괴물은 서로 마주칠 뿐 아직은 서로 싸우지도 못합니다. 개발이 진행될수록 개발이 아니라 노가다가 되는 이유입니다.
-개발 할 수록 늘어나는 것
- 코드의 라인수가 늘어납니다.
- 복사/붙여넣기의 횟수가 늘어납니다.
- 개발 할 수록 줄어드는 것
- 코드를 자랑하고 싶은 마음이 줄어듭니다.
- 게임이 완성될 가망이 줄어듭니다.
프로그래머들은 점점 복잡해지는 코드를 정리하기 위해 State(상태)를 도입했습니다.
위 예에서 용사와 괴물은 걷기/뛰기라는 공통 된 "상태"를 가지는데요. 그리고 충분히 뛰기를 하고나면 걷기로 "상태를 변경"하는 기능을 가집니다. 코드를 잘 보면 등장인물은 상태를 기준으로 각각에 맞는 애니메이션과 소리를 동일하게 구현해야 합니다.
- 용사에서 구현해야 할 것
- 애니메이션
- 걷기 상태 : 팔은 흔들지 않고 발만 움직인다.
- 뛰기 상태 : 팔과 다리를 힘차게 흔든다.
- 소리
- 걷기 상태 : "저벅 저벅" 소리가 난다.
- 뛰기 상태 : "타박 타박" 소리가 난다.
- 괴물에서 구현해야 할 것
- 애니메이션
- 걷기 상태 : 한 쪽 다리를 절면서 움직인다.
- 뛰기 상태 : 앞다리까지 네 발로 움직인다.
- 소리
- 걷기 상태 : "그르렁" 소리가 난다.
- 뛰기 상태 : "컹컹!" 소리가 난다.
따라서 각 상태에 맞게 필요한 것들을 차근차근 구현하다보면, 걷는 애니메이션을 빼먹고 구현하지 않는다던지 뛰고 있는데 걷는 소리가 난다던지 하는 실수를 줄일 수 있습니다. 또 단순히 스테미너가 다 닳았을 때 상태를 뛰기에서 걷기로 바꾸는 기능만 구현만 하면, 모든 구현을 일일이 바꾸지 않아도 되는데요. 다음 항목에서 보여드릴 Switch문을 이용하면 코드가 제법 깔끔하게 정리되어 멀리서 보면 멋지게 보이기까지 합니다.
2. Enum과 Switch를 이용한 구현
보통 Game State는 Enum과 Switch를 이용해서 구현합니다.
2.1. 구현
enum CharactorState { WALKING, RUNNING }
Enum을 이용해서 위와 같이 상태를 정의합니다. 각 상태에 맞는 구현은 아래와 같겠지요?
class Warrior { private CharactorState state; public Warrior(CharactorState state) { this.state = state; } public void move() { switch (state) { case WALKING : // 용사가 한번에 한 걸음씩 걷는 코드 case RUNNING : // 용사가 한번에 열 걸음씩 걷는 코드 } } public void animate() { switch (state) { case WALKING : // 용사가 팔은 흔들지 않고 다리만 움직이는 코드 case RUNNING : // 용사가 팔과 다리를 힘차게 움직이는 코드 } } public void sound() { switch (state) { case WALKING : // 용사가 "저벅 저벅"거리는 코드 case RUNNING : // 용사가 "타박 타박"거리는 코드 } } }
괴물도 똑같습니다.
2.2. 문제점
우리는 기존의 엉망진창 코드를 Enum과 Switch를 이용해 정리했습니다. 그 결과 프로그래머가 "실수 할 가능성"을 줄일 수 있었고, "가독성"있는 코드를 얻게 되었는데요. 그럼에도 불구하고 아직 부족한 점이 남아있습니다.
public void move() { switch (state) { case WALKING : // 용사가 한번에 한 걸음씩 걷는 코드 case RUNNING : // 용사가 한번에 열 걸음씩 걷는 코드 } }
위 메서드를 보면, 아직 우리가 없애지 못한 실수의 가능성을 볼 수 있습니다.
- state를 바꿔적는 실수
이런 멍청한 짓을 하는 프로그래머가 있을까 싶지만, 종종 이런 일을 벌이고 자책해본 경험이 한번씩은 있을 것입니다. 이 실수는 간단하면서도 짜증나는 결과를 가져오는데, 컴파일러가 아무런 불만도 하지 않고 넘어가기 때문에 좀처럼 찾아내기가 어렵기 때문입니다.
- 특정 state의 case를 구현하지 않는 실수
이 실수는 앞선 것보다 더 하기 쉽습니다. 지금은 state가 두 가지밖에 안되지만 수십가지로 늘어나면 어떤 state를 구현했고 하지 않았는지 알기 어렵습니다. 팀원 중 하나가 말도없이 state를 추가하기까지 한다면, 나는 무엇이 잘못되었는지 조차 알지 못하는 상태에 이르게 됩니다. 결국 프로젝트를 마감을 앞두고 "어 왜 아무것도 안하지?"하는 팀원의 모습을 볼 수 있습니다.
- 존재하지 않는 state가 주어지는 경우
Enum에 정의하지 않은 state가 있을 수 있는가 싶지만 실재로 가능합니다. Enum은 WALKING, RUNNING과 같이 특수한 상태를 가지는 것 처럼 보이지만 실재로는 0, 1, 2, 3 따위의 정수 값입니다. 별도의 코드를 작성하지 않으면 CharactorState는 아래 코드와 같습니다.
enum CharactorState { WALKING = 0, RUNNING = 1 }
이는 CharactorState이 int값을 가짐은 물론이고, CharactorState에 int값을 캐스팅해서 넣을 수도 있다는것을 의미하는데요. 이를 이용하면 아래처럼 멍청한 코드도 가능합니다.
CharactorState state0 = (CharactorState)0; // 0이 캐스팅되어 WALKING이 되었습니다. CharactorState state1 = (CharactorState)1; // 1이 캐스팅되어 RUNNING이 되었습니다. CharactorState state2 = (CharactorState)2; // 2가 캐스팅되어 ???????이 되었습니다?
안타깝게도 대부분의 컴파일러는 state2에서 아무런 불만도 하지 않습니다. 따라서 Warrior의 state가 항상 유효하다는 보장을 할 수 없게 되어버렸습니다. 역시나 프로젝트 마감을 앞두고 "어 왜 아무것도 안하지?"하는 팀원의 모습을 볼 수 있습니다.
- 여러 case가 공유하는 변수를 두고 싶은 유혹
public void move() { int speed = 1; switch (state) { case WALKING : // 용사가 한번에 한 걸음씩 걷는 코드, speed는 1이다 case RUNNING : // 용사가 한번에 열 걸음씩 걷는 코드 } }
충분히 switch문 밖에 변수를 둘 수 있습니다. 만약 걷는 속도가 너무 느리다고 생각돼서 speed를 2로 올리면 뛰는 속도는 어떻게 될까요? 뛰는 속도는 10을 곱한 20이 될까요, 아니면 9를 더한 11이 될까요? 단순히 여러 case가 변수를 공유하는 것 만으로도 코드는 불확실해졌습니다. WALKING을 바꾸고 싶었을 뿐인데 원치 않는 변화가 RUNNING까지 퍼져나갔기 때문이지요. 이러한 코드를 "산탄총 수술"이라고 합니다. 한 발의 총알로도 수술해야할 부위는 수십군데가 되버리기 때문입니다.
위 문제점 중 하나만 어기더라도 용사는 제 구실을 하지 못할 것입니다. 이러한 문제는 Warrior 클래스가 관리해야할 CharactorState가 늘어날수록 심각해지며, 어떠한 실수도 놓치지 않기 위해 들여햐 하는 노력도 코드의 양 만큼 늘어납니다. 결국 코드가 늘어날수록 엉망이 되는 것은 같네요.
객체지향 5원칙에 따르면 클래스와 메서드는 한 가지 기능만 책임져야 하며(SRP), 기능을 추가하기는 쉽고 이로인한 부작용은 없어야 합니다(OCP). 왜 그래야만 하는지 밝히는 것은 이 글의 범위를 벗어나서 생략하겠습니다. 하지만 CharactorState가 추가될수록 스크롤과 복사/붙여넣기 횟수는 증가하고, 원치 않는 기능의 변화들이 발생함은 분명하지요?
2.3. 파국으로 치닫는 코드
이 게임을 망치는 한가지 경우가 더 있습니다. 만약 CharactorState외에 GameState가 또 있다면 어떻게 될까요?
한참 유행했던 "OO런"류의 러닝 액션 게임을 보면, 특정 아이템을 먹었을 때 캐릭터가 빨라지면서 반짝거리고 다른 소리가 나는 것을 보셨을 것입니다. 이러한 게임 전역적인 상태를 관리하기 위해 아래와 같은 Enum을 정의할 수 있겠네요.
enum GameState { NORMAL, BONUS // 캐릭터가 빨라지고 반짝거리며 다른 소리가 납니다. 물론 괴물이 그럴수도 있습니다. }
GameState가 BONUS로 바뀜에 따라 우리 용사도 변해야 할 것입니다.
class Warrior { // ... public void animate() { switch (gameState) { case NORMAL : switch (state) { case WALKING : // 용사가 팔은 흔들지 않고 다리만 움직이는 코드 case RUNNING : // 용사가 팔과 다리를 힘차게 움직이는 코드 } case BONUS : case NORMAL : switch (state) { case WALKING : // 용사가 팔은 흔들지 않고 다리만 움직이지만 반짝거리는 코드 case RUNNING : // 용사가 팔과 다리를 힘차게 움직이지만 반짝거리는 코드 } } } // ... }
우리가 원하던 "정리된" 코드는 아닙니다. 더 심각한 것은, GameState는 Warrior 클래스 뿐만 아니라 각종 괴물들 클래스에 뿔뿔이 흩어져 있습니다. Warrior 클래스는 메서드들을 서로 비교해가면서 실수를 줄여나갈 수 있었지만, 이제는 클래스와 파일을 넘나들며 확인해야만 합니다.
실수하는 것이 아주 쉬워졌네요.
3. 리팩토링
이 글의 제목을 보면 디자인패턴과 리팩토링을 통해 문제를 해결할 수 있다고 합니다. 바로 시작해봅시다.
3.1. 단계 1 - 함수로 추출하기
위 문제의 시작은, 하나의 메소드가 여러 기능을 책임지고 있는 것에서 시작됐습니다. 따라서 하나의 메소드가 하나의 기능만 하도록 해줍시다.
public void animate() { switch (state) { case WALKING : walkAnimate(); break; case RUNNING : runAnimate(); break; } } private walkAnimate() { // 용사가 팔은 흔들지 않고 다리만 움직이는 코드 } private runAnimate() { // 용사가 팔과 다리를 힘차게 움직이는 코드 }
아직 animate()는 두 개의 기능을 책임지고 있지만, 적어도 walkAnimate()와 runAnimate()가 변수를 공유하지는 않게 됐습니다. 두 함수는 분리되어있기 때문에 한 쪽의 변화가 다른 쪽에 영향을 미칠 가능성이 낮아졌습니다.
3.2. 단계 2 - 클래스로 추출하기
이제는 animate()도 하나의 기능만 책임지게 해줍시다. 그 시작은 walkAnimate()와 runAnimate()를 다른 클래스로 분리 시키는 것부터 시작합니다.
class WalkState { public animate() { // 용사가 팔은 흔들지 않고 다리만 움직이는 코드 } } class RunState { public animate() { // 용사가 팔과 다리를 힘차게 움직이는 코드 } }
메서드가 클래스로 빠져나왔으니, Warrior 클래스는 이런식으로 되어야 원래 기능을 유지하겠지요?
class Warrior { private CharactorState state; private WalkState walkState; private RunState runState; public void animate() { switch (state) { case WALKING : walkState.animate(); case RUNNING : runState.animate(); } } // ... }
3.3. 단계 3 - 상속 관계로 묶기
두 개의 클래스가 추가된 것이 오히려 역행하는 기분입니다. 조금만 참고 두 클래스를 상속관계로 묶어봅시다.
interface IState { void animate(); } class WalkState : public IState {/* ... */} class RunState : public IState {/* ... */}
클래스를 상속 관계로 묶으면 Warrior 클래스에 추가될 클래스를 하나로 줄일 수 있습니다.
class Warrior { private CharactorState state; private IState walkrunState; public void animate() { switch (state) { case WALKING : walkState.animate(); case RUNNING : runState.animate(); } } // ... }
3.4. 단계 4 - Enum과 Switch 없애기
상속 관계로 묶인 클래스는 다형성을 가집니다. 따라서 CharactorState와 Switch를 이용한 분기 없이도 상황에 따른 다른 동작을 할 수 있습니다.
class Warrior { private IState state; public void animate() { state.animate(); } // ... }
만약 state의 타입이 WalkState라면 state.animate()는 팔은 흔들지 않고 다리만 움직일 것이고, RunState라면 state.animate()는 팔과 다리를 힘차게 움직일 것입니다.
3.5. 결과
결국 코드는 이렇게 변했습니다.
interface IState { void move(); void animate(); void sound(); } class WalkState : public IState { public move() { // 용사가 한번에 한 걸음씩 걷는 코드 } public animate() { // 용사가 팔은 흔들지 않고 다리만 움직이는 코드 } public sound() { // 용사가 "저벅 저벅"거리는 코드 } } class RunState : public IState { public move() { // 용사가 한번에 열 걸음씩 걷는 코드 } public animate() { // 용사가 팔과 다리를 힘차게 움직이는 코드 } public sound() { // 용사가 "타박 타박"거리는 코드 } } class Warrior { private IState state; public Warrior(IState state) { this.state = state; } public void move() { state.move(); } public void animate() { state.animate(); } public void sound() { state.sound(); } }
어째 코드가 더 늘어나 버렸습니다. 하지만 이제 이런 걱정은 하지 않아도 됩니다.
- state를 바꿔적는 실수
애초에 생성자로 전달되는 IState state가 잘못되지 않은 이상 바뀌는 경우는 없습니다. 잘못 전달되더라도 일관성있게 변하기 때문에 찾아내기도 쉽습니다.
- 특정 state의 case를 구현하지 않는 실수
마찬가지로 특정 state만 누락될 수 없습니다. 누락되더라도 모든 state가 누락되는 경우 외에는 일어날 수 없으며, 이마저도 state가 생성자를 통해 전달되는 경우에는 누락 자체가 일어날 수 없습니다.
- 존재하지 않는 state가 주어지는 경우
IState state는 int로 캐스팅 되거나 하지 않습니다. 따라서 존재하지 않는 state가 있을 수 없습니다.
- 여러 case가 공유하는 변수를 두고 싶은 유혹
각 case가 다른 객체로 분리되어있기 때문에 그런 코드를 작성할 수 없습니다.
이러한 코드는 확장도 쉽습니다.
- 새로운 state가 필요할 경우
IState를 상속하는 새로운 클래스 정의하면 됩니다. 자연스럽게 IState가 요구하는 메서드도 함께 구현하게 됩니다. 그러지 않으면 컴파일이 안되니까요.
- 새로운 기능이 필요한 경우
IState에 해당 기능의 메소드를 추가하면 됩니다. 자연스럽게 IState를 상속하는 모든 클래스는 해당 메소드를 반드시 구현해야만 합니다. 그러지 않으면 컴파일이 안되니까요.
4. State Pattern
위 리팩토링 과정을 거쳐 구현한 패턴은 "State Pattern"입니다. Gang Of Four(GoF)의 디자인 패턴을 참고하면 더 자세한 내용을 알 수 있습니다.
댓글 없음:
댓글 쓰기