헤드퍼스트 디자인패턴
- 본 책을 읽고 책의 내용을 간략하게 정리한 글입니다.
Chapter 10. 객체의 상태 바꾸기 - 상태 패턴
예제 프로젝트 - 최첨단 뽑기 기계
상태 기계 기초 지식 알아보기
- 우선 뽑기 기계의 상태를 모아본다.
- 동전 없음
- 동전 있음
- 알맹이 매진
- 알맹이 판매
- 현재 상태를 저장하는 인스턴스 변수를 만들고 각 상태의 값을 정의한다.
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; // 현재 상태를 저장하는 인스턴스 변수
- 이 시스템에서 일어날 수 있는 모든 행동을 모아본다.
- 동전 투입
- 동전 반환
- 손잡이 돌림
- 알맹이 내보냄
- 여기 있는 행동들은 뽑기 기계의 인터페이스라고 할 수 있다.
- 이런 행동을 실행할 때 상태가 바뀐다.
- 상태 기계 역할을 하는 클래스를 만든다.
- 각 행동을 구현할 때는 조건문을 써서 어떤 작업을 처리해야 할지 결정한다.
// 예) 동전 투입 메서드
public void insertQuarter {
if (state == HAS_QUARTER) {
System.out.println("동전은 한 개만 넣어주세요.");
} else if (state == NO_QUARTER) {
state = HAS_QUARTER;
Sytstem.out.println("동전이 투입되었습니다.");
} else if (state == SOLD_OUT) {
System.out.println("매진되었습니다. 다음 기회에 이용해주세요.");
} else if (state == SOLD) {
// 알맹이 내보냄
}
}
뽑기 기계 코드 수정 요청
- 10분의 1 확률로 보너스 알맹이를 획득할 수 있도록 기능을 추가해야 한다.
- 10번에 1번 꼴로 손잡이를 돌릴 때 알맹이 2개가 나오도록 코드를 수정해야 한다.
새로운 디자인 구상하기
객체들을 별도의 코드에 넣고, 어떤 행동이 일어나면 현재 상태 객체에서 필요한 작업을 처리하게 한다.
- 우선 뽑기 기계와 관련된 모든 행동에 관한 메서드가 들어있는 State 인터페이스를 정의해야 한다.
- 기계의 모든 상태를 대상으로 상태 클래스를 구현해야 한다. 기계가 어떤 상태에 있다면, 그 상태에 해당하는 상태 클래스가 모든 작업을 책임져야 한다.
- 조건문 코드를 모두 없애고 상태 클래스에 모든 작업을 위임한다.
State 인터페이스 및 상태 클래스 구현
// 모든 상태 클래스에서 구현할 인터페이스
// 메서드는 뽑기 기계에서 일어날 수 있는 모든 행동에 직접적으로 대응된다.
public interface State {
void insertQuarter();
void ejectQuarter();
void turnCrank();
void dispense();
}
// 동전 없음 상태 클래스
public class NoQuarterState implements State {
GumballMachine gumballMachine;
public NoQuarterState(GumballMachine gumballMachine) {
this.gumballMachine = gumballMachine;
}
public void insertQuarter() {
System.out.println("동전을 넣으셨습니다.");
gumballMachine.setState(gumballMachine.getHasQuarterState());
}
public void ejectQuarter() {
System.out.println("동전을 넣어 주세요.");
}
public void turnCrank() {
System.out.println("동전을 넣어 주세요.");
}
public void dispense() {
System.out.println("동전을 넣어 주세요.");
}
}
// 동전 있음 상태 클래스
public class HasQuarterState implements State {
GumballMachine gumballMachine;
public HasQuarterState(GumballMachine gumballMachine) {
this.gumballMachine = gumballMachine;
}
public void insertQuarter() {
System.out.println("동전은 한 개만 넣어 주세요.");
}
public void ejectQuarter() {
System.out.println("동전이 반환됩니다.");
gumballMachine.setState(gumballMachine.getNoQuarterState());
}
public void turnCrank() {
System.out.println("손잡이를 돌렸습니다.");
gumballMachine.setState(gumballMachine.getSoldState());
}
public void dispense() {
System.out.println("알맹이를 내보낼 수 없습니다.");
}
}
// 알맹이 판매 상태 클래스
public class SoldState implements State {
GumballMachine gumballMachine;
public SoldState(GumballMachine gumballMachine) {
this.gumballMachine = gumballMachine;
}
public void insertQuarter() {
System.out.println("알맹이를 내보내고 있습니다.");
}
public void ejectQuarter() {
System.out.println("이미 알맹이를 뽑으셨습니다.");
}
public void turnCrank() {
System.out.println("손잡이는 한 번만 돌려주세요.");
}
// 중요한 부분: 사용자가 동전을 넣고 돌렸을 떄만 이 상태가 될 수 있다.
// 일단 알맹이를 내보내도록 만듬
public void dispense() {
gumballMachine.releaseBall();
// 현재 알맹이 개수를 구한 후, 개수에 따라 NoQuarter 또는 SoldOut 상태로 전환한다.
if (gumballMachine.getCount() > 0) {
gumballMachine.setState(gumballMachine.getNoQuarterState());
} else {
System.out.println("Out of Gumballs!");
gumballMachine.setState(gumballMachine.getSoldOutState());
}
}
}
뽑기 기계 코드 살펴보기
public class GumballMachine {
State soldOutState;
State noQuarterState;
State hasQuarterState;
State soldState;
State state;
int count = 0;
public GumballMachine(int numberGumballs) {
soldOutState = new SoldOutState(this);
noQuarterState = new NoQuarterState(this);
hasQuarterState = new HasQuarterState(this);
soldState = new SoldState(this);
this.count = numberGumballs;
if (numberGumballs > 0) {
state = noQuarterState;
} else {
state = soldOutState;
}
}
public void insertQuarter() {
state.insertQuarter();
}
public void ejectQuarter() {
state.ejectQuarter();
}
public void turnCrank() {
state.turnCrank();
state.dispense();
}
//dispense() 는 구현할 필요 없음. 내부에서 필요한 행동이기 때문
void setState(State state) {
this.state = state;
}
void releaseBall() {
System.out.println("알맹이를 내보내고 있습니다.");
if (count > 0) {
count--;
}
}
}
- 각 상태의 행동을 별개의 클래스로 국지화
- 관리하기 힘든 if 선언들을 없앰
- 각 상태를 변경에는 닫혀 있게 했고, GumballMachine 클래스는 새로운 상태 클래스를 추가하는 확장에는 열려 있도록 함 (OCP)
상태 패턴의 정의
- 상태 패턴을 사용하면 객체의 내부 상태가 바뀜에 따라서 객체의 행동을 바꿀 수 있다. 마치 객체의 클래스가 바뀌는 것과 같은 결과를 얻을 수 있다.
- 클라이언트 관점에서 볼 때, 객체의 행동이 완전히 달라진다면 그 객체가 마치 다른 클래스로 바뀐 것처럼 느껴진다는 의미
- 다이어그램은 똑같지만, 상태 패턴과 전략 패턴의 용도는 다르다.
- 상태 패턴을 사용할 때는 상태 객체에 일련의 행동이 캡슐화된다.
- 상황에 따라 Context 객체에서 여러 상태 객체 중 한 객체에게 모든 행동을 맡기게 되며, 그 객체의 내부 상태에 따라 현재 상태를 나타내는 객체가 바뀌게 된다.
- 클라이언트는 상태 객체를 몰라도 된다.
- 전략 패턴을 사용할 때는 일반적으로 클라이언트가 Context 객체에게 어떤 전략 객체를 사용할지를 지정해 준다.
- 전략 패턴은 주로 실행 시에 전략 객체를 변경할 수 있는 유연성을 제공하는 용도로 쓰인다.
- 상태 패턴을 사용할 때는 상태 객체에 일련의 행동이 캡슐화된다.
- Context 객체에 수많은 조건문을 넣는 대신 상태 패턴을 사용한다고 생각하면 된다.
보너스 알맹이 당첨 기능 추가
- 10번에 1번 꼴로 알맹이를 2개 얻는 기능 추가하기
public class GumballMachine {
State soldOutState;
State noQuarterState;
State hasQuarterState;
State soldState;
// GumballMachine 클래스에 보너스 알맹이 당첨 상태 추가
State winnerState;
State state = soldOutState;
int count = 0;
// 메서드 구현
}
// WinnerState 상태 구현
public class WinnerState implements State {
// dispencse() 를 제외한 나머지 부분은 SoldState와 일치
GumballMachine gumballMachine;
public SoldState(GumballMachine gumballMachine) {
this.gumballMachine = gumballMachine;
}
public void insertQuarter() {
System.out.println("알맹이를 내보내고 있습니다.");
}
public void ejectQuarter() {
System.out.println("이미 알맹이를 뽑으셨습니다.");
}
public void turnCrank() {
System.out.println("손잡이는 한 번만 돌려주세요.");
}
public void dispense() {
gumballMachine.releaseBall();
if (gumballMachine.getCount() == 0) {
gumballMachine.setState(gumballMachine.getSoldOutState());
} else {
gumballMachine.releaseBall();
System.out.println("축하드립니다! 알맹이를 하나 더 받으실 수 있습니다.");
if (gumballMachine.getCount() > 0) {
gumballMachine.setState(gumballMachine.getNoQuarterState());
} else {
System.out.println("더 이상 알맹이가 없습니다.");
gumballMachine.setState(gumballMachine.getSoldOutState());
}
}
}
}
// 10% 확률로 당첨 여부를 결정하기 위해 HasQuarterState 내에서 난수 발생기 추가
public class HasQuarterState implements State {
Random randomWinner = new Random(System.currentTimeMillis());
GumballMachine gumballMachine;
public HasQuarterState(GumballMachine gumballMachine) {
this.gumballMachine = gumballMachine;
}
public void insertQuarter() {
System.out.println("동전을 한 개만 넣어 주세요.");
}
public void ejectQuarter() {
System.out.println("동전이 반환됩니다.");
gumballMachine.setState(gumballMachine.getNoQuarterState());
}
public void turnCrank() {
System.out.println("손잡이를 돌리셨습니다.");
int winner = randomWinner.nextInt(10);
// 10% 확률에 당첨되고 남아있는 알갱이도 2개 이상이면 WinnerState로 전환
if ((winner == 0) && (gumballMachine.getCount() > 1)) {
gumballMachine.setState(gumballMachine.getWinnerState());
} else {
gumballMachine.setState(gumballMachine.getSoldState());
}
}
public void dispense() {
System.out.println("알맹이를 내보낼 수 없습니다.");
}
}
뽑기 기계 테스트하기
public class GumballMachineTestDrive {
public static void man(String[] args) {
GumballMachine gumballMachine = new GumballMachine(5);
System.out.println(gumballMachine);
gumballMachine.insertQuarter();
gumballMachine.turnCrank();
System.out.println(gumballMachine);
// 당첨되는 것을 확인해야 하니 여러 번 돌려본다.
gumballMachine.insertQuarter();
gumballMachine.turnCrank();
gumballMachine.insertQuarter();
gumballMachine.turnCrank();
System.out.println(gumballMachine);
}
}
정상성 점검하기
- SoldState와 WinnerState에는 중복되어 있는 코드가 꽤 많아 고쳐야겠다.
- State를 추상 클래스로 만들고 몇 가지 기본 기능을 추가하는 것은 어떨까?
dispense()
메서드는 항상 호출된다. 심지어 동전 없이 손잡이를 돌려도 호출된다.turnCrank()
에서 Boolean 값을 리턴하게 하거나, 예외를 도입한다면 해결할 수 있을 것
- 상태 전환 정보는 모두 상태 클래스에 있다.
- GumballMachine 객체의 인스턴스를 여러 개 만들게 된다면, 상태 인스턴스를 정적 인스턴스 변수로 만들어서 공유하는 편이 좋다.