읽은 책 정리/Head Firstr Design Pattern

CHAPTER 10.상태 패턴

상태 패턴 이해를 위한 간단한 문제 제안

  • 어릴때 문방구에서 위와 같은 뽑기 기계를 보신 경험이 있을것입니다.
  • 뽑기 기계를 만드는 CEO가 작은 컴퓨터 칩을 넣을건데 작은 칩에서 뽑기 기계가 동작할 수 있는 SW시스템을 구성하고 싶다고 의뢰가 들어왔습니다.
  • CEO는 위의 그림과 같은 플로우로 SW를 만들어달라고 요청했습니다.
  • 상태를 확인해보면 아래 처럼 4가지의 상태로 구분할 수 있습니다.
public class GumballMachine {
 
	final static int SOLD_OUT = 0; //알맹이 품절
	final static int NO_QUARTER = 1; //동전미 보유 상태
	final static int HAS_QUARTER = 2; //동전 보유 상태
	final static int SOLD = 3; //알맹이 판매
}
  • 위 상태에서 일어날 수 있는 행동들은 동전 보유 상태를 위한 동전 투입, 알맹이를 판매해서 내보내기 위한 손잡이 돌림, 알맹이가 판매되고난 뒤의 알맹이 내보냄, 동전을 넣은 뒤 변심으로 동전을 반환받으려는 동전 반환 4가지 행동이 존재합니다.
  • 행동들은 인터페이스로 정의할 수 있으며 상태들은 정적변수로 표현할 수 있습니다.
package com.example.designpattern.state.gumball;

public class GumballMachine {

	//4가지 상태
	final static int SOLD_OUT = 0; //알맹이 품절
	final static int NO_QUARTER = 1; //동전미 보유 상태
	final static int HAS_QUARTER = 2; //동전 보유 상태
	final static int SOLD = 3; //알맹이 판매 완료
 
	int state = SOLD_OUT; //현재 알맹이 상태
	int count = 0; //알맹이의 개수를 나타냄
  
	public GumballMachine(int count) {
		this.count = count;
		if (count > 0) {
			state = NO_QUARTER;
		}
	}

	//행동들은 메소드로 구현한다.
	//동전을 넣는 행동
	public void insertQuarter() {
		if (state == HAS_QUARTER) {
			System.out.println("You can't insert another quarter");
		} else if (state == NO_QUARTER) {
			state = HAS_QUARTER;
			System.out.println("You inserted a quarter");
		} else if (state == SOLD_OUT) {
			System.out.println("You can't insert a quarter, the machine is sold out");
		} else if (state == SOLD) {
        	System.out.println("Please wait, we're already giving you a gumball");
		}
	}

	//사용자가 동전을 반환 받으려는 행동
	public void ejectQuarter() {
		if (state == HAS_QUARTER) {
			System.out.println("Quarter returned");
			state = NO_QUARTER;
		} else if (state == NO_QUARTER) {
			System.out.println("You haven't inserted a quarter");
		} else if (state == SOLD) {
			System.out.println("Sorry, you already turned the crank");
		} else if (state == SOLD_OUT) {
        	System.out.println("You can't eject, you haven't inserted a quarter yet");
		}
	}

	//손잡이를 돌리는 경우
	public void turnCrank() {
		if (state == SOLD) {
			System.out.println("Turning twice doesn't get you another gumball!");
		} else if (state == NO_QUARTER) {
			System.out.println("You turned but there's no quarter");
		} else if (state == SOLD_OUT) {
			System.out.println("You turned, but there are no gumballs");
		} else if (state == HAS_QUARTER) {
			System.out.println("You turned...");
			state = SOLD;
			dispense();
		}
	}

	//알맹이를 내보내는 행동
	private void dispense() {
		if (state == SOLD) {
			System.out.println("A gumball comes rolling out the slot");
			count = count - 1;
			if (count == 0) {
				System.out.println("Oops, out of gumballs!");
				state = SOLD_OUT;
			} else {
				state = NO_QUARTER;
			}
		} else if (state == NO_QUARTER) {
			System.out.println("You need to pay first");
		} else if (state == SOLD_OUT) {
			System.out.println("No gumball dispensed");
		} else if (state == HAS_QUARTER) {
			System.out.println("No gumball dispensed");
		}
	}
 
	//알맹이 부족으로 알맹이를 다시 채우려는 행동
	public void refill(int numGumBalls) {
		this.count = numGumBalls;
		state = NO_QUARTER;
	}

	public String toString() {
		StringBuffer result = new StringBuffer();
		result.append("\nMighty Gumball, Inc.");
		result.append("\nJava-enabled Standing Gumball Model #2004\n");
		result.append("Inventory: " + count + " gumball");
		if (count != 1) {
			result.append("s");
		}
		result.append("\nMachine is ");
		if (state == SOLD_OUT) {
			result.append("sold out");
		} else if (state == NO_QUARTER) {
			result.append("waiting for quarter");
		} else if (state == HAS_QUARTER) {
			result.append("waiting for turn of crank");
		} else if (state == SOLD) {
			result.append("delivering a gumball");
		}
		result.append("\n");
		return result.toString();
	}
}
  • 위의 상태와 행동들을 가지고 위와 같이 구현하였습니다.

테스트 코드 & 수행결과

public class GumballMachineTestDrive {

	public static void main(String[] args) {
		GumballMachine gumballMachine = new GumballMachine(5);

		System.out.println(gumballMachine);
//동전투입 → 손잡이 회전
		gumballMachine.insertQuarter();
		gumballMachine.turnCrank();

		System.out.println(gumballMachine);
//동전투입 → 동전반환 → 손잡이 회전
		gumballMachine.insertQuarter();
		gumballMachine.ejectQuarter();
		gumballMachine.turnCrank();

		System.out.println(gumballMachine);
//동전투입 → 손잡이 회전 → 동전투입 → 손잡이 회전 → 동전 반환
		gumballMachine.insertQuarter();
		gumballMachine.turnCrank();
		gumballMachine.insertQuarter();
		gumballMachine.turnCrank();
		gumballMachine.ejectQuarter();

		System.out.println(gumballMachine);
//동전투입 → 동전투입 → 손잡이 회전 → 동전투입 → 손잡이 회전 → 동전 투입 → 손잡이 회전
		gumballMachine.insertQuarter();
		gumballMachine.insertQuarter();
		gumballMachine.turnCrank();
		gumballMachine.insertQuarter();
		gumballMachine.turnCrank();
		gumballMachine.insertQuarter();
		gumballMachine.turnCrank();

		System.out.println(gumballMachine);
	}
}

  • 테스트 시나리오는 아래와 같이 이뤄집니다.
    • 동전투입 → 손잡이 회전
    • 동전투입 → 동전반환 → 손잡이 회전
    • 동전투입 → 손잡이 회전 → 동전투입 → 손잡이 회전 → 동전 반환
    • 동전투입 → 동전투입 → 손잡이 회전 → 동전투입 → 손잡이 회전 → 동전 투입 → 손잡이 회전
  • 우측 실행결과를 보았을 때 테스트가 성공적으로 이뤄진걸 확인할 수 있습니다.
  • 구현이 잘 마무리 되었으나 뽑기 기계 CEO의 추가 요구 사항으로 문제가 발생합니다.
  • 뽑기 기계에 1/10 확률로 손잡이를 돌렸을때 알맹이가 2개가 나오는 기능을 추가하고 싶다고 요구가 들어온것입니다.

추가 요청 사항 살펴보기

  • 기존 코드 상태와 행동 코드를 기반으로 분석해봤을 때 먼저 이벤트 당첨 상태를 추가해야할 거 같습니다.
  • 문제는 행동(메소드)입니다. 행동을 나타내는 메소드에 이벤트 당첨 상태를 확인하는 조건문을 모든 메소드에 추가해야 해서 코드에 변경이 너무나도 많이 발생합니다. 특히 손잡이 회전 행동인 TurnCrank()메소드에 많은 변화가 예측됩니다. 왜냐하면 손잡이를 회전하고 GambleMachine의 현재 상태를 WINNER 또는 SOLD 상태로 전환해야 하기 때문입니다.
  • 이러한 문제를 해결하기 위해 이전에 배운 디자인 법칙중 2가지를 수행할 것입니다.
    • 바뀌는 부분을 캡슐화하는 규칙을 수행할 것입니다.
    • 구성을 활용하라는 원칙을 살려 각각의 상태가 자기 할일을 하도록 구현하여 뽑기 기계 상태에 맞게 상태 스스로가 자신의 기능을 수행하도록 구현할 것입니다.

코드 리팩토링 해보기

  • GambelMachine의 행동에 관한 메소드가 들어있는 State 인터페이스를 정의하도록 하겠습니다.
  • 4가지의 상태들은 각각의 클래스로 분리할 것이며 상태들은 State 인터페이스를 정의하도록 구현할것입니다. 이를 통해 상태가 변해도 행동들은 똑같이 수행하도록 구현할 것입니다.
    • CEO의 추가 조건으로 새로운 상태가 추가됩니다.(WinnerState-이벤트당첨)
  • 이런 구현은 상태가 주체적으로 행동들을 수행하도록 구현되며 모든 작업들을 상태가 위임받도록 수행됩니다.
  • 결과적으로 기존 코드의 조건문 코드를 전부 없애고 상태 클래스에서 작업이 수행됩니다.
public interface State {
 
	public void insertQuarter();
	public void ejectQuarter();
	public void turnCrank();
	public void dispense();
	public void refill();
}
  • 코드를 구현하도록 해보겠습니다.
  • 먼저 행동들을 나타내는 인터페이스인 State입니다.
package com.example.designpattern.state.gumballstatewinner;

//staet 인터페이스를 구현합니다.
public class NoQuarterState implements State {

    GumballMachine gumballMachine;
 
	//생성자로 GamebleMachine의 레퍼런스가 전달됩니다. 해당 레퍼런스를 인스턴스 변수에 저장합니다.
    public NoQuarterState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }

	//상태가 주체가 되어 행동을 수행하며 상태 변환이 필요할 경우에는 GambleMachine의 레퍼런스를 확인하여 상태를 변경합니다.
    public void insertQuarter() {
        System.out.println("You inserted a quarter");
        gumballMachine.setState(gumballMachine.getHasQuarterState());
    }

    public void ejectQuarter() {
        System.out.println("You haven't inserted a quarter");
    }

    public void turnCrank() {
        System.out.println("You turned, but there's no quarter");
    }

    public void dispense() {
        System.out.println("You need to pay first");
    }

    public void refill() {
    }

    public String toString() {
        return "waiting for quarter";
    }
}
  • 동전을 않 넣은 상태인 NoQuarterState 구현입니다. GambleMachine 레퍼런스를 전달하여 상태 클래스가 주체가 되어 행동을 수행하고 상태 변환이 필요하다면 GamblemMachine을 참조하여 상태를 변환합니다.
  • 이외의 상태 코드 구현이 궁금하다면 아래 Toggle들을 확인해주시면 됩니다.(구현이 비슷하나 글이 길어져서 접어 놨습니다)
    HasQuarterState
    public class HasQuarterState implements State {
    	//1/10의 확률을 추출하기 위한 Random 객체
    	Random randomWinner = new Random(System.currentTimeMillis());
    	GumballMachine gumballMachine;
     
    	public HasQuarterState(GumballMachine gumballMachine) {
    		this.gumballMachine = gumballMachine;
    	}
      
    	public void insertQuarter() {
    		System.out.println("You can't insert another quarter");
    	}
     
    	public void ejectQuarter() {
    		System.out.println("Quarter returned");
    		gumballMachine.setState(gumballMachine.getNoQuarterState());
    	}
     
    	public void turnCrank() {
    		System.out.println("You turned...");
    		int winner = randomWinner.nextInt(10);
    		//0은 당첨자를 의미합니다. Winner이며 알맹이 개수가 2개 이상일 경우 WinnerState로 변경합니다.
    		if ((winner == 0) && (gumballMachine.getCount() > 1)) {
    			gumballMachine.setState(gumballMachine.getWinnerState());
    		} else {
    			gumballMachine.setState(gumballMachine.getSoldState());
    		}
    	}
    
        public void dispense() {
            System.out.println("No gumball dispensed");
        }
        
        public void refill() { }
     
    	public String toString() {
    		return "waiting for turn of crank";
    	}
    }
    SoldOutStae
    public class SoldOutState implements State {
        GumballMachine gumballMachine;
     
        public SoldOutState(GumballMachine gumballMachine) {
            this.gumballMachine = gumballMachine;
        }
     
    	public void insertQuarter() {
    		System.out.println("You can't insert a quarter, the machine is sold out");
    	}
     
    	public void ejectQuarter() {
    		System.out.println("You can't eject, you haven't inserted a quarter yet");
    	}
     
    	public void turnCrank() {
    		System.out.println("You turned, but there are no gumballs");
    	}
     
    	public void dispense() {
    		System.out.println("No gumball dispensed");
    	}
    	
    	public void refill() { 
    		gumballMachine.setState(gumballMachine.getNoQuarterState());
    	}
     
    	public String toString() {
    		return "sold out";
    	}
    }
    SoldState
    public class SoldState implements State {
        GumballMachine gumballMachine;
     
        public SoldState(GumballMachine gumballMachine) {
            this.gumballMachine = gumballMachine;
        }
           
    	public void insertQuarter() {
    		System.out.println("Please wait, we're already giving you a gumball");
    	}
     
    	public void ejectQuarter() {
    		System.out.println("Sorry, you already turned the crank");
    	}
     
    	public void turnCrank() {
    		System.out.println("Turning twice doesn't get you another gumball!");
    	}
    
    	//알맹이를 내보낼때. 현재 알맹이 개수가 0개이면 이후 사용자가 사용할 수 있도록 초기 상태인 NoQuaterState로 변경해준다.
    	//알맹이가 없을 경우 품절 상태인 SoldState로 변경해준다.
    	public void dispense() {
    		gumballMachine.releaseBall();
    		if (gumballMachine.getCount() > 0) {
    			gumballMachine.setState(gumballMachine.getNoQuarterState());
    		} else {
    			System.out.println("Oops, out of gumballs!");
    			gumballMachine.setState(gumballMachine.getSoldOutState());
    		}
    	}
    	
    	public void refill() { }
     
    	public String toString() {
    		return "dispensing a gumball";
    	}
    }
    WinnerState
    public class WinnerState implements State {
        GumballMachine gumballMachine;
     
        public WinnerState(GumballMachine gumballMachine) {
            this.gumballMachine = gumballMachine;
        }
     
    	public void insertQuarter() {
    		System.out.println("Please wait, we're already giving you a Gumball");
    	}
     
    	public void ejectQuarter() {
    		System.out.println("Please wait, we're already giving you a Gumball");
    	}
     
    	public void turnCrank() {
    		System.out.println("Turning again doesn't get you another gumball!");
    	}
     	
    	//이벤트 당첨 상태에서 알맹이를 내보내는 경우
    	public void dispense() {
    		gumballMachine.releaseBall(); //먼저 알맹이 하나를 내보낸다
    		if (gumballMachine.getCount() == 0) { //알맹이가 한 개인 상태에서 이벤트 당첨이 걸린 경우의 예외 처리
    			gumballMachine.setState(gumballMachine.getSoldOutState());
    		} else {
    			gumballMachine.releaseBall(); //이벤트 당첨이 되었으므로 알맹이를 하나 더 내보낸다.
    			System.out.println("YOU'RE A WINNER! You got two gumballs for your quarter");
    			if (gumballMachine.getCount() > 0) { //알맹이가 없을 경우의 상태변화문
    				gumballMachine.setState(gumballMachine.getNoQuarterState());
    			} else {
                	System.out.println("Oops, out of gumballs!");
    				gumballMachine.setState(gumballMachine.getSoldOutState());
    			}
    		}
    	}
     
    	public void refill() { }
    	
    	public String toString() {
    		return "despensing two gumballs for your quarter, because YOU'RE A WINNER!";
    	}
    }
package com.example.designpattern.state.gumballstatewinner;

public class GumballMachine {
 
	//모든 상태 객체를 선언합니다.
	State soldOutState;
	State noQuarterState;
	State hasQuarterState;
	State soldState;
	State winnerState;
 
	//현재 상태 선언
	State state = soldOutState;
	int count = 0;
 
	//알맹이의 초기 개수를 생성자로 받아서 생성하고 필요한 상태들을 생성자에서 생성합니다.
	public GumballMachine(int numberGumballs) {
		soldOutState = new SoldOutState(this);
		noQuarterState = new NoQuarterState(this);
		hasQuarterState = new HasQuarterState(this);
		soldState = new SoldState(this);
		winnerState = new WinnerState(this);

		this.count = numberGumballs;
 		if (numberGumballs > 0) {
			state = noQuarterState;
		} 
	}
 
	public void insertQuarter() {
		state.insertQuarter();
	}
 
	public void ejectQuarter() {
		state.ejectQuarter();
	}
 
	public void turnCrank() {
		state.turnCrank();
		state.dispense();
	}

	void setState(State state) {
		this.state = state;
	}
 
	void releaseBall() {
		System.out.println("A gumball comes rolling out the slot...");
		if (count > 0) {
			count = count - 1;
		}
	}
 // 아래부터는 기타 메소드
	int getCount() {
		return count;
	}
 
	void refill(int count) {
		this.count += count;
		System.out.println("The gumball machine was just refilled; its new count is: " + this.count);
		state.refill();
	}

    public State getState() {
        return state;
    }

    public State getSoldOutState() {
        return soldOutState;
    }

    public State getNoQuarterState() {
        return noQuarterState;
    }

    public State getHasQuarterState() {
        return hasQuarterState;
    }

    public State getSoldState() {
        return soldState;
    }

    public State getWinnerState() {
        return winnerState;
    }
 
	public String toString() {
		StringBuffer result = new StringBuffer();
		result.append("\nMighty Gumball, Inc.");
		result.append("\nJava-enabled Standing Gumball Model #2004");
		result.append("\nInventory: " + count + " gumball");
		if (count != 1) {
			result.append("s");
		}
		result.append("\n");
		result.append("Machine is " + state + "\n");
		return result.toString();
	}
}
  • 마지막으로 GambleMachine의 구현입니다.
  • GambleMachine의 각 메소드들이 수행되며 상태들이 다음 상태로 변환을 시켜줄것입니다.
  • 이를 통해 상태들은 각 상태에 적합한 행동들을 수행하게 됩니다.
    • ex:) 이벤트가 당첨된 경우에는 이벤트 당첨 상태의 dispense() 메소드가 수행되고 이벤트 당첨이 안된 경우에는 soldState() 메소드가 수행된다.

상태 패턴 정의

상태 패턴 다이어그램
  • 위에서 뽑기 기계 예시들의 문제점을 해결하기 위한 리팩토링 과정들이 상태 패턴을 적용하기 위한 절차들과 동일합니다.
  • 상태 패턴은 객체의 내부 상태가 바뀜에 따라 객체의 행동을 바꾸는 디자인 패턴입니다. 이러한 변화들은 객체의 클래스가 바뀌는것과 같은 결과를 가져올 수 있습니다.
예시에서 사용된 이름상태 패턴 이름
ConetxtGambleMachine
StateState
각각의 상태 5가지ConcreteState
  • 상태 패턴에서 사용되는 정의들과 예시에서 사용된 이름들을 비교하면서 흐름들을 이해해보면 도움이 됩니다.

전략 패턴 VS 상태 패턴

  • 상태 패턴과 전략 패턴 다이어그램은 매우 유사하여 혼동될수 있으나 엄연히 두 패턴은 다른 패턴입니다.
  • 상태 패턴은 상태 객체들의 행동이 캡슐화 되며 상황에 따라 Context 객체의 여러 상태 객체 중 한 객체에 모든 행동을 맡깁니다.
  • 하지만 전략 패턴을 클라이언트가 Context 객체에게 어떤 전략 객체를 사용할 지를 지정하여 사용합니다. 이러한 특징 때문에 전략 패턴은 실행시에 전략 객체를 변경할 수 있는 유연성이 필요할 때 사용된다고 이해하면 됩니다.

정리

  • 상태 패턴은 내부 상태가 바뀜에 따라 객체의 행동이 변경될 수 있도록 구현하는 디자인 패턴입니다.
  • 상태 패턴을 사용하여 여러 가지 서로 다른 행동을 사용할 수 있습니다.
  • Context 객체는 현재 상태에게 행동을 위임합니다.
  • 각 상태를 클래스로 캡슐화해서 나중에 변경되는 내용을 국지화할수 있습니다.
  • 상태 패턴과 전략 패턴의 클래스 다이어그램은 유사하지만 용도와 사용시기는 다릅니다.
  • 상태 패턴을 사용하면 클래스의 개수가 상태만큼 늘어나게 됩니다.