상태 패턴 State Pattern

상태패턴

  • 자신의 상태를 평가하고 행위를 정의하는 코드는 복잡하다. 왜냐하면 if절을 반복하여 사용하기 때문이다.
  • 상태가 확장되면 이를 반영하기 위한 if절이 추가된다. 변경에 열려 있다.
  • 상태패턴은 자신의 상태를 평가하고 수행하는 로직을 별도의 클래스에 위임한다.
  • 구현은 전략 패턴과 유사하다.
    • 전략 패턴이 자신의 전략을 interface 필드로 두는 것처럼 상태 패턴 역시 그러하다.
    • 다만, 전략 패턴의 전략은 외부에서 주입된다. 상태 패턴의 상태는 객체 내부 혹은 상태 내부 로직에 의하여 결정된다.

쿠폰과 상태 변환

  • 상태 패턴에 대한 예제를 쿠폰 시스템으로 구현해봤다.
  • 상태 패턴으로 구현하기에 앞서 if절과 enum으로 구현하였다.
  • 아래 코드는 쿠폰이 새로 발행된 NEW 상태와 사용 중인 USING 상태로 구분된다.
Coupon coupon = repository.findCoupon(any());
if(coupon.State==NEW){
    // ...
}else if(coupon.State=USING){
    // ...
}
  • 만약 사용 완료 상태USED가 새로 생긴다면 아래와 같이 코드가 작성될 것이다.
Coupon coupon = repository.findCoupon(any());
if(coupon.status()==NEW){
    // ...
}else if(coupon.status()==USING){
    // ...
}else if(coupon.status()==USED){
    // ...
}
  • 내부 코드의 변경에 닫혀있고 확장으로부터 열려 있도록 만들기 위하여 생태 패턴을 적용한다. NEW, USING 등 enum으로 단순한 상태를 표현하는 것에서 만족하지 않고, 각 쿠폰의 상태에 따른 동작까지 정의하는 State 인터페이스를 구현한다.
public interface State {
    void use(int use);
}

public class New implements State {
    private final Coupon coupon;

    public New(Coupon coupon) {
        this.coupon = coupon;
    }

    @Override
    public void use(int use) {
        coupon.changeState(new Using(coupon));
        coupon.use(use); // Using에 대리한다.
    }
}

public class Using implements State {
    private final Coupon coupon;

    public Using(Coupon coupon) {
        this.coupon = coupon;
    }

    @Override
    public void use(int use) {
        coupon.minus(use);
        if(coupon.balance() == 0) coupon.changeState(new Used(coupon)); // 잔액이 없으면 Used로 변경한다.
    }
}
  • State는 use메서드만 존재한다. use 메서드 내부에서 각 상태는 다른 상태로의 전이를 위한 로직을 가진다. Coupon은 State의 use 메서드에 의해서만 상태가 판정된다. Coupon은 더 이상 자신의 상태를 결정하는 코드를 가지지 않는다.
public class Coupon {
    private final int money;
    private int balance;
    private State state;

    public Coupon(int money) {
        this.money = money;
        this.balance = money;
        this.changeState(new New(this));
    }

    public void use(int use) {
        if(use<=0) throw new IllegalArgumentException("0원 이하로 사용할 수 없습니다.");
        if(use>money) throw new IllegalArgumentException("쿠폰의 총액을 초과하여 사용할 수 없습니다.");
        state.use(use); // 현재의 상태(state)가 use의 실질적인 동작과 상태의 판정까지 수행한다.
    }

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

    public void minus(int use) {
        this.balance -= use;
    }

    public int balance() {
        return balance;
    }
}
  • Coupon은 자신의 상태와 상태에 따른 행위에 대한 코드를 가지지 않는다. 그러므로 상태가 추가되면 State 구현체의 변경만으로 코드를 확장할 수 있다.
  • enum 등 단순한 상수가 일종의 행위자로서 전환된다.

상태패턴 간 의존성

  • 객체는 상태를 알아야 하는가?
  • 백기선 개발자님의 경우 앞서 작성한 것과 유사한 방식으로 코드를 작성하였다. 그러니까 Coupon은 State의 구현체를 전혀 모른다.
  • 하지만 헤드 퍼스트 디자인 패턴은 다음과 같은 형태로 코드가 작성되었다.
public class Coupon{
    State new_;
    State used;
    State using;
    State state;

    State getNewState(){}
    State getUsedState(){}
    State getUsingState(){}
}
  • 개인적으로 백기선 개발자님의 작성한 형태가 옳다고 판단한다.
  • 상태에 대한 구현체가 필드에 선언된다. 이 말은 상태가 확장될 경우 코드가 변경되어야 함을 암시한다. OCP를 위반한다.
  • 상태의 생성 비용이 클 경우 헤드 퍼스트의 예제처럼 상태를 공유하여 사용할 수 있다. coupon.getUsingState() 의 형태로 재활용한다.
  • 생성 비용이 크지 않을 경우 new Using(this); 로 정의하는 것이 낫다고 판단한다. Coupon과 State간 의존성을 최소화 한다.
  • 이번 예제는 백기선 개발자님의 예제에 준하여 Coupon 로직을 작성하였다.

참조

  • 백기선, 디자인 패턴 강의(https://www.inflearn.com/course/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4)
  • O’Reilly, “헤드 퍼스트 디자인 패턴”