Development/Code

Spring State Machine을 어떻게 활용할 수 있을까요? (사용 방법 및 예제)

Danny Seo 2024. 6. 26. 14:33

Finite State Machine 사진 [Spring State Machine 설명글]

상태 기계란 무엇인가?

상태 기계(State Machine)는 시스템이 다양한 조건에서 어떻게 동작하는지를 모델링하는 방법입니다. 이를 특정 시점에 유한한 수의 상태 중 하나에 있을 수 있는 기계로 생각할 수 있습니다. 이 기계는 받은 입력에 따라 상태를 변경하며, 각 상태에서는 특정 작업이나 출력을 수행할 수 있습니다.

상태 기계란 무엇인가? (Spring State Machine 4.0 설명글)

작동 방식의 개요:

  • 상태 : 시스템이 있을 수 있는 다양한 조건입니다. 예를 들어, 신호등은 빨간색, 노란색, 녹색의 상태를 가질 수 있습니다.
  • 전이 : 시스템이 한 상태에서 다른 상태로 이동하는 규칙입니다. 신호등의 예에서, 빨간 상태의 타이머가 만료되면 노란 상태로 이동하는 전이가 있을 수 있습니다.
  • 입력 : 상태 기계를 상태 변경하게 만드는 이벤트입니다. 예를 들어 사용자 동작, 센서 판독값, 외부 신호 등이 있습니다.
  • 출력 : 각 상태에서 시스템이 수행하는 작업입니다. 이는 화면에 메시지를 표시하거나 기계를 제어하는 신호를 보내는 것일 수 있습니다.

상태 기계는 간단한 신호등에서부터 복잡한 자동판매기나 비디오 게임까지 다양한 시스템을 모델링하는 데 강력한 도구입니다. 이들은 시스템의 동작을 명확하고 간결하게 표현할 수 있어 이해, 설계 및 디버깅을 용이하게 합니다.

Spring State Machine이란?

Spring State Machine(SSM)은 Spring 플랫폼 위에 구축된 프레임워크로, Spring 애플리케이션 내에서 상태 기계를 쉽게 구현할 수 있게 해 줍니다. 이 프레임워크는 상태, 전이, 이벤트를 관리하는 기능을 제공합니다.

What is Spring State Machine?

SSM의 주요 기능:

  • 단순 및 복잡한 상태 기계 : 기본적인 사용 사례를 위한 평면 구조와 복잡한 워크플로우 관리를 위한 계층적 구조를 모두 지원합니다.
  • 이벤트 기반 전이 : 상태 변경은 사용자 동작, 센서 판독값 또는 타이머와 같은 이벤트에 의해 트리거됩니다.
  • 가드 및 액션 : 전이가 발생할 때 조건(가드)을 정의하고 상태 진입 또는 종료 시 실행할 작업을 정의할 수 있습니다.
  • 타입 안전성 : SSM은 타입 안전한 구성 방식을 사용하여 코드를 더욱 견고하고 유지 관리하기 쉽게 만듭니다.

SSM을 사용하면 애플리케이션의 상태 전이를 관리하는 논리를 핵심 코드 외부에 정의할 수 있습니다. 이는 코드를 더 깔끔하고 모듈화 하며 이해하기 쉽게 만듭니다. 또한, 복잡한 워크플로우와 상태 변경을 처리하기 쉽게 합니다.

Spring State Machine 사용의 장점

  • 유지 관리성 향상 : 중앙 집중화된 상태 관리로 코드의 명확성이 높아지고, 분산된 상태 논리로 인한 오류 위험이 감소합니다.
  • 예측 가능성 향상 : 명시적으로 정의된 상태 전이와 조건으로 애플리케이션의 동작이 더 예측 가능해집니다.
  • 테스트 용이성 : 테스트 가능한 상태 전이로 인해 다양한 애플리케이션 상태와 전이를 격리하여 테스트하기 쉽습니다.
  • 비즈니스 로직 간소화 : 복잡한 상태 변경과 전이를 처리함으로써 수동 코딩 작업이 줄어듭니다.

언제 Spring State Machine을 사용해야 할까요?

애플리케이션이 다음과 같은 특성을 가질 때 Spring State Machine(SSM)을 사용하는 것이 좋습니다:

  • 상태 기반 동작 : 애플리케이션의 핵심 로직이 상태, 전이, 이벤트 시리즈로 자연스럽게 표현될 수 있는 경우, SSM은 이 복잡성을 관리하는 구조화된 접근 방식을 제공합니다. 예를 들어 주문 처리 워크플로우, 유한 상태 자동차 구현 또는 턴제 게임 등이 있습니다.
  • 복잡한 로직 관리 : SSM은 복잡한 애플리케이션 로직을 더 작고 관리하기 쉬운 상태 전이로 분할하는 데 도움이 됩니다. 이를 통해 코드 가독성과 유지 관리성이 향상됩니다.
  • 동시성 문제 : 비동기 작업을 처리할 때 발생하는 동시성 문제를 SSM이 제어하고 예측 가능한 방식으로 상태 변경을 관리하는 데 도움이 됩니다.

다음은 불리언 플래그나 열거형으로 상태를 관리하는 것이 번거로워지고 SSM이 더 나은 해결책이 될 수 있는 징후입니다:

  • 상태 기반 조건 논리 : 코드가 다양한 애플리케이션 상태를 나타내기 위해 불리언 플래그나 열거형에 크게 의존하고, 이러한 상태에 기반한 조건 논리가 많은 경우, SSM은 이러한 전이를 처리하는 더 구조화된 방법을 제공합니다.
  • 상태별 변수 : 특정 상태 내에서만 의미가 있는 변수를 사용하는 경우, SSM은 이러한 변수를 관련 상태와 연관시키는 메커니즘을 제공합니다.

실제 사용 사례

Order Lifecycle Management

주문 생명 주기 관리

전자상거래 애플리케이션에서 주문 상태는 생성, 결제, 배송, 수령 확인, 완료 또는 취소로 변경될 수 있습니다. 상태 기계를 사용하여 이러한 상태 간의 합법적인 전이 과정을 명확하게 정의하고 제어할 수 있으며, 상태 변경 시 이메일 알림 발송, 재고 업데이트 등의 관련 작업을 트리거할 수 있습니다.

Workflow Engine

워크플로우 엔진

기업 애플리케이션에서 휴가 승인 프로세스 및 경비 보고서 처리와 같은 워크플로우는 여러 단계와 결정 포인트를 갖습니다. 상태 기계를 사용하여 각 단계 간의 관계와 전이 조건을 설명하여 프로세스가 사전 설정된 규칙에 따라 진행되도록 할 수 있습니다.

 

게임 로직

게임 개발에서 게임 캐릭터, 레벨, 전투 장면은 각각 고유한 상태를 가집니다. 상태 기계를 사용하여 캐릭터의 동작 모드 전환, 레벨 완료 조건 판단, 전투 상태 사이클과 같은 복잡한 로직을 구현할 수 있습니다.

Device status monitoring

장치 상태 모니터링

사물인터넷(IoT) 애플리케이션에서 장치의 실행 상태를 실시간으로 추적하고 관리할 때, 장치가 수신한 다양한 신호나 지시에 따라 상태 전이를 트리거할 수 있습니다. 예를 들어, 장치 시작, 대기, 실행, 고장, 유지보수 등의 상태 전이가 있습니다.

 

세션 관리

웹 애플리케이션에서 사용자 세션 상태(로그인, 로그아웃, 활성, 타임아웃 등)를 상태 기계를 통해 효과적으로 관리하여 세션 상태와 관련된 복잡한 비즈니스 로직을 간소화할 수 있습니다.

 

접근 제어

보안 요구 사항이 높은 시스템에서는 사용자의 권한이 작업 동작 및 시스템 상태에 따라 변경될 수 있습니다. 상태 기계를 사용하여 권한 상태 변경 프로세스를 정확하게 표현할 수 있습니다.

 

마이크로서비스 아키텍처에서의 트랜잭션 일관성

다중 마이크로서비스 간의 협업이 필요한 시나리오에서 상태 기계를 사용하여 각 서비스의 상태 전이를 조정하여 전체 비즈니스 프로세스의 일관성을 보장할 수 있습니다.

잘 사용하는 방법

효과적인 SSM 사용을 위한 몇 가지 주요 원칙:

  • 명확한 상태 모델링 : 애플리케이션 상태와 전이를 신중하게 정의하여 명확하고 잘 구성된 상태 모델을 보장합니다.
  • 명시적 가드 및 액션 : 전이가 허용되는지 여부를 결정하는 가드를 사용하고 상태 전이 시 실행할 액션을 정의합니다.
  • 상태 계층 구조(선택 사항) : 복잡한 애플리케이션의 경우, 복잡한 상태를 하위 상태로 나누어 더 나은 조직을 위해 계층적 상태 기계를 활용합니다.
  • 이벤트 기반 아키텍처 : 상태 전이를 트리거하는 이벤트를 중심으로 애플리케이션을 설계하여 느슨한 결합과 모듈화를 촉진합니다.
  • 테스트 및 디버깅 : 상태 전이를 확인하기 위해 테스트 케이스를 사용하고 SSM의 디버깅 기능을 활용하여 문제를 식별합니다.

코드 예시

프로젝트 구조 :

Spring State Machine Project Structure

라이브러리 :

<properties>
  <spring-statemachine.version>4.0.0</spring-statemachine.version>
</properties>        

<dependency>
    <groupId>org.springframework.statemachine</groupId>
    <artifactId>spring-statemachine-starter</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.st

atemachine</groupId>
    <artifactId>spring-statemachine-data</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.statemachine</groupId>
    <artifactId>spring-statemachine-spring-data-jpa</artifactId>
</dependency>

 

Spring State Machine 설명

 

다이어그램에서 우리는 다음과 같은 상태를 가지고 있습니다 :

 

5개의 상태: A B C D E

 

4개의 이벤트: E1 E2 E3 E4

public enum StateEnum {
    A,
    B,
    C,
    D,
    E
}

public enum EventEnum {
    E1,
    E2,
    E3,
    E4
}

Configuration 클래스 :

  • 상태, 전환, 구성을 정의합니다.
  • 전환에 가드와 액션을 추가합니다.
@Slf4j
@EnableStateMachineFactory
@Configuration
public class StateMachineMainConfig extends StateMachineConfigurerAdapter<StateEnum, EventEnum> {

    @Autowired
    private StateMachineRuntimePersister<StateEnum, EventEnum, String> stateMachineRuntimePersister;

    @Autowired
    StateMachineListener<StateEnum, EventEnum> stateMachineListener;

    @Autowired
    MyGuard myGuard;

    @Autowired
    LogAction logAction;

    @Override
    public void configure(StateMachineStateConfigurer<StateEnum, EventEnum> states) throws Exception {
        states.withStates()
                .initial(StateEnum.A)
                .states(EnumSet.allOf(StateEnum.class))
                .end(StateEnum.E);
    }

    @Override
    public void configure(StateMachineTransitionConfigurer<StateEnum, EventEnum> transitions) throws Exception {
        transitions
                .withExternal()
                .source(StateEnum.A).target(StateEnum.B).event(EventEnum.E1)
                .guard(myGuard)
                .and()
                .withExternal()
                .source(StateEnum.B).target(StateEnum.C).event(EventEnum.E2)
                .and()
                .withExternal()
                .source(StateEnum.C).target(StateEnum.D).event(EventEnum.E3)
                .and()
                .withExternal()
                .source(StateEnum.D).target(StateEnum.E).event(EventEnum.E4)
                .action(logAction);
    }

    @Override
    public void configure(StateMachineConfigurationConfigurer<StateEnum, EventEnum> config) throws Exception {
        config.withConfiguration()
                .autoStartup(true)
                .listener(stateMachineListener)
                .and()
                .withPersistence()
                .runtimePersister(stateMachineRuntimePersister);
    }
}

StateMachineListenerConfig

@Configuration
@Slf4j
public class StateMachineListenerConfig {

    @Bean
    public StateMachineListener<StateEnum, EventEnum> stateMachineListener() {
        return new StateMachineListenerAdapter<StateEnum, EventEnum>() {
            @Override
            public void stateChanged(State<StateEnum, EventEnum> from, State<StateEnum, EventEnum> to) {
                log.info("Transitioned from {} to {}", from == null ? "none" : from.getId(), to.getId());
            }

            @Override
            public void stateEntered(State<StateEnum, EventEnum> state) {
                log.info("Entered state: {}", state.getId());
            }

            @Override
            public void stateExited(State<StateEnum, EventEnum> state) {
                log.info("Exited state: {}", state.getId());
            }

            @Override
            public void eventNotAccepted(Message<EventEnum> event) {
                log.info("Event not accepted: {}", event.getPayload());
            }

            @Override
            public void transition(Transition<StateEnum, EventEnum> transition) {
                log.info("Transition: {} -> {}",
                        transition.getSource() != null ? transition.getSource().getId() : transition.getSource(),
                        transition.getTarget().getId());
            }

            @Override
            public void stateMachineError(StateMachine<StateEnum, EventEnum> stateMachine, Exception exception) {
                log.info("stateMachineError: {}", stateMachine.getId(), exception);
            }

            @Override
            public void stateContext(StateContext<StateEnum, EventEnum> stateContext) {
                //log.info("stateContent: {}", stateContext);
            }
        };
    }
}

StateMachinePersistenceConfig

@Configuration
public class StateMachinePersistenceConfig {

    @Bean
    public StateMachineRuntimePersister<StateEnum, EventEnum, String> stateMachineRuntimePersister(
            final JpaStateMachineRepository jpaStateMachineRepository) {
        return new JpaPersistingStateMachineInterceptor<>(jpaStateMachineRepository);
    }
}

StateMachineServiceConfig

@Configuration
public class StateMachineServiceConfig {

    @Autowired
    StateMachineFactory<StateEnum, EventEnum> stateMachineFactory;

    @Autowired
    StateMachineRuntimePersister<StateEnum, EventEnum, String> stateMachineRuntimePersister;

    @Bean
    public StateMachineService<StateEnum, EventEnum> stateMachineService() {
        return new DefaultStateMachineService<>(stateMachineFactory, stateMachineRuntimePersister);
    }
}

LogAction

@Slf4j
@Service
public class LogAction implements Action<StateEnum, EventEnum> {
  @Override
  public void execute(StateContext<StateEnum, EventEnum> stateContext) {
    log.info("Log Action Executed. {}", stateContext.getStateMachine().getState().getId());
  }
}

MyGuard

기계 ID가 존재하는지 확인합니다.

@Service
public class MyGuard implements Guard<StateEnum, EventEnum> {
    @Override
    public boolean evaluate(StateContext<StateEnum, EventEnum> stateContext) {
        StateEnum currentState = stateContext.getStateMachine().getState().getId();
        EventEnum event = stateContext.getEvent();
        ExtendedState extendedState = stateContext.getExtendedState();

        if (currentState == StateEnum.A && event == EventEnum.E1) {
            if (!StringUtils.hasText(extendedState.get("machineId", String.class))) {
                throw new RuntimeException("uuid not found!");
            }
            return true;
        }
        return false;
    }
}

사용 예제

@Slf4j
@SpringBootTest
class SimpleDemoStateMachineApplicationTests {

    @Autowired
    private StateMachineService<StateEnum, EventEnum> stateMachineService;

    StateMachine<StateEnum, EventEnum> stateMachine;

    @BeforeEach
    void init () {
        stateMachine = stateMachineService.acquireStateMachine(UUID.randomUUID().toString(), true);
    }

    @Test
    void testSendEvent() {
        log.info("Initial: {}", stateMachine.getState().getId());

        stateMachine.sendEvent(Mono.just(MessageBuilder.withPayload(EventEnum.E1).build())).subscribe();
        stateMachine.sendEvent(Mono.just(MessageBuilder.withPayload(EventEnum.E2).build())).subscribe();
        stateMachine.sendEvent(Mono.just(MessageBuilder.withPayload(EventEnum.E3).build())).subscribe();
        stateMachine.sendEvent(Mono.just(MessageBuilder.withPayload(EventEnum.E4).build())).subscribe();
    }

    @Test
    void testSendEventCollect_byOrder(){
        log.info("Initial: {}", stateMachine.getState().getId());

        stateMachine.sendEventCollect(Mono.just(MessageBuilder.withPayload(EventEnum.E1).build()))
                .flatMap(results -> stateMachine.sendEventCollect(Mono.just(MessageBuilder.withPayload(EventEnum.E2).build())))
                .flatMap(results -> stateMachine.sendEventCollect(Mono.just(MessageBuilder.withPayload(EventEnum.E3).build())))
                .flatMap(results -> stateMachine.sendEventCollect(Mono.just(MessageBuilder.withPayload(EventEnum.E4).build())))
                .subscribe();
    }


    @Test
    void testPassParametersWithGuard() {
        String stateMachineId = stateMachine.getId();
        Map<String, Object> variables = new HashMap<>();
        variables.put("machineId", stateMachineId);
        variables.put("content", "sth important");
        stateMachine.getExtendedState().getVariables().putAll(variables);

        stateMachine.sendEvent(Mono.just(MessageBuilder.withPayload(EventEnum.E1).build())).subscribe();
    }

    @Test
    void testPassParametersWithGuard_WithAction() {
        String stateMachineId = stateMachine.getId();
        Map<String, Object> variables = new HashMap<>();
        variables.put("machineId", stateMachineId);
        variables.put("content", "sth important");
        stateMachine.getExtendedState().getVariables().putAll(variables);

        stateMachine.sendEventCollect(Mono.just(MessageBuilder.withPayload(EventEnum.E1).build()))
                .flatMap(results -> stateMachine.sendEventCollect(Mono.just(MessageBuilder.withPayload(EventEnum.E2).build())))
                .flatMap(results -> stateMachine.sendEventCollect(Mono.just(MessageBuilder.withPayload(EventEnum.E3).build())))
                .flatMap(results -> stateMachine.sendEventCollect(Mono.just(MessageBuilder.withPayload(EventEnum.E4).build())))
                .subscribe();
    }
}

 

testSendEvent() 유닛 테스트에서는 Guard가 올바르게 동작하는 것을 확인할 수 있습니다.

Spring State Machine 작동

 

testPassParametersWithGuard_WithAction() 유닛 테스트에서는 Log Action이 실행된 결과를 볼 수 있습니다.

Spring State Machine 작동

 

라이브러리 4.0.0+에는 더 많은 API가 있으며, 좋은 사용 예가 있을 때 업데이트하겠습니다.

 

끝까지 읽어주셔서 정말 감사합니다!