관리 메뉴

SW

[개발서적] 헤드퍼스트 디자인 패턴 Ch10. 상태 패턴 본문

개발서적/헤드퍼스트 디자인패턴

[개발서적] 헤드퍼스트 디자인 패턴 Ch10. 상태 패턴

SWKo 2024. 9. 22. 23:47

1. 뽑기 기계 예시

뽑기 기계 회사는 최근 기술 발달에 맞춰 뽑기 기계에 CPU를 탑재하여 매출을 늘리고, 네트워크 연결을 통해 재고 관리 및 고객 만족도 집계를 하려는 목표를 가지고 있습니다.

아래 그림처럼 뽑기 기계를 제어할 수 있는 코드를 요청받았습니다.

 

1. 상태 정의 및 상태 변수 설정

// 상태 정의
final static int SOLD_OUT = 0;   // 매진 상태
final static int NO_QUARTER = 1; // 동전 없음 상태
final static int HAS_QUARTER = 2; // 동전 있음 상태
final static int SOLD = 3;       // 알맹이 판매 상태

// 현재 상태를 저장하는 변수
int state = SOLD_OUT;  // 처음에는 매진 상태로 시작

 

2. 행동 정의

시스템에서 일어날 수 있는 행동들을 모읍니다.

동전 투입, 동전 반환, 손잡이 돌림, 알맹이 내보냄

 

3. 행동을 상태에 따라 구현

public void insertQuarter() {
    if (state == HAS_QUARTER) {
        System.out.println("동전은 한 개만 넣어주세요.");
    } else if (state == NO_QUARTER) {
        state = HAS_QUARTER;  // 상태 변경: 동전 있음
        System.out.println("동전이 투입되었습니다.");
    } else if (state == SOLD_OUT) {
        System.out.println("매진되었습니다. 다음 기회에 이용해주세요.");
    } else if (state == SOLD) {
        System.out.println("알맹이를 내보내고 있습니다. 잠시만 기다려주세요.");
    }
}

 

뽑기 기계 코드

public class GumballMachine {
    // 상태 정의
    final static int SOLD_OUT = 0;
    final static int NO_QUARTER = 1;
    final static int HAS_QUARTER = 2;
    final static int SOLD = 3;
    
    // 현재 상태를 저장하는 변수
    int state = SOLD_OUT;
    // 알맹이 개수를 저장하는 변수
    int count = 0;
    
    // 생성자: 알맹이 개수를 초기화
    public GumballMachine(int count) {
        this.count = count;
        if (count > 0) {
            state = NO_QUARTER;  // 알맹이가 있으면 동전 대기 상태로
        }
    }

    // 동전 투입
    public void insertQuarter() {
        if (state == HAS_QUARTER) {
            System.out.println("동전은 한 개만 넣어 주세요.");
        } else if (state == NO_QUARTER) {
            state = HAS_QUARTER;
            System.out.println("동전을 넣으셨습니다.");
        } else if (state == SOLD_OUT) {
            System.out.println("매진되었습니다. 다음 기회에 이용해 주세요.");
        } else if (state == SOLD) {
            System.out.println("알맹이를 내보내고 있습니다.");
        }
    }

    // 동전 반환
    public void ejectQuarter() {
        if (state == HAS_QUARTER) {
            System.out.println("동전이 반환됩니다.");
            state = NO_QUARTER;
        } else if (state == NO_QUARTER) {
            System.out.println("동전을 넣어 주세요.");
        } else if (state == SOLD) {
            System.out.println("이미 알맹이를 뽑으셨습니다.");
        } else if (state == SOLD_OUT) {
            System.out.println("동전을 넣지 않으셨습니다. 동전이 반환되지 않습니다.");
        }
    }

    // 손잡이 돌림
    public void turnCrank() {
        if (state == SOLD) {
            System.out.println("손잡이는 한 번만 돌려 주세요.");
        } else if (state == NO_QUARTER) {
            System.out.println("동전을 넣어 주세요.");
        } else if (state == SOLD_OUT) {
            System.out.println("매진되었습니다.");
        } else if (state == HAS_QUARTER) {
            System.out.println("손잡이를 돌리셨습니다.");
            state = SOLD;
            dispense();
        }
    }

    // 알맹이 내보내기
    public void dispense() {
        if (state == SOLD) {
            System.out.println("알맹이를 내보내고 있습니다.");
            count = count - 1;
            if (count == 0) {
                System.out.println("더 이상 알맹이가 없습니다.");
                state = SOLD_OUT;
            } else {
                state = NO_QUARTER;
            }
        } else if (state == NO_QUARTER) {
            System.out.println("동전을 넣어 주세요.");
        } else if (state == SOLD_OUT) {
            System.out.println("알맹이가 없습니다.");
        } else if (state == HAS_QUARTER) {
            System.out.println("알맹이를 내보낼 수 없습니다.");
        }
    }

    // 기타 메소드: 기계 상태 출력
    @Override
    public String toString() {
        return "남은 알맹이 개수: " + count + ", 현재 상태: " + getStateString();
    }

    // 상태 문자열 반환
    private String getStateString() {
        switch (state) {
            case SOLD_OUT: return "매진";
            case NO_QUARTER: return "동전 대기";
            case HAS_QUARTER: return "동전 있음";
            case SOLD: return "알맹이 판매 중";
            default: return "알 수 없음";
        }
    }
    
    // 재고를 채우는 메소드
    public void refill(int numGumballs) {
        this.count = numGumballs;
        state = NO_QUARTER;
        System.out.println("재고가 보충되었습니다. 현재 알맹이 개수: " + count);
    }
}

 

사용 예시

public class GumballMachineTestDrive {
    public static void main(String[] args) {
        GumballMachine gumballMachine = new GumballMachine(5);

        System.out.println(gumballMachine);

        gumballMachine.insertQuarter();
        gumballMachine.turnCrank();

        System.out.println(gumballMachine);

        gumballMachine.insertQuarter();
        gumballMachine.ejectQuarter();
        gumballMachine.turnCrank();

        System.out.println(gumballMachine);
    }
}

 

출력 예시

남은 알맹이 개수: 5, 현재 상태: 동전 대기
동전을 넣으셨습니다.
손잡이를 돌리셨습니다.
알맹이를 내보내고 있습니다.
남은 알맹이 개수: 4, 현재 상태: 동전 대기
동전을 넣으셨습니다.
동전이 반환됩니다.
동전을 넣어 주세요.
남은 알맹이 개수: 4, 현재 상태: 동전 대기

 

여기서, 뽑기에 게임 기능을 추가하면 좋겠다는 요청이 들어왔습니다.

뽑기에 게임 기능을 추가하려면 WINNER 상태를 추가해야 합니다.

그런데, 새로 추가된 WINNER 상태를 확인하는 조건문을 모든 메서드에 추가해줘야 하는 문제점이 생겼습니다.

2. 새로운 디자인 구상하기

기존 코드를 활용하는 대신 상태 객체를 별도 코드에 넣고, 어떤 행동이 일어나면 현재 상태 객체에서 필요한 작업을 처리하게 합니다.

  1. 뽑기 기계 행동에 관한 메서드가 들어있는 State 인터페이스를 정의해야 합니다. 
  2. 기계의 모든 상태를 대상으로 상태 클래스를 구현해야 합니다.
  3. 조건문 코드를 모두 없애고, 상태 클래스에 모든 작업을 위임합니다.

모든 상태 클래스에서 구현할 State Interface를 정의해봅니다.

상태 클래스를 구현합니다.

NoQuarterState

public class NoQuarterState implements State {
    GumballMachine gumballMachine;

    // GumballMachine 인스턴스를 전달받는 생성자
    public NoQuarterState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }

    // 동전이 투입되었을 때 처리
    public void insertQuarter() {
        System.out.println("동전을 넣으셨습니다.");
        // 상태를 HasQuarterState로 전환
        gumballMachine.setState(gumballMachine.getHasQuarterState());
    }

    // 동전을 반환할 때 처리
    public void ejectQuarter() {
        System.out.println("동전을 넣지 않았습니다.");
    }

    // 손잡이를 돌렸을 때 처리
    public void turnCrank() {
        System.out.println("동전을 넣어 주세요.");
    }

    // 알맹이를 내보낼 때 처리
    public void dispense() {
        System.out.println("동전을 넣어 주세요.");
    }
}

 

HasQuarterState

public class HasQuarterState implements State {
    GumballMachine gumballMachine;

    public HasQuarterState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }

    // 동전이 이미 들어 있는 상태에서 동전을 다시 넣으려 할 때
    public void insertQuarter() {
        System.out.println("동전은 한 개만 넣어 주세요.");
    }

    // 동전 반환 요청 시, 동전을 반환하고 상태를 동전 없음 상태로 전환
    public void ejectQuarter() {
        System.out.println("동전이 반환됩니다.");
        gumballMachine.setState(gumballMachine.getNoQuarterState());
    }

    // 손잡이를 돌리면 알맹이를 내보낼 준비가 되고 상태를 판매 상태로 전환
    public void turnCrank() {
        System.out.println("손잡이를 돌리셨습니다.");
        gumballMachine.setState(gumballMachine.getSoldState());
    }

    // 아직 손잡이를 돌리지 않았으므로 알맹이를 내보낼 수 없음
    public void dispense() {
        System.out.println("알맹이를 내보낼 수 없습니다.");
    }
}

 

SoldState

public class SoldState implements State {
    GumballMachine gumballMachine;

    // 생성자: GumballMachine 인스턴스를 받음
    public SoldState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }

    // 동전 투입: 이미 알맹이를 내보내는 중이므로 부적절한 행동
    public void insertQuarter() {
        System.out.println("알맹이를 내보내고 있습니다.");
    }

    // 동전 반환: 이미 알맹이가 나갔기 때문에 동전을 반환할 수 없음
    public void ejectQuarter() {
        System.out.println("이미 알맹이를 뽑으셨습니다.");
    }

    // 손잡이 돌림: 이미 돌렸으므로 다시 돌리는 것은 부적절함
    public void turnCrank() {
        System.out.println("손잡이는 한 번만 돌려 주세요.");
    }

    // 알맹이 내보내기
    public void dispense() {
        // 알맹이를 내보내는 작업
        gumballMachine.releaseBall();

        // 남은 알맹이가 있는지 확인
        if (gumballMachine.getCount() > 0) {
            // 알맹이가 남아 있다면 상태를 NoQuarterState로 전환
            gumballMachine.setState(gumballMachine.getNoQuarterState());
        } else {
            // 알맹이가 없다면 SoldOutState로 전환
            System.out.println("Oops, out of gumballs!");
            gumballMachine.setState(gumballMachine.getSoldOutState());
        }
    }
}

 

SoldoutState

public class SoldOutState implements State {
    GumballMachine gumballMachine;

    // 생성자: GumballMachine 인스턴스를 받음
    public SoldOutState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }

    // 동전 투입: 매진 상태이므로 동전을 받을 수 없음
    public void insertQuarter() {
        System.out.println("매진되었습니다. 동전을 넣을 수 없습니다.");
    }

    // 동전 반환: 동전을 넣을 수 없으므로 반환할 동전도 없음
    public void ejectQuarter() {
        System.out.println("동전을 넣지 않으셨습니다. 반환할 동전이 없습니다.");
    }

    // 손잡이 돌림: 알맹이가 없으므로 손잡이를 돌려도 소용없음
    public void turnCrank() {
        System.out.println("매진되었습니다.");
    }

    // 알맹이 내보내기: 알맹이가 없으므로 내보낼 수 없음
    public void dispense() {
        System.out.println("알맹이가 없습니다.");
    }
}

 

뽑기 기계 코드를 수정합니다.

public class GumballMachine {
    // 상태 객체 선언
    State soldOutState;
    State noQuarterState;
    State hasQuarterState;
    State soldState;

    // 현재 상태를 저장하는 변수
    State state;
    // 알맹이 개수를 저장하는 변수
    int count = 0;

    // 생성자: 알맹이 개수를 설정하고 상태 객체들을 초기화
    public GumballMachine(int numberGumballs) {
        // 상태 객체 생성
        soldOutState = new SoldOutState(this);
        noQuarterState = new NoQuarterState(this);
        hasQuarterState = new HasQuarterState(this);
        soldState = new SoldState(this);
        
        // 알맹이 개수 설정
        this.count = numberGumballs;

        // 알맹이가 있으면 noQuarterState로, 없으면 soldOutState로 초기화
        if (numberGumballs > 0) {
            state = noQuarterState;
        } else {
            state = soldOutState;
        }
    }

    // 동전 투입
    public void insertQuarter() {
        state.insertQuarter();
    }

    // 동전 반환
    public void ejectQuarter() {
        state.ejectQuarter();
    }

    // 손잡이 돌리기
    public void turnCrank() {
        state.turnCrank();
        state.dispense();  // 알맹이 내보내기
    }

    // 상태 전환
    void setState(State state) {
        this.state = state;
    }

    // 알맹이 내보내기 (보조 메소드)
    void releaseBall() {
        System.out.println("알맹이를 내보내고 있습니다.");
        if (count > 0) {
            count = count - 1;
        }
    }

    // 현재 남은 알맹이 개수를 반환
    public int getCount() {
        return count;
    }

    // 상태 객체 반환 메소드들
    public State getSoldOutState() {
        return soldOutState;
    }

    public State getNoQuarterState() {
        return noQuarterState;
    }

    public State getHasQuarterState() {
        return hasQuarterState;
    }

    public State getSoldState() {
        return soldState;
    }

    // 재고를 보충하는 메소드
    public void refill(int numGumballs) {
        this.count += numGumballs;
        System.out.println("재고가 보충되었습니다. 현재 알맹이 개수: " + this.count);
        if (count > 0) {
            state = noQuarterState;
        }
    }

    @Override
    public String toString() {
        return "GumballMachine{알맹이 개수: " + count + ", 현재 상태: " + state.getClass().getSimpleName() + "}";
    }
}

 

3. 뽑기 기계 구조 다시 살펴보기

지금까지 구현한 GumballMachine 클래스 구조는 상태 패턴을 사용하여 개선되었으며, 초기의 조건문 기반 구조와는 큰 차이가 있습니다. 하지만, 기능적으로는 완전히 동일한 동작을 수행합니다.

  1. 상태의 행동을 별도의 클래스로 분리
    상태 국지화, 모듈화
  2. 복잡한 조건문 제거
  3. OCP(Open-Closed Principle) 적용
    GumballMachine은 새로운 상태를 추가할 때 기존 코드를 수정할 필요 없이(Closed) 새로운 상태 클래스를 추가하면 됩니다.(Open)
  4. 더 직관적이고 이해하기 쉬운 구조
    상태마다 독립적인 클래스가 있으므로, 모드를 읽는 사람이 상태별로 어떤 일이 벌어지는지 쉽게 알 수 있습니다.

4. 상태 패턴의 정의

상태 패턴(State Pattern)을 사용하면 객체의 내부 상태가 바뀜에 따라 객체의 행동을 바꿀 수 있습니다.

마치 객체의 클래스가 바뀌는 것과 같은 결과를 얻을 수 있습니다.

 

1. "객체의 내부 상태가 바뀜에 따라 객체의 행동을 바꿀 수 있습니다."

상태 패턴에서는 상태를 별도의 클래스로 캡슐화하고, 객체는 현재 상태를 나타내는 객체에게 행동을 위임합니다. 이로 인해 내부 상태가 바뀔 때 행동이 달라지게 됩니다.

  • NoQuarterState일 때: 동전을 넣으면 기계가 동전을 받아들임.
  • HasQuarterState일 때: 이미 동전이 들어 있으므로, 다시 동전을 넣으면 기계가 동전을 받아들이지 않음.

따라서 상태가 달라짐에 따라 동일한 메소드 호출이 다른 결과를 만들어냅니다.

 

2. "클래스가 바뀌는 것 같은 결과"

객체의 행동이 상태에 따라 완전히 달라진다면, 이는 객체가 다른 클래스로부터 만들어진 것처럼 느껴집니다. 실제로는 객체가 상태에 맞는 다른 상태 객체에게 행동을 위임하면서 이러한 결과가 나타나는 것입니다.

  • 예를 들어, 사용자는 뽑기 기계가 NoQuarterStateHasQuarterState에서 완전히 다른 방식으로 동작하는 것을 보고, 마치 기계가 다른 클래스로 변신한 것처럼 느낄 수 있습니다. 이는 상태 객체를 바꿔서 처리하는 것이므로, 객체가 실제로 다른 클래스로 변신하지는 않지만 다른 클래스로 바뀐 것 같은 효과를 줍니다.

 

  • Context(문맥, 예: GumballMachine): 상태 객체를 관리하고, 상태에 따른 행동을 위임합니다.
  • State(상태 인터페이스): 상태가 수행해야 할 행동을 정의합니다.
  • ConcreteState(구체적인 상태, 예: NoQuarterState, HasQuarterState): 상태 인터페이스를 구현하며, 상태별로 구체적인 동작을 정의합니다.

5. 전략 패턴 vs 상태 패턴

유사점

  • 두 패턴 모두 행동(알고리즘)을 별도의 클래스로 캡슐화하고, Context 객체에서 그 행동을 위임받아 수행합니다.
  • 다이어그램 상으로는 ContextState/Strategy 객체들이 존재하며, Context 객체는 행동의 변화를 그 객체에 위임하여 처리한다는 공통점을 가지고 있습니다.

차이점

1. 상태 패턴

 

  • 목적: 객체가 상태에 따라 행동이 달라질 때 사용됩니다. 각 상태는 독립된 클래스로 관리되고, 객체는 내부 상태에 따라 다른 상태 객체로 전환됩니다.
  • 동작 방식: Context 객체는 상태가 바뀌면 내부적으로 상태 객체를 교체하며, 그에 따라 행동이 자동으로 달라집니다. 클라이언트는 상태 객체를 직접 지정하지 않습니다.
  • 예시: 뽑기 기계에서 동전이 없을 때(상태 1), 동전이 있을 때(상태 2), 매진일 때(상태 3) 등 상태에 따라 행동이 달라집니다. 상태 전환은 내부적으로 이루어집니다.

2. 전략 패턴

  • 목적: 특정 행동(알고리즘)을 실행할 때 클라이언트가 어떤 전략(행동)을 사용할지 직접 지정할 수 있도록 유연성을 제공하는 데 사용됩니다.
  • 동작 방식: Context 객체가 어떤 전략 객체를 사용할지를 클라이언트가 선택합니다. 이는 주로 행동을 동적으로 변경할 필요가 있을 때 사용됩니다.
  • 예시: 오리 게임에서 날 수 있는 오리와 날 수 없는 오리를 구분하여 날아가는 전략(알고리즘)을 클라이언트가 지정합니다. 각 오리에게 날 수 있는 전략이나 날지 못하는 전략을 선택해서 적용할 수 있습니다.

3. 핵심 차이점

  • 상태 패턴에서는 상태에 따라 Context 객체가 스스로 변화하며, 클라이언트는 상태에 대해 직접 신경 쓸 필요가 없습니다. 상태가 자동으로 전환됩니다.
  • 전략 패턴에서는 클라이언트가 전략을 명시적으로 선택합니다. 전략 객체는 동적으로 변경될 수 있으며, 클라이언트가 적절한 전략을 직접 설정합니다.

6. 보너스 알맹이 당첨 기능 추가하기

10번에 1번 꼴로 알맹이를 하나 더 주는 기능을 추가해봅니다.

GumballMachine 클래스에 먼저 상태를 추가해줍니다.

public class GumballMachine {
    State soldOutState;
    State noQuarterState;
    State hasQuarterState;
    State soldState;
    State winnerState;  // WinnerState 추가

    State state;
    int count = 0;

    public GumballMachine(int numberGumballs) {
        soldOutState = new SoldOutState(this);
        noQuarterState = new NoQuarterState(this);
        hasQuarterState = new HasQuarterState(this);
        soldState = new SoldState(this);
        winnerState = new WinnerState(this);  // WinnerState 초기화

        this.count = numberGumballs;
        if (numberGumballs > 0) {
            state = noQuarterState;
        } else {
            state = soldOutState;
        }
    }

    // 상태 전환 메소드
    public void setState(State state) {
        this.state = state;
    }

    public void releaseBall() {
        System.out.println("알맹이가 나왔습니다.");
        if (count > 0) {
            count--;
        }
    }

    // 알맹이 개수 반환 메소드
    public int getCount() {
        return count;
    }

    // 각 상태 객체에 대한 게터 메소드들
    public State getSoldOutState() {
        return soldOutState;
    }

    public State getNoQuarterState() {
        return noQuarterState;
    }

    public State getHasQuarterState() {
        return hasQuarterState;
    }

    public State getSoldState() {
        return soldState;
    }

    public State getWinnerState() {
        return winnerState;  // WinnerState 게터
    }
}

 

WinnerState 클래스 구현 - 당첨되었을 때 알맹이를 2개 배출하는 역할, 당첨 시에 첫 번째 알맹이를 배출 후, 기계에 알맹이가 남아있으면 추가로 한 개를 더 배출합합니다.

public class WinnerState implements State {
    GumballMachine gumballMachine;

    // 생성자
    public WinnerState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }

    // 동전 투입 시 오류 메시지
    public void insertQuarter() {
        System.out.println("알맹이가 나오는 중입니다. 잠시만 기다려 주세요.");
    }

    // 동전 반환 시 오류 메시지
    public void ejectQuarter() {
        System.out.println("이미 알맹이를 뽑으셨습니다.");
    }

    // 손잡이 돌림 시 오류 메시지
    public void turnCrank() {
        System.out.println("손잡이는 한 번만 돌려 주세요.");
    }

    // 알맹이 배출
    public void dispense() {
        // 첫 번째 알맹이 배출
        gumballMachine.releaseBall();
        if (gumballMachine.getCount() == 0) {
            gumballMachine.setState(gumballMachine.getSoldOutState());
        } else {
            // 두 번째 알맹이 배출
            System.out.println("축하드립니다! 알맹이를 하나 더 받으실 수 있습니다.");
            gumballMachine.releaseBall();
            if (gumballMachine.getCount() > 0) {
                gumballMachine.setState(gumballMachine.getNoQuarterState());
            } else {
                gumballMachine.setState(gumballMachine.getSoldOutState());
            }
        }
    }
}

 

 

GumballMachine에서 WinnerState로 상태 전환하기

public class HasQuarterState implements State {
    GumballMachine gumballMachine;

    public HasQuarterState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }

    public void insertQuarter() {
        System.out.println("동전은 한 개만 넣어 주세요.");
    }

    public void ejectQuarter() {
        System.out.println("동전이 반환됩니다.");
        gumballMachine.setState(gumballMachine.getNoQuarterState());
    }

    public void turnCrank() {
        System.out.println("손잡이를 돌리셨습니다.");
        // 10번에 1번 꼴로 WinnerState로 전환
        if (Math.random() < 0.1 && gumballMachine.getCount() > 1) {
            gumballMachine.setState(gumballMachine.getWinnerState());
        } else {
            gumballMachine.setState(gumballMachine.getSoldState());
        }
    }

    public void dispense() {
        System.out.println("알맹이를 내보낼 수 없습니다.");
    }
}

 

테스트 코드

public class GumballMachineTestDrive {
    public static void main(String[] args) {
        // 뽑기 기계에 5개의 알맹이를 넣고 생성
        GumballMachine gumballMachine = new GumballMachine(5);

        // 현재 기계 상태 출력
        System.out.println(gumballMachine);

        // 동전 투입 및 손잡이 돌리기
        gumballMachine.insertQuarter();
        gumballMachine.turnCrank();

        // 현재 상태 출력
        System.out.println(gumballMachine);

        // 다시 동전 투입 및 손잡이 돌리기
        gumballMachine.insertQuarter();
        gumballMachine.turnCrank();

        // 동전 투입 및 손잡이 돌리기
        gumballMachine.insertQuarter();
        gumballMachine.turnCrank();

        // 마지막 상태 출력
        System.out.println(gumballMachine);
    }
}

7. 정상성 점검(sanity check)하기

1. SoldState와 WinnerState의 중복 코드

  • 문제점: SoldState와 WinnerState에서 알맹이를 내보내는 동작이 중복되어 있습니다. 비슷한 로직이 반복되므로 코드 중복 문제가 발생합니다.
  • 해결책: 중복된 코드를 해결하려면, State를 추상 클래스로 만들어 공통 기능을 상속받도록 하면 됩니다. dispense()와 같은 공통 메소드를 추상 클래스에서 정의하고, 상태별로 필요한 부분만 오버라이드하면 됩니다. 각 상태 클래스는 공통 기능을 상속받고, 상태 전환 로직만 다르게 구현합니다.
public abstract class AbstractState implements State {
    GumballMachine gumballMachine;

    public AbstractState(GumballMachine gumballMachine) {
        this.gumballMachine = gumballMachine;
    }

    public void insertQuarter() {
        System.out.println("동전은 한 개만 넣어 주세요.");
    }

    public void ejectQuarter() {
        System.out.println("이미 알맹이를 뽑으셨습니다.");
    }

    public void turnCrank() {
        System.out.println("손잡이는 한 번만 돌려 주세요.");
    }

    // 공통 dispense() 메소드
    public void dispense() {
        System.out.println("알맹이를 내보낼 수 없습니다.");
    }
}

2. dispense() 메소드 호출 문제

  • 문제점: dispense() 메소드가 동전 없이 손잡이를 돌렸을 때도 호출됩니다. 잘못된 상태에서 호출되더라도 알맹이는 배출되지 않지만, 비효율적인 호출이 발생할 수 있습니다.
  • 해결책 1. 불리언 값을 리턴하는 방식
    turnCrank() 메소드가 성공적으로 동작했는지를 불리언 값으로 리턴하여, 그 값에 따라 dispense()를 호출할지 말지를 결정할 수 있습니다.
public boolean turnCrank() {
    if (state == hasQuarterState || state == winnerState) {
        state.turnCrank();
        state.dispense();
        return true;
    }
    return false;
}

 

  • 해결책 2. 예외 처리 방식
    예외 처리를 도입하여, 잘못된 상태에서 호출될 때 예외를 던지게 할 수 있습니다. 이는 잘못된 상태에서의 호출을 명확하게 처리할 수 있지만, 예외 처리가 복잡해질 수 있습니다.
public void dispense() throws IllegalStateException {
    if (state != hasQuarterState && state != winnerState) {
        throw new IllegalStateException("잘못된 상태에서 dispense 호출");
    }
    state.dispense();
}

    

3. 상태 전환 정보를 상태 클래스에 넣는 문제

  • 문제점: 현재 상태 전환 로직이 각 상태 클래스에 분산되어 있습니다. 이는 상태 전환 관리의 복잡성을 초래할 수 있습니다.
  • 해결책: 상태 전환 로직을 GumballMachine 클래스에서 관리하는 방법을 고려할 수 있습니다. 이렇게 하면 상태 전환을 한 곳에서 중앙 집중식으로 관리할 수 있어, 상태 간의 흐름이 명확해집니다.
    • 장점 : 상태 전환 로직이 한 곳에 모이기 때문에 유지보수가 쉬워집니다.
    • 단점 : 상태별로 독립적인 처리가 어려워질 수 있으며, 상태 패턴의 캡슐화 이점이 사라질 수 있습니다. 이는 상태 패턴의 의도를 훼손할 수 있습니다.

4. 상태 인스턴스를 정적 변수로 만들어 공유하는 문제

 

  • 문제점: 여러 개의 GumballMachine 인스턴스를 만들 때 각 기계가 상태 인스턴스를 공유할 수 있도록 개선할 수 있습니다.
  • 해결책: 상태 인스턴스를 정적(static) 변수로 만들어, 모든 기계가 상태 인스턴스를 공유하도록 설계할 수 있습니다. 이를 통해 상태 인스턴스의 재사용이 가능해져 메모리 효율을 높일 수 있습니다.
public class GumballMachine {
    private static final State soldOutState = new SoldOutState();
    private static final State noQuarterState = new NoQuarterState();
    private static final State hasQuarterState = new HasQuarterState();
    private static final State soldState = new SoldState();
    private static final State winnerState = new WinnerState();

    // 상태 전환 로직
}

 

 

  • 장점: 여러 기계가 동일한 상태를 사용할 경우 메모리 절약 효과가 있습니다.
  • 단점: 상태 인스턴스를 공유하면 상태 변경이 전역적으로 적용될 수 있습니다. 즉, 한 기계의 상태가 변경될 때 다른 기계에도 영향을 줄 수 있으므로, 상태 관리가 더욱 복잡해질 수 있습니다.

 


FE 상태 패턴

 

프론트엔드에서도 **상태 패턴(State Pattern)**을 적용할 수 있습니다. 특히 상태에 따라 UI가 변화해야 하거나 사용자 인터랙션에 따라 여러 동작이 다르게 수행되는 경우에 유용합니다.

다음은 React에서 상태 패턴을 적용하여 버튼의 상태에 따라 다른 동작을 수행하는 간단한 예시입니다. 여기서 버튼의 상태는 "idle", "loading", "success", "error" 등으로 변하고, 각 상태에 따라 버튼의 UI와 동작이 달라집니다.

 

1. 상태 인터페이스 정의

React에서 상태 패턴을 적용하려면, 각 상태의 행동을 정의하는 인터페이스처럼 사용할 수 있습니다.

// ButtonState.js
export class ButtonState {
    onClick(context) {
        throw new Error("This method should be overridden by subclasses.");
    }
}

export class IdleState extends ButtonState {
    onClick(context) {
        context.setState({ status: "loading" });
        setTimeout(() => {
            if (Math.random() > 0.5) {
                context.setState({ status: "success" });
            } else {
                context.setState({ status: "error" });
            }
        }, 1000); // Simulate network request
    }
}

export class LoadingState extends ButtonState {
    onClick(context) {
        console.log("Loading... please wait.");
    }
}

export class SuccessState extends ButtonState {
    onClick(context) {
        console.log("Already successful.");
    }
}

export class ErrorState extends ButtonState {
    onClick(context) {
        context.setState({ status: "idle" });
    }
}

2. React 컴포넌트에서 상태 패턴 적용

이제 React 컴포넌트에서 상태에 따라 버튼의 동작을 다르게 구현합니다.

import React, { useState } from "react";
import {
    ButtonState,
    IdleState,
    LoadingState,
    SuccessState,
    ErrorState,
} from "./ButtonState";

const StatefulButton = () => {
    // 상태 관리 (idle, loading, success, error)
    const [status, setStatus] = useState("idle");

    // 상태에 따른 클래스 할당
    const getButtonState = () => {
        switch (status) {
            case "idle":
                return new IdleState();
            case "loading":
                return new LoadingState();
            case "success":
                return new SuccessState();
            case "error":
                return new ErrorState();
            default:
                return new IdleState();
        }
    };

    const buttonState = getButtonState();

    // 버튼 클릭 시 상태에 따른 동작 수행
    const handleClick = () => {
        buttonState.onClick({ setState: setStatus });
    };

    // 상태에 따른 버튼 스타일 및 텍스트 변경
    const renderButtonText = () => {
        switch (status) {
            case "idle":
                return "Submit";
            case "loading":
                return "Loading...";
            case "success":
                return "Success!";
            case "error":
                return "Error! Try Again";
            default:
                return "Submit";
        }
    };

    return (
        <button
            onClick={handleClick}
            disabled={status === "loading"}
            style={{
                padding: "10px 20px",
                backgroundColor: status === "success" ? "green" : status === "error" ? "red" : "blue",
                color: "white",
                border: "none",
                borderRadius: "5px",
                cursor: status === "loading" ? "not-allowed" : "pointer",
            }}
        >
            {renderButtonText()}
        </button>
    );
};

export default StatefulButton;

 

3. 설명:

  • StatefulButton 컴포넌트는 상태에 따라 버튼의 동작과 UI가 달라집니다.
  • IdleState: 사용자가 버튼을 클릭하면, onClick 메소드를 통해 상태가 loading으로 전환되고, 가상 네트워크 요청이 완료되면 상태가 success 또는 error로 전환됩니다.
  • LoadingState: 버튼이 "Loading..." 상태일 때는 클릭 이벤트가 비활성화되며, 콘솔에 "Loading... please wait." 메시지가 출력됩니다.
  • SuccessState: 성공 상태에서는 버튼을 다시 클릭해도 아무런 동작을 하지 않습니다.
  • ErrorState: 실패 상태에서는 다시 "idle" 상태로 전환됩니다.

4. 상태 변화 과정

  • 처음 버튼은 idle 상태에서 "Submit" 텍스트를 표시합니다.
  • 사용자가 버튼을 클릭하면 loading 상태로 전환되며, "Loading..." 텍스트가 표시됩니다.
  • 네트워크 요청이 완료된 후 성공 시 success 상태로, 실패 시 error 상태로 전환됩니다.
  • success 상태에서는 "Success!"를 표시하며, error 상태에서는 "Error! Try Again"을 표시하여 사용자가 재시도를 할 수 있습니다.

요약:

이 예시에서 상태 패턴은 각 버튼 상태(Idle, Loading, Success, Error)에 대한 행동을 별도의 클래스로 관리하고, React의 상태와 연결하여 버튼의 동작과 UI를 제어합니다. 상태 패턴을 사용하면 상태별로 서로 다른 행동을 캡슐화하고, 상태 전환에 따른 행동 변화를 명확하게 관리할 수 있습니다.

Comments