[Java] 상태 패턴(State Pattern)

유콩·2022년 4월 3일
1
post-thumbnail

개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴의 내용을 정리하였으나, 실제로 프로그램을 작동시키기 위해 일부 코드를 수정하였습니다.

간단한 자판기 기능을 하는 프로그램을 구현한다고 가정해보자. 자판기 기능을 이용하려면, 우선 자판기에 동전을 넣어야 한다. 사고 싶은 음료 금액만큼 동전을 넣고 원하는 음료수를 선택한다. 음료수를 구매하면 사용한 금액만큼 차감된 금액만 남는다. 표로 정리하면 다음과 같다.

조건동작실행결과
동전없음동전넣음금액증가제품선택가능
동전없음제품선택X동전없음
제품선택가능동전넣음금액증가제품선택가능
제품선택가능제품선택잔액감소잔액있으면 제품선택가능, 없으면 동전없음

표의 내용을 코드로 작성해보았다. 상태 정보를 enum 으로 선언하고 상태에 맞는 로직을 수행한다.

public enum State {
    NO_COIN, SELECT_TABLE;
}
public class VendingMachine {

    private State state;
    private final Products products;
    private int coin;

    public VendingMachine(final Products products, final int coin) {
        this.products = products;
        this.coin = coin;
        this.state = getInitialState(coin);
    }

    private static State getInitialState(final int coin) {
        if (coin == 0) {
            return State.NO_COIN;
        }
        return State.SELECT_TABLE;
    }

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

    public void select(final Product product) {
        switch (state) {
            case NO_COIN:
                break;
            case SELECT_TABLE:
                provideProduct(product);
                decreaseCoin(product);
                if (hasNoCoin()) {
                    state = State.NO_COIN;
                }
        }
    }

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

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

    private void provideProduct(final Product product) {
        products.provide(product);
    }

    private void decreaseCoin(final Product product) {
        this.coin -= product.getCoin();
    }

    public State getState() {
        return state;
    }
}

자판기가 관리하는 값(상태, 음료들, 코인)이 많아 로직이 길어졌다. 또한 코인이 없는 상태, 코인이 있는 상태일 때 수행할 수 있는 기능이 동일하기 때문에 메서드 내부에서 중복적으로 현재 상태를 확인하는 과정이 필요하다. 여기서 자판기 점검인 상태와 같이 또 다른 상태를 추가한다면 스위치문의 조건이 하나 더 늘어나야 한다. 요구사항이 늘어남에 따라 조건을 추가하는 방식은 가독성도 좋지 않고 유지보수도 어렵다.

이처럼 각 상태에 따라 수행해야 하는 기능은 동일하지만 내부적으로 다르게 수행해야 할 때 상태 패턴을 사용한다.

상태 패턴이란?

위에서 이미 언급 되었지만, 상태 패턴이란 기능이 상태에 따라 다르게 동작해야 할 때 사용하는 디자인 패턴이다. 위의 예시를 상태 패턴을 사용한 구조로 클래스다이어그램을 그리면 다음과 같다.

상수로 가지고 있던 상태 정보를 객체로 분리하고 자판기 객체가 보유한다. 첫 상태를 지정하고 이후에 동작하는 기능에 따라 상태가 바뀌면 상태 정보를 변경한다. 자판기 내부적으로 상태 정보를 변경함으로써 각 상태에 맞는 로직을 수행할 수 있다.

public class VendingMachine {

    private State state;

    public VendingMachine(final Products products) {
        this.state = new NoCoinState(products);
    }

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

    public void select(final Product product) {
        state.select(product, this);
    }

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

    public boolean hasCoin() {
        return state.hasCoin();
    }

    public State getState() {
        return state;
    }
}
public class NoCoinState implements State {

    private final Coin coin = new Coin();
    private final Products products;

    public NoCoinState(final Products products) {
        this.products = products;
    }

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

    @Override
    public void select(final Product product, final VendingMachine vendingMachine) {
        System.out.println("코인이 부족합니다!");
    }

    @Override
    public boolean hasCoin() {
        return coin.hasCoin();
    }
}
public class SelectableState implements State {

    private final Coin coin;
    private final Products products;

    public SelectableState(final Coin coin, final Products products) {
        this.coin = coin;
        this.products = products;
    }

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

    @Override
    public void select(final Product product, final VendingMachine vendingMachine) {
        coin.decrease(product.getCoin());
        products.provide(product);

        if (!hasCoin()) {
            vendingMachine.changeState(new NoCoinState(products));
        }
    }

    @Override
    public boolean hasCoin() {
        return coin.hasCoin();
    }
}

상태 정보에 영향을 미치는 값들은 상태 객체에게 넘겨주고 책임을 위임하여 자판기 객체의 코드가 단순해졌다. 상태에 따른 로직을 각각의 객체가 가지고 있기 때문에 불필요한 조건문도 사라졌다. 또한 다른 상태가 추가되더라도 상태 객체를 추가하여 맞는 로직을 작성하면 되기 때문에 유지보수가 용이해진다.

참고한 서적에서 상태 정보를 변경하는 위치에 대해 또 다른 방식을 제공한다. 현재 코드는 상태 객체 메서드를 호출할 때 자신(자판기)의 객체를 넘겨주어 상태 객체가 스스로 메서드를 호출하여 변경한다. 다른 방식으로는 상태 객체는 로직 수행만 담당하고 자판기 객체에서 상태를 확인 후 변경하는 방법이 있다.

상태 정보를 바꾸는 객체(위치)에 관해 장단점을 이렇게 설명한다.

  1. 자판기 객체가 수정하는 경우
  • 현재 어떤 상태인지 확인하는 과정이 필요하기 때문에 자판기 객체의 코드가 지저분해 질 수 있다.
  • 어떤 상태가 있으며 각각 상태에 대한 기준을 자판기 객체에서 바로 확인할 수 있다. -> 상태의 수가 적은 경우에 사용하는 것이 좋다.
  1. 상태가 직접 수정하는 경우
  • 자판기 객체에 영향을 주지 않는다.
  • 상태 변경 규칙이 복잡한 경우 흐름을 파악하기 어렵다.

개인적으로 2번의 방식을 사용하되, 자판기 객체의 상태 정보를 변경하는 메서드를 호출하는 것이 아닌 상태 객체 자체가 변경해야 하는 상태 정보를 반환하는 방식을 사용한다.

public class VendingMachine {

    private State state;

    public VendingMachine(final Products products) {
        this.state = new NoCoinState(products);
    }

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

    public void select(final Product product) {
        state = state.select(product);
    }

    public boolean hasCoin() {
        return state.hasCoin();
    }

    public State getState() {
        return state;
    }
}
public class NoCoinState implements State {

    private final Coin coin = new Coin();
    private final Products products;

    public NoCoinState(final Products products) {
        this.products = products;
    }

    @Override
    public State increaseCoin(final int insertedCoin) {
        coin.increase(insertedCoin);
        return new SelectableState(coin, products);
    }

    @Override
    public State select(final Product product) {
        System.out.println("코인이 부족합니다!");
        return this;
    }

    @Override
    public boolean hasCoin() {
        return coin.hasCoin();
    }
}
public class SelectableState implements State {

    private final Coin coin;
    private final Products products;

    public SelectableState(final Coin existCoin, final Products products) {
        this.coin = existCoin;
        this.products = products;
    }

    @Override
    public State increaseCoin(final int insertedCoin) {
        coin.increase(insertedCoin);
        return this;
    }

    @Override
    public State select(final Product product) {
        coin.decrease(product.getCoin());
        products.provide(product);

        if (!hasCoin()) {
            return new NoCoinState(products);
        }
        return this;
    }

    @Override
    public boolean hasCoin() {
        return coin.hasCoin();
    }
}

기능을 수행할 때마다 파라미터로 자판기 객체를 넘기지 않아도 되어 코드가 깔끔해진다. 상태 정보가 많아질 수록 코드를 파악하기 어렵다는 단점은 여전히 존재하나, 상태 정보에 관한 기능은 상태가 맡는 것이 적절하다고 생각한다.

참고

0개의 댓글