Java에서 Enum의 한계를 극복하기 위한 FSM(Finite State Machine) 디자인 패턴 (feat. State Pattern, Spring StateMachine)

BlackBean99·2024년 5월 23일
23

디자인패턴

목록 보기
1/1
post-thumbnail

안녕하세요! 이번에는 Enum의 확장 시 if else switch로 분기처리하는 방법의 문제를 해결하는 객체지향적 방법을 소개하며 이를 활용하는 사례까지 딥하게 알아보도록 하죠!

일반적인 Enum의 사용

흔히 열거식으로 이렇게 사용하시기도 하죠. 이 방법은 차 후 확장에 불리한 점이 많습니다. 만약 Switch를 쓰기라도 했다면, 3개중에 2개만 동작을 정의해도 컴파일시 문제가 발생하지 않습니다. 남은 1개의 동작을 구현하지 않고 넘어갈 수 있다는 확장에 안전한 구조는 아니겠죠.

public enum LeaveRequestState {
    Submitted,
    Escalated,
    Approved
}
LeaveRequestState state = LeaveRequestState.Submitted;

이렇게 선언했지만? Enum마다 별도의 메소드 선언을 해서 위 문제를 어느정도 해결은 가능합니다. 각 추상 메소드로 상태마다의 동작을 명시하면 아래와 같습니다.

public enum LeaveRequestState {
    Submitted {
        @Override
        public String responsiblePerson() {
            return "Employee";
        }
    },
    Escalated {
        @Override
        public String responsiblePerson() {
            return "Team Leader";
        }
    },
    Approved {
        @Override
        public String responsiblePerson() {
            return "Department Manager";
        }
    };

    public abstract String responsiblePerson();
}

하지만 객체지향적으로 더욱 개선해보도록 하죠.
그래서 GoF의 디자인 패턴중 Finite Automata라고 불리는 Finite State Machine 이라는 디자인 패턴으로 위 이넘의 문제를 해결해보고자 합니다.

State Pattern

SM을 구현하는 방법으로 State Pattern이 사용됩니다. 때문에 이걸 먼저 이해해봅시다.

Context는 State 구현에 동작을 위임(Delegate)하게 됩니다. 들어오는 모든 요청은 Concrete한 구현체에 의해 처리되게 위임하는 것이죠.

로직이 분리돼있기 때문에 다른 상태를 추가하고 싶은 경우 새 상태를 추가하는 것이 더 간단해지겠죠.

public class Package {

    private PackageState state = new OrderedState();

    // getter, setter

    public void previousState() {
        state.prev(this);
    }

    public void nextState() {
        state.next(this);
    }

    public void handle() {
        state.printStatus();
    }
}

이전 상태와 다음 상태를 명시하는 메소드를 선언해서 해당 객체가 그 이동을 위임받는 것을 확인할 수 있습니다.

이제 객체지향 답게 State를 인터페이스로 선언하여 구현체를 분리하면 되겠죠 UML의 State를 담당하는 부분입니다.

public interface PackageState {

    void next(Package pkg);
    void prev(Package pkg);
    void handle();
}

이제 각각의 상태를 정의하는 구현 클래스를 선언하면 됩니다. 이제 마음껏 확장!

public class OrderedState implements PackageState {

    @Override
    public void next(Package pkg) {
        pkg.setState(new DeliveredState());
    }

    @Override
    public void prev(Package pkg) {
        System.out.println("The package is in its root state.");
    }

    @Override
    public void handle() {
        System.out.println("Package ordered, not delivered to the office yet.");
    }
}

위에서 주문을 했으니 아래서 배달하는 케이스로 옮길 수 있게 합니다.

public class DeliveredState implements PackageState {

    @Override
    public void next(Package pkg) {
        pkg.setState(new ReceivedState());
    }

    @Override
    public void prev(Package pkg) {
        pkg.setState(new OrderedState());
    }

    @Override
    public void handle() {
        System.out.println("Package delivered to post office, not received yet.");
    }
}

배달하면 받았다는 상태를 또 정의할 수 있고, next 상태에 다음 상태를 정의하지 않으면 됩니다. 만약 처음 상태로 돌아갈 수 있게 한다면 Circular한 상태 머신을 정의할 수 있겠죠.

public class ReceivedState implements PackageState {

    @Override
    public void next(Package pkg) {
        System.out.println("This package is already received by a client.");
    }

    @Override
    public void prev(Package pkg) {
        pkg.setState(new DeliveredState());
    }

테스팅 코드로 동작하는 것을 보면

public class StateDemo {

    public static void main(String[] args) {

        Package pkg = new Package();
        pkg.handle();

        pkg.nextState();
        pkg.handle();

        pkg.nextState();
        pkg.handle();

        pkg.nextState();
        pkg.handle();
    }
}
---
결과

Package ordered, not delivered to the office yet.
Package delivered to post office, not received yet.
Package was received by client.
This package is already received by a client.
Package was received by client.

단점 (side effect)

이런 경우의 단점은 next 상태를 추가하는 것은 쉽지만 상태와 상태 사이에 새로운 상태를 추가할 때 좀 어렵다는 문제가 발생합니다. 이것도 엄연한 하드코딩이니까요.

그래서 이걸 왜? 왜쓰는건데? 코드만 늘어나고 별로인거 아니야?

프로그램이라면 if else switch case로 분기처리를 할거면 되는데 왜 굳이 저런 복잡한걸 써야해?

맞습니다 상황에 따라 다르지만 이넘이라고 다 도입해서 쓰는게 아닙니다.

어떤 Enum 타입은 순서와 관련이 없는 경우가 있습니다.

어떤 집단의 지원 경로를 표시한다면

enum SupportPath {
    POSTER("홍보 포스터"),
    DEPARTMENT_ANNOUNCEMENT("학과 공지사항"),
    ACQUAINTANCE("지인 소개"),
    INSTAGRAM("인스타그램"),
    EVERYTIME("에브리타임");
    private final String pathName;
}

이런 경우는 순서와 상관없이 그 상태만 정의된 케이스입니다. 전이가 의미없는 케이스죠. 이런 경우는 FSM을 사용하기에는 부적절하다고 생각합니다.

하지만 ENUM의 상태 이전이 꼭 명시되고 그 흐름을 정의해야 한다면. 1 → 2 → 3이렇게만 변경돼야 하는데, 1 → 3 → 2 로도 수정하는 케이스를 제한하고 싶다면 위 경우를 적극 추천합니다.


SSM(Simple State Machine)활용 사례

Java의 Enum 추상 클래스를 이용해서 다음 상태를 정의하는 예시를 보겠습니다.

public enum LeaveRequestState {

    Submitted {
        @Override
        public LeaveRequestState nextState() {
            return Escalated;
        }

        @Override
        public String responsiblePerson() {
            return "Employee";
        }
    },
    Escalated {
        @Override
        public LeaveRequestState nextState() {
            return Approved;
        }

        @Override
        public String responsiblePerson() {
            return "Team Leader";
        }
    },
    Approved {
        @Override
        public LeaveRequestState nextState() {
            return this;
        }

        @Override
        public String responsiblePerson() {
            return "Department Manager";
        }
    };

    public abstract LeaveRequestState nextState(); 
    public abstract String responsiblePerson();
}
}

enum이 context를 포함한 케이스죠. 이렇게 구현해도 똑같이 활용할 수 있습니다. 다만 enum에 케이스를 추가하는 경우는 동일합니다.

next 상태를 추상 클래스로 정의하여 각 상태를 유한하게 사용하는 것은 동일합니다.


FSM으로 State Pattern 제한하기

FSM(Finite State Machine, 유한상태머신)란?

주로 컴퓨터 프로그램이나 전자 회로 설계할 때 개수가 한정된 상태와 이동을 트리거하는 조건들의 집합으로 정의됩니다.

FSM 디자인 패턴은 정해져있는 유한한 상태와 전이를 정의하는 트리거를 정의해 상태를 관리하는 디자인 패턴인데요. 쉽게 말해 State Pattern의 next prev가 아닌 input으로 받는 값에 따라 상태를 바꾸는 것을 의미합니다.


이런식으로 상태가 변화하는 것을 검증하고 정의하게 되면, enum을 제한된 상황으로 하지만 일방향적으로만 변경할 수 있는 State Pattern의 문제를 해결할 수 있습니다.

그리고, 오류가 발생할 확률이 줄어들고 유지보수 하는데 저런 다이어그램으로 비즈니스적인 가치를 쉽게 표현해볼 수 있습니다.

말보다는 코드가 이해하기 쉽도록 짜는 것이 섹시하니까요.

말 보다는 코드로 이해해라. 보시죠

public enum ButtonState {
    IDLE, FETCHING, ERROR
}

이 스테이트를 지금부터 위 State Diagram에 맞게 FSM을 구현해보겠습니다.

IDLE는 Click event가 발생하면 fetching으로 변경시켜야 합니다. 때문에 그러한 event도 명시해야 합니다.

public enum ButtonEvent {
    ONCLICK, FAILURE, SUCCESS, RETRY
}

이제 이 상태를 변경하고 활용하는 인터페이스를 선언

public interface State {
    void handleEvent(ButtonContext context, ButtonEvent event);
}

이제 각 Event에 따라 상태가 변경되는 각자의 케이스를 추가해봅시다.

public class IdleState implements State {
    @Override
    public void handleEvent(ButtonContext context, ButtonEvent event) {
        switch (event) {
            case ONCLICK:
                context.setState(new FetchingState());
                System.out.println("Fetching data...");
                break;
            default:
                System.out.println("Invalid event for IDLE state.");
        }
    }
}

public class FetchingState implements State {
    @Override
    public void handleEvent(ButtonContext context, ButtonEvent event) {
        switch (event) {
            case FAILURE:
                context.setState(new ErrorState());
                System.out.println("Error occurred.");
                break;
            case SUCCESS:
                context.setState(new IdleState());
                System.out.println("Data fetched successfully.");
                break;
            default:
                System.out.println("Invalid event for FETCHING state.");
        }
    }
}

public class ErrorState implements State {
    @Override
    public void handleEvent(ButtonContext context, ButtonEvent event) {
        switch (event) {
            case RETRY:
                context.setState(new FetchingState());
                System.out.println("Retrying...");
                break;
            default:
                System.out.println("Invalid event for ERROR state.");
        }
    }
}

상태를 정의했으니 이제 Event인 Context를 수정해봅시다.

public class ButtonContext {
    private State state;

    public ButtonContext() {
        state = new IdleState();
    }

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

    public void handleEvent(ButtonEvent event) {
        state.handleEvent(this, event);
    }
}

실제로 State는 사전에 정의된 것이구요. input으로 Context를 받으면서 사용하실 수 있겠습니다.

테스트 코드를 보면서 어떤 결과가 나오는지 볼게요.

public class FSMExample {
    public static void main(String[] args) {
        ButtonContext button = new ButtonContext();
        
        button.handleEvent(ButtonEvent.ONCLICK);   // Fetching data...
        button.handleEvent(ButtonEvent.FAILURE);   // Error occurred.
        button.handleEvent(ButtonEvent.RETRY);     // Retrying...
        button.handleEvent(ButtonEvent.SUCCESS);   // Data fetched successfully.
        button.handleEvent(ButtonEvent.ONCLICK);   // Fetching data...
        button.handleEvent(ButtonEvent.SUCCESS);   // Data fetched successfully.
    }
}

--- 
결과

Fetching data...
Error occurred.
Retrying...
Data fetched successfully.
Fetching data...
Data fetched successfully.

이제 FSM을 다들 이해하셨죠?

그럼 이 케이스를 실제로는 어떻게 활용되고 있을까요?


🌱 Spring StateMachine

스프링은 이러한 패턴 하나도 모두 lib로 만들어 놨더라구요! 안써볼 수가 없지.

써보면서 스프링스럽게 위 예제를 수정해보겠습니다.

Maven

<dependency>
    <groupId>org.springframework.statemachine</groupId>
    <artifactId>spring-statemachine-core</artifactId>
    <version>2.2.3.RELEASE</version>
</dependency>

Gradle
implementation 'org.springframework.statemachine:spring-statemachine-core:3.2.0'

1. 상태 및 이벤트 정의

public enum ButtonState {
    IDLE, FETCHING, ERROR
}

public enum ButtonEvent {
    ONCLICK, FAILURE, SUCCESS, RETRY
}

2. State Machine Configuration

이 Configuration에서 각 클래스로 관리하던 상태와 전이를 모두 정의할 수 있습니다.

import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachine;
import org.springframework.statemachine.config.StateMachineConfigurerAdapter;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;

@Configuration
@EnableStateMachine
public class StateMachineConfig extends StateMachineConfigurerAdapter<ButtonState, ButtonEvent> {

    @Override
    public void configure(StateMachineStateConfigurer<ButtonState, ButtonEvent> states) throws Exception {
        states
            .withStates()
            .initial(ButtonState.IDLE)
            .state(ButtonState.FETCHING)
            .state(ButtonState.ERROR);
    }

    @Override
    public void configure(StateMachineTransitionConfigurer<ButtonState, ButtonEvent> transitions) throws Exception {
        transitions
            .withExternal().source(ButtonState.IDLE).target(ButtonState.FETCHING).event(ButtonEvent.ONCLICK)
            .and()
            .withExternal().source(ButtonState.FETCHING).target(ButtonState.ERROR).event(ButtonEvent.FAILURE)
            .and()
            .withExternal().source(ButtonState.FETCHING).target(ButtonState.IDLE).event(ButtonEvent.SUCCESS)
            .and()
            .withExternal().source(ButtonState.ERROR).target(ButtonState.FETCHING).event(ButtonEvent.RETRY);
    }
}

한 클래스로 모든 상태의 전이를 표현할 수 있으니 참 편리한 것 같습니다.

이제 설정을 모두 끝냈으니 사용해볼까요?

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.statemachine.StateMachine;
import org.springframework.statemachine.config.StateMachineFactory;
import org.springframework.stereotype.Service;

@Service
public class ButtonService {

    @Autowired
    private StateMachineFactory<ButtonState, ButtonEvent> factory;

    public void processEvent(ButtonEvent event) {
        StateMachine<ButtonState, ButtonEvent> stateMachine = factory.getStateMachine();
        stateMachine.start();
        System.out.println("Current state: " + stateMachine.getState().getId());

        stateMachine.sendEvent(event);
        System.out.println("Current state: " + stateMachine.getState().getId());
    }
}

이런식으로 init해서 event에 따라 전이를 처리할 수 있습니다.

테스트

간단하게 돌려볼까요?

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class StateMachineApplication implements CommandLineRunner {

    @Autowired
    private ButtonService buttonService;

    public static void main(String[] args) {
        SpringApplication.run(StateMachineApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        buttonService.processEvent(ButtonEvent.ONCLICK);   // Fetching data...
        buttonService.processEvent(ButtonEvent.FAILURE);   // Error occurred.
        buttonService.processEvent(ButtonEvent.RETRY);     // Retrying...
        buttonService.processEvent(ButtonEvent.SUCCESS);   // Data fetched successfully.
    }
}

결과

Current state: IDLE
Current state: FETCHING
Current state: FETCHING
Current state: ERROR
Current state: ERROR
Current state: FETCHING
Current state: FETCHING
Current state: IDLE

원하던 상태 변경이 잘 되는 것을 알 수 있습니다.

굿!

더 나아가 실제 활용 사례를 아래서 알아보겠습니다.


Circuit Breaker Pattern

Circuit Breaker 패턴은 분산 시스템에서 서비스 간 호출 실패가 연쇄적으로 발생하는 것을 방지하기 위해 사용되는 패턴입니다.

마치 전기 회로에서 과부하를 방지하기 위해 회로 차단기를 사용하는 것과 유사합니다. 이 패턴은 서비스의 장애를 감지하고, 장애가 확산되지 않도록 하는 데 중점을 두고 사용합니다.

아래 그림을 보시죠.

이제 Context만 timeout과 threshhold 를 사용한 조건만 추가하면서 상태를 변환시켜주면 구현을 해볼 수 있겠죠? 다음에는 실제 Circuit Breaker를 보면서 더 구체적으로 글을 써보도록 하겠습니다.
분산 서버에서 상태를 장애를 전파를 제한하기 위한 케이스로도 사용됨을 알 수 있으며, 이 외에도 2PL이라는 상태 관리도 있습니다. 이런 키워드들이 객체지향적인 아이디어로 시작했다는 것을 알고 오늘 포스팅 마무리!

Reference

https://projects.spring.io/spring-statemachine/
https://www.baeldung.com/java-state-design-pattern
https://www.baeldung.com/java-enum-simple-state-machine

profile
like_learning

0개의 댓글

관련 채용 정보