상태(State) 패턴

오직 하나의 제품만 판매하는 자판기를 위한 소프트웨어를 제작한다고 생각해보자. 동작방식은 아래와 같을 것이다.

동작

조건

실행

결과

Coin In

If Haven't Coin

Increase Cost

Selectable Item

Coin In

If Selectable Item

Increase Cost

Selectable Item

Select Item

If Haven't Coin

Nothing

No Coin

Select Item

If Selectable Item

Decrease Balance, After Give Product

If Have Balance, Selectable Item. If Haven't Balance, No Coin

자판기 소프트웨어 개발자는 위와 같은 조건을 분기 처리해야 한다는 판단으로 아래와 같은 코드를 작성할 수 있다.

public class VendingMachine {
    private Integer balance;
    private Map<Integer, Product> products;
    private State state;

    public VendingMachine() {
        balance = 0;
        state = State.NOCOIN;
        initProducts();

    }

    private void initProducts() {
        products = new HashMap<>();
        products.put(1, new Product("오이", 500));
        products.put(2, new Product("참외", 1000));

    }

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

            case SELECTABLE:
                increaseCoin(coin);

        }

    }

    public void select(int id) {
        switch (state) {
            case NOCOIN:
                // Nothing
                break;

            case SELECTABLE:
                provideProduct(id);
                decreaseCoin(id);
                if(hasNoCoin()) {
                    state = State.NOCOIN;

                }

        }

    }

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

    }

    private void decreaseCoin(int id) {
        this.balance -= products.get(id).price;

    }

    private boolean hasNoCoin() {
        return balance == 0;

    }

    private String provideProduct(int id) {
        return products.get(id).dispense();

    }

    private class Product {
        private String name;
        private int price;
        private Product(String name, int price) {
            this.name = name;
            this.price = price;

        }

        String dispense() {
            return name;

        }

    }

    private enum State {
        NOCOIN, SELECTABLE;

    }

}

만약 프로그램 구현 도중에 아래와 같은 새로운 요구 사항이 들어왔다고 하자.

자판기에 제품이 없는 경우에는 동전을 넣으면 바로 동전을 되돌려 준다.

이러한 기능을 추가하기 위하여 아래와 같이 프로그램은 수정된다.

public class VendingMachine {

    // ...

    public void insertCoin(int coin) {
        switch (state) {
            case NOCOIN:
                // ...

            case SELECTABLE:
                // ...

            case SOLDOUT:
                returnCoin();

        }

    }

    public void select(int id) {
        switch (state) {
            case NOCOIN:
                // ...

            case SELECTABLE:
                // ...

            case SOLDOUT:
                // Nothing

        }

    }

    // ....

    private void returnCoin() { }

    private enum State {
        NOCOIN, SELECTABLE, SOLDOUT;

    }

}

만약 새로운 요구 사항이 들어온다면 insertCoin() 메서드와 select() 메서드에 또 다른 조건문이 추가될 것이다.

insertCoin() 메서드와 select() 메서드는 동일한 구조의 조건문을 갖고 있다. 이는 상태가 많아질수록 복잡해지는 조건문이 여러 코드에서 중복해서 출현하게 되고, 결과적으로 코드 변경을 어렵게 만든다. 만약 새로운 상태가 추가되거나 기존 상태를 변경해야 한다면, 모든 조건문을 찾아서 수정해야 한다.

상태 패턴 적용

VendingMachine 객체를 다시 살펴보면, 조건문은 다음과 같은 의미를 내포한다.

상태에 따라 동일한 기능 요청의 처리를 다르게 함

위 예로 보면, insertCoin() 메서드는 상태가 NOCOIN, SELECTABLE, SOLDOUT 인지에 따라 요청 처리를 다르게 하고 있다. 이렇게 상태에 따라 다르게 동작해야 할 때, 사용할 수 있는 패턴이 상태(State) 패턴이다. 상태 패턴은 상태를 별도의 타입으로 분리하고, 각 상태 별로 알맞은 하위 타입으로 구현한다.

상태 패턴에서 중요한 점은 상태 객체가 기능을 제공한다는 점이다. State 인터페이스는 increaseCoin()과 select() 두 개의 오퍼레이션을 정의하며 모든 하위 상태 타입에서 동일하게 구현되어야 한다.

Context는 필드로 상태(State) 객체를 가지고 있다. Context는 Client로 부터 기능 요청을 받으면, 상태 객체에 처리를 위임하는 방식으로 구현한다.

public class VendingMachine {
    private State state;

    // ...

    public VendingMachine() {
        state = new NoCoinState();

        // ...

    }

    public void insertCoin(int coin) {
        state.increaseCoin(coin, this);     // 상태 객체에 위임

    }

    public String select(int id) {
        return state.select(id, this);     // 상태 객체에 위임

    }

    void changeState(State newState) {
        this.state = newState;

    }

    // ...

}

state 필드는 NoCoinState 객체로 초기화하였다.

public class NoCoinState implements State {
    @Override
    public void increaseCoin(int coin, VendingMachine vendingMachine) {
        vendingMachine.increaseCoin(coin);
        vendingMachine.changeState(new SelectableState());

    }

    @Override
    public String select(int productId, VendingMachine vendingMachine) {
        return "코인이 존재하지 않습니다.";

    }

}

NoCoinState 객체의 increaseCoin() 메서드는 VendingMachine 객체의 동전 수를 증가시키고, 상태를 SelectableState로 변경한다. select() 메서드는 동전이 없는 상태이므로 예외를 발생시키거나 check() 메서드를 제공하는 것이 정상적이지만, 우선 "코인이 존재하지 않습니다." 반환하도록 한다.

마찬가지로 SelectableState 객체도 increaseCoin(), select() 메서드를 아래와 같이 구현한다.

public class SelectableState implements State {
    @Override
    public void increaseCoin(int coin, VendingMachine vendingMachine) {
        vendingMachine.increaseCoin(coin);

    }

    @Override
    public String select(int productId, VendingMachine vendingMachine) {
        String product = vendingMachine.provideProduct(productId);
        vendingMachine.decreaseCoin(productId);
        if(vendingMachine.hasNoCoin()) {
            vendingMachine.changeState(new NoCoinState());

        }

        return product;

    }

}

상태 패턴을 적용하며 VendingMachine 객체에 구현되어 있던 상태 별 동작 구현 코드가 NoCoinState, SelectableState 객체로 이동하는 것을 알 수 있다. 결과적으로 VendingMachine 객체는 위임 방식으로 구현되며 코드가 단순해지는 것을 알 수 있다.

상태 패턴의 장점

상태 패턴의 장점의 크게 두 가지로 볼 수 있다.​

상태 패턴의 장점은 새로운 상태가 추가되더라도 Context 객체가 받는 영향은 최소화된다는 점이다.

조건문을 이용한 방식은 상태가 많아질수록 유지 보수를 어렵게 만들지만, 상태 패턴은 상태가 많아지더라도 코드의 복잡도는 증가하지 않기 때문에 유지 보수에 유리하다.

상태에 따른 동작을 구현한 코드가 각 Concrete State 객체 별로 구분되기 때문에 상태 별 동작을 수정하기가 쉽다는 점이다.

조건문을 이용한 방식은 동전 없음 상태의 동작을 수정하려면 각 메서드를 찾아다니면서 수정해 주어야 하는 반면에, 상태 패턴은 동전 없음 상태를 표현하는 NoCoinState 객체만 수정해 주면 된다. 상태 패턴을 적용하면 관련된 코드를 한 곳으로 모을 수 있기 때문에, 응집도가 높아지고 안전하게 기능 변경이 가능하다.

상태 변경은 누가 하는가?

상태 패턴을 적용할 때 고려해야 할 문제는 Context의 State(상태)를 누가 변경하는냐에 대한 것이다. 상태 변경을 하는 주체는 Context or Concrete State 객체 둘 중 하나가 된다.

Concrete State 객체에서 변경

앞에서 보인 예제에서는 Concrete State 객체에서 상태 변경을 하였다.

public class NoCoinState implements State {
    @Override
    public void increaseCoin(int coin, VendingMachine vendingMachine) {
        vendingMachine.increaseCoin(coin);

        // VendingMachine Context 상태를 SelectableState로 변경
        vendingMachine.changeState(new SelectableState());

    }

    // ...

}

또한, 상태 객체에서 Context 객체의 상태를 변경하기 위해서 Context의 다른 값에 접근해야 할 경우도 있다. 아래와 같이, SelectableState 객체의 select() 메서드는 VendingMachine 객체의 상태를 NoCoinState로 변경해야 하는지 확인하기 위해 hasNoCoin() 메서드를 이용하고 있다.

public class SelectableState implements State {

    // ...

    @Override
    public String select(int productId, VendingMachine vendingMachine) {
        // ...

        // VendingMachine 객체에 나머지 Coin이 있는지 확인
        if(vendingMachine.hasNoCoin()) {
            vendingMachine.changeState(new NoCoinState());

        }

       // ...

    }

}

Context 객체에서 변경

Context에서 상태를 변경할 경우 Context 코드가 다소 복잡해질 수 있다.

public class VendingMachine {
    private State state;

    public VendingMachine() {
        state = new NoCoinState();

    }


    public void insertCoin(int coin) {
        state.increaseCoin(coin, this);     
        if(hasCoin()) {
            changeState(new SelectableState());     // Context 객체에서 상태 변경

        }

    }

    public String select(int id) {
        String product = state.select(id, this);    
        if(hasNoCoin()) {
            changeState(new NoCoinState());     // Context 객체에서 상태 변경

        }

        return product;

    }

    private boolean hasCoin() {
        return !hasNoCoin();

    }

    private boolean hasNoCoin() {
        return balance <= 0;

    }


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

    }

    // ...

}

Context에서 상태 변경을 한다면, 위의 메서드는 상태 객체에서 호출되지 않으므로 private 접근 범위로 변경한다. 이제 상태 객체는 자신이 수행해야 하는 작업만 처리하면 된다.

public class SelectableState implements State {

    @Override
    public String select(int productId, VendingMachine vendingMachine) {
        String product = vendingMachine.provideProduct(productId);
        vendingMachine.decreaseCoin(productId);
        return product;

    }

}

상태 객체에서 변경 VS Context 객체에서 변경

Context의 상태 변경을 누가 할지는 주어진 상황에 따라 달라진다.

Context 객체에서 변경하는 경우

비교적으로 상태 개수가 적고 변경 규칙이 거의 바뀌지 않는 경우에 유리하다. 상태 종류가 지속적으로 변경되거나 상태 변경 규칙이 자주 바뀐다면, Context의 상태 변경 처리 코드가 복잡해질 가능성이 높아지고 결국 상태 변경의 유연함까지 잃을 수 있다.

Concrete State 객체에서 Context 상태를 변경할 경우

Context에 영향을 주지 않으면서 상태를 추가하거나 상태 변경 규칙을 바꿀 수 있게 된다. 하지만, 상태 변경 규칙이 여러 상태 객체에 분산되기 때문에, 상태 구현 객체가 많아질수록 상태 변경 규칙을 파악하기 어려워진다는 단점이 있다. 또한, Concrete State 객체간에 의존도가 발생하는 단점도 있다.

두 방식은 서로 상반되는 장단점을 가지고 있으며, 개발자 자신의 상황에 맞게 알맞는 방식을 선택해야 한다.

Enum을 이용한 상태(State) 패턴 및 Context 객체에서 상태 변경

위 예제에서와 같이, 상태 변경마다 Concrete State 객체를 생성한다면 메모리 측면에서 비효율적이다. 만약 모든 상태 객체가 POJO 라면, 각 상태 객체를 Enum 으로 만들수 있다. Enum 객체는 Singleton 특징을 가지고 있으므로, 매번 상태 객체를 생성하지 않고 재사용이 가능하다.

단점으로는 상태에 따라 다르게 동작하기 위해, 각 Enum 상태 객체의 생성자에서 로직을 작성한다. 로직이 간단한 경우는 유용하지만, 복잡한 경우라면 가독성이 매우 떨어진다.

State
public enum State {
    NOCOIN((coin, vm) -> vm.increaseCoin(coin), (id, vm) -> "코인이 존재하지 않습니다."),
    SELECTABLE((coin, vm) -> vm.increaseCoin(coin), (id, vm) -> {
        String product = vm.provideProduct(id);
        vm.decreaseCoin(id);
        return product;

    });

    private BiConsumer<Integer, VendingMachine> increaseFunc;
    private BiFunction<Integer, VendingMachine, String> selectFunc;

    State(BiConsumer<Integer, VendingMachine> increaseFunc, BiFunction<Integer, VendingMachine, String> selectFunc) {
        this.increaseFunc = increaseFunc;
        this.selectFunc = selectFunc;

    }

    public void increaseCoin(int coin, VendingMachine vm) {
        increaseFunc.accept(coin, vm);

    }

    public String select(int id, VendingMachine vm) {
        return selectFunc.apply(id, vm);

    }

}
VendingMachine
public class VendingMachine {
    private int balance;
    private State state;

    public VendingMachine() {
        balance = 0;
        state = State.NOCOIN;

    }

    public void insertCoin(int coin) {
        state.increaseCoin(coin, this);     // 상태 Enum 객체에 위임
        if (hasCoin()) {
            changeState(State.SELECTABLE);     // Context에서 상태 변경

        }

    }

    public String select(int id) {
        String product = state.select(id, this);     // 상태 Enum 객체에 위임
        if(hasNoCoin()) {
            changeState(State.NOCOIN);     // Context에서 상태 변경

        }

        return product;

    }

    void increaseCoin(int coin) {
        this.balance += coin;

    }

    void decreaseCoin(int id) {
        this.balance -= products.get(id).price;

    }

    boolean hasCoin() {
        return !hasNoCoin();

    }

    boolean hasNoCoin() {
        return balance <= 0;

    }

    String provideProduct(int id) {
        return products.get(id).dispense();

    }

    void changeState(State newState) {
        this.state = newState;

    }
    
    // ...

}

Last updated