Spring StateMachine 상태 관리

Spring StateMachine이란?

주문 처리, 결제 흐름, 배송 추적 등 비즈니스 로직에는 상태 전이(State Transition)가 핵심인 도메인이 많습니다. if-else 체인이나 플래그 변수로 상태를 관리하면 복잡도가 기하급수적으로 증가합니다. Spring StateMachine은 상태 기계(FSM) 패턴을 Spring 생태계에 통합하여, 상태·이벤트·전이·가드·액션을 선언적으로 정의할 수 있게 합니다.

핵심 개념 정리

개념 역할 예시
State 현재 상태 CREATED, PAID, SHIPPED
Event 상태 전이를 트리거 PAY, SHIP, CANCEL
Transition State A → Event → State B CREATED → PAY → PAID
Guard 전이 허용 조건 검사 잔액 충분한가?
Action 전이 시 실행되는 로직 결제 API 호출

프로젝트 설정

// build.gradle.kts (Spring Boot 3.x)
dependencies {
    implementation("org.springframework.statemachine:spring-statemachine-starter:4.0.0")
    implementation("org.springframework.statemachine:spring-statemachine-data-jpa:4.0.0")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
}

상태·이벤트 Enum 정의

주문 도메인을 예시로 상태와 이벤트를 정의합니다.

public enum OrderState {
    CREATED,      // 주문 생성
    PAID,         // 결제 완료
    PREPARING,    // 상품 준비
    SHIPPED,      // 배송 시작
    DELIVERED,    // 배송 완료
    CANCELLED,    // 주문 취소
    REFUNDED      // 환불 완료
}

public enum OrderEvent {
    PAY,          // 결제
    PREPARE,      // 준비 시작
    SHIP,         // 발송
    DELIVER,      // 배송 완료
    CANCEL,       // 취소
    REFUND        // 환불
}

StateMachine 설정 클래스

전이 규칙, Guard, Action을 선언적으로 구성합니다.

@Configuration
@EnableStateMachineFactory
public class OrderStateMachineConfig
    extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {

    @Override
    public void configure(StateMachineStateConfigurer<OrderState, OrderEvent> states)
            throws Exception {
        states.withStates()
            .initial(OrderState.CREATED)
            .end(OrderState.DELIVERED)
            .end(OrderState.REFUNDED)
            .states(EnumSet.allOf(OrderState.class));
    }

    @Override
    public void configure(
            StateMachineTransitionConfigurer<OrderState, OrderEvent> transitions)
            throws Exception {
        transitions
            // 결제
            .withExternal()
                .source(OrderState.CREATED)
                .target(OrderState.PAID)
                .event(OrderEvent.PAY)
                .guard(paymentGuard())
                .action(paymentAction())
                .and()
            // 준비
            .withExternal()
                .source(OrderState.PAID)
                .target(OrderState.PREPARING)
                .event(OrderEvent.PREPARE)
                .and()
            // 발송
            .withExternal()
                .source(OrderState.PREPARING)
                .target(OrderState.SHIPPED)
                .event(OrderEvent.SHIP)
                .action(shipmentAction())
                .and()
            // 배송 완료
            .withExternal()
                .source(OrderState.SHIPPED)
                .target(OrderState.DELIVERED)
                .event(OrderEvent.DELIVER)
                .and()
            // 취소 (CREATED, PAID에서만 가능)
            .withExternal()
                .source(OrderState.CREATED)
                .target(OrderState.CANCELLED)
                .event(OrderEvent.CANCEL)
                .and()
            .withExternal()
                .source(OrderState.PAID)
                .target(OrderState.CANCELLED)
                .event(OrderEvent.CANCEL)
                .action(refundAction())
                .and()
            // 환불 (CANCELLED → REFUNDED)
            .withExternal()
                .source(OrderState.CANCELLED)
                .target(OrderState.REFUNDED)
                .event(OrderEvent.REFUND)
                .action(refundAction());
    }
}

Guard: 전이 조건 검증

Guard는 전이가 허용되는지 boolean으로 판단합니다. 잔액 부족, 재고 부족 등 비즈니스 규칙을 여기에 캡슐화합니다.

@Bean
public Guard<OrderState, OrderEvent> paymentGuard() {
    return context -> {
        Long orderId = context.getExtendedState()
            .get("orderId", Long.class);
        BigDecimal amount = context.getExtendedState()
            .get("amount", BigDecimal.class);

        // 결제 가능 여부 검증
        boolean hasBalance = paymentService
            .checkBalance(orderId, amount);

        if (!hasBalance) {
            context.getExtendedState().getVariables()
                .put("error", "잔액 부족");
        }
        return hasBalance;
    };
}

@Bean
public Guard<OrderState, OrderEvent> stockGuard() {
    return context -> {
        Long productId = context.getExtendedState()
            .get("productId", Long.class);
        int quantity = context.getExtendedState()
            .get("quantity", Integer.class);
        return inventoryService.hasStock(productId, quantity);
    };
}

Action: 전이 시 실행 로직

Action은 전이가 수락된 후 실행되는 부수 효과입니다. 외부 API 호출, DB 업데이트, 이벤트 발행 등을 처리합니다.

@Bean
public Action<OrderState, OrderEvent> paymentAction() {
    return context -> {
        Long orderId = context.getExtendedState()
            .get("orderId", Long.class);
        BigDecimal amount = context.getExtendedState()
            .get("amount", BigDecimal.class);

        PaymentResult result = paymentService.charge(orderId, amount);

        // 결과를 ExtendedState에 저장
        context.getExtendedState().getVariables()
            .put("paymentId", result.getId());
        context.getExtendedState().getVariables()
            .put("paidAt", Instant.now());

        log.info("결제 완료: orderId={}, paymentId={}",
            orderId, result.getId());
    };
}

@Bean
public Action<OrderState, OrderEvent> shipmentAction() {
    return context -> {
        Long orderId = context.getExtendedState()
            .get("orderId", Long.class);
        String trackingNumber = shippingService.createShipment(orderId);
        context.getExtendedState().getVariables()
            .put("trackingNumber", trackingNumber);

        // 알림 이벤트 발행
        applicationEventPublisher.publishEvent(
            new OrderShippedEvent(orderId, trackingNumber));
    };
}

@Bean
public Action<OrderState, OrderEvent> refundAction() {
    return context -> {
        String paymentId = context.getExtendedState()
            .get("paymentId", String.class);
        paymentService.refund(paymentId);
        log.info("환불 처리 완료: paymentId={}", paymentId);
    };
}

서비스에서 StateMachine 사용

StateMachineFactory로 주문별 독립 인스턴스를 생성합니다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final StateMachineFactory<OrderState, OrderEvent> factory;
    private final OrderRepository orderRepository;
    private final StateMachinePersister<OrderState, OrderEvent, String> persister;

    @Transactional
    public void pay(Long orderId, BigDecimal amount) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow();

        // 주문별 상태 기계 복원
        StateMachine<OrderState, OrderEvent> sm =
            factory.getStateMachine(orderId.toString());
        sm.startReactively().block();
        persister.restore(sm, orderId.toString());

        // ExtendedState에 컨텍스트 주입
        sm.getExtendedState().getVariables().put("orderId", orderId);
        sm.getExtendedState().getVariables().put("amount", amount);

        // 이벤트 전송
        boolean accepted = sm.sendEvent(
            Mono.just(MessageBuilder
                .withPayload(OrderEvent.PAY).build()))
            .blockLast()
            .getResultType() == ResultType.ACCEPTED;

        if (!accepted) {
            String error = sm.getExtendedState()
                .get("error", String.class);
            throw new IllegalStateException(
                "결제 전이 실패: " + error);
        }

        // 상태 영속화
        persister.persist(sm, orderId.toString());
        order.updateState(sm.getState().getId());
        orderRepository.save(order);
    }
}

JPA 영속화: 상태 복원

인스턴스 재시작 후에도 상태를 복원하려면 JPA 영속화가 필수입니다.

@Configuration
public class StateMachinePersistConfig {

    @Bean
    public StateMachineRuntimePersister<OrderState, OrderEvent, String>
            runtimePersister(
                JpaStateMachineRepository repository) {
        return new JpaPersistingStateMachineInterceptor<>(repository);
    }

    @Bean
    public StateMachinePersister<OrderState, OrderEvent, String>
            persister(
                StateMachineRuntimePersister<OrderState, OrderEvent, String>
                    runtimePersister) {
        return new DefaultStateMachinePersister<>(runtimePersister);
    }

    @Bean
    public StateMachineFactory<OrderState, OrderEvent>
            stateMachineFactory(
                OrderStateMachineConfig config,
                StateMachineRuntimePersister<OrderState, OrderEvent, String>
                    persister) throws Exception {
        // Factory에 persister 인터셉터 등록
        return config.buildFactory(persister);
    }
}

리스너: 상태 전이 모니터링

전이 로그, 메트릭 수집, 알림 발송을 리스너로 분리합니다.

@Component
@WithStateMachine
@RequiredArgsConstructor
public class OrderStateMachineListener {

    private final MeterRegistry meterRegistry;

    @OnTransition
    public void onAnyTransition(
            @OnTransitionStart StateContext<OrderState, OrderEvent> context) {
        OrderState source = context.getSource().getId();
        OrderState target = context.getTarget().getId();
        OrderEvent event = context.getEvent();

        log.info("상태 전이: {} → {} (event={})", source, target, event);

        meterRegistry.counter("order.state.transition",
            "from", source.name(),
            "to", target.name(),
            "event", event.name()
        ).increment();
    }

    @OnTransition(source = "PAID", target = "CANCELLED")
    public void onPaidToCancelled(
            StateContext<OrderState, OrderEvent> context) {
        Long orderId = context.getExtendedState()
            .get("orderId", Long.class);
        log.warn("결제 후 취소 발생: orderId={}", orderId);
        // 환불 프로세스 자동 트리거
    }

    @OnStateEntry(target = "DELIVERED")
    public void onDelivered(
            StateContext<OrderState, OrderEvent> context) {
        Long orderId = context.getExtendedState()
            .get("orderId", Long.class);
        notificationService.sendDeliveryComplete(orderId);
    }
}

에러 처리 전략

Action 내부에서 예외 발생 시 RetryTemplate과 Error Action을 결합합니다.

@Override
public void configure(
        StateMachineTransitionConfigurer<OrderState, OrderEvent> transitions)
        throws Exception {
    transitions
        .withExternal()
            .source(OrderState.CREATED)
            .target(OrderState.PAID)
            .event(OrderEvent.PAY)
            .guard(paymentGuard())
            .action(paymentAction(), paymentErrorAction())  // 에러 핸들러
            .and();
}

@Bean
public Action<OrderState, OrderEvent> paymentErrorAction() {
    return context -> {
        Exception exception = context.getException();
        Long orderId = context.getExtendedState()
            .get("orderId", Long.class);

        log.error("결제 Action 실패: orderId={}", orderId, exception);

        // 에러 상태 기록
        context.getExtendedState().getVariables()
            .put("lastError", exception.getMessage());
        context.getExtendedState().getVariables()
            .put("errorAt", Instant.now());

        // 보상 트랜잭션
        compensationService.handlePaymentFailure(orderId);
    };
}

Hierarchical States: 복합 상태

SHIPPED 상태를 세부 하위 상태로 분리하면 배송 추적을 더 정밀하게 관리할 수 있습니다.

@Override
public void configure(StateMachineStateConfigurer<OrderState, OrderEvent> states)
        throws Exception {
    states
        .withStates()
            .initial(OrderState.CREATED)
            .state(OrderState.PAID)
            .state(OrderState.PREPARING)
            .state(OrderState.SHIPPED)  // 부모 상태
            .end(OrderState.DELIVERED)
            .and()
        .withStates()
            .parent(OrderState.SHIPPED)
            .initial(OrderState.IN_TRANSIT)     // 하위 상태
            .state(OrderState.OUT_FOR_DELIVERY) // 하위 상태
            .end(OrderState.AT_DESTINATION);    // 하위 상태
}

테스트 전략

@SpringBootTest
class OrderStateMachineTest {

    @Autowired
    StateMachineFactory<OrderState, OrderEvent> factory;

    @Test
    void happyPath_CreatedToDelivered() {
        var sm = factory.getStateMachine("test-order-1");
        sm.startReactively().block();

        assertThat(sm.getState().getId()).isEqualTo(OrderState.CREATED);

        sendEvent(sm, OrderEvent.PAY);
        assertThat(sm.getState().getId()).isEqualTo(OrderState.PAID);

        sendEvent(sm, OrderEvent.PREPARE);
        assertThat(sm.getState().getId()).isEqualTo(OrderState.PREPARING);

        sendEvent(sm, OrderEvent.SHIP);
        assertThat(sm.getState().getId()).isEqualTo(OrderState.SHIPPED);

        sendEvent(sm, OrderEvent.DELIVER);
        assertThat(sm.getState().getId()).isEqualTo(OrderState.DELIVERED);
    }

    @Test
    void cannotShipBeforePayment() {
        var sm = factory.getStateMachine("test-order-2");
        sm.startReactively().block();

        // CREATED에서 SHIP 이벤트 → 무시됨
        var result = sendEvent(sm, OrderEvent.SHIP);
        assertThat(result.getResultType())
            .isEqualTo(ResultType.DENIED);
        assertThat(sm.getState().getId())
            .isEqualTo(OrderState.CREATED);
    }

    @Test
    void cancelFromPaidTriggersRefundAction() {
        var sm = factory.getStateMachine("test-order-3");
        sm.startReactively().block();
        sm.getExtendedState().getVariables()
            .put("paymentId", "pay_123");

        sendEvent(sm, OrderEvent.PAY);
        sendEvent(sm, OrderEvent.CANCEL);

        assertThat(sm.getState().getId())
            .isEqualTo(OrderState.CANCELLED);
        // refundAction 호출 검증
        verify(paymentService).refund("pay_123");
    }

    private StateMachineEventResult<OrderState, OrderEvent>
            sendEvent(StateMachine<OrderState, OrderEvent> sm,
                      OrderEvent event) {
        return sm.sendEvent(Mono.just(
            MessageBuilder.withPayload(event).build()))
            .blockLast();
    }
}

운영 체크리스트

항목 확인 사항
영속화 JPA Persister로 상태 복원 보장
Guard 검증 모든 비즈니스 규칙을 Guard로 캡슐화
Error Action Action 실패 시 보상 트랜잭션 처리
메모리 관리 SM 사용 후 stopReactively() 호출
전이 메트릭 Micrometer로 상태 전이 카운터 모니터링
동시성 같은 주문에 동시 이벤트 → 분산 락 필요

마치며

Spring StateMachine은 복잡한 상태 전이 로직을 선언적 설정으로 관리할 수 있게 합니다. Guard로 비즈니스 규칙을, Action으로 부수 효과를, Error Action으로 예외 처리를 캡슐화하면, if-else 지옥에서 벗어나 유지보수 가능한 상태 관리 코드를 작성할 수 있습니다. JPA 영속화와 Hierarchical States까지 활용하면 실무 수준의 주문·결제·배송 워크플로우를 안정적으로 구현할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux