BackEnd/Design Patterens

[Design Patterns] State Pattern : 스테이트 패턴

샤아이인 2022. 1. 13.

State Pattern 이란?

 
State Pattern - 객체의 내부 상태가 바뀜에 따라서 객체의 행동을 바꿀 수 있습니다.
마치 객체의 클래스가 바뀌는 것과 같은 결과를 얻을 수 있습니다.

이 패턴에서는 상태를 별도의 클래스로 캡슐화한 다음 현재 상태를 나타내는 객체에게 행동을 위임하기 때문에, 내부 상태가 바뀜에 따라서 행동이 달라지게 된다는 것을 알 수 있습니다.

 

"클래스가 바뀌는 것과 같은" 결과를 얻는다는 것이 어떠한 의미일까요? 클라이언트 입장에서 생각해봅시다!

만약 클라이언트가 사용중이던 객체의 행동이 완전히 달라진다면 마치 다른 클래스로부터 만들어진 객체처럼 느껴지겠죠?

물론 실제로 바뀌는 것은 아니고 여러 상태객체를 바꿔가면서 사용하는 방식입니다.

 

Context라는 클래스에는 여러가지 내부 상태가 들어가게됩니다. 곧 밑에서 설명할 GumballMachine에 해당하게 됩니다.

Context의 request()가 호출되면 그 작업은 state객체에게 맡겨집니다.

State는 모든 구상 상태 클래스의 공통 인터페이스 입니다.

ConcreteState 들 에서는 Context로부터 전달된 요청을 처리합니다. 각 ConcreteState에서는 자기 자신의 방식으로 구현하죠!

이렇게 하면 Context에서 상태를 바꾸기만 하면 행동도 바뀌게 됩니다.

 

이말이 무슨 의미인지 다음 단락부터 예시를 통하여 설명해 보겠습니다!


패턴 소개

뽑기 회사로부터 다음 다이어그램에 해당하는 프로그램을 만들어달라는 요청을 받았습니다.

우선 다음 다이어 그램을 확인해 보실까요?

총 4개의 상태가 있군요? 사용 가능한 행동들에는 무엇이 있을까요?

(동전 투입, 동전 반환, 손잡이 돌림, 알맹이 내보냄) 으로 4가지가 있군요. 구현할 프로그램의 인터페이스가 될 것 입니다.

 

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;
 

이제 전반적인 뽑기 기계를 구현해 봅시다! 코드가 길어서 그렇지, 이해하는데 어려움은 없는 코드입니다!

package com.company.State;

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() {
        if (state == HAS_QUARTER) {
            System.out.println("동전은 한개만 투입해주세요!");
        } else if (state == NO_QUARTER) {
            state = HAS_QUARTER;
            System.out.println("동전을 넣으셨습니다.");
        } else if (state == SOLD_OUT) {
            System.out.println("매진되었습니다.");
        } else if (state == SOLD) {
            System.out.println("잠시만 기다려 주십시오. 알맹이가 나가는 중 입니다.");
        }
    }

    public void ejectQuarter() { // 동전 반환을 원하는 경우
        if (state == HAS_QUARTER) {
            System.out.println("동전이 반환됩니다");
            state = NO_QUARTER;
        } else if (state == NO_QUARTER) {
            System.out.println("동전을 넣어주세요");
        } else if (state == SOLD) {
            System.out.println("이미 알맹이를 뽑으셨습니다.");
        } else if (state == SOLD_OUT) {
            System.out.println("동전을 넣지 않으셨습니다. 반환되지 않습니다");
        }
    }

    public void turnCrank() { // 손잡이를 돌리는 경우
        if (state == SOLD) {
            System.out.println("손잡이는 한번만 돌려주세요!");
        } else if (state == NO_QUARTER) {
            System.out.println("동전을 넣어주세요");
        } else if (state == SOLD_OUT) {
            System.out.println("매진되었습니다");
        } else if (state == HAS_QUARTER) {
            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_QUARTER;
            }
        } else if (state == NO_QUARTER) {
            System.out.println("동전을 넣어주세요");
        } else if (state == SOLD_OUT) {
            System.out.println("매진입니다.");
        } else if (state == HAS_QUARTER) {
            System.out.println("알맹이가 나갈 수 없습니다");
        }
    }

    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("재고소진");
        } else if (state == NO_QUARTER) {
            result.append("동전을 기다리는중");
        } else if (state == HAS_QUARTER) {
            result.append("손잡이를 돌리시길 기다리는중");
        } else if (state == SOLD) {
            result.append("알맹이 전달!");
        }
        result.append("\n");
        return result.toString();
    }
}
 

지금까지 작성된 코드를 실행해봐야 겠죠? 다음 테스트 코드를 확인해보실까요? (좀 길어요.... )

package com.company.State;

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);
    }
}
 

결과는 다음과 같아요!

Mighty Gumball, Inc.
Java-enabled Standing Gumball Model #2004
Inventory: 5 gumballs
Machine is 동전을 기다리는중

동전을 넣으셨습니다.
손잡이를 돌리셨습니다
알맹이가 나가고 있습니다.

Mighty Gumball, Inc.
Java-enabled Standing Gumball Model #2004
Inventory: 4 gumballs
Machine is 동전을 기다리는중

동전을 넣으셨습니다.
동전이 반환됩니다
동전을 넣어주세요

Mighty Gumball, Inc.
Java-enabled Standing Gumball Model #2004
Inventory: 4 gumballs
Machine is 동전을 기다리는중

동전을 넣으셨습니다.
손잡이를 돌리셨습니다
알맹이가 나가고 있습니다.
동전을 넣으셨습니다.
손잡이를 돌리셨습니다
알맹이가 나가고 있습니다.
동전을 넣어주세요

Mighty Gumball, Inc.
Java-enabled Standing Gumball Model #2004
Inventory: 2 gumballs
Machine is 동전을 기다리는중

동전을 넣으셨습니다.
동전은 한개만 투입해주세요!
손잡이를 돌리셨습니다
알맹이가 나가고 있습니다.
동전을 넣으셨습니다.
손잡이를 돌리셨습니다
알맹이가 나가고 있습니다.
알맹이가 전부 소진되었습니다.
매진되었습니다.
매진되었습니다

Mighty Gumball, Inc.
Java-enabled Standing Gumball Model #2004
Inventory: 0 gumballs
Machine is 재고소진


Process finished with exit code 0
 

문제는 다음과 같은 요구가 추가적으로 들어왔다고 해봅시다!

 

"10분의 1 확률로 알맹이가 2개 나오는 기능을 추가해주세요!"

 

오... 주여.. 말만 하면 알아서 만들어지는 것 도 아니구... 우리의 코드는 확장성이 좀 떨어지는것 같거든요...

 

WINNER 상태는 간단하게 final static 변수를 하나더 추가해주면 될것 같습니다.

문제는... 추가된 WINNER 상태를 확인하기 위한 조건문들을 기존의 메소드에 추가해야 한다는 점 입니다.

 

이외에도 다음과 같은 문제들이 고려됩니다.

 

1) OCP 원칙을 지키지 않고 있습니다.

2) 객체지향 디자인이 아닙니다. 너무 하드코딩 스타일 입니다.

3) 상태 전환이 복잡한 조건문안에 숨어있습니다.

4) 바뀌는 부분들이 캡술화 되어있지도 않습니다.

 

이 난관을 어떻게 해결해야 할까요? 일단 큰 그림을 그려봅시다!

 

각 상태별 클래스를 만들고 그안에 행동을 구현하면 될 것 같습니다. 또한 이러한 상태 객체를 현재상태를 나타내는

state object 에게 전달하면 될것 같군요? 구성을 활용하라는 다지인 원칙에도 적합한것 같구요!

 

숲을 봤으니 조금더 자세하게 들어가볼까요?

 

1) 우선 뽑기 기계에서 필요로 하는 행동에 대한 메소드가 들어있는 State 인터페이스를 구현해야 합니다.

2) 그 다음 기계의 모든 상태에 대해서 클래스를 구현해야 합니다. 각 상태의 모든 행동을 해당 클래스에 집어넣읗 것 입니다.

3) 모든 조건문을 제거하고, 상태 클래스에 모든 작업을 위임합니다.

 

다이어그램으로 살펴보실까요? 다음과 같을꺼에요!

 

출처 -  https://bb-dochi.tistory.com/83
이전에는 각 상태별로 static final 변수를 이용했다면, 이번에는 각 상태를 직접 클래스로 대응시킵니다.

 

우선 State 인터페이스를 구현해봐야 겠죠? 인터페이스는 뽑기 기계에서 일어날 수 있는 모든 행동들을 필요료 합니다.

State interface

package com.company.State;

public interface State {
    public void insertQuarter();
    public void ejectQuarter();
    public void turnCrank();
    public void dispense();
}
 

이제 각각 상태에 해당하는 클래스를 만들어야겠죠? 우선 NoQuarterState class를 만들어 봅시다!

NoQuarterState

package com.company.State;

public class NoQuarterState implements State{
    GumballMachine gumballMachine;

    public NoQuarterState(GumballMachine gumballMachine){
        this.gumballMachine = gumballMachine;
    }

    @Override
    public void insertQuarter() {
        System.out.println("동전을 넣으셨습니다.");
        gumballMachine.setState(gumballMachine.getHasQuarterState());
    }

    @Override
    public void ejectQuarter() {
        System.out.println("동전을 넣어주세요");
    }

    @Override
    public void turnCrank() {
        System.out.println("동전을 넣어주세요.");
    }

    @Override
    public void dispense() {
        System.out.println("동전을 넣어주세요.");
    }
}
 

각 상태에 따라 적절한 행동을 구현합니다. 상황에 따라서 뽑기의 기계 상태가 다른 상태로 전환되는 경우도 있습니다.

 

다음은 GumballMachine class(뽑기 기계) 입니다. 코드먼저 살펴봅시다!

GumballMachine

package com.company.State;

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();
    }

    void setState(State state) {this.state = state;}
    public State getState() {
        return state;
    }
    void releaseBall() {
        System.out.println("알맹이가 나오는 중 입니다!");
        if(count != 0){
            count -= 1;
        }
    }

    public State getSoldOutState() {
        return soldOutState;
    }

    public State getNoQuarterState() {
        return noQuarterState;
    }

    public State getHasQuarterState() {
        return hasQuarterState;
    }

    public State getSoldState() {
        return soldState;
    }
}
 

State 인터페이스를 활용한 다형성을 적극 이용하는 코드입니다. 또한 상속보다는 구성을 활용하고 있죠!

 

나머지 상태들도 구현해 볼까요?

HasQuarterState

package com.company.State;

public class HasQuarterState implements State{
    GumballMachine gumballMachine;

    public HasQuarterState(GumballMachine gumballMachine){
        this.gumballMachine = gumballMachine;
    }

    @Override
    public void insertQuarter() {
        System.out.println("동전은 하나만 넣어주세요");
    }

    @Override
    public void ejectQuarter() {
        System.out.println("동전이 반환됩니다.");
        gumballMachine.setState(gumballMachine.getNoQuarterState());
    }

    @Override
    public void turnCrank() {
        System.out.println("손잡이를 돌리셨습니다.");
        gumballMachine.setState(gumballMachine.getSoldOutState());
    }

    @Override
    public void dispense() {
        System.out.println("알맹이가 나갈 수 없습니다.");
    }
}
 

SoldState

package com.company.State;

public class SoldState implements State{
    GumballMachine gumballMachine;

    public SoldState(GumballMachine gumballMachine){
        this.gumballMachine = gumballMachine;
    }
    @Override
    public void insertQuarter() {
        System.out.println("잠시만 기다려 주세요. 알맹이가 나가는 중 입니다.");
    }

    @Override
    public void ejectQuarter() {
        System.out.println("이미 알맹이를 반환중 입니다.");
    }

    @Override
    public void turnCrank() {
        System.out.println("손잡이는 한 번만 돌려주세요");
    }

    @Override
    public void dispense() {
        gumballMachine.releaseBall();
        if(gumballMachine.getCount() > 0){
            gumballMachine.setState(gumballMachine.getNoQuarterState());
        }else{
            gumballMachine.setState(gumballMachine.getSoldOutState());
        }
    }
}
 

SoldOutState

package com.company.State;

public class SoldOutState implements State{
    GumballMachine gumballMachine;

    public SoldOutState(GumballMachine gumballMachine){
        this.gumballMachine = gumballMachine;
    }

    @Override
    public void insertQuarter() {
        System.out.println("죄송합니다 매진되었습니다.");
    }

    @Override
    public void ejectQuarter() {
        System.out.println("죄송합니다 매진되었습니다.");
    }

    @Override
    public void turnCrank() {
        System.out.println("죄송합니다 매진되었습니다.");
    }

    @Override
    public void dispense() {
        System.out.println("알맹이가 나갈 수 없습니다.");
    }
}
 

 

위와같이 코드를 변경함으로써 얻는 이득에는 무엇이 있엇을까요?

 

▶ 각 상태의 행동을 개별 클래스가 책임지도록 국지화 시켰습니다.

▶ 유지보수가 힘든 조건문들을 없앴습니다.

▶ 각 상태 클래스들은 변경에 대해 닫혀있으면서도, GumballMachine 자체는 새로운 상태 클래스를 추가하는 확장에 대해 여렬있습니다.(OCP)

 

몇가지 질문과 답변을 공부해 봅시다!

 

◆ 클라이언트와 상태객체가 직접적으로 연락을 할까요?

그런일은 없습니다. 상태는 Context쪽에서 내부 상태를 표현하기 위해 사용하는 것 입니다.

따라서 상태에 대한 요청은 전부 Context로부터 오게됩니다. Client 에서는 상태를 직접 바꿀수가 없죠!

전반적인 상태의 관리는 Context가 해야한다 이말입니다.

 

끝나신줄 아셨죠? 아직 안끝났습니다 ㅎㅎ 10분의 1 확률로 알맹이 2개주는 기능을 마무리해야죠!

우선 GumballMachine에 상태를 추가하는것부터 시작해 봅시다.

package com.company.State;

public class GumballMachine {
    State soldOutState;
    State noQuarterState;
    State hasQuarterState;
    State soldState;
    State winnerState; // 추가되는 부분

    State state;
    int count = 0;

    ....

    public State getWinnerState() {return winnerState;} // 추가되는 부분

    ....
}
 

이제 WinnerState class를 구현해 봅시다.

package com.company.State;

public class WinnerState {
    GumballMachine gumballMachine;

    public WinnerState(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(); // 알맹이 1개 먼저 방출
        if (gumballMachine.getCount() == 0) {
            gumballMachine.setState(gumballMachine.getSoldOutState());
        } else {
            gumballMachine.releaseBall(); // 남은 1개더 방출
            System.out.println("당첨되셧습니다! 1개 더 드려요!");
            if (gumballMachine.getCount() > 0) {
                gumballMachine.setState(gumballMachine.getNoQuarterState());
            } else {
                System.out.println("더이상 알맹이가 없습니다!");
                gumballMachine.setState(gumballMachine.getSoldOutState());
            }
        }
    }
}
 

이제 마지막 한가지 기능만 더 추가하면 됩니다. 10% 확률로 당첨 여부를 결정해서 WinnerState로 전환되는 기능만 추가하면 됩니다. 이 기능은 고객이 손잡이를 돌릴때 작동해야 하므로 HasQuarterState에 추가하도록 하겠습니다. 다음처럼 말이죠!

package com.company.State;

import java.util.Random;

public class HasQuarterState implements State{
    Random randomWinner = new Random(System.currentTimeMillis()); // 렌던함 수 추가
    GumballMachine gumballMachine;

    public HasQuarterState(GumballMachine gumballMachine){
        this.gumballMachine = gumballMachine;
    }

    ...

    @Override
    public void turnCrank() {
        System.out.println("손잡이를 돌리셨습니다.");
        int winner = randomWinner.nextInt(10);
        if((winner == 0) && (gumballMachine.getCount() > 1)){ // 당첨이되고, 남은 알맹이 수가 2개 이상일때만
            gumballMachine.setState(gumballMachine.getWinnerState());
        }else{
            gumballMachine.setState(gumballMachine.getSoldState());
        }
        gumballMachine.setState(gumballMachine.getSoldState());
    }
    ...
}

 

한가지 궁금한 점이 있습니다. WinnerState가 꼭 필요한것 일까요? 그냥 SoldState에 구현하면 안될까요?

사실 알맹이를 2개 내보낸다는 점 말고는 둘이 거의 똑같습니다. 따라서 SoldState에서 알맹이를 2개 내보내는 방식으로 해도 별 문제는 없습니다. 이렇게 하면 코드 중복또한 어느정도 줄일 수 있을것 같군요

대신! 상태 클래스가 조금 불분명해지는 단점이 생깁니다. 이전 글에서 언급한 단일 역할 원칙에도 위배되게 됩니다.

댓글