[DesignPattern] State Pattern

suhan0304·2024년 9월 10일

Design Pattern

목록 보기
5/16
post-thumbnail

State Pattern

상태 패턴은 객체가 특정 상태에 따라 행위를 달리하는 상황에서, 상태를 조건문으로 검사해서 행위를 달리하는 것이 아닌, 상태를 객체화하여 상태가 행동을 할 수 있도록 위임하는 패턴을 말한다. 객체 지향 프로그래밍에서의 클래스는 꼭 어떠한 형태가 있는 물체의 데이터만 표현할 수 있는게 아니라 동작, 행위들도 클래스로 묶어 표현할 수 있다. 그렇기 때문에! 상태를 클래스로 표현하면 클래스를 교체해서 상태의 변화를 표현할 수 있고, 객체 내부 상태 변경에 따라 객체의 행동을 상태의 특화된 행동들로 분리해 낼 수 있으며, 새로운 행동을 추가하더라도 다른 행동에 영향을 주지 않는다.

상태(State)? 객체가 가질 수 있는 조건이나 상황, 예를 들면 에어컨이 꺼진 상태에서는 희망 온도가 바뀌지 않는다. 하지만 켜진 상태에서는 희망 온도가 변한다. 즉, 에어컨의 전춴 상태에 따라 행동이 바뀐다. 이렇게 객체의 상태에 따라 행위를 달리하는 상황에서 사용하는 최적의 패턴이 state pattern이라고 보면 된다.

전략 패턴이 전략 알고리즘을 클래스로 표현한 패턴이라면, 상태 패턴은 객체 상태를 클래스로 표현한 패턴이다! 그래서 그런지 상태 패턴의 클래스 다이어그램을 보면 전략 패턴과 매우 유사한데, 전략 패턴은 전략을 객체화한 것이고, 상태 패턴은 상태를 객체화 한것인데 결국 둘 다 클래스 묶음인 것은 똑같기 때문이다.


Structure

  1. State Interface : 상태를 추상화한 고수준 모듈
  2. ConcreteState : 구체적인 각각의 상태를 클래스로 표현. State 역할로 결정되는 인터페이스를 구체적으로 구현 + 다음 상태가 결정되면 Context에 상태 변경을 요청
  3. Context : State를 이용하는 시스템. 시스템 상태를 나타내는 State 객체를 합성하여 가지고 있음. 요청을 받으면 State 객체에 행위 실행을 위임

상태 클래스는 싱글톤 클래스로 구현한다. 전략 패턴의 전략 객체 같은 경우 매개 값에 따라 알고리즘 수행 형태가 달라질 수 있지만, 상태는 그 객체의 현 폼을 나타내는 것이기 때문에 대부분의 상황에서 유일하게 있어야 한다.


How

interface AbstractState {
    void requestHandle(Context cxt);
}

class ConcreteStateA implements AbstractState {
    @Override
    public void requestHandle(Context cxt) {}
}

class ConcreteStateB implements AbstractState {
    @Override
    public void requestHandle(Context cxt) {
        // 상태에서 동작을 실행한 후 바로 다른 상태로 바꾸기도 함
        // 예를 들어 전원 on 상태에서 끄기 동작을 실행한후 객체 상태를 전원 off로 변경 하듯이
        cxt.setState(ConcreteStateC.getInstance());
    }
}

class ConcreteStateC implements AbstractState {
    @Override
    public void requestHandle(Context cxt) {}
}
class Context {
    AbstractState state; // composition

    void setState(AbstractState state) {
        this.state = state;
    }

    // 상태에 의존한 처리 메소드로서 state 객체에 처리를 위임함
    void request() {
        state.requestHandle(this);
    }
}

class Client {
    public static void main(String[] args) {
        Context context = new Context();

        // 1. StateA 상태 설정
        context.setState(new ConcreteStateA());

        // 2. 현재 StateA 상태에 맞는 메소드 실행
        context.request();

        // 3. StateB 상태 설정
        context.setState(new ConcreteStateB());

        // 4. StateB 상태에서 또다른 StateC 상태로 변경
        context.request();

        // 5. StateC 상태에 맞는 메소드 실행
        context.request();
    }
}

When

  • 객체가 상태에 따라 각기 다른 행동을 할 때
  • 상태 및 전환에 걸쳐 대규모 조건 분기 코드와 중복 코드가 많을 경우
  • 조건문의 각 분기를 별도의 클래스에 넣는 것이 상태 패턴의 핵심
  • 런타임단에서 객체의 상태를 유동적으로 변경해야 할 때

이러한 상태 패턴을 사용하게 되면..

  • 상태에 따라 동작을 개별 클래스로 옮겨서 관리할 수 있다.
  • 상태와 관련된 모든 동작을 각각의 상태 클래스에 분산시킴으로써, 코드 복잡도를 줄일 수 있다.
  • 단일 책임 원칙 준수 : 특정 상태와 관련된 코드를 별도의 클래스로 구성
  • 개방 폐쇄 원칙 준수 : 기존 상태 클래스나 컨텍스트를 변경하지 않고 새 상태를 도입 가능
  • 하나의 상태 객체만 사용하여 상태 변경을 하므로 일관성 없는 상태 주입을 방지하는데 도움이 된다.

But

전략 패턴과 유사한 단점을 지니고 있다.

  • 상태 별로 클래스를 생성하므로, 관리해야할 클래스 수 증가
  • 상태 클래스 개수가 많아지고 상태 규칙이 자주 변경되면, Context의 상태 변경 코드가 복잡해질 수 있음
  • 객체가 적용할 상태가 몇 가지 밖에 없거나 거의 상태 변경이 이루어지지 않는 경우 패턴의 적용이 낭비가 될 수 있음

Example

문을 여는 버튼이 있다고 생각해보자.

문을 여는 버튼을 누르면 나타나는 상태 변화는 아래와 같이 3단계와 같다.

  1. 문이 Open 상태에서 버튼을 누르면 상태로 변경
  2. 문이 Close 상태에서 버튼을 누르면 Open 상태로 변경
  3. 문이 lock 상태에서 버튼을 눌므녀 Open 상태로 변경

보통이라면! 상태에 따른 동작 분기는 if 문이나 switch 문으로 처리하기 마련이다.

using UnityEngine;

public class OpenDoor
{
    public static readonly int OPEN = 0;
    public static readonly int CLOSE = 1;
    public static readonly int LOCK = 2;

    private int DoorState;

    public OpenDoor() {
        this.DoorState = OpenDoor.CLOSE; //닫은 상태로 시작
    }

    public void changeState(int state) { //상태 전환
        this.DoorState = state;
    }

    public void DoorOpenButtonClick() {
        if (DoorState == OPEN) {
            Debug.Log("문 CLOSE");
            changeState(OpenDoor.CLOSE);
        }
        else if (DoorState == CLOSE) {
            Debug.Log("문 OPEN");
            changeState(OpenDoor.OPEN);
        }
        else if (DoorState == LOCK) {
            Debug.Log("문 OPEN");
            changeState(OpenDoor.OPEN);
        }
    }

    public void TryUnlockDoor() { //자물쇠로 문을 따기 (LOCK에서만 동작)
        if (DoorState == OPEN) {
            throw new System.Exception("문이 UNLOCK 된 상태입니다.");
        }
        else if (DoorState == CLOSE) {
            throw new System.Exception("문이 UNLOCK 된 상태입니다.");
        }
        else if (DoorState == LOCK) {
            Debug.Log("잠금을 해제할 수 있습니다.");
        }
    }

    public void setLockState() {
        Debug.Log("문을 잠궜습니다.");
        changeState(OpenDoor.LOCK);
    }

    public void PrintCurrentState() {
        if (DoorState == OPEN) {
            Debug.Log("문은 현재 OPEN 상태입니다.");
        }
        else if (DoorState == CLOSE) {
            Debug.Log("문은 현재 CLOSE 상태입니다.");
        }
        else if (DoorState == LOCK) {
            Debug.Log("문은 현재 LOCK 상태입니다.");
        }
    }
}
using UnityEngine;

public class Player : MonoBehaviour
{
    void Start() {
        OpenDoor door = new OpenDoor();
        door.PrintCurrentState();

        // 문 열기 : CLOSE → OPEN
        door.DoorOpenButtonClick();
        door.PrintCurrentState();

        // 문 잠구기 : OPEN → LOCK
        door.setLockState();
        door.PrintCurrentState();
        door.TryUnlockDoor(); // 문 따기 시도
        door.PrintCurrentState();

        // 문 다시 열기 : LOCK → OPEN
        door.DoorOpenButtonClick();
        door.PrintCurrentState();

        // 문 닫기 : OPEN → CLOSE
        door.DoorOpenButtonClick();
        door.PrintCurrentState();
    }
}

그러나 상태 변수를 써서 굉장히 쉽게 해결한 것 처럼 보이지만 협업이나 실무에서 전혀 좋지 않은 방법이다. 좋지 않은 방법인 이유?

  1. 객체 지향적 코드가 아님 (하드 코딩 스타일)
  2. 상태 전환을 조건 분기문에 나열해놔서 가독성이 안 좋음
  3. 바뀌는 부분들이 캡슐화 되어있지 않고 노출됨
  4. 상태 기능 추가시 메소드를 통짜 수정 = OCP 원칙에 위배

상태 변수는 변수와 행위와의 결합을 만들어 내고, 이 과정에서 조건문들을 부수적으로 생산해 내기 때문이다. 언적으로 허용되는 한 상태 변수는 최대한 없애주는 것이 좋다.

enum을 써도 마찬가지이다. 핵심은 상태 상수화를 자제하라는 것이다

상태 패턴을 적용해보자! 상태를 개체화해야한다. 문의 상태 3가지를 모두 클래스로 구성한다. 이때 인터페이스나 추아 클래스로 묶어 추상화/캡슐화를 한다. 상태를 클래스로 분리하였으니, 상태에 따른 행동 메소드도 각 상태 클래스마다 구현을 해준다. 코드의 전체 라인수가 길어지고 괜히 클래스도 많아진 것 같지만, 오히려 이러한 벙법이 지속적인 유지보수를 용이하게 해준다.

using System;
using UnityEngine;

public interface  DoorState
{
    void DoorOpenButtonClick(DoorContext cxt);

    void TryUnlockDoor(DoorContext cxt);

    string currentState();
}

//OPEN State
class OpenState : DoorState {
    public void DoorOpenButtonClick(DoorContext cxt) {
        Debug.Log("문 CLOSE");
        cxt.ChangeState(new CloseState());

    }

    public void TryUnlockDoor(DoorContext cxt) {
        throw new System.Exception("문이 UNLOCK 된 상태입니다.");
    }

    public String currentState() {
        return "문은 현재 OPEN 상태입니다.";
    }
}

//CLOSE State
class CloseState : DoorState {
    public void DoorOpenButtonClick(DoorContext cxt) {
        Debug.Log("문 OPEN");
        cxt.ChangeState(new OpenState());
        
    }

    public void TryUnlockDoor(DoorContext cxt) {
        throw new System.Exception("문이 UNLOCK 된 상태입니다.");
    }

    public String currentState() {
        return "문은 현재 CLOSE 상태입니다.";
    }
}

//LOCK State
class LockState : DoorState {
    public void DoorOpenButtonClick(DoorContext cxt) {
        Debug.Log("문을 열 수 없습니다.");
    }

    public void TryUnlockDoor(DoorContext cxt) {
        Debug.Log("문을 땄습니다!");
        cxt.ChangeState(new OpenState());
    }

    public String currentState() {
        return "문은 현재 LOCK 상태입니다.";
    }
}
using UnityEngine;

public class DoorContext
{
    DoorState doorState;

    public DoorContext() {
        this.doorState = new CloseState();
    }

    public void ChangeState(DoorState state) {
        this.doorState = state;
    }

    public void setLockState() {
        Debug.Log("문을 잠궜습니다.");
        ChangeState(new LockState());
    }

    public void DoorOpenButtonClick() {
        doorState.DoorOpenButtonClick(this);
    }

    public void TryUnlockDoor() {
        doorState.TryUnlockDoor(this);
    }

    public void PrintCurrentState() {
        Debug.Log(doorState.currentState());
    }
}
using UnityEngine;

public class Player : MonoBehaviour
{
    void Start() {
        DoorContext door = new DoorContext();        
        door.PrintCurrentState();

        // 문 열기 : CLOSE → OPEN
        door.DoorOpenButtonClick();
        door.PrintCurrentState();

        // 문 잠구기 : OPEN → LOCK
        door.setLockState();
        door.PrintCurrentState();
        door.TryUnlockDoor(); // 문 따기 시도
        door.PrintCurrentState();

        // 문 다시 열기 : LOCK → OPEN
        door.DoorOpenButtonClick();
        door.PrintCurrentState();

        // 문 닫기 : OPEN → CLOSE
        door.DoorOpenButtonClick();
        door.PrintCurrentState();
    }
}

동일한 결과가 나온다.

Singleton

상태를 변경할 때 마다 새로 객체를 생성하는 문제가 있다.

cxt.changeState(new OpenState());

물론 가비지 컬렉션에 의해 자동으로 지워지겠지만, 이런 가비지 값이 늘어나게 되면 결국 개체 제거 과정에서 Stop-the-world가 일어나게 되고 렉의 원인이 된다. 웬만한 상황에선 상태는 새로 인스턴스화 할 필요가 전혀 없다. 따라서 각 상태 클래스들을 싱글톤화 한다.

//OPEN State
class OpenState : DoorState {
    private OpenState() {}

    private static class SingleInstanceHolder {
        public static readonly OpenState INSTANCE = new OpenState();
    }
    
    public static OpenState getInstance() {
        return SingleInstanceHolder.INSTANCE;
    }

    public void DoorOpenButtonClick(DoorContext cxt) {
        Debug.Log("문 CLOSE");
        cxt.ChangeState(CloseState.getInstance());

    }

    public void TryUnlockDoor(DoorContext cxt) {
        throw new System.Exception("문이 UNLOCK 된 상태입니다.");
    }

    public String currentState() {
        return "문은 현재 OPEN 상태입니다.";
    }
}

이 외에도 나머지 상태 클래스와 DoorContext에서 인스턴스 생성 부분을 모두 getInstance()로 바꿔준다. 실행해보면 아래처럼 동일한 결과가 나온다.


Other

State vs Strategy

유사점

  • 클래스 다이어그램도 거의 동일 + 코드 사용법도 비슷
  • 둘 다 난잡한 조건 분기 극복을 위해 전략 or 상태를 객체화
  • 둘 다 합성을 통해 상속의 한계를 극복
  • 둘 다 객체의 일련의 행동이 캡슐화되어 객체 지향 원칙을 준수
  • State는 Strategy의 확장으로 간주될 수 있음

차이점

구조는 거의 비슷해도 어떤 목적을 위해 사용되는가에 따라 차이가 있다.

  • 전략 패턴은 알고리즘을 객체화하여 클라이언트에게 유연적으로 전략을 제공 + 교체한다.

  • 전략 패턴의 전략 객체는 그 전략만의 알고리즘 동작을 정의 및 수행한다.

  • 전략 패턴의 전략 객체는 입력값에 따라 전략 형태가 다양하게 될 수 있으니 인스턴스로 구성한다.

  • 상태 패턴은 각체 상태를 객체화하여 클라이언트와 상태 클래스 내부에서 다른 상태로 교체한다.

  • 상태 패턴의 상태 객체는 상태가 적용되는 대상 객체가 할 수 있는 일련의 모든 행동들을 정의 및 수행한다.

  • 상태 패턴의 상태 객체는 정의된 상태를 서로 스위칭하기에 메모리 절약을 위해 싱글톤으로 구성한다.


profile
Be Honest, Be Harder, Be Stronger

0개의 댓글