OOP

예시로 이해하는 상태(State) 패턴

짜비 2021. 6. 28. 13:13

자판기에 들어가는 소프트웨어를 만든다고 상상하자. 자판기는 다음과 같이 동작한다.

 

동작 상태 실행 결과
동전을 넣는다 동전 없음 금액을 증가 제품 선택 가능 상태
동전을 넣는다 제품 선택 가능 금액을 증가 제품 선택 가능 상태
제품을 선택한다 동전 없음 아무 동작하지 않음 동전 없음 상태 유지
제품을 선택한다 제품 선택 가능 제품을 주고 잔액 감소 잔액 있으면 제품 선택 가능
잔액 없으면 동전 없음 상태

 

위 표를 토대로 다음과 같은 프로그램을 작성했다.

 

public class VendingMachine{
	public static enum State { NOCOIN, SELECTABLE }
    
    private State state = State.NOCOIN;
    
    public void insertCoin(int coin){
    	switch(state){
			case NOCOIN:
            	increaseCoin(coin);
                state = State.SELECTABLE;
                break;
            case SELECTABLE:
            	increaseCoin(coin);
         }
    }
    
    public void select(int productId){
    	switch(state){
        	case NOCOIN:
            	//아무 것도 하지 않음
                break;
            case SELECTABLE:
            	provideProduct(productId);
                decreaseCoin();
                if (hasNoCoin())
                	state = State.NOCOIN;
        }
    }
	...
}

만약 "제품 없음" 상태가 추가된다면 어떻게 될까?

State에 'SOLDOUT'을 추가하고, insertCoin()과 select() 내에 case SOLDOUT: ... 코드를 추가해야 할 것이다. 이처럼 위 코드는 상태가 많아질수록 switch 문이 복잡해지고 변경이 어려워진다는 문제가 있다.

상태에 따라 메소드가 다르게 동작할 때 적용할 수 있는 것이 바로 상태(State) 패턴이다.

 

 

상태를 interface 객체로 분리하고, 각각의 구체적인 상태가 interface를 구현하도록 설계했다.

자판기 기능을 담당하는 VendingMachine을 Context라고 부른다.

 

상태 패턴을 적용한 VendingMachine의 코드는 다음과 같다.

public class VendingMachine{
	private State state;
    
    public VendingMachine(){
    	state = new NoCoinState();
    }
    
    public void insertCoin(int coin){
    	state.increaseCoin(coin,this);	//상태 객체에 위임
    }
    
    public void select(int productId){
    	state.select(productId,this);	//상태 객체에 위임
    }
    
    public void changeState(State newState){
    	this.state = newState;
    }
	...
}

'동전 없음' 상태를 가정하고 생성자를 통해 state를 NoCoinState로 설정했다. 그리고 각 상태에 따른 동작은 State의 구체 클래스에게 위임했다.

 

구체 클래스 NoCoinState 의 코드를 살펴보자.

public class NoCoinSate implements State{
	
    @Override
    public void increaseCoin(int coin, VendingMachine vm){
    	vm.increaseCoin(coin);
        vm.changeState(new SelectableState());
    }
    
    @Override
    public void select(int productId, VendingMachine vm){
    	//아무 것도 하지 않음
    }
}

맨 처음 테이블에 명시된 것처럼 동전없는 상태는 동전이 들어올 경우 '선택가능상태'로 바꾸고, 제품선택 버튼이 눌려졌을 경우 아무런 동작을 하지 않는다.

 

유사하게, SelectableState 클래스도 구현할 수 있다.

public class SelectableState implements State{
	
    @Override
    public void increaseCoin(int coin, VendingMachine vm){
    	vm.increaseCoin(coin);
    }
    
    @Override
    public void select(int productId, VendingMachine vm){
    	vm.provideProduct(productId);
        vm.decreaseCoin();
        
        if(vm.hasNoCoin())
        	vm.changeState(new NoCoinState());
    }
}

 

 

 

위와 같이 상태 패턴을 적용했을 때 어떤 장점이 있을까?

이제 "Soldout"과 같은 새로운 상태를 추가하더라도 Context(VendingMachine)의 코드는 변경할 필요가 없다. SoldoutState라는 class를 추가해서 State를 Implement 하면 된다. 즉, 확장이 쉬워졌다.

또한, 특정 상태와 관련된 코드를 수정하기도 쉬워졌다. 예를 들어 "동전 없음" 상태 코드를 수정하고 싶다면 NoCoinState 클래스로 가서 작업하면 된다. 복잡한 VendingMachine 클래스를 여기저기 찾아봐야했던 이전 코드보다 훨씬 간단해졌다.

 


 

※고려해볼 점 : State를 누가 변경할 것인가

위 예시에서 우리는 상태 객체에서 상태를 변경했다. (e.g NoCoinState, SelectableState) 하지만 Context에서 상태를 변경하도록 코드를 작성할 수도 있다. 그러면 VendingMachine 클래스와 SelectableState 클래스는 다음과 같이 변경된다.

public class VendingMachine{
	private State state;
    
    public VendingMachine(){
    	state = new NoCoinState();
    }
    
    public void insertCoin(int coin){
    	state.increaseCoin(coin,this);	//상태 객체에 위임
        if(hasCoin())
        	changeState(new SelectableState());	//Context에서 상태 변경
    }
    
    public void select(int productId){
    	state.select(productId,this);	//상태 객체에 위임
        if(state.isSelectable() && hasNoCoin())
        	changeState(new NoCoinState());	//Context에서 상태 변경
    }
    
    private void changeState(State newState){
    	this.state = newState;
    }
    
    private boolean hasCoin(){
    	...
    }
	...
}
public class SelectableState implements State{
	
    @Override
    public void increaseCoin(int coin, VendingMachine vm){
    	vm.increaseCoin(coin);
    }
    
    @Override
    public void select(int productId, VendingMachine vm){
    	vm.provideProduct(productId);
        vm.decreaseCoin();
        
        /*
        Context가 상태를 변경하므로 상태 객체는 자신이 할 작업만 처리한다.
        if(vm.hasNoCoin())
        	vm.changeState(new NoCoinState());
        */
    }
}

상태 객체 내에서 사용되던 changeState 메소드가 Context에서만 사용되도록 바뀌었다. 접근자도 private으로 변경했다. 

 

위와 같이 코드를 작성하는 것도 가능하다. 상태 변경을 Context에서 할 것인지 혹은 상태 객체에서 할 것인지는 프로그래머가 판단해야 한다.

 

  • 상태변경을 Context에서 할 경우, 상태가 늘어나거나 줄어들 경우 Context의 코드를 수정해야 한다는 단점이 있다. 반면 상태 객체의 코드는 단순해진다는 장점이 있다. 이 방식은 상태 개수가 적고 상태 변경 규칙이 잘 변하지 않을 경우 유리하다.
  • 상태변경을 상태 객체에서 할 경우, 상태를 추가하거나 뺄 때 Context의 코드를 바꾸지 않아도 된다는 장점이 있다. 하지만 상태 변경 규칙이 여러 클래스에 분산되어 있어 상태 변경 규칙을 파악하기가 어렵다는 단점이 있다. 

 

이러한 장단점을 파악하고 상황에 맞는 상태 변경 방식을 사용하는 것이 좋겠다.

 

 

코드 출처, 참고 도서 : 개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴, 최범균 지음, 인투북스