[디자인패턴] 10. the State Pattern

StandingAsh·2024년 12월 1일
3

참고: Head First Design Patterns

개요


모두 한번씩 본 적 있을 추억의 껌볼 기계. 오늘은 이 기계를 Java로 구현해 달라는 의뢰를 받았다.

의뢰인이 요청한 플로우 차트이다. 각 동그라미를 하나의 상태(State)라고 본다면, 아래와 같이 4가지 상태로 구분할 수 있겠다.

  • 동전(Quarter) 있음
  • 동전 없음
  • 껌볼 판매
  • 껌볼 모두 소진

이제 화살표를 살펴보자. 기계의 디폴트 상태는 동전 없음 상태일 것이다. 이제 사용자가 동전을 삽입한다면, 상태가 동전 있음으로 바뀌어야 한다. 동전 있음 상태에서 크랭크를 돌린다면 껌볼 판매 상태가 되고, 동전을 도로 꺼낸다면 다시 동전 없음 상태가 된다. 껌볼 판매 상태에서는 껌볼을 하나 꺼낸다. 남아 있는 껌볼이 있다면 동전 없음 상태로 돌아가고, 껌볼이 0이 된다면 껌볼 모두 소진 상태로 바뀐다.

그렇다면, 행동(Action)도 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;

우선 위와 같이 상태를 저장할 인스턴스 변수 state를 선언하고, 상태를 나타낼 final static 정수를 만들었다.

이제 각 행동에 해당하는 메소드들을 구현하여 적절하게 상태를 바꾸도록 해보자.

public void insertQuarter() {
	if (state == HAS_QUARTER) {
        System.out.println(“이미 동전이 삽입되어 있습니다.);
    } else if (state == SOLD_OUT) {
        System.out.println(“껌볼이 모두 소진되었습니다.);
    } else if (state == SOLD) {
        System.out.println(“껌볼을 꺼내는 중입니다. 기다려 주세요.);
    } else if (state == NO_QUARTER) {
        state = HAS_QUARTER;
        System.out.println(“동전이 삽입되었습니다.);
    }
}

NO_QUARTER 상태를 제외하고는 동전이 삽입되지 않도록 구현해야 한다. 따라서, 위와 같이 조건문으로 상태가 바뀌지 않도록 만들었다. NO_QUARTER 상태에서 동전이 삽입된다면 HAS_QUARTER 상태로 바꿔준다.

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;
 
    int state = SOLD_OUT;
    int count = 0;
  
    public GumballMachine(int count) {
        this.count = count;
        if (count > 0) {
            state = NO_QUARTER;
        }
    }  
  
    public void insertQuarter() { ... }
    public void ejectQuarter() { ... } 
    public void turnCrank() { ... } 
    public void dispense() { ... }
}

껌볼 기계의 전체 코드이다. 위에서 정리한 4가지 행동에 대한 메소드들을 가지며, 껌볼 개수와 상태를 저장할 인스턴스 변수 두 개를 가진다. 일단 기능에 문제는 없어 보인다. 다만 한 눈에 봐도 코드가 썩 이쁘지 못해 보이는데...

State 패턴


기존 코드의 문제점

올 것이 왔다. 항상 문제는 유지보수 과정에서 발생한다. 우리의 의뢰인이 껌볼 기계에 새로운 기능을 요구했다. 고객에게 10%의 확률껌볼을 2개 주도록 해보자!

우선 새로운 stateWINNER을 추가하고, 모든 메소드에 WINNER 상태를 처리하는 조건문을 추가해야 하고... 생각만 해도 머리가 아파온다. 이쯤 되면 구조적인 문제가 있는 것이 확실해졌으므로, 구조 자체를 뜯어 고쳐보자.

계획은 이렇다:

  • State를 인터페이스로 독립시키고, 모든 행동 메소드를 선언해두자.
  • 각 상태에 대한 클래스를 구현하고, 행동에 대한 책임을 가지도록 하자.
  • 불필요한 조건문을 모두 제거하고 State 구현체들이 알아서 하도록 하자.

구현

클래스 다이어그램으로 만들어보면 위와 같을 것이다.

public class NoQuarterState implements State {
    GumballMachine gumballMachine;
 
    public NoQuarterState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }
 
    public void insertQuarter() {
        gumballMachine.setState(gumballMachine.getHasQuarterState());
    }
 
    public void ejectQuarter() { 에러 문구 출력 }
    public void turnCrank() { 에러 문구 출력 } 
    public void dispense() { 에러 문구 출력 } 
}

State는 위의 예시처럼 적절한 행동이 실행됐을 때 껌볼 기계의 상태를 적절하게 세팅해주고, 적절하지 못한 행동은 실행되지 못하도록 메소드를 구현하면 되겠다.

마찬가지로, 껌볼 기계 클래스의 코드에도 변경이 있어야 할 것이다.

public class GumballMachine {
 
    State soldOutState;
    State noQuarterState;
    State hasQuarterState;
    State soldState;
 
    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);
        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() {
        if (count != 0) {
            count = count - 1;
        }
    }State 구현체들에 대한 getter들...
}

위와 같이 구현할 수 있겠다. 모든 State 구현체들을 멤버로 가지며, 현재 상태를 나타낼 하나의 state 변수를 가진다. 행동 메소드들은 state의 행동 메소드를 호출한다.

이제 나머지 State 구현체들을 완성해보자.

public class HasQuarterState implements State {
    GumballMachine gumballMachine;
 
    public HasQuarterState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }
  
    public void insertQuarter() { 에러 문구 출력 }
    
    public void ejectQuarter() {
        gumballMachine.setState(gumballMachine.getNoQuarterState());
    }
    
    public void turnCrank() {
        gumballMachine.setState(gumballMachine.getSoldState());
    }
    
    public void dispense() { 에러 문구 출력 }
}
public class SoldState implements State {
    GumballMachine gumballMachine;
 
    public HasQuarterState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }
 
    public void insertQuarter() { 에러 문구 출력 }
    public void ejectQuarter() { 에러 문구 출력 }
    public void turnCrank() { 에러 문구 출력 }
 
    public void dispense() {
        gumballMachine.releaseBall();
        if (gumballMachine.getCount() > 0) {
            gumballMachine.setState(gumballMachine.getNoQuarterState());
        } else {
            gumballMachine.setState(gumballMachine.getSoldOutState());
        }
    }
}
public class SoldOutState implements State {
    GumballMachine gumballMachine;
 
    public HasQuarterState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }
 
    public void insertQuarter() { 에러 문구 출력 }
    public void ejectQuarter() { 에러 문구 출력 }
    public void turnCrank() { 에러 문구 출력 }
    public void dispense() { 에러 문구 출력 }
}

분석

우리는 기존의 코드와 기능적으론 동일하지만 구조적으로 사뭇 다른 코드를 완성했다. 이렇게 함으로써 우리가 얻게 되는 점은 아래와 같다.

  • 각 상태에 따른 행동을 상태 자신이 관리하도록 쥐어주었다.
  • 유지보수하기 까다로운 조건문들을 모두 없앴다.
  • State들을 수정에 있어서 닫혀있도록 하였다. Open-Closed Principle에 조금 더 부합하는 코드가 되었다.
  • 가독성이 훨씬 증가하였다.

정의

State 패턴은 아래와 같이 정의한다.

객체의 상태에 따라 행동을 바꿀 수 있도록 해주는 디자인 패턴.

클래스 다이어그램으로 나타내면 위와 같다. State를 갖는 객체를 Context라고 한다. 우리의 껌볼 기계가 컨텍스트 객체에 해당한다. 컨텍스트는 상태 객체의 행동 메소드를 호출해 사용한다.

그런데, 클래스 다이어그램을 보아하니 Strategy 패턴과 상당히 유사하다! 아니, 유사한 수준이 아니라 사실상 동일하다.
참고: [디자인 패턴] 1. the Strategy Pattern

  • 그렇다면 둘의 차이점이 무엇일까?

바로 사용 의도에 있다. State 패턴은 주로 상황에 따라 객체의 행동이 변해야 하는 경우에 사용한다. 반면, Strategy 패턴을 사용하는 경우엔 행동을 상황에 따라 바꿀 순 있지만, 주로 컨텍스트마다 적절한 행동이 정해져있는 경우가 많다.

따라서, 정리하자면 Strategy 패턴은 상속의 보다 유연한 대안, State 패턴은 과도한 조건문에 대한 좋은 대안으로 생각하면 좋을 것 같다.

마무리


맞다, 아직 10% 확률로 두개 이벤트에 대한 구현이 안끝났지! 미리 적용해 둔 State 패턴을 이용해서 완성해보자.

 public class WinnerState implements State {
    GumballMachine gumballMachine;
 
    public HasQuarterState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }
 
    public void insertQuarter() { 에러 문구 출력 }
    public void ejectQuarter() { 에러 문구 출력 }
    public void turnCrank() { 에러 문구 출력 }
 
    public void dispense() {
        gumballMachine.releaseBall();
        if (gumballMachine.getCount() == 0) {
            gumballMachine.setState(gumballMachine.getSoldOutState());
        } else {
            gumballMachine.releaseBall();
            if (gumballMachine.getCount() > 0) {
                gumballMachine.setState(gumballMachine.getNoQuarterState());
            } else {
                gumballMachine.setState(gumballMachine.getSoldOutState());
            }
        }
    }
}

우선 위와 같이 WinnerState를 구현하였다. 껌이 2개 이상 남아있는 경우 2개를 꺼내도록 만들었다.

public class HasQuarterState implements State {
    Random randomWinner = new Random(System.currentTimeMillis());
    GumballMachine gumballMachine;
 
    public HasQuarterState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }
  
    public void insertQuarter() { 에러 문구 출력 }
 
    public void ejectQuarter() {
        gumballMachine.setState(gumballMachine.getNoQuarterState());
    }
 
    public void turnCrank() {
        System.out.println(You turned...);
        int winner = randomWinner.nextInt(10);
        if ((winner == 0) && (gumballMachine.getCount() > 1)) {
            gumballMachine.setState(gumballMachine.getWinnerState());
        } else {
            gumballMachine.setState(gumballMachine.getSoldState());
        }
    }
    
    public void dispense() { 에러 문구 출력 }
}

이제 동전이 삽입된 상태에서 크랭크를 돌리면, 10% 확률로 WinnerState로 상태가 세팅되도록 HasQuarterState를 수정하였다. 껌볼 기계의 코드는 멤버 변수에 WinnerState를 추가해주는 것 외에는 손대지 않아도 된다! 따라서, 만약 행사 기간이 끝나거나 하더라도 수정해야 할 코드는 극히 일부분 뿐이다.

이 외에도 refill() 등의 행동으로 SoldOutState에서 다시 NoQuarterState로 상태를 바꿔주는 등의 기능을 추가하더라도 코드의 수정에는 닫혀있다고 할 수 있다. Open-Closed 디자인 원칙을 잘 지키고 있다고 할 수 있다.

profile
우당탕탕 백엔드 생존기

0개의 댓글