상태 패턴 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, “헤드 퍼스트 디자인 패턴”