본문 바로가기
Programming

디자인패턴 시리즈 8. 상태 패턴 (State Pattern)

by LeeJ1Hyun 2023. 1. 23.

상태 패턴 (State Pattern)

내부 상태가 바뀜에 따라 객체의 행동이 바뀔 있도록 한다. 객체의 클래스가 바뀌는 것과 같은 결과를 얻을 수 있다.

코드에 적용해보기

어릴 때 한번쯤은 동전을 넣고 레버를 돌려 장난감이나 간식을 뽑는 뽑기 기계를 해봤을 것이다. 뽑기를 하는 일련의 행동들을 다이어그램으로 표시하면 그림과 같다.

 

뽑기 다이어그램

 

동그라미 안에 있는 부분은 '상태'를 의미하고 화살표는 '상태 전환'을 나타낸다. 총 4개의 상태와 5개의 상태 전환이 있다.

 

final static int SOLD_OUT = 0; // 상품 없음
final static int NO_COIN = 1; // 동전 없음
final static int HAS_COIN = 2; // 동전 있음
final static int SOLD = 3; // 상품 판매

int state = SOLD_OUT; // 현재 상태를 저장할 인스턴스 변수

 

위와 같이 뽑기 기계의 상태를 의미하는 인스턴스 변수를 만들고 현재 상태를 저장할 state 변수를 선언한다. 가장 처음엔 state 변수에 SOLD_OUT 상태를 설정한다. 이제 기계 역할을 하는 클래스를 만들어보자. 상태 별로 어떤 작업을 해야할지 결정하는 로직을 작성해야 한다. 동전 투입과 같은 행동을 메소드로 만들어보면 다음과 같다.

 

public void insertCoin() {
	if(state == HAS_COIN) {
    	System.out.println("동전은 1개만 넣을 수 있습니다.");
    } else if(state == NO_COIN) {
    	state = HAS_COIN;
    	System.out.println("동전이 투입되었습니다.");
    } else if(state == SOLD_OUT) {
    	System.out.println("매진되었습니다.");
    } else if(state == SOLD) {
    	System.out.println("상품을 반환중입니다.");
    }
}

 

조건문으로 상태를 확인하고 적절한 행동을 취한다. 특히 동전이 없는 상태였을 때는 state 변수를 HAS_COIN으로 재할당 해주고 기능을 수행한다. 이렇게 각각의 상태에 따른 메소드를 작성하여 뽑기 기계 프로그램을 만들 것이다.

 

public class GumballMachine {
 
	final static int SOLD_OUT = 0;
	final static int NO_COIN = 1;
	final static int HAS_COIN = 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_COIN;
		}
	}
  
	public void insertCoin() {
		if (state == HAS_COIN) {
			System.out.println("동전은 1개만 넣을 수 있습니다.");
		} else if (state == NO_COIN) {
			state = HAS_COIN;
			System.out.println("동전이 투입되었습니다.");
		} else if (state == SOLD_OUT) {
			System.out.println("매진입니다.");
		} else if (state == SOLD) {
        	System.out.println("상품을 반환중입니다.");
		}
	}

	public void ejectCoin() {
		if (state == HAS_COIN) {
			System.out.println("동전이 반환됩니다.");
			state = NO_COIN;
		} else if (state == NO_COIN) {
			System.out.println("동전을 넣지 않았습니다.");
		} else if (state == SOLD) {
			System.out.println("이미 상품을 뽑았습니다.");
		} else if (state == SOLD_OUT) {
        	System.out.println("꺼낼 수 없습니다. 아직 동전을 넣지 않았습니다.");
		}
	}
 
	public void turnLever() {
		if (state == SOLD) {
			System.out.println("두번 돌려도 상품은 더 나오지 않습니다.");
		} else if (state == NO_COIN) {
			System.out.println("돌렸지만 동전이 없어요.");
		} else if (state == SOLD_OUT) {
			System.out.println("매진입니다.");
		} else if (state == HAS_COIN) {
			System.out.println("돌리는중입니다.");
			state = SOLD;
			dispense();
		}
	}
 
	private void dispense() {
		if (state == SOLD) {
			System.out.println("상품이 나오고 있습니다.");
			count = count - 1;
			if (count == 0) {
				System.out.println("상품이 떨어졌습니다.");
				state = SOLD_OUT;
			} else {
				state = NO_COIN;
			}
		} else if (state == NO_COIN) {
			System.out.println("동전을 먼저 넣으세요.");
		} else if (state == SOLD_OUT) {
			System.out.println("매진입니다.");
		} else if (state == HAS_COIN) {
			System.out.println("상품이 떨어지지 않았습니다.");
		}
	}
 
	public void refill(int numGumBalls) {
		this.count = numGumBalls;
		state = NO_COIN;
	}
}

 

public class GumballMachineTest {

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

		gumballMachine.insertCoin();
		gumballMachine.turnLever();
	}
}

// 동전이 투입되었습니다.
// 돌리는중입니다.
// 상품이 나오고 있습니다.

 

성공적으로 뽑기 기계를 만들었습니다. 더 고도화된 게임을 만들기 위해 당첨 규칙을 추가하기로 했습니다. 이제 10분의 1의 확률로 보너스 상품을 얻을 수 있습니다. 즉, 10번에 1번 꼴로 레버를 돌릴 때 상품 2개가 나와야 합니다. 당첨인지 아닌지를 확인하는 상태를 저장할 인스턴스 변수를 추가로 선언하고 이를 매번 메소드에서 확인해야 할 것이다.

 

불가피하게도 insertCoin, ejectCoin, turnLever, dispense() 메소드를 전부 수정해야 한다. 특히 turnLever() 메소드를 많이 수정해야 한다. 당첨인지 아닌지를 확인하고 당첨 또는 SOLD 상태로 전환해야 한다. 기능의 추가에 따라 기존의 코드를 수정해야 하므로 OCP를 위반한다. 또한 상태 전환이 복잡한 조건문 속에 숨어 있어 흐름을 파악하기 힘들다. 바뀌는 부분을 캡슐화해야 한다는 결론이 났다.

 

전략 패턴을 소개할 때 했던 말이자 모든 디자인패턴에 1대 원칙이라고 할 수 있는 "바뀌는 부분을 캡슐화한다." 라는 말을 꺼낼 때가 왔다. 상태별 행동을 별도의 클래스에 넣고 모든 상태에서 각각 할 일들을 구현하면 된다. 그런 다음 뽑기 기계가 현재 상태를 나타내는 상태 객체에게 작업을 넘기게 하면 된다. 즉, 구성을 사용한다.

 

 

뽑기 기계와 관련된 모든 행동에 관한 메소드는 State 인터페이스에 넣는다. 모든 상태를 대상으로 상태 클래스를 구현한다. 이제 특정한 상태에 해당하면 그 클래스가 모든 기능을 담당한다. 상태 클래스에 모든 작업을 위임하는 것이다. 

 

public interface State {
 
	public void insertCoin();
	public void ejectCoin();
	public void turnLever();
	public void dispense();
	public void refill();
}

 

상태에 따라 이를 상속 받아 구현하는 구상 클래스를 만든다.

 

public class NoCoinState implements State {
    GumballMachine gumballMachine;
 
    public NoCoinState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }
 
	public void insertCoin() {
		System.out.println("동전이 투입되었습니다.");
		gumballMachine.setState(gumballMachine.getHasCoinState());
	}
 
	public void ejectCoin() {
		System.out.println("동전을 넣지 않았습니다.");
	}
 
	public void turnLever() {
		System.out.println("돌렸지만 동전이 없어요.");
	 }
 
	public void dispense() {
		System.out.println("동전을 먼저 넣으세요.");
	} 
	
	public void refill() { }
}

 

public class HasCoinState implements State {
	GumballMachine gumballMachine;
 
	public HasCoinState(GumballMachine gumballMachine) {
		this.gumballMachine = gumballMachine;
	}
  
	public void insertCoin() {
		System.out.println("동전은 1개만 넣을 수 있습니다.");
	}
 
	public void ejectCoin() {
		System.out.println("동전이 반환됩니다.");
		gumballMachine.setState(gumballMachine.getNoCoinState());
	}
 
	public void turnLever() {
		System.out.println("돌리는중입니다.");
		gumballMachine.setState(gumballMachine.getSoldState());
	}

    public void dispense() {
        System.out.println("상품이 떨어지지 않았습니다.");
    }
    
    public void refill() { }
}

 

public class SoldState implements State {
 
    GumballMachine gumballMachine;
 
    public SoldState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }
       
	public void insertCoin() {
		System.out.println("상품을 반환중입니다.");
	}
 
	public void ejectCoin() {
		System.out.println("이미 상품을 뽑았습니다.");
	}
 
	public void turnLever() {
		System.out.println("두번 돌려도 상품은 더 나오지 않습니다.");
	}
 
	public void dispense() {
		gumballMachine.releaseBall();
		if (gumballMachine.getCount() > 0) {
			gumballMachine.setState(gumballMachine.getNoCoinState());
		} else {
			System.out.println("상품이 떨어졌습니다.");
			gumballMachine.setState(gumballMachine.getSoldOutState());
		}
	}
	
	public void refill() { }
}

 

public class SoldOutState implements State {
    GumballMachine gumballMachine;
 
    public SoldOutState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }
 
	public void insertCoin() {
		System.out.println("매진입니다.");
	}
 
	public void ejectCoin() {
		System.out.println("꺼낼 수 없습니다. 아직 동전을 넣지 않았습니다.");
	}

	public void turnLever() {
		System.out.println("매진입니다.");
	}
 
	public void dispense() {
		System.out.println("매진입니다.");
	}
	
	public void refill() { 
		gumballMachine.setState(gumballMachine.getNoCoinState());
	}
}

 

이제 GumballMachine 클래스에서 모든 상태를 분리했다.

 

public class GumballMachine {
 
	State soldOutState;
	State noCoinState;
	State hasCoinState;
	State soldState;
 
	State state;
	int count = 0;
 
	public GumballMachine(int numberGumballs) {
		soldOutState = new SoldOutState(this);
		noCoinState = new NoCoinState(this);
		hasCoinState = new HasCoinState(this);
		soldState = new SoldState(this);

		this.count = numberGumballs;
 		if (numberGumballs > 0) {
			state = noCoinState;
		} else {
			state = soldOutState;
		}
	}
 
	public void insertCoin() {
		state.insertCoin();
	}
 
	public void ejectCoin() {
		state.ejectCoin();
	}

	public void turnLever() {
		state.turnLever();
		state.dispense();
	}
 
	void releaseBall() {
		System.out.println("상품이 나오고 있습니다.");
		if (count > 0) {
			count = count - 1;
		}
	}
 
	int getCount() {
		return count;
	}
 
	void refill(int count) {
		this.count += count;
		System.out.println("상품이 리필되었습니다. 현재 상품의 수: " + this.count);
		state.refill();
	}

	void setState(State state) {
		this.state = state;
	}
    public State getState() {
        return state;
    }

    public State getSoldOutState() {
        return soldOutState;
    }

    public State getNoCoinState() {
        return noCoinState;
    }

    public State getHasCoinState() {
        return hasCoinState;
    }

    public State getSoldState() {
        return soldState;
    }
}

 

모든 상태 객체를 선언하고 생성자를 통해 인스턴스를 생성한다. soldOutState, noCoinState, hasCoinState, soldState에 따라 기능을 수행하도록 현재 상태를 담은 변수 state. 으로 메소드를 호출한다. 상태의 인스턴스를 만들 때는 GumballMachine의 레퍼런스를 전달하여 나중에 다른 상태로 전환할 때 이 레퍼런스가 필요하다.

 

상태 패턴을 적용한 뽑기 기계

 

상태 패턴을 적용하여 리팩토링한 구조이다. 각 상태의 행동을 별도의 클래스로 분리했다. 이제 GumballMachine 내부의 if문 지옥은 사라졌다. 각각의 상태는 변경에는 닫혀 있고, GumballMachine 클래스는 확장에는 열려있는 형태가 되었다.

 

상태 패턴의 다이어그램

 

Context 클래스는 GumballMachine의 역할에 해당한다. 여러 가지 내부 상태가 들어있을 수 있다. Context의 request() 메소드가 호출되면 그 작업은 상태 객체에게 위임한다. State 인터페이스는 모든 구상 상태 클래스의 공통 인터페이스를 정의한다. 각각의 ConcreteState 구상 상태 클래스를 원하는 만큼 만들 수 있으며 각 상태에 맞는 기능들을 구현한다.

 

기억력이 좋다면 전략 패턴의 다이어그램과 똑같다는 걸 알 수 있다. 상태 패턴은 상태 객체에 일련의 행동이 캡슐화된다. 상황에 따라 Context 객체에서 여러 상태 객체 중 한 객체에게 모든 행동을 위임한다. 객체 내부 상태에 따라 현재 상태를 나타내는 객체가 바뀌게 되고 Context 객체의 행동도 바뀌게 된다. 즉, 클라이언트는 상태 객체를 몰라도 된다. 반면 전략 패턴의 경우 일반적으로 클라이언트가 Context 객체에게 어떤 전략 객체를 사용할지 지정해줘야 한다. 

 

이제 10번중에 1번꼴로 2개의 상품을 반환하는 기능을 추가해보자.

 

public class WinnerState implements State {
    GumballMachine gumballMachine;
 
    public WinnerState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }
 
	public void insertCoin() {
		System.out.println("이미 상품을 꺼내고 있습니다.");
	}
 
	public void ejectCoin() {
		System.out.println("이미 상품을 꺼내고 있습니다.");
	}
 
	public void turnLever() {
		System.out.println("다시 돌려도 상품을 꺼내진 않습니다.");
	}
 
	public void dispense() {
		gumballMachine.releaseBall();
		if (gumballMachine.getCount() == 0) {
			gumballMachine.setState(gumballMachine.getSoldOutState());
		} else {
			gumballMachine.releaseBall();
			System.out.println("2개의 상품 당첨!");
			if (gumballMachine.getCount() > 0) {
				gumballMachine.setState(gumballMachine.getNoCoinState());
			} else {
            	System.out.println("상품을 반환중입니다.");
				gumballMachine.setState(gumballMachine.getSoldOutState());
			}
		}
	}
 
	public void refill() { }
}

 

2개의 상품을 반환할 WinnerState 클래스를 추가한다.

 

public class HasCoinState implements State {
	Random randomWinner = new Random(System.currentTimeMillis());
	GumballMachine gumballMachine;
 
	public HasCoinState(GumballMachine gumballMachine) {
		this.gumballMachine = gumballMachine;
	}
  
	public void insertCoin() {
		System.out.println("동전은 1개만 넣을 수 있습니다.");
	}
 
	public void ejectCoin() {
		System.out.println("동전이 반환됩니다.");
		gumballMachine.setState(gumballMachine.getNoCoinState());
	}
 
	public void turnLever() {
		System.out.println("돌리는중입니다.");
		int winner = randomWinner.nextInt(10);
		if ((winner == 0) && (gumballMachine.getCount() > 1)) {
			gumballMachine.setState(gumballMachine.getWinnerState());
		} else {
			gumballMachine.setState(gumballMachine.getSoldState());
		}
	}

    public void dispense() {
        System.out.println("상품이 떨어지지 않았습니다.");
    }
    
    public void refill() { }
}

 

HasCoinState 클래스의 turnLever() 메소드의 로직을 수정한다.

 

public class GumballMachine {
 
	State soldOutState;
	State noCoinState;
	State hasCoinState;
	State soldState;
	State winnerState;
 
	State state = soldOutState;
	int count = 0;
 
	public GumballMachine(int numberGumballs) {
		soldOutState = new SoldOutState(this);
		noCoinState = new NoCoinState(this);
		hasCoinState = new HasCoinState(this);
		soldState = new SoldState(this);
		winnerState = new WinnerState(this);

		this.count = numberGumballs;
 		if (numberGumballs > 0) {
			state = noCoinState;
		} 
	}
 
	public void insertCoin() {
		state.insertCoin();
	}
 
	public void ejectCoin() {
		state.ejectCoin();
	}
 
	public void turnLever() {
		state.turnLever();
		state.dispense();
	}

	void setState(State state) {
		this.state = state;
	}
 
	void releaseBall() {
		System.out.println("상품이 나오고 있습니다.");
		if (count > 0) {
			count = count - 1;
		}
	}
 
	int getCount() {
		return count;
	}
 
	void refill(int count) {
		this.count += count;
		System.out.println("상품이 리필되었습니다. 현재 상품의 수: " + this.count);
		state.refill();
	}

    public State getState() {
        return state;
    }

    public State getSoldOutState() {
        return soldOutState;
    }

    public State getNoCoinState() {
        return noCoinState;
    }

    public State getHasCoinState() {
        return hasCoinState;
    }

    public State getSoldState() {
        return soldState;
    }

    public State getWinnerState() {
        return winnerState;
    }
}

 

GumballMachine 클래스에 winnerState 상태를 추가한다. 이제 10번에 1번 꼴로 2개의 상품을 주는 기능을 추가했다. 하지만 아직 생각해볼 점이 있다. SoldState, WinnerState에는 겹치는 코드가 많이 있으므로 State를 인터페이스가 아닌 추상 클래스로 만들어 공통의 메소드들을 선언하는 것이 좋다. 또한 dispense() 메소드는 반환을 할 수 있을 때만 호출되어야 하는데 지금의 코드는 동전 없이 레버만 돌려도 호출이 된다. 물론 동전을 넣으라는 등의 처리를 하고 있다. turnLever() 메소드에서 boolean 값을 반환하게 하여 그에 따라 dispense() 호출에 대한 예외 처리를 하는 방법이 좋다.

 

 

 

 

 

* 아래의 자료들을 참고하였습니다.

에릭 프리먼 · 엘리자베스 롭슨 · 케이시 시에라 · 버트 베이츠, 『헤드퍼스트 디자인패턴 개정판』, 서환수 옮김, 한빛미디어(2022), p416-454.

댓글