개발 이야기/디자인 패턴

[디자인 패턴] 상태(State) 패턴이란?

제이온 (Jayon) 2021. 3. 12.

안녕하세요? 제이온입니다.

 

저번 시간에는 템플릿 메소드 패턴에 대해서 알아 보았습니다. 오늘은 상태 패턴을 설명하겠습니다.

 

 

상태(State) 패턴

상태 패턴은 일련의 규칙에 따라 객체의 상태를 변화시켜, 객체가 할 수 있는 행위를 바꾸는 패턴을 말합니다. 이것만 들으면 감이 잘 안 오실텐데 예제를 들어서 설명해 보겠습니다.

 

가격이 1원인 한 가지 제품만을 판매하는 자판기가 있습니다. 이 자판기는 아래와 같은 요구 사항에 따라 동작되어야 한다고 가정합시다.

 

 

동작 조건 실행 결과
동전을 넣음 동전이 없다면 금액을 증가 제품 선택 가능
동전을 넣음 제품을 선택할 수 있다면 금액을 증가 제품 선택 가능
제품 선택 동전이 없다면 아무 동작도 하지 않음 동전 없는 상태 유지
제품 선택 제품을 선택할 수 있다면 제품을 주고 잔액 감소 잔액이 있으면 제품을 선택하고, 그렇지 않으면 동전 없음 상태로 변경

 

 

위 요구 사항대로 코드를 작성해 보겠습니다.

 

 

public enum State {
    NOCOIN,
    SELECTABLE;
}

 

 

먼저, 자판기의 조건이 '동전 없음'과 '제품 선택 가능'이므로 이를 상수화해서 enum 클래스를 만들어 주었습니다.

 

 

package state;

public class VendingMachine {

    private State state = State.NOCOIN;
    private int coin;

    public VendingMachine() {
        this.coin = 0;
    }

    public void insertCoin(final int coin) {
        switch (state) {
            case NOCOIN:
                increaseCoin(coin);
                state = State.SELECTABLE;
                break;
            case SELECTABLE:
                increaseCoin(coin);
                break;
        }
    }

    private void increaseCoin(final int coin) {
        this.coin += coin;
    }

    public void buyProduct() {
        switch (state) {
            case NOCOIN:
                break;
            case SELECTABLE:
                provideProduct();
                decreaseCoin();
                if (hasNoCoin()) {
                    state = State.NOCOIN;
                }
                break;
        }
    }

    private void provideProduct() {
        System.out.println("제품을 성공적으로 구매하였습니다.");
    }

    private void decreaseCoin() {
        coin--;
    }

    private boolean hasNoCoin() {
        return coin == 0;
    }

    public State getState() {
        return state;
    }

    public int getCoin() {
        return coin;
    }
}

 

 

그리고 표의 내용에 따라 그대로 구현해 주면 됩니다. 어려운 내용이 아니므로 소스 코드만으로도 이해하실 수 있을겁니다.

 

자, 이제 프로그램을 모두 구현하였는데, "자판기에 제품이 없는 경우에는 동전을 넣으면 바로 동전을 되돌려 준다."라는 추가 요구 사항이 생겼다고 합시다. 그렇다면, 아래처럼 switch문에 case를 추가하면 됩니다.

 

 

public class VendingMachine {

    private State state = State.NOCOIN;
    private int coin;

    public VendingMachine() {
        this.coin = 0;
    }

    public void insertCoin(final int coin) {
        switch (state) {
            case NOCOIN:
                increaseCoin(coin);
                state = State.SELECTABLE;
                break;
            case SELECTABLE:
                increaseCoin(coin);
                break;
            case SOLDOUT:
                returnCoin(coin);
                break;
        }
    }

    private void increaseCoin(final int coin) {
        this.coin += coin;
    }

    private void returnCoin(final int coin) {
        System.out.println("상품이 품절되었습니다.");
        System.out.println(coin + "원을 다시 받으시길 바랍니다.");
    }

    public void buyProduct() {
        switch (state) {
            case NOCOIN:
                break;
            case SELECTABLE:
                provideProduct();
                decreaseCoin();
                if (hasNoCoin()) {
                    state = State.NOCOIN;
                }
                break;
            case SOLDOUT:
                break;
        }
    }

    private void provideProduct() {
        System.out.println("제품을 성공적으로 구매하였습니다.");
    }

    private void decreaseCoin() {
        coin--;
    }

    private boolean hasNoCoin() {
        return coin == 0;
    }

    public State getState() {
        return state;
    }

    public int getCoin() {
        return coin;
    }
}

 

 

State enum 클래스 상수에 SOLDOUT을 추가해 주고, 위와 같이 VendingMachine을 변경할 수 있습니다. 이때, 또 다시 "자판기를 점검중일 때는 동전을 넣으면 동전을 바로 돌려주어야 한다."라는 요구 사항이 추가 되었습니다. 물론, switch 안의 case를 생성해 줄 수도 있지만 반복된 구조를 피할 수 없게 됩니다.

 

따라서, 추상화를 통해 중복을 피해야 합니다. 현재 VendingMachine 클래스의 코드를 다시 보면, 조건문은 "상태에 따라 동일한 기능 요청의 처리를 다르게 한다."는 특징이 있다. 예를 들어, insertCoin() 메소드는 상태가 NOCOIN이냐, SELECTABLE이냐, SOLDOUT이냐에 따라 기능이 다르게 동작합니다.

 

 

    public void insertCoin(final int coin) {
        switch (state) {
            case NOCOIN:
                increaseCoin(coin);
                state = State.SELECTABLE;
                break;
            case SELECTABLE:
                increaseCoin(coin);
                break;
            case SOLDOUT:
                returnCoin(coin);
                break;
        }
    }

 

 

select()도 마찬가지로 상태에 따라 기능이 다르게 동작합니다. 이렇게 기능이 상태에 따라 다르게 동작해야 할 때 사용할 수 있는 패턴이 상태 패턴입니다.

 

 

상태 패턴을 적용해 보자

 

 

기존에 enum 클래스에 정의해 두었던 상수 각각을 단일 클래스로 만든 다음, 그것의 상위 인터페이스인 State를 정의합니다. 그리고 Context인 VendingMachine 클래스는 이 State를 직접적으로 이용하게 됩니다.

 

 

public class VendingMachine {

    private State state;
    private int coin;

    public VendingMachine() {
        state = new NoCoinState();
        this.coin = 0;
    }

    public void insertCoin(final int coin) {
        state.increaseCoin(coin, this);
    }

    public void increaseCoin(final int coin) {
        this.coin += coin;
    }

    public void buyProduct() {
        state.buyProduct(this);
    }

    public void changeState(final State state) {
        this.state = state;
    }


    public void returnCoin(final int coin) {
        System.out.println("상품이 품절되었습니다.");
        System.out.println(coin + "원을 다시 받으시길 바랍니다.");
    }


    public void provideProduct() {
        System.out.println("제품을 성공적으로 구매하였습니다.");
    }

    public void decreaseCoin() {
        coin--;
    }

    public boolean hasNoCoin() {
        return coin == 0;
    }

    public State getState() {
        return state;
    }

    public int getCoin() {
        return coin;
    }
}

 

 

필드로 State를 갖고 있고, 생성자를 통해서 NoCoinState로 초기화를 해 줍니다. 그리고 우리가 반복된 구조라고 비판하였던 메소드인 insertCoin()과 buyProduct()는 모두 State에게 책임을 위임하게 됩니다. 즉. State 객체가 기능을 제공해 주는 것입니다.

 

 

public interface State {

    void increaseCoin(final int coin, final VendingMachine vendingMachine);

    void buyProduct(final VendingMachine vendingMachine);

}

 

 

이렇게 Context에게 실질적으로 기능을 제공할 메소드를 정의해 줍니다. 그리고 하위 상태 클래스가 메소드를 재정의하는 것이죠.

 

 

public class NoCoinState implements State {

    @Override
    public void increaseCoin(int coin, VendingMachine vendingMachine) {
        vendingMachine.increaseCoin(coin);
        vendingMachine.changeState(new SelectableState());
    }

    @Override
    public void buyProduct(VendingMachine vendingMachine) {
        System.out.println("동전을 투입한 후 제품을 구매해 주세요.");
    }
}

 

 

public class SelectableState implements State {

    @Override
    public void increaseCoin(final int coin, final VendingMachine vendingMachine) {
        vendingMachine.increaseCoin(coin);
    }

    @Override
    public void buyProduct(final VendingMachine vendingMachine) {
        vendingMachine.provideProduct();
        vendingMachine.decreaseCoin();

        if (vendingMachine.hasNoCoin()) {
            vendingMachine.changeState(new NoCoinState());
        }
    }
}

 

 

public class SoldOutState implements State {

    @Override
    public void increaseCoin(final int coin, final VendingMachine vendingMachine) {
        vendingMachine.returnCoin(coin);
    }

    @Override
    public void buyProduct(final VendingMachine vendingMachine) {
        System.out.println("상품이 품절되었습니다.");
    }
}

 

 

어떤가요? 각각의 상태에게 책임을 분배하여 기존 코드보다 깔끔해진 것 같다는 생각이 듭니다. 보시다시피, 상태 패턴은 새로운 상태가 추가되더라도 Context 코드가 받는 영향은 최소화되며, 상태가 많아지더라도 클래스의 개수는 증가하지만 코드의 복잡도는 증가하지 않기 때문에 유지 보수에 유리하다는 장점이 있습니다. 또한, 상태에 따른 동작을 구현한 코드가 각 상태 별로 구분되기 때문에 상태 별 동작을 수정하기가 쉽습니다. 

 

 

상태 패턴에서 상태를 변경하는 주체

상태 패턴을 적용할 때 고려할 문제는 Context의 상태 변경을 누가 하느냐에 대한 것입니다. 상태 변경을 하는 주체는 Context나 상태 객체 둘 중 하나가 되는데, 저는 후자를 택하였습니다.

 

이번에는 Context가 상태 변경의 주체가 되어 봅시다.

 

 

public class VendingMachine {

    private State state;
    private int coin;

    public VendingMachine() {
        state = new NoCoinState();
        this.coin = 0;
    }

    public void insertCoin(final int coin) {
        state.increaseCoin(coin, this);
        if (!hasNoCoin()) {
            changeState(new SelectableState());
        }
    }

    public void increaseCoin(final int coin) {
        this.coin += coin;
    }

    public void buyProduct() {
        state.buyProduct(this);
        if (state.isSelectable() && hasNoCoin()) {
            changeState(new NoCoinState());
        }
    }

    private void changeState(final State state) {
        this.state = state;
    }


    public void returnCoin(final int coin) {
        System.out.println("상품이 품절되었습니다.");
        System.out.println(coin + "원을 다시 받으시길 바랍니다.");
    }


    public void provideProduct() {
        System.out.println("제품을 성공적으로 구매하였습니다.");
    }

    public void decreaseCoin() {
        coin--;
    }

    private boolean hasNoCoin() {
        return coin == 0;
    }

    public State getState() {
        return state;
    }

    public int getCoin() {
        return coin;
    }
}

 

 

핵심은 이전 상태 객체 안에서 상태를 바꿔주기 위해 사용하였던 조건이나 메소드를 모두 Context 안으로 옮겨오는 것입니다. 혹은, 접근 제한자를 private으로 변경합니다.

 

다만, 상태가 "제품 구매 가능"이고 buyProduct() 메소드를 호출하였을 때는 상태를 NoCoinState로 바꿔야하는데, 여기서 State에 isSelectable()라는 메소드를 부득이하게 생성할 수 밖에 없습니다.

 

 

public class NoCoinState implements State {

    @Override
    public void increaseCoin(int coin, VendingMachine vendingMachine) {
        vendingMachine.increaseCoin(coin);
    }

    @Override
    public void buyProduct(VendingMachine vendingMachine) {
        System.out.println("동전을 투입한 후 제품을 구매해 주세요.");
    }

    @Override
    public boolean isSelectable() {
        return false;
    }
}

 

 

public class SelectableState implements State {

    @Override
    public void increaseCoin(final int coin, final VendingMachine vendingMachine) {
        vendingMachine.increaseCoin(coin);
    }

    @Override
    public void buyProduct(final VendingMachine vendingMachine) {
        vendingMachine.provideProduct();
        vendingMachine.decreaseCoin();
    }

    @Override
    public boolean isSelectable() {
        return true;
    }
}

 

 

public class SoldOutState implements State {

    @Override
    public void increaseCoin(final int coin, final VendingMachine vendingMachine) {
        vendingMachine.returnCoin(coin);
    }

    @Override
    public void buyProduct(final VendingMachine vendingMachine) {
        System.out.println("상품이 품절되었습니다.");
    }

    @Override
    public boolean isSelectable() {
        return false;
    }
}

 

 

Context가 상태 변경의 주체가 되었으므로 각 State 객체들은 상태를 변경할 책임이 없어져서 코드가 더 간결해지는 효과가 있습니다.

 

 

그렇다면, Context에서 변경할 때의 단점은 무엇일까요? isSelectable() 메소드가 State에 추가된 것을 보면 알 수 있듯이, 상태 개수가 많고 상태 변경 규칙이 자주 바뀐다면 Context의 상태 변경 코드가 복잡해지게 됩니다. 그리고 이에 따라 State도 원치 않은 메소드가 생겨날 수 있습니다. 따라서, Context가 상태 변경의 주체가 될 때는 상태 개수와 상태 변경 규칙을 유심히 살펴봐야 합니다.

 

반대로, 상태 객체에서 Context의 상태를 변경할 때의 단점은 무엇일까요? 상태 변경 규칙이 여러 클래스에 분산되어 있기 때문에, 상태 구현 클래스가 많아질수록 상태 변경 규칙을 파악하기 어려워지는 단점이 있습니다. 또한, 한 상태 클래스에서 다른 상태 클래스에 대한 의존도가 발생하기도 합니다.

 

두 가지 방식 모두 장단점이 명확하므로 잘 구분해서 사용해야겠습니다.

 

 

 

 

전략 패턴 vs 상태 패턴

전략 패턴과 상태 패턴 모두 무언가 반복된 if ~ else문일 경우 추상화하여 중복을 피하는 것처럼 보입니다. 그래서 처음 두 가지 패턴을 받아들일 때는 명확한 차이를 느끼지 못할 수도 있습니다. 그래서 공통점과 차이점을 간단하게 나눠보겠습니다.

 

 

(1) 공통점

- 인터페이스를 사용하여 구현 클래스를 캡슐화합니다. 전략 패턴에서는 할인 정책의 종류가 될 수 있고, 상태 패턴에서는 위의 예시인 자판기의 여러 상태가 될 수 있습니다.

 

- 위 원리 덕분에, Context 클래스에서는 어떠한 하위 클래스를 할당받는지 알지 못한 상태로 단순히 Strategy나 State 객체의 추상 메소드를 실행합니다. 즉, 두 패턴 모두 Context 클래스는 영향을 받지 않고 유연하게 변화에 대처할 수 있습니다.

 

 

(2) 차이점

아니, 둘다 인터페이스로 특정 객체들을 추상화하여 Context 클래스의 필드로 사용하는 것은 알겠습니다. 그러면 왜 둘을 분리를 해 두었을까요? 각 패턴의 작동 과정을 더 구체적으로 써 보겠습니다.

 

 

전략 패턴은 클라이언트 객체가 Context 객체에게 다른 특정 객체를 지정해주고 실행하게 합니다. 따라서, 프로그램 실행시 Context 객체가 실행할 객체를 외부(클라이언트 객체)에서 유연하게 지정할 수 있는 장점이 있습니다.



Context 객체 내에 참조 멤버 변수를 사용하여 하위 클래스에 객체의 참조를 전달하면, Context 객체의 코드 변경 없이도 Context의 행동을 변경할 수 있다. 즉, 다형성과 추상 메소드 호출을 이용하여 Context 클래스의 코드를 간략하게 할 수 있는 방법입니다.

 

 

상태 패턴도 추상 메소드 호출을 이용한다는 점에서 전략 패턴과 유사합니다. 하지만, 상태 패턴은 외부(클라이언트 객체)의 개입 없이 상태 객체 내부에서 현재 상태에 따라 Context 객체의 멤버인 상태 객체를 변경하여 사용한다는 점에서 차이가 있습니다.

 

위 자판기 예시는 처음에 생성자에서 초기 상태인 NoCoinState로 초기화한 후, 외부에서 단순히 자판기의 동전을 넣는 기능이나 제품을 구매하는 기능을 수행할 때마다 State 구현 클래스 안에서 자동으로 상태가 바뀌도록 설계되어있습니다. 만약, 자판기가 전략 패턴으로 구현되려면, 상태를 변경해 줄 때마다 외부에서 객체를 넣어주어야 합니다.


 

이것이 전략 패턴과 상태 패턴의 차이입니다. 핵심은 로직을 수행하면서 행동 또는 상태의 변화가 외부의 개입이 필요한지, 또는 특정 인터페이스의 구현 클래스에서 알아서 하는지입니다.

 

 

정리

오늘은 상태 패턴에 대해서 알아 보았습니다. 전략 패턴과 유사한 점은 있지만, 분명히 다르다고 언급을 하였습니다. 열심히 개발을 하면서 두 가지 차이를 느껴보시길 바랍니다.

 

 

출처

개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴 - 최범균

 

 

 

State Pattern과 Strategy Pattern의 공통점과 차이점

공통점 인터페이스를 사용함으로써 Concrete Class를 캡슐화 한다. 따라서 Context Class에서, 어떤 클래스가 할당 됐는지에 관계 없이 인터페이스 만을(Strategy, State) 인자로 받아서 그대로 가상 메서드

defacto-standard.tistory.com

 

 

 

Strategy, State pattern example

Strategy, State Pattern 의 예 Strategy Pattern 클라이언트 객체가 컨텍스트 객체에게 다른 특정 객체를 지정해주고 실행하게 한다. 프로그램 실행시 컨텍스트 객체가 실행할 객체를 외부(클라이언트 객

micropilot.tistory.com

 

 

 

'전략(Strategy) 패턴'과 '상태(State) 패턴'의 차이점

구분 기준

jaeseongdev.github.io

 

댓글

추천 글