만약 새로운 요구 사항이 들어온다면 insertCoin() 메서드와 select() 메서드에 또 다른 조건문이 추가될 것이다.
insertCoin() 메서드와 select() 메서드는 동일한 구조의 조건문을 갖고 있다. 이는 상태가 많아질수록 복잡해지는 조건문이 여러 코드에서 중복해서 출현하게 되고, 결과적으로 코드 변경을 어렵게 만든다. 만약 새로운 상태가 추가되거나 기존 상태를 변경해야 한다면, 모든 조건문을 찾아서 수정해야 한다.
상태 패턴 적용
VendingMachine 객체를 다시 살펴보면, 조건문은 다음과 같은 의미를 내포한다.
상태에 따라 동일한 기능 요청의 처리를 다르게 함
위 예로 보면, insertCoin() 메서드는 상태가 NOCOIN, SELECTABLE, SOLDOUT 인지에 따라 요청 처리를 다르게 하고 있다. 이렇게 상태에 따라 다르게 동작해야 할 때, 사용할 수 있는 패턴이 상태(State) 패턴이다. 상태 패턴은 상태를 별도의 타입으로 분리하고, 각 상태 별로 알맞은 하위 타입으로 구현한다.
상태 패턴에서 중요한 점은 상태 객체가 기능을 제공한다는 점이다. State 인터페이스는 increaseCoin()과 select() 두 개의 오퍼레이션을 정의하며 모든 하위 상태 타입에서 동일하게 구현되어야 한다.
Context는 필드로 상태(State) 객체를 가지고 있다. Context는 Client로 부터 기능 요청을 받으면, 상태 객체에 처리를 위임하는 방식으로 구현한다.
publicclassVendingMachine {privateState state;// ...publicVendingMachine() { state =newNoCoinState();// ... }publicvoidinsertCoin(int coin) {state.increaseCoin(coin,this); // 상태 객체에 위임 }publicStringselect(int id) {returnstate.select(id,this); // 상태 객체에 위임 }voidchangeState(State newState) {this.state= newState; }// ...}
NoCoinState 객체의 increaseCoin() 메서드는 VendingMachine 객체의 동전 수를 증가시키고, 상태를 SelectableState로 변경한다. select() 메서드는 동전이 없는 상태이므로 예외를 발생시키거나 check() 메서드를 제공하는 것이 정상적이지만, 우선 "코인이 존재하지 않습니다." 반환하도록 한다.
마찬가지로 SelectableState 객체도 increaseCoin(), select() 메서드를 아래와 같이 구현한다.
상태 패턴을 적용하며 VendingMachine 객체에 구현되어 있던 상태 별 동작 구현 코드가 NoCoinState, SelectableState 객체로 이동하는 것을 알 수 있다. 결과적으로 VendingMachine 객체는 위임 방식으로 구현되며 코드가 단순해지는 것을 알 수 있다.
상태 패턴의 장점
상태 패턴의 장점의 크게 두 가지로 볼 수 있다.
상태 패턴의 장점은 새로운 상태가 추가되더라도 Context 객체가 받는 영향은 최소화된다는 점이다.
조건문을 이용한 방식은 상태가 많아질수록 유지 보수를 어렵게 만들지만, 상태 패턴은 상태가 많아지더라도 코드의 복잡도는 증가하지 않기 때문에 유지 보수에 유리하다.
상태에 따른 동작을 구현한 코드가 각 Concrete State 객체 별로 구분되기 때문에 상태 별 동작을 수정하기가 쉽다는 점이다.
조건문을 이용한 방식은 동전 없음 상태의 동작을 수정하려면 각 메서드를 찾아다니면서 수정해 주어야 하는 반면에, 상태 패턴은 동전 없음 상태를 표현하는 NoCoinState 객체만 수정해 주면 된다. 상태 패턴을 적용하면 관련된 코드를 한 곳으로 모을 수 있기 때문에, 응집도가 높아지고 안전하게 기능 변경이 가능하다.
상태 변경은 누가 하는가?
상태 패턴을 적용할 때 고려해야 할 문제는 Context의 State(상태)를 누가 변경하는냐에 대한 것이다. 상태 변경을 하는 주체는 Context or Concrete State 객체 둘 중 하나가 된다.
또한, 상태 객체에서 Context 객체의 상태를 변경하기 위해서 Context의 다른 값에 접근해야 할 경우도 있다. 아래와 같이, SelectableState 객체의 select() 메서드는 VendingMachine 객체의 상태를 NoCoinState로 변경해야 하는지 확인하기 위해 hasNoCoin() 메서드를 이용하고 있다.
publicclassSelectableStateimplementsState {// ... @OverridepublicStringselect(int productId,VendingMachine vendingMachine) {// ...// VendingMachine 객체에 나머지 Coin이 있는지 확인if(vendingMachine.hasNoCoin()) {vendingMachine.changeState(newNoCoinState()); }// ... }}
Context 객체에서 변경
Context에서 상태를 변경할 경우 Context 코드가 다소 복잡해질 수 있다.
publicclassVendingMachine {privateState state;publicVendingMachine() { state =newNoCoinState(); }publicvoidinsertCoin(int coin) {state.increaseCoin(coin,this); if(hasCoin()) {changeState(new SelectableState()); // Context 객체에서 상태 변경 } }publicStringselect(int id) {String product =state.select(id,this); if(hasNoCoin()) {changeState(new NoCoinState()); // Context 객체에서 상태 변경 }return product; }privatebooleanhasCoin() {return!hasNoCoin(); }privatebooleanhasNoCoin() {return balance <=0; }privatevoidchangeState(State newState) {this.state= newState; }// ...}
Context에서 상태 변경을 한다면, 위의 메서드는 상태 객체에서 호출되지 않으므로 private 접근 범위로 변경한다. 이제 상태 객체는 자신이 수행해야 하는 작업만 처리하면 된다.
비교적으로 상태 개수가 적고 변경 규칙이 거의 바뀌지 않는 경우에 유리하다. 상태 종류가 지속적으로 변경되거나 상태 변경 규칙이 자주 바뀐다면, Context의 상태 변경 처리 코드가 복잡해질 가능성이 높아지고 결국 상태 변경의 유연함까지 잃을 수 있다.
Concrete State 객체에서 Context 상태를 변경할 경우
Context에 영향을 주지 않으면서 상태를 추가하거나 상태 변경 규칙을 바꿀 수 있게 된다. 하지만, 상태 변경 규칙이 여러 상태 객체에 분산되기 때문에, 상태 구현 객체가 많아질수록 상태 변경 규칙을 파악하기 어려워진다는 단점이 있다. 또한, Concrete State 객체간에 의존도가 발생하는 단점도 있다.
두 방식은 서로 상반되는 장단점을 가지고 있으며, 개발자 자신의 상황에 맞게 알맞는 방식을 선택해야 한다.
Enum을 이용한 상태(State) 패턴 및 Context 객체에서 상태 변경
위 예제에서와 같이, 상태 변경마다 Concrete State 객체를 생성한다면 메모리 측면에서 비효율적이다. 만약 모든 상태 객체가 POJO 라면, 각 상태 객체를 Enum 으로 만들수 있다. Enum 객체는 Singleton 특징을 가지고 있으므로, 매번 상태 객체를 생성하지 않고 재사용이 가능하다.
단점으로는 상태에 따라 다르게 동작하기 위해, 각 Enum 상태 객체의 생성자에서 로직을 작성한다. 로직이 간단한 경우는 유용하지만, 복잡한 경우라면 가독성이 매우 떨어진다.