상태 패턴
객체 내부 상태 변경에 따라 객체의 행동이 달라지는 패턴. 상태에 특화된 행동들을 분리해 낼 수 있으며, 새로운 행동을 추가하더라도 다른 행동에 영향을 주지 않는다.
구조
- Context - 사용자가 관심 있는 인터페이스를 정의한다. 객체의 현재 상태를 정의한 ConcreteState 클래스 객체를 유지 및 관리한다.
- State - Context의 각 상태별로 필요한 핸동을 캡슐화하여 인터페이스로 정의한다.
- ConcreteState 클래스들 - 각 서브클래스들은 Context의 상태에 따라 처리되어야 할 실제 행동을 구혀한다.
기본 인터페이스
public interface IState {
void Handle(Context context);
}
public class ConcreteStateA : IState {
public void Handle(Context context) {
Console.WriteLine("Handling request in State A.");
context.State = new ConcreteStateB();
}
}
public class ConcreteStateB : IState {
public void Handle(Context context) {
Console.WriteLine("Handling request in State B.");
context.State = new ConcreteStateA();
}
}
public class Context {
public IState State { get; set; }
public Context(IState state) {
this.State = state;
}
public void Request() {
State.Handle(this);
}
}
class Program {
static void Main(string[] args) {
Context context = new Context(new ConcreteStateA());
context.Request();
context.Request();
}
}
Handling request in State A.
Handling request in State B.
왜 사용해야 할까?
상태 패턴은 유한 상태 기계(FSM) 개념과 밀접하게 관련되어 있다.
이 패턴의 주요 개념은 모든 주어진 순간에 프로그램이 속해 있을 수 있는 상태들의 수는 유한하다는 것이다. 어떤 고유한 상태 내에서든 프로그램은 다르게 행동하며, 한 상태에서 다른 상태로 즉시 전환될 수 있다. 하지만 현재의 상태에 따라 프로그램은 특정 다른 상태로 전환되거나 전환되지 않을 수 있다. 이러한 전환 규칙들을 천이(transition) 라고도 하는데, 이러한 규칙들 또한 유한하고 미리 결정되어 있다.
이 접근 방식을 객체들에 적용할 수도 있다.
예를 들어 어떤 게임의 몬스터가 한 자리에서 플레이어를 기다리다 플레이어가 근처에 있다면 다가가 공격하는 몬스터 AI를 생각해보자.
이 몬스터의 행동은 다음 3가지 상태로 구분할 수 있다.
- Idle
- 아무런 행동 없이 대기하고 있는 상태
- 플레이어가 인지 가능 범위 안에 있다면 Move상태로 전환
- 플레이어가 공격 가능한 거리에 있다면 Attack상태로 전환
- Move
- 플레이어에게 이동하는 상태
- 플레이어가 인지 가능 범위를 벗어나면 Idle상태로 전환
- 플레이어가 공격 가능한 거리에 있다면 Attack상태로 전환
- Attack
- 플레이어를 공격하는 상태
- 플레이어가 인지 가능 범위를 벗어나면 Idle상태로 전환
- 플레이어가 공격 가능 거리를 벗어나고 인지 가능 범위 안에 있다면 Move상태로 전환
FSM은 몬스터의 행동 양식을 각 상태별로 구분하고 각 상태별로 전환 조건을 설정함으로써 상태별로 코드를 작성함으로써 코드 가독성을 높이고 유지 보수에 용이해진다. 또한 추후 추가로 상태를 정의한다고 하더라도 해당 상태에 대한 추가 코드를 작성하고 각 상태별 전환 조건만 추가해주면 되기 때문에 유연성도 높일 수 있다.
public class Monster : MonoBehaviour
{
private enum State
{
Idle,
Move,
Attack,
Jump // 추가된 상태
}
private State _state;
private void Start()
{
_state = State.Idle;
}
private void Update()
{
switch (_state)
{
case State.Idle:
// Idle 행동 구현...
break;
case State.Move:
// Move 행동 구현...
break;
case State.Attack:
// Attack 행동 구현...
break;
//추가된 상태 로직
case State.Jump:
//Jump 행동 구현...
break;
}
}
}
하지만 위와 같이 조건문에 기반한 상태 머신은 그 한계가 명확하다. 상태 전이를 객체 자체에서 담당하기 때문에 클래스는 상태에 의존하는 행동들을 추가할수록 FSM의 장점을 잃게 된다.
정의하는 상태가 점점 많아질수록 구분하는 조건문의 크기가 점점 커질 것이고, 새로운 상태를 추가하거나 전환 로직을 수정할 때에도 다른 조건문들을 검토하고 수정해야 한다. 이는 곧 유지보수에 문제를 초래하기 때문에 예상치 못한 상태 전환에 대한 누락 등 버그의 발생이 많아지고 그 버그들을 해결하는 데에도 큰 어려움을 겪을 것이다.
가장 큰 문제는, 코드 재사용성 및 확장성을 보장할 수 없게 된다. 모든 몬스터들이 각각 다른 행동 양식을 갖도록 구현하기 위해선 모든 몬스터들이 해당 몬스터 클래스를 그대로 사용할 수 없다. 각 몬스터별로 행동 양식을 다시 작성하고, 새로운 클래스를 생성하여 기존 몬스터 클래스를 상속받아 오버라이딩하는 등의 방식으로 코드를 반복해서 작성해야 할 것이다.
상태 패턴은 객체의 모든 가능한 상태들에 대해 새 클래스들을 만들고 모든 상태별 행동들을 이러한 클래스들로 추출함으로써 해결한다. 콘텍스트라는 원래 객체는 모든 행동을 자체적으로 구현하는 대신 현재 상태를 나타내는 상태 객체 중 하나에 대해 저장하고 모든 상태에 관련된 작업을 해당 객체에 위임한다.
각 상태(State)는 별도의 클래스로 분리되므로, 상태별 로직은 해당 상태 클래스 내부에 캡슐화되어 있다. 이는 코드의 가독성을 획기적으로 향상시키고, 각 상태의 행동을 쉽게 찾고 수정할 수 있게 한다. 예를 들어, 'Idle', 'Move', 'Attack', 'Jump' 등의 각 상태는 자신의 클래스를 가지며, 각 클래스는 상태별로 특정한 행동을 정의한다. 이 방식으로, 새로운 상태를 추가하는 것이 단순히 새로운 클래스를 추가하는 것만큼 간단해진다. 따라서 상태의 추가나 변경이 필요할 때 기존 코드를 건드리지 않고도 확장할 수 있다.
Context 클래스는 현재 상태를 나타내는 객체를 저장하며, 상태 변경이 필요할 때 이 객체를 교체하기만 하면 된다. 콘텍스트 클래스는 상태 객체에게 행동의 실행을 위임함으로써, 상태에 따라 달라지는 행동을 각 상태 객체가 처리하게 한다. 이러한 위임 메커니즘은 콘텍스트 클래스의 복잡성을 대폭 줄이며, 각 상태가 독립적으로 관리되도록 한다.
아래 예시와 같이 몬스터의 FSM을 상태 패턴으로 구현할 수 있다.
//** Context */
public class Monster : MonoBehaviour {
private IMonsterState _currentState;
private void Start() {
_currentState = new IdleState();
}
private void Update() {
_currentState.Handle(this);
}
public void TransitionToState(IMonsterState newState) {
_currentState = newState;
}
public bool IsPlayerInDetectionRange() {
// 플레이어 감지 로직
return true; // 예시
}
public bool IsPlayerInAttackRange() {
// 플레이어 공격 범위 로직
return false; // 예시
}
}
//** Contrete StateClasses */
public class IdleState : IMonsterState {
public void Handle(Monster monster) {
// Idle 상태의 로직 구현
if (monster.IsPlayerInDetectionRange()) {
monster.TransitionToState(new MoveState());
}
}
}
public class MoveState : IMonsterState {
public void Handle(Monster monster) {
// Move 상태의 로직 구현
if (!monster.IsPlayerInDetectionRange()) {
monster.TransitionToState(new IdleState());
} else if (monster.IsPlayerInAttackRange()) {
monster.TransitionToState(new AttackState());
}
}
}
Monster 클래스는 현재 상태를 _currentState로 관리하며, 각 상태의 Handle 메서드를 Update에서 호출한다. 상태 전환은 TransitionToState 메서드를 통해 수행된다. 각 상태 클래스는 IMonsterState 인터페이스를 구현하며, 몬스터의 상태에 따른 특정 행동을 정의한다.
장점
- 상태에 따른 동작을 개별 클래스로 옮겨서 관리할 수 있다.
- 코드 복잡도를 줄일 수 있다.
- 기존의 특정 상태에 따른 동작을 변경하지 않고 새로운 상태에 다른 동작을 추가할 수 있다.
단점
- 코드 복잡도가 증가한다.
참고자료
'프로그래밍 이론 > 디자인 패턴' 카테고리의 다른 글
22. [Design Pattern] 전략 패턴(Strate) (0) | 2024.02.27 |
---|---|
21. [Design Pattern] 옵저버 패턴(Observer) (1) | 2024.02.26 |
19. [Design Pattern] 메멘토(Memento) 패턴 (0) | 2024.02.19 |
18. [Design Pattern] 중재자(Mediator) 패턴 (0) | 2024.01.22 |
17. [Design Pattern] 전략(Strategy) 패턴 (0) | 2024.01.17 |