헤드퍼스트 디자인패턴 Chapter10 - 상태 패턴

헤드퍼스트 디자인패턴

  • 본 책을 읽고 책의 내용을 간략하게 정리한 글입니다.

Chapter 10. 객체의 상태 바꾸기 - 상태 패턴

예제 프로젝트 - 최첨단 뽑기 기계

상태 기계 기초 지식 알아보기

  1. 우선 뽑기 기계의 상태를 모아본다.
    1. 동전 없음
    2. 동전 있음
    3. 알맹이 매진
    4. 알맹이 판매
  2. 현재 상태를 저장하는 인스턴스 변수를 만들고 각 상태의 값을 정의한다.
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;                // 현재 상태를 저장하는 인스턴스 변수
  1. 이 시스템에서 일어날 수 있는 모든 행동을 모아본다.
    1. 동전 투입
    2. 동전 반환
    3. 손잡이 돌림
    4. 알맹이 내보냄
      • 여기 있는 행동들은 뽑기 기계의 인터페이스라고 할 수 있다.
      • 이런 행동을 실행할 때 상태가 바뀐다.
  2. 상태 기계 역할을 하는 클래스를 만든다.
    • 각 행동을 구현할 때는 조건문을 써서 어떤 작업을 처리해야 할지 결정한다.
// 예) 동전 투입 메서드
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개가 나오도록 코드를 수정해야 한다.

새로운 디자인 구상하기

객체들을 별도의 코드에 넣고, 어떤 행동이 일어나면 현재 상태 객체에서 필요한 작업을 처리하게 한다.

  1. 우선 뽑기 기계와 관련된 모든 행동에 관한 메서드가 들어있는 State 인터페이스를 정의해야 한다.
  2. 기계의 모든 상태를 대상으로 상태 클래스를 구현해야 한다. 기계가 어떤 상태에 있다면, 그 상태에 해당하는 상태 클래스가 모든 작업을 책임져야 한다.
  3. 조건문 코드를 모두 없애고 상태 클래스에 모든 작업을 위임한다.

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 객체의 인스턴스를 여러 개 만들게 된다면, 상태 인스턴스를 정적 인스턴스 변수로 만들어서 공유하는 편이 좋다.