본문 바로가기
디자인 패턴

State Pattern 이해하기

by simplify-len 2021. 7. 14.

아래 내용은 클린 소프트웨어의 일부 내용을 재편집한 내용입니다.

스테이트 패턴

왜 이 패턴을 쓸까?

스테이트 패턴을 이해하기 위해서는 유한 상태 기계(FSM: finite state machine) 를 먼저 이해해보자.

image-20201118041911798

이 다이어그램을 상태 전이 다이어그램(STD: state transition diagram) 이라 한다.

모서리가 둥근 상자는 상태(state) . 상태를 연결하는 화살표는 전이(transtion). 전이에는 이벤트(event) 의 이름과 그 이벤트에 따르는 행동(action) 이 이름표가 붙는다.

  • 만약 기게가 Locked 상태가 있는데 coin 이벤트를 받는다면, unlocked 상태로 전이하고 unlock 행동을 호출
  • 만약 기계가 Unlocked 상태에 있는데 pass 이벤트를 받는다면, Locked 상태로 전이하고 lock 행동 호출

이 문장들을 상태 전이 테이블(STT: state transition table) 이라는 단순한 표로도 요약될 수 있다.

Locked | coin | Unlocked | unlock

Unlocked | pass |Locked | lock

구현

package statepattern;

public class Turnstile {

  // 상태
  public static final int LOCKED = 0;
  public static final int UNLOCKED = 1;
  //event
  public static final int COIN = 0;
  public static final int PASS = 0;
  // state
  int state = LOCKED;

  private TurnstileController turnstileController;

  public Turnstile(TurnstileController action) {
    this.turnstileController = action;
  }

  public void event(int event){
    switch (state){
      case LOCKED:
        switch (event) {
          case COIN:
            state = UNLOCKED;
            turnstileController.unlock();
            break;
          case PASS:
            turnstileController.alarm();
            break;
          default:
            throw new IllegalStateException("Unexpected value: " + event);
        }
        break;

      case UNLOCKED:
        switch (event){
          case COIN:
            turnstileController.thankyou();
            break;
          case PASS:
            state = LOCKED;
            turnstileController.lock();
            break;
          default:
            throw new IllegalStateException("Unexpected value: " + event);
        }
        break;
    }
  }
}

테스트 코드

package statepattern;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class TurnstileTest {

  private Turnstile dut;
  private boolean lockCalled = false;
  private boolean unlockCalled = false;
  private boolean thankyouCalled = false;
  private boolean alarmCalled = false;

  @BeforeEach
  void setUp() {
    TurnstileController controllerSpoof = new TurnstileController() {
      @Override
      public void lock() {
        lockCalled = true;
      }

      @Override
      public void unlock() {
        unlockCalled = true;
      }

      @Override
      public void thankyou() {
        thankyouCalled = true;
      }

      @Override
      public void alarm() {
        alarmCalled = true;
      }
    };
    dut = new Turnstile(controllerSpoof);
  }

  @Test
  void initialConditions() {
    assertEquals(Turnstile.LOCKED, dut.state);

  }

  @Test
  void coinInLockedState() {
    dut.state = Turnstile.LOCKED;
    dut.event(Turnstile.COIN);
    assertEquals(Turnstile.UNLOCKED, dut.state);
    assert unlockCalled;
  }

  @Test
  void coinInUnLockedState() {
    dut.state = Turnstile.UNLOCKED;
    dut.event(Turnstile.COIN);
    assertEquals(Turnstile.UNLOCKED, dut.state);
    assert thankyouCalled;
  }

  @Test
  void passInLockedState() {
    dut.state = Turnstile.LOCKED;
    dut.event(Turnstile.PASS);
    assertEquals(Turnstile.LOCKED, dut.state);
    assert alarmCalled;
  }
  @Test
  void passInUnLockedState() {
    dut.state = Turnstile.UNLOCKED;
    dut.event(Turnstile.PASS);
    assertEquals(Turnstile.LOCKED, dut.state);
    assert lockCalled;
  }
}

상태와 이벤트를 public 으로 만든 이유는?

private로 하면 접근할 수 없고, 이를 getter/setter로 만들어 접근할 이유가 없기 때문이다.

위와 같은 코드에서 중첩된 switch/case 구현의 비용과 장점

간단한 기계라면 switch/case 구현이 명쾌하기도 하고 효율적이지만, 규모가 커진다면 달라진다. 상태와 이벤트의 수가 몇십개나 되는 상태 기계라면 case 문들이 여러 페이지에 걸쳐 계속 이어지면서 코드를 알아보기가 힘들어진다.

또한, 유한 상태 기계의 논리와 행동을 구현하는 코드 사이의 구별이 명확하지 않다는 점이 중첩된 switch/case의 또다른 비용이다.

이번에는 전이 테이블 해석이다.

전이를 설명하는 데이터 테이블을 만드는 것은 FSM을 구현하는 매우 흔한 방법이다.

public Turnstile(TurnstileController action) {
  this.turnstileController = action;
  addTransition(LOCKED, COIN, UNLOCKED, unlock());
  addTransition(LOCKED, PASS, LOCKED, alaram());
  addTransition(UNLOCKED, COIN, UNLOCKED, thankyou());
  addTransition(UNLOCKED, PASS, UNLOCKED, lock());
}

public void event(int event){
  for (int i = 0; i < transtion.size(); i++) {
    Transition transition = transitions.elementAt(i);
    if(state == transition.currentState && event == transition.event){
      state = transition.newState;
      transition.action.execute();
    }
  }

전이테이블을 만들 경우, 중첩된 switch/case 구현과 비교해볼 때, 유지하는 작업은 휠씬 쉽다. 새로운 전이를 추가하려면 단지 Turnstile 생성자에 새로운 addTranstion 라인을 추가하기만 하면 된다.

이 접근 방식의 주된 비용은 속도다. 전이 테이블을 검색하려면 시간이 걸린다. 커다란 상태 기계라면 이 시간이 무시못할 정도로 오래 걸릴 수도 있다. 테이블을 지원하기 위해 작성해야 하는 코드의 양도 또 다른 비용으로 작용한다.

스테이트 패턴

스테이트 패턴도 유한 상태 기계를 구현하기 위한 또 다른 기법이다.

중첩된 switch/case 문의 효율성과 상태 테이블을 해석하는 기법의 유연성을 결합한 패턴

image-20201118045743388

package statepattern;

import statepattern.lagarcy.TurnstileController;

public class Turnstile {

  private static TurnstileState lockedState = new LockedTurnstileState();
  private static TurnstileState unlockedState = new UnLockedTurnstileState();

  private TurnstileController turnstileController;
  private TurnstileState state = lockedState;

  public Turnstile(TurnstileController action) {
    this.turnstileController = action;
  }

  public void setUnlocked() {
    state = unlockedState;
  }

  public void unlock() {
    turnstileController.unlock();
  }

  public void alarm() {
    turnstileController.alarm();
  }

  public void thankyou() {
    turnstileController.thankyou();
  }

  public void setLocked() {
    state = lockedState;
  }

  public void lock() {
    turnstileController.lock();
  }
}
package statepattern;

public interface TurnstileState {

  void coin(Turnstile t);

  void pass(Turnstile t);
}

class LockedTurnstileState implements TurnstileState {

  @Override
  public void coin(Turnstile t) {
    t.setUnlocked();
    t.unlock();
  }

  @Override
  public void pass(Turnstile t) {
      t.alarm();
  }
}

class UnLockedTurnstileState implements TurnstileState {

  @Override
  public void coin(Turnstile t) {
    t.thankyou();
  }

  @Override
  public void pass(Turnstile t) {
    t.setLocked();
    t.lock();
  }
}

스테이트와 스트레터지

두 패턴 모두 컨텍스트(context)클래스가 있으며, 두 패턴 모두 파생형이 여러개 있는 다형적인 기반 클래스에 위임한다. *스테이트에서는 파생형이 컨텍스트 클래스에 대한 참조를 갖고 있다는 점이 두 패턴 사이의 차이점이다.***

이 참조를 통해 컨텍스트 클래스의 어떤 메소드를 부를지 선택해서 호출하는 것이 스테이트에서 파생형의 중심 기능이다.

스트래티지 패턴에서는 이러한 제약이나 의도가 존재하지 않는다. 스트래터지 패턴의 파생형은 컨텍스트의 참조를 꼭 갖고 있을 필요도 없으며, 컨텍스트의 메소드를 반드시 불러야 할 필요도 없다. 따라서 스테이트 패턴의 모든 적용 사례는 스트래티지 패턴이라고 볼 수 있지만, 스트래티지 패턴의 적용 사례가 모두 스테이트 패턴인 것은 아니다.

image-20201118051051486

image-20201125203056961

스테이트 패턴의 비용과 장점은, 상태 기계의 논리와 행동을 매우 분명히 분리하게 해준다.

행동은 Context 클래스에서 구현되고, 논리는 State 클래스의 파생형들 사이에 분산된다. 이렇게 하면 다른 쪽에 영향을 주지 않고도 한쪽을 변경하는 일이 매우 쉬워진다. 예를 들어, 단지 종류가 다른 state클래스의 파생형을 사용하는 것만으로도 Context 클래스의 행동들을 다른 상태 논리에 재사용할 수 있다. 반대로 Context의 다른 파생형을 만들기만 하면 State 파생형들의 논리에 영향을 주지 않고도 행동들을 변경하거나 교체할 수 있다.

매우 효율적이라는 점도 이 기법의 또 다른 장점. 그러나, 기법의 비용이 비싸다. State의 파생형을 작성하는 작업을 아무리 좋게 봐줘도 20개의 상태 기계라면, 작성하는 작업을 하다 보면 지루해진다. 논리가 분산되, 상태 기계의 논리를 모두 볼 수 있는 장소가 없으며, 따라서 코드를 유지보수 하기 힘들어진다.

댓글