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까지 활용하면 실무 수준의 주문·결제·배송 워크플로우를 안정적으로 구현할 수 있습니다.