Spring Statemachine 도입기

Oct.10.2024 이정수

Backend

로봇과 함께 배달하는 서비스의 필요

우아한형제들 로보틱스LAB의 로봇딜리버리플랫폼팀에서는, 로봇으로 배달을 하기 위해 필요한 여러 서비스를 개발하고 있습니다. 이러한 서비스들 중에는 로봇의 배달 수행 과정을 관리하는 서비스가 있습니다. 이 서비스는 배달 요청을 수신한 후 가용 로봇을 찾아 그 로봇에게 배달 임무를 할당합니다. 임무를 받은 로봇은 배달을 시작하게 되고, 이렇게 시작된 로봇의 배달 과정 전체를 이 서비스가 관리합니다. 이 글에서는 이 서비스를 ‘로봇 배달 관리 서비스’라고 부르겠습니다.

로봇이 배달을 수행하는 과정은 배민 라이더 분들이 배달을 하시는 과정과 같습니다. 가게나 물류센터에 가서, 거기에서 음식이나 물품을 싣고, 고객이 있는 곳으로 이동해서, 고객에게 물품을 전달합니다. 짧고 간단해 보이는 과정이지만, 이제 막 용기를 내어 인간 세상에 적응하기 시작한 로봇에게는 험난한 여정이 될 수 있습니다. 이 여정을 ‘로봇 배달 관리 서비스’가 함께 합니다.

로봇이 배달을 수행하는 동안 ‘로봇 배달 관리 서비스’는, 로봇의 현재 배달 상태(state)를 확인하고, 로봇이 다음 배달 상태로 잘 넘어가는지 살펴보며, 현재 배달 상태에 따라 필요한 명령을 로봇에게 전달합니다. 여기에서의 ‘배달 상태’란, 로봇이 배달 과정 중 어떤 단계에 있는지를 말합니다.

효율적인 상태 관리 방법

‘로봇 배달 관리 서비스’는 이처럼 로봇의 배달 수행 상태를 중앙 서버에서 실시간으로 관리하며 로봇과 소통합니다. 이러한 일을 여러 대의 로봇들에 대해 동시에 수행해야 합니다. 어떻게 하면 동시에 여러 대의 로봇을 실시간으로 관리하고 로봇과 소통할 수 있을까요?

우선 추상적인 수준에서 구현 방법을 생각해 보겠습니다. 먼저 로봇의 배달 상태들을 정의해 둡니다. 그리고 배달 중인 로봇이 보내오는 정보들을 바탕으로 로봇의 현재 배달 상태를 실시간으로 계속 파악합니다. 파악한 현재 상태는 다음에 참조하기 위해 외부 저장소에 저장합니다. 그리고 로봇의 상태가 변경될 때마다 필요한 부가 작업을 수행합니다.

이러한 기능들을 잘 구현하면 되는데, 조금 더 생각해 보면 어려운 문제들이 있습니다. 새로운 배달 상태가 추가되거나 기존의 배달 상태가 삭제될 수도 있고, 배달 상태를 변경해야 할지를 판단하는 기준도 바뀔 수 있습니다. 그러다 보면 이벤트 기반으로 실행되는 배달 상태 판단 코드들이 여기저기 어지럽게 놓일 수 있습니다. 또 상태 변경 시 필요한 부가 작업도 언제든 추가될 수 있는데, 그 코드들 또한 얽히고설킬 수 있습니다. 조건문, flag, callback 등에 의존한 코드는 시간이 지날수록 읽기 어려워지고, 그에 따라 코드를 수정할 때 실수할 가능성이 높아지는 등 여러 문제가 생깁니다. 결국 코드의 유지보수가 어려워집니다.

이러한 ‘상태 관리’를 할 때에 유용한 도구로 state machine이 있습니다. State machine을 사용하면, 상태들, 이벤트들을 명시적으로 정의하고 전이 조건들까지 명확하게 표현할 수 있어, 엔지니어들이 상태 전이 흐름을 보다 수월하게 구현하고 이해할 수 있습니다. 코드가 깔끔해져서 나중에 로직을 바꾸기도 쉽고 유지보수가 쉬워집니다.

이러한 state machine은 게임 업계를 비롯한 여러 산업 분야에서 널리 쓰이고 있습니다. 실제 생활에서 쓰이는 많은 로직들이 state machine으로 표현될 수 있기 때문입니다. 예를 들어 바닥에 떨어진 동전의 상태는 앞면, 뒷면으로 표현할 수 있고, 보행자 신호등은 빨간불, 초록불, 깜박임 상태로 표현할 수 있고, 게임 속 캐릭터 객체는 이동, 공격, 방어, 정지 등의 상태를 가질 수 있습니다.

배달 로봇의 상태도 아래처럼 표현할 수 있습니다.

유휴 → 픽업지로 이동 중 → 물품 적재 대기 → 물품 적재 중 → 전달지로 이동 중 → 전달 대기 → 전달 중 → 유휴

그럼 이제 state machine을 구현해 볼까요?

State Machine을 직접 구현한다면

구현에 앞서 state machine에서 쓰는 기본 개념과 용어 몇 가지를 짚어 보겠습니다. State machine은 상태들의 변화 흐름을 관리하는 도구입니다. 아래 그림처럼 state machine은 여러 상태(state) 중 하나의 state에 있게 되는데, 특정 이벤트(event)가 발생하면 다른 상태로 전이(transition)됩니다. 이때 guard 로직으로 이 transition를 허용할지 결정하고, 특정 상태로 transition이 일어날 때에는 특정 action의 수행이 동반됩니다.

  • state: 변화 없이 유지되는 상태
  • transition: 하나의 상태에서 다른 상태로 바뀌는 과정
  • event: transition을 촉발시키는 사건
  • action: 상태 흐름 과정 중 특정 조건이 만족될 때 수행되는 작업
  • guard: event가 발생했을 때 그에 따른 transition을 실행할지를 판단하는 로직

이제 state machine을 실제로 구현해 보겠습니다. 여러 가지 구현 방법이 있겠지만 여기에서는 간단한 방법을 사용하겠습니다.

State machine 구현에는 크게 두 가지가 필요합니다. 첫째는 state machine의 각종 요소들을 표현할 자료구조이고, 둘째는 상태의 전이를 수행해 줄 프레임워크입니다.

자료구조 작성

먼저 state machine의 각종 요소들을 표현할 자료구조를 만들어 보고자 합니다. Enum 타입을 사용하면 쉬울 것입니다. 각 상태를 enum type의 상수로 정의하고, 필요한 필드 및 메서드들을 추가하면 됩니다. 추가할 필드로는, 이 상태에서 가능한 상태 전이들인 transitions, 그리고 이 상태로 진입할 때 수행되어야 할 action 등이 있습니다.

public record Transition(Event event, State nextState, Predicate guard) {}

public enum State {
  IDLE(() -> idleAction()),
  GOING_TO_LOADING_POINT(() -> goingAction()),
  WAITING_FOR_LOADING(() -> waitingAction()),
  LOADING(() -> loadingAction());
  // ...

  static {
    IDLE.transitions = List.of(
        new Transition(Event.DISPATCHED, GOING_TO_LOADING_POINT, c -> true));
    GOING_TO_LOADING_POINT.transitions = List.of(
        new Transition(Event.ARRIVED, WAITING_FOR_LOADING, c -> true),
        new Transition(Event.CANCELED, IDLE, c -> true));
    WAITING_FOR_LOADING.transitions = List.of(
        new Transition(Event.OPENED, LOADING, c -> true),
        new Transition(Event.CANCELED, IDLE, c -> true));
    // ...
  }

  State(Runnable action) {
    this.action = action;
  }

  private List transitions;
  private final Runnable action;
}

상태와 전이 등을 표현할 수 있는 간단한 자료구조를 작성해 보았습니다.

프레임워크 작성

다음으로 위 자료구조를 다뤄 상태 전이를 수행해 줄 간단한 프레임워크를 구현하면 됩니다. 프레임워크의 예제 코드는 생략하고 설명만 하겠습니다. 이 프레임워크는 아래와 같이 동작합니다.

  1. 트리거링 이벤트에 따라 상태 전이를 시도합니다.
  2. 현재 상태에서 가능한 전이들(state의 transitions 필드에 있는 것들) 중 이 이벤트에 의해 트리거되는 것이 있는지 확인합니다.
  3. Transition이 있다면, 그 transition의 guard를 실행하여 전이해도 되는지를 판단합니다.
  4. 판단 결과가 true이면 실제 전이를 수행합니다.
  5. 새로운 상태로 전이된 후에는 그 상태의 action을 실행합니다.

이 정도로 끝나면 정말 좋겠습니다만, 실제 상황에서는 더 많은 요구사항들이 있습니다. 예를 들어 상태에 진입할 때 수행할 action뿐만 아니라 상태에서 빠져나올 때의 action도 필요할 수 있습니다. 또 특정 상태를 초기 상태라는 특수한 상태로 지정하고 싶을 수도 있습니다. 또한 분산 환경이라면 전이된 결과 상태들을 공유 저장소에 저장하거나 분산 서버 간 상태를 동기화하고 싶을 수도 있고요. 이런 요구사항들을 하나씩 덧붙이다 보면, 자료구조도 프레임워크도 더 크고 복잡해질 것입니다.

Spring Statemachine이라는 대안

위에서 예로 든 요구사항들을 모두 만족시키는 프레임워크들이 이미 존재합니다. Spring Statemachine, Squirrel Framework 등이 있습니다. 로봇딜리버리플랫폼팀은 state machine을 직접 구현하는 일을 피하기 위해, Spring 친화적이며 업데이트가 더 활발한 Spring Statemachine을 써 보기로 하였습니다. Spring Statemachine은 우리가 상태 관리를 위해 필요한 기본적인 요구사항들 외에도 많은 기능들을 제공하고 있었습니다. 그래서 우리는 오히려 그 많은 기능들을 남용하지 않고 꼭 필요한 기능들만 선택해서 사용하려고 노력해야 했습니다.

장점

이처럼 공개된 프레임워크를 외부에서 가져와 쓰면 어떤 장점이 있을까요?

첫 번째 장점은 역시 직접 구현하지 않아도 된다는 점입니다. 그 덕분에 시간을 절약하여 생산성 향상을 꾀할 수 있습니다. 물론 프레임워크를 배우기 위해 시간이 걸리긴 하지만, 이러한 것을 직접 만드는 것보다는 낫습니다. Spring Statemachine을 사용하실 분들은 이 글을 통해 배우는 시간을 절약하실 수 있기를 기대합니다.

두 번째 장점으로는 프레임워크가 좋은 구조를 제공해 준다는 점입니다. 대부분의 다른 프레임워크들처럼, Spring Statemachine도 잘 갖춰진 틀을 제공합니다. 프로그래머는 자신이 만든 것들을 그 틀에 적절히 끼워 넣으면 됩니다. 예를 들어, 원하는 상태들을 설정하고, 전이를 설정하고, guard 로직을 등록하고, action을 등록하면 됩니다. 이러한 틀에 맞추어 코드를 작성하게 되므로, 코드가 구조화되어 가독성이 높아지고, 코드의 일관성이 유지됩니다. 자칫하면 엉키기 쉬운 상태들간의 복잡한 관계를, 잘 짜여진 구조 안에서 깔끔하게 표현할 수 있습니다.

세 번째 장점으로는 Spring Statemachine의 설정 코드가 기술 문서 역할을 한다는 점을 들고 싶습니다. 많은 엔지니어들이 테스트 코드를 통해 처음 접하는 코드의 동작을 이해하듯, 우리는 Spring Statemachine의 설정 코드들을 통해 해당 서비스의 상태 전이 흐름을 쉽게 이해할 수 있습니다. 이처럼 체계적이고 명시적인 설정 파일이, 기술 문서에서 자연어로 길게 설명을 하는 것보다 오해의 여지가 더 적을 수도 있습니다.

네 번째 장점은 모듈화가 쉽다는 것입니다. 협업하는 사람들이 나누어 개발하기 좋습니다.

다섯 번째 장점은 오픈 소스이고 사용자가 많아서 수많은 사람들에 의해 검증되었다는 점입니다. 여러 사람들이 버그를 발견하고 고쳐 왔습니다.

여섯 번째는 공식 레퍼런스 문서가 제공된다는 것입니다. 사용 방식이 표준화되어 있다시피 해서 누구나 쉽게 배워 프로젝트에 합류할 수 있습니다.

마지막으로 테스트 작성이 쉽다는 점도 장점으로 들 수 있을 것 같습니다. 이 글 후반부에서는 Spring Statemachine이 제공하는 StateMachineTestPlan을 소개드릴 텐데요. 이것을 사용하면 설정을 검증하기 위한 테스트 케이스들을 쉽게 작성할 수 있습니다.

단점

단점으로는 무엇이 있을까요? 앞서 잠깐 언급했듯, 배우는 데 시간이 걸린다는 점이 아닐까 합니다. 잘 짜인 틀에 익숙해지기 위한 학습이 필요합니다. 필요한 상태 관리 로직이 비교적 간단한 경우에는 직접 자체 state machine을 구현하는 게 낫고, 다양한 기능을 필요로 한다면 Spring Statemachine과 같은 도구가 좋은 선택일 수 있습니다.

Spring Statemachine 사용하기

로봇딜리버리플랫폼팀은 앞서 말했던 장점들이 도움이 되리라 판단하였기에 ‘로봇 배달 관리 서비스’에 Spring Statemachine을 도입하였습니다. 이제 우리가 Spring Statemachine을 어떻게 사용하였는지 설명하겠습니다. 먼저 상태도(state diagram)부터 소개합니다.

위의 그림은 로봇딜리버리플랫폼팀에서 사용하는 상태도를 더 간단히 바꾼 것입니다. 설명하기 쉽도록 예외 상황에 발생하는 상태 흐름 등을 제외했습니다. 이 상태도에는 상태, 이벤트, 전이, guard, action 등이 잘 표현되어 있습니다. 참고로, Spring Statemachine은 action의 수행 시점이 여럿입니다. 상태에 진입할 때, 상태로부터 빠져나올 때, 그리고 전이할 때 등입니다. 위 그림에서 보이는 것처럼 우리는 상태가 전이될 때에 action을 수행하도록 설정할 예정입니다. 이 상태도에 맞춰 Spring Statemachine을 설정해 보겠습니다.

먼저 gradle.build 파일에 Spring Statemachine에 대한 의존 관계를 추가해 줍니다. 이 글에서는 로봇딜리버리플랫폼팀이 도입한 3.2.1 버전의 Spring Statemachine을 기준으로 설명합니다. 더 자세한 설명은 Spring Statemachine 레퍼런스를 함께 참고해 주세요.

implementation platform('org.springframework.statemachine:spring-statemachine-bom:3.2.1')
implementation 'org.springframework.statemachine:spring-statemachine-starter'
implementation 'org.springframework.statemachine:spring-statemachine-data-redis'
testImplementation 'org.springframework.statemachine:spring-statemachine-test'

Spring Statemachine 설정하기

Spring Statemachine이 프로젝트에 성공적으로 추가되었다면, 이제 본격적으로 Spring Statemachine을 설정해 보겠습니다.

상태 정의

상태를 정의합니다. 위 상태도에 표현된 7개의 상태를 enum 상수로 선언합니다. 원한다면 enum 상수 대신 String 상수를 사용해도 됩니다.

public enum DeliveryStateType {
  IDLE,
  GOING_TO_LOADING_POINT,
  WAITING_FOR_LOADING,
  LOADING,
  GOING_TO_UNLOADING_POINT,
  WAITING_FOR_UNLOADING,
  UNLOADING
}

이벤트 정의

상태의 전이를 유발하는 이벤트를 정의합니다. 역시 위 상태도에 표현된 6개의 이벤트들을 enum 상수들로 선언합니다. 역시 String 상수들로 대신할 수 있습니다.

public enum MissionWorkerEventType {
  DISPATCHED,
  ARRIVED,
  OPENED_LID,
  CLOSED_LID,
  COMPLETED_LOADING,
  COMPLETED_UNLOADING
}

Spring Statemachine 주요 설정

Spring Statemachine 설정 클래스를 추가합니다.

@Configuration
@RequiredArgsConstructor
@EnableStateMachineFactory(name = "missionStateMachineFactory")
@Slf4j
public class StateMachineConfig extends EnumStateMachineConfigurerAdapter {
  private final StateAction stateAction;
  private final TransitionGuard transitionGuard;
  private final TransitionAction transitionAction;
  @Override
  public void configure(StateMachineConfigurationConfigurer configurationConfigure) throws Exception {
    // ...
  }
  @Override
  public void configure(StateMachineStateConfigurer stateConfigurer) throws Exception {
    // ...
  }
  @Override
  public void configure(StateMachineTransitionConfigurer transitionConfigurer) throws Exception {
    // ...
  }
  // ...
}

이 클래스에 @EnableStateMachineFactory 어노테이션을 붙여서, Statemachine들을 동적으로 만들어주는 팩토리 빈(factory bean)을 활성화합니다. @EnableStateMachineFactory 대신 @EnableStateMachine를 붙일 수도 있는데 그러면 서비스 내에 단 하나의 정적 Statemachine을 생성하게 됩니다. 여러 로봇들이 동시에 배달 임무를 수행할 예정이므로, 단 하나의 Statemachine을 공용으로 사용한다면 신경 쓸 일이 많아집니다. 여러 로봇의 각기 다른 상태들을 하나의 Statemachine으로 표현하고 관리하다 보면, 어쩔 수 없는 간섭이 발생할 것입니다. 이런 로봇들 사이의 서로의 간섭을 막기 위한 격리 조치가 필요하고 그러다보면 응답이 느려질 수 있습니다. 그래서 동시에 여러 개의 Statemachine을 생성하고 사용하기 위해 @EnableStateMachineFactory를 사용합니다. 참고로 이런 동시성 문제를 해결하는 방법으로 로봇딜리버리플랫폼팀에서 선택한 @EnableStateMachineFactory 대신 StateMachineBuilder를 활용하는 방법도 있으니, 궁금하신 분들은 Spring Statemachine 레퍼런스를 참고해 주세요. StateMachineBuilder를 사용한다는 것은, @EnableStateMachineFactory가 팩토리 빈을 생성하거나 @EnableStateMachine가 하나의 Statemachine 빈을 생성하는 것과 달리, 빈의 도움 없이 필요에 따라 직접 Statemachine을 자유롭게 생성하겠다는 것입니다. 동적으로 Statemachine을 생성하고 싶다면 @EnableStateMachineFactory 어노테이션 방식과 StateMachineBuilder 방식 둘 중 하나를 사용하면 됩니다.

StateMachineConfig 설정 클래스는 EnumStateMachineConfigurerAdapter 클래스를 확장하고 있습니다. 이름에서 추측할 수 있듯 상태들과 이벤트들 둘 다 enum 타입으로 정의하여 Statemachine을 설정하겠다는 의미입니다. 만약 상태들과 이벤트들을 enum 타입이 아닌 String 등 다른 클래스 타입으로 정의하고자 한다면 EnumStateMachineConfigurerAdapter 대신 StateMachineConfigurerAdapter를 상속하면 됩니다. 직접 확인해 보면 알겠지만 EnumStateMachineConfigurerAdapter는 StateMachineConfigurerAdapter를 상속받아 상태와 이벤트에 대한 제네릭 타입 파라미터들을 enum 타입으로 한정하고 있는 게 전부입니다. 이제 우리가 해야 할 일은 EnumStateMachineConfigurerAdapter의 configure 메서드 3개를 재정의(override)하는 일입니다. 이 재정의를 통해 우리 입맛에 맞는 Statemachine을 설정하는 것입니다. 그러면 위에서 활성화시킨 팩토리 빈이 Statemachine을 동적으로 생성할 때마다 이 설정대로 생성해 줍니다.

여기서 하나 알아둘 점은 Statemachine을 생성하는 비용이 상대적으로 크다는 점입니다. 우리가 설정할 내용을 보면 생성 비용이 어떨지 대충 짐작이 됩니다. 이 생성 비용을 아끼기 위한 노력이 필요한데 이 주제는 뒤에서 다루겠습니다.

이제 재정의된 configure 메서드 3개를 살펴보겠습니다.

일반 등록
@Override
  public void configure(StateMachineConfigurationConfigurer configurationConfigure) throws Exception {
    configurationConfigure
     .withConfiguration()
      .autoStartup(true)
      .listener(
          new StateMachineListenerAdapter() {
            @Override
            public void stateChanged(State from, State to) {
              String fromState = (from == null ? "null" : from.getId().name());
              String toState = to.getId().name();
              log.info("State changed from {} to {}", fromState, toState);
            }
          });
  }

첫 번째 configure 메서드의 역할은 StateMachineConfigurationConfigure를 설정하는 것입니다. Statemachine 자체를 위한 일반 설정이라고 이해하면 됩니다. 위 설정된 내용을 보면 자동 시작을 true로 했고, 별도의 listener를 등록하고 있습니다. 자동 시작이 true이므로, Statemachine이 빌드되면 자동으로 초기 상태값이 되며 이벤트를 수신하기 시작합니다. 메서드 listener에는 Statemachine이 동작하는 중 내부 주요 포인트에서 호출해 주는 콜백 함수를 등록합니다. 여기서는 상태가 변경될 때마다 로깅하는 listener를 예로 들었습니다. 이 외에도 에러 발생, 상태 진입, 전이 시작 등 다양한 시점에 동작하는 listener를 등록할 수 있습니다.

상태 등록
  @Override
  public void configure(StateMachineStateConfigurer stateConfigurer) throws Exception {
    stateConfigurer
        .withStates()
        .initial(DeliveryStateType.IDLE)
        .state(DeliveryStateType.IDLE, stateAction.publishChangedEvent())
        .state(DeliveryStateType.GOING_TO_LOADING_POINT, stateAction.publishChangedEvent())
        .state(DeliveryStateType.WAITING_FOR_LOADING, stateAction.publishChangedEvent())
        .state(DeliveryStateType.LOADING, stateAction.publishChangedEvent())
        .state(DeliveryStateType.GOING_TO_UNLOADING_POINT, stateAction.publishChangedEvent())
        .state(DeliveryStateType.WAITING_FOR_UNLOADING, stateAction.publishChangedEvent())
        .state(DeliveryStateType.UNLOADING, stateAction.publishChangedEvent());
  }

두 번째 configure 메서드는 StateMachineStateConfigure를 설정합니다. 여기에서 상태들을 설정하는 것입니다. 상태도에 있는 모든 상태들을 등록하고, 추가로 state action을 각 상태별로 지정합니다. State action은 해당 상태에 안착할 때마다 실행되는 비즈니스 로직을 말합니다. 이 예제에서는 간단하게 상태가 변경됐음을 알리는 액션이 공통적으로 지정되어 있음을 알 수 있습니다.

전이 등록
  @Override
  public void configure(StateMachineTransitionConfigurer transitionConfigurer) throws Exception {
    transitionConfigurer
      .withExternal()
        .source(DeliveryStateType.IDLE)
        .target(DeliveryStateType.GOING_TO_LOADING_POINT)
        .event(MissionWorkerEventType.DISPATCHED)
        .guard(transitionGuard.justPass())
        .action(transitionAction.moveToPickupPoint())
       .and()
      .withExternal()
        .source(DeliveryStateType.GOING_TO_LOADING_POINT)
        .target(DeliveryStateType.WAITING_FOR_LOADING)
        .event(MissionWorkerEventType.ARRIVED)
        .guard(transitionGuard.justPass())
        .action(transitionAction.doNothing())
        .and()
      .withExternal()
        .source(DeliveryStateType.WAITING_FOR_LOADING)
        .target(DeliveryStateType.LOADING)
        .event(MissionWorkerEventType.OPENED_LID)
        .guard(transitionGuard.justPass())
        .action(transitionAction.setLidOpened())
        .and()
      .withInternal()
        .source(DeliveryStateType.LOADING)
        .event(MissionWorkerEventType.CLOSED_LID)
        .action(transitionAction.setLidClosed())
        .and()
      .withInternal()
        .source(DeliveryStateType.LOADING)
        .event(MissionWorkerEventType.OPENED_LID)
        .action(transitionAction.setLidOpened())
        .and()
      .withExternal()
        .source(DeliveryStateType.LOADING)
        .target(DeliveryStateType.GOING_TO_UNLOADING_POINT)
        .event(MissionWorkerEventType.COMPLETED_LOADING)
        .guard(transitionGuard.isLidClosed()) // Allow transition only if the lid is closed.
        .action(transitionAction.moveToDropPoint())
        .and()
      // ...
  }

세 번째 configure 메서드는 StateMachineTransitionConfigurer를 설정합니다. 바로 전이를 설정하는 것입니다. 두 상태 노드를 방향성 있는 화살표 선으로 연결해 주고, 그 선을 따라 전이를 일으킬 이벤트, 그 선을 통과할 수 있는지 판단하는 guard 로직, 그리고 그 선을 통과할 때 수행시킬 transition action을 붙여주는 작업입니다. Transition action은 state action과 같은 비즈니스 로직인데, 실행되는 시점에 차이가 있습니다. 이름에서 알 수 있듯 transition action는 전이가 발생하는 시점에 실행됩니다. 만약 하나의 타깃 상태로 유입되는 선이 여럿 있고, 각 유입선마다 실행해야 하는 비즈니스 로직이 다르다면, 이 transition action을 유용하게 활용할 수 있습니다.

위 예제를 자세히 보면 두 상태 노드의 연결되는 선 말고도 다른 종류의 선도 찾을 수 있습니다. 두 개 상태를 연결하는 설정은 withExternal()로 시작하고, 또 다른 종류의 설정은 withInternal()로 시작합니다. withInternal()로 설정한 전이는 출발 노드와 도착 노드가 같습니다. 이런 것도 전이 설정의 일부이지만 withInternal()로 설정된 전이는 실제 상태 전이를 일으키지 않습니다. 어떤 이벤트에 반응하되, 다른 상태로 전이하지 않고 필요한 transition action만 수행하고 싶을 경우에 이러한 것이 유용합니다. 로봇딜리버리플랫폼팀은 로봇의 적재함 문 상태를 기록하기 위해 이 withInternal() 설정을 활용하고 있습니다. 참고로 Spring Statemachine은 특정 상태 진입 시 예약 작업을 등록하는 timer 기능도 제공하는데, 이 timer를 설정할 때에도 withInternal() 설정을 사용합니다. 이처럼 withInternal()을 이용하면 필요에 따라 상태 전이 없이 transition action 로직과 timer를 설정할 수 있습니다.

사용하기

지루할 수도 있는 설정 작업이 이제 끝났습니다. 설정이라고는 했지만 사실 중요한 비즈니스 로직이 함께 반영되어 있었습니다. 바로 guard와 action을 통해서입니다. Guard 로직이 상태 전이에 필요한 환경 조건(condition)을 검사하고, action이 전이와 함께 중요한 환경 변화(side effect)를 일으킬 수 있습니다. 이제 state machine은 우리가 설정한 대로 살아 움직입니다. 우리가 콕 찌르면 꿈틀거리며 주위에 파동까지 일으킵니다. 어떻게 하면 이것을 찔러볼 수 있을까요?

상태 전이시키기

State machine의 동작을 보기 위해 이것을 찔러보려면, 이벤트를 전송하면 됩니다(설정에 대해 설명하는 부분에서 잠깐 언급했던 timer도 state machine 변화의 시작을 트리거링하기 위해 사용할 수 있지만, 이 글에서 다루지 않습니다). 위에서 로봇딜리버리플랫폼팀의 상태도를 소개하며 언급했듯, state machine에 이벤트를 전달함으로써 state machine의 상태 전이를 유발합니다. 다시 위의 상태도를 볼까요?

"유휴" 상태에 있는 state machine에 "로봇이 배차됨" 이벤트를 전달해서 "픽업지로 이동 중" 상태로 전이시킬 수 있습니다. 만약 이때 state machine의 현재 상태가 "유휴" 상태가 아니었다면 "로봇이 배차됨" 이벤트는 state machine에 아무런 변화도 일으키지 못합니다. 우리가 그렇게 설정했기 때문입니다.

아래 예제를 통해 실제 이벤트를 전달하는 코드를 살펴보겠습니다. robotStateMachine이라는 state machine이 있습니다. 여기에 sendEvents() 메서드를 호출함으로써 이벤트를 전송하고 있습니다. sendEvents() 메서드를 호출하면서 인수로 message를 만들어 전달하고 있습니다. 이 message에서 제일 중요한 것은 Payload인데, 이 Payload 값이 상태 전이를 유발시킬 이벤트가 됩니다.

// Make event message.
MissionWorkerEventType eventsToSend = MissionWorkerEventType.DISPATCHED
Flux<Message> eventMessages = Flux.fromIterable(List.of(eventsToSend)).map(MessageBuilder::withPayload).map(MessageBuilder::build);

// Send events to statemachine.
robotStateMachine
    .sendEvents(eventMessages)
    .doOnNext(
        result ->
            log.info(
                "Sending event {} to robot {} is {}",
                result.getMessage().getPayload(),
                robotId,
                result.getResultType()))
    .doOnComplete(() -> missionWorkerPersister.persistStateMachine(robotStateMachine))
    .doOnError(error -> log.error("Failed to send event to robot {}", robotId, error))
    .subscribe();

데이터 전달하기

State machine이 살아 움직이고 있는 동안 state machine에게 데이터를 전달하고 싶을 수 있습니다. 한 상태에서 다른 상태로 전이할 때 데이터를 전달하고 싶을 수도 있고, state machine이 살아 있는 동안 데이터를 계속해서 저장하고 있다가 필요할 때마다 꺼내 볼 수 있도록 하고 싶을 수도 있습니다. Spring Statemachine은 이 두 가지를 모두 지원합니다.

상태 전이 때 전달하기

상태 전이 때 데이터를 전달하려면, sendEvents() 메서드를 호출할 때 전달하는 message안에 header 값을 넣으면 됩니다.

// Make event message.
MessageBuilder
    .withPayload(MissionWorkerEventType.DISPATCHED)
    .setHeader(HEADER_KEY, someValue)
    .build();

이렇게 전이를 유발하며 header 값을 함께 전달했다면, 해당 전이가 완료될 때까지 수행되는 guard 나 action들에서 그 header 값을 꺼내 쓸 수 있습니다.

public Action someAction() {
  return context -> {
      Object value = context.getMessageHeader(HEADER_KEY);
      // ...
  };
}

public Guard someGuard() {
  return context -> {
      Object value = context.getMessageHeader(HEADER_KEY);
      // ...
  };
}

전이 유발을 위해 전달하는 message 객체와 그 안의 header 데이터는 해당 전이 동안만 유효합니다. 상태 전이가 완료되면 사라집니다.

State Machine에 데이터 저장하기

반면, 두 번째 데이터 공유 방법을 쓰는 경우 그 데이터의 life cycle은 state machine의 life cycle과 동일합니다. State machine이 파기될 때까지 데이터가 살아 있습니다. 이 방법은 해당 state machine의 extended state에 데이터를 저장하는 것입니다.

// Initialize state variable into the new state machine.
robotStateMachine
    .getExtendedState()
    .getVariables()
    .put(VARIABLE_KEY,
        MissionStateVariable.builder()
            .robotId(robotId)
            .deliveryId(deliveryId)
            .loadingLatLng(loadingLatLng)
            .unloadingLatLng(unloadingLatLng)
            .build());

위와 같이 extended state의 variables라는 Map에 저장한 데이터는 state machine에 설정된 모든 guard와 action들에서 접근할 수 있습니다.

public Action someAction() {
  return context -> {
      MissionStateVariable value = context.getExtendedState().get(VARIABLE_KEY, MissionStateVariable.class);
      // ...
 };
}

위 코드 조각들에서 예상할 수 있듯, 로봇딜리버리플랫폼팀의 ‘로봇 배달 관리 서비스’는 요청받은 배달 임무를 수행할 로봇을 배차할 때 extended state 안에 로봇 ID, 배달 ID, 픽업지 위치, 전달지 위치를 저장합니다. 그리고 해당 배달 임무가 완료될 때까지 필요한 때마다 이 자료들을 조회하고 있습니다. 예를 들면 로봇이 "적재 중" 상태에서 "픽업지로 이동 중" 상태로 전이할 때, transition action에서 로봇에게 이동 요청을 하기 위해 extended state에 저장된 픽업지 위치를 읽어오는 식입니다.

동시성 고려하기

지금까지 우리는 state machine의 상태를 자유자재로 변경시키는 방법을 알아보았습니다. 이제 동시성을 고려할 차례입니다. 배달 중인 로봇이 매 순간 한 대 뿐일리는 없습니다. 여러 대의 로봇이 동시에 각자의 배달 임무를 수행 중일 수 있습니다.

여러 로봇이 공용 State Machine 하나만 사용하기

그렇다 하더라도, 서버 자원의 효율을 극대화하기 위해서 state machine를 하나만 생성하여 모든 로봇들이 공용으로 사용하도록 하고 싶을 수 있습니다. 좋은 생각입니다. 하지만 여러 로봇이 동시에 상태 전이를 하는 과정에서 서로 간섭이 일어나는 것을 막아야만 합니다. 그러기 위해서는 상태 전이를 위한 작업 큐(queue)를 활용해야 할 것 같습니다. 문제는 state action 로직이 비동기 방식으로 수행되기에 상황이 조금 복잡해질 수 있다는 것입니다. 아래 로그 기록을 보면, state action 로직은 transition를 처리하는 스레드(task-6)와 다른 별도 스레드(parallel-4)에서 동작하는 것을 알 수 있습니다.

INFO 91054 --- [         task-6] c.w.t.t.config.StateMachineConfig        : State changed from null to IDLE
INFO 91054 --- [         task-6] o.s.s.support.AbstractStateMachine       : Got null context, resetting to initial state, clearing extended state and machine id
INFO 91054 --- [         task-6] c.w.t.t.s.s.MissionWorkerEventSender     : Try to send event [DISPATCHED] to robot RobotId[robotId=TEST-ROBOT-ID]
INFO 91054 --- [         task-6] c.w.t.t.c.statemachine.TransitionAction  : Asked robot RobotId[robotId=TEST-ROBOT-ID] to move to pick up: 36.51, 127.51
INFO 91054 --- [         task-6] c.w.t.t.config.StateMachineConfig        : State changed from IDLE to GOING_TO_LOADING_POINT
INFO 91054 --- [     parallel-4] c.w.t.t.config.statemachine.StateAction  : Published application event MissionStateChangedEvent[robotId=RobotId[robotId=TEST-ROBOT-ID], deliveryId=1, previousState=IDLE, changedState=GOING_TO_LOADING_POINT]
INFO 91054 --- [         task-8] c.w.t.t.c.DeliveryExecutorNotifier       : Received MissionStateChangedEvent MissionStateChangedEvent[robotId=RobotId[robotId=TEST-ROBOT-ID], deliveryId=1, previousState=IDLE, changedState=GOING_TO_LOADING_POINT]

그렇기 때문에, 배달 수행 중이던 로봇이 점유하고 있던 공용 state machine을 다음 로봇에게 양보하기 전에 비동기 로직들이 모두 완료되었는지를 파악해야만 합니다. 자칫 완료 전에 state machine의 사용권을 다음 로봇에 넘기면, 이전 로봇의 action이 아직 수행되고 있으면서 다음 로봇의 state context 데이터를 참조하는 불상사가 일어날 수도 있습니다. 이러한 문제를 잘 다루고 난 후에도 문제가 하나 남아 있습니다. 바로 반응성이 떨어진다는 점입니다. 작업 큐에 대기 중인 로봇들이 많을 경우, 공용 state machine의 사용을 위해 기다리는 마지막 차례의 로봇은 한참을 기다려야 할 수 있습니다. 이런 예상되는 문제들로 인해 공용 State Machine 활용 방법은 현실적으로 쓰기가 어려울 것 같습니다.

이벤트마다 신규 State Machine 쓰고 버리기

서버 메모리를 아끼기 위한 또 다른 대안으로 극단적이긴 하지만 이벤트가 발생할 때마다 state machine을 새로 생성할 수도 있을 것 같습니다. 전이 때마다 휘발성 state machine를 매번 생성시키고 전이 완료 후에는 버리는 방법인데 추천하고 싶지 않습니다. 이글 초반부에 언급했듯이 state machine을 생성하는 비용이 큰 편입니다. 메모리를 아끼는 대신 불필요한 반복 생성 작업으로 CPU 리소스를 많이 쓰게 될 것입니다. 이 방법 또한 실제로 사용할 만한 방법은 아닌 것 같습니다.

로봇마다 각자의 전용 State Machine 사용하기

앞 방법들의 어려움과 단점들을 회피하는 방법은 매우 간단합니다. 로봇별 전용 state machine을 사용하는 방법입니다. 로봇딜리버리플랫폼팀이 채택한 방법입니다. 로봇마다 하나씩 state machine을 생성합니다. 로봇별 state machine을 (lazy하게, 즉 실제로 사용해야 하는 시점이 되어서야) 생성하여 맵에 넣어 두고 전이 이벤트마다 해당 로봇의 state machine을 찾아 씁니다. 이 방법은 서버 리소스를 더 쓰게 되므로 운영하는 로봇 수에 맞게 서버 인스턴스들의 scale 조정을 잘해야 됩니다. 로봇딜리버리플랫폼팀은 운영하는 로봇 수를 미리 알기 때문에 이 점은 충분히 사전에 해결할 수 있습니다.

StateMachine stateMachine = robotStateMachines.computeIfAbsent(robotId, this::createStateMachine);

State Machine 풀 사용하기

공용 state machine과 로봇별 전용 state machine 방식을 섞은 하이브리드 방식도 있습니다. 바로 state machine 풀(pool)을 사용하는 방법입니다. Spring Statemachine 레퍼런스에서도 풀을 활용하는 예제를 제공하고 있으니 관심 있으시다면 해당 예제를 참고해 보시면 좋겠습니다. 동시에 운용 중인 로봇의 수가 풀 사이즈 이내라면 로봇별 전용 state machine 방식처럼 풀 안의 state machine를 로봇 한 대당 하나씩 배정하여 쓸 수 있고, 동시에 운용되는 로봇 수가 풀 사이즈보다 더 커지면 그 차이만큼의 로봇들은 풀의 대기큐에서 기다리게 하면 됩니다. 로봇들이 풀의 대기큐에서 state machine을 기다리는 상황에서는 공용 state machine 활용 때와 똑같은 고려사항들을 챙겨야 되겠습니다.

서버 간 상태 동기화하기

동시성 문제까지 다뤄봤으니 다음 주제로 넘아가 보겠습니다. 가용성이 중요한 서비스를 단일 서버에서만 구동하는 경우는 거의 없습니다. 우리 회사도 당연히 서비스를 다중 서버로 구동하고 있습니다. 이런 다중 분산 환경에서는 항상 데이터의 동기화에 각별히 신경을 써야 합니다. ‘로봇 배달 관리 서비스’의 로봇별 state machine의 현재 상태(state context)도 당연히 다중 서버 간 동기화해야 할 대상입니다. 서버 간 state machine 데이터를 동기화하는 것이 왜 필요한지는 다음 예를 통해 더 쉽게 이해할 수 있습니다. 특정 로봇의 "배차됨" 이벤트가 서버 인스턴스 1번에서 처리되고, 곧이어 동일 로봇의 "픽업지에 도착함" 이벤트가 서버 인스턴스 2번에서 처리될 수 있습니다. 이때 2번 인스턴스는 당연하게도 1번 인스턴스에서 전이됐던 state machine의 context를 투명하게 볼 수 있어야 됩니다. 1번 인스턴스에서 로봇 A의 현재 배달 상태가 "픽업지로 이동 중"인데, 2번 인스턴스에서는 로봇 A의 현재 배달 상태가 여전히 "유휴" 상태라면 안됩니다. 가장 쉬운 해결책은, 짐작대로 모든 인스턴스가 함께 액세스하는 외부 공유 저장소를 활용하는 것입니다.

Spring Statemachine은 이런 상황에서 쓰라고 StateMachinePersist 기능을 제공하고 있습니다. 이 기능을 통해 특정 state machine의 message, extended state, 현재 상태 등을 통틀어 일컫는 state context라는 것을 Redis와 같은 외부 공유 저장소에 저장할 수 있습니다. 또 반대로 외부에 저장했던 state context를 인스턴스의 메모리로 복구시킬 수 있습니다. 따라서 모든 인스턴스가 상태 전이 시작 전에 Redis로부터 state context를 읽어와서 데이터를 최신화한 이후에 상태 전이를 수행하고, 상태 전이가 완료됐을 때는 state context를 Redis에 다시 저장하면 됩니다. 이러면 서버 간 동기화 문제가 손쉽게 해결됩니다. 참고로 이때 Spring Statemachine에서 내부적으로 사용하는 serializer는 Kryo라고 합니다. Kryo가 state context의 복잡한 Object 구조를 직렬화하고 역직렬화하는데 적합하다고 합니다.

Redis 저장소를 활용한 StateMachinePersist를 위해 필요한 Spring 설정은 아래와 같습니다.

@Configuration
public class StateMachineConfig extends EnumStateMachineConfigurerAdapter {
  // ...
  @Bean
  public StateMachinePersist stateMachinePersist(RedisConnectionFactory connectionFactory) {
    RedisStateMachineContextRepository repository =
        new RedisStateMachineContextRepository(connectionFactory);
    return new RepositoryStateMachinePersist(repository);
  }
  @Bean
  public RedisStateMachinePersister redisStateMachinePersister(
      StateMachinePersist stateMachinePersist) {
    return new RedisStateMachinePersister(stateMachinePersist);
  }
}

다음은 state context를 Redis에 저장하고, 복구시키는 예제 코드입니다.

public class MissionWorkerEventSender {
  private final StateMachineFactory missionStateMachineFactory;
  private final MissionWorkerPersister missionWorkerPersister;
  // ...
  private void sendEvents(RobotId robotId, List events) {
    // ...
    // Get statemachine.
    getRestoredStateMachine(robotId)
        .ifPresentOrElse(
            robotStateMachine -> {
              // Send event to statemachine and persist statemachine.
              sendEventAndPersist(robotId, robotStateMachine, events);
            },
            () -> doSomthingOnEmpty());
  }
  private Optional<StateMachine> getRestoredStateMachine(RobotId robotId) {
    // Get state machine from cache or create it.
    StateMachine stateMachine = robotStateMachines.computeIfAbsent(robotId, this::createStateMachine);
    // Update state machine with the latest state context stored in Redis.
    try {
      return Optional.of(stateMachinePersister.restore(robotStateMachine, redisKey));
    } catch (Exception e) {
      // ...
      return Optional.empty();
    }
  }

  private void sendEventAndPersist(...) { 
    // Send events to statemachine.
    robotStateMachine
        .sendEvents(eventMessages)
        .doOnNext(() -> doSomthingOnNext())
        .doOnComplete(() -> persistStateMachine(robotStateMachine)) // Store state context into Redis.
        .doOnError(() -> doSomthingOnError())
        .subscribe();
  }
  private void persistStateMachine(StateMachine robotStateMachine) {
    try {
      stateMachinePersister.persist(robotStateMachine, redisKey);
    } catch (Exception e) {
      // ...
    }
  }
}

테스트하기

이렇게 해서 Spring Statemachine 프레임워크를 이용하여 로봇들의 배달 상태를 관리하고 로봇과 소통할 수 있게 됐습니다. 마지막으로, 지금까지 구현한 내용을 검증하는 테스트 코드를 작성하고 싶을 것입니다.

State Machine 설정 내용 검증

우선 state machine의 설정이 올바르게 됐는지 간단하게 확인하는 테스트 코드를 작성해 보겠습니다. Spring Statemachine는 설정에 대해 간단하게 검증할 수 있는 StateMachineTestPlan을 제공하고 있습니다.

@Test
void testStatemachine_goingThroughNormalFlow() throws Exception {
  // ...
  StateMachineTestPlan plan =
      StateMachineTestPlanBuilder.builder()
          .stateMachine(testStateMachine)
          // Check initial state.
          .step()
          .expectState(DeliveryStateType.IDLE)
          .and()
          // Check transition.
          .step()
          .sendEvent(MissionWorkerEventType.DISPATCHED)
          .expectState(DeliveryStateType.GOING_TO_LOADING_POINT)
          .expectVariable(MissionStateVariable.VARIABLE_KEY, missionStateVariable)
          .and()
          // Check transition.
          .step()
          .sendEvent(MissionWorkerEventType.ARRIVED)
          .expectState(DeliveryStateType.WAITING_FOR_LOADING)
          .expectVariable(MissionStateVariable.VARIABLE_KEY, missionStateVariable)
          .and()
          // Check transition.
          .step()
          .sendEvent(MissionWorkerEventType.OPENED_LID)
          .expectState(DeliveryStateType.LOADING)
          .expectVariable(MissionStateVariable.VARIABLE_KEY, missionStateVariable)
          .and()
          // ...
          // Check transition.
          .step()
          .sendEvent(MissionWorkerEventType.COMPLETED_UNLOADING)
          .expectState(DeliveryStateType.IDLE)
          .expectVariable(MissionStateVariable.VARIABLE_KEY, missionStateVariable)
          .and()
          .build();
  // Test execution.
  plan.test();
}

위 예제 코드처럼 StateMachineTestPlan을 통해서 state machine이 우리의 의도대로 제대로 전이되는지, 그리고 각 전이 시점에 extended state의 variables 내용이 우리가 기대한 바와 같은지를 검사할 수 있습니다. 그리고 전이 결과를 확인함으로써 간접적으로 guard 로직의 동작까지 확인할 수 있습니다. 그러나 아쉽게도 기대하는 action의 동작까지는 확인할 방법이 없습니다. StateMachineTestPlan를 통한 Test의 한계입니다.

통합 테스트

그렇다면 action까지 확인하려면 어떻게 해야 할까요? 더 나아가 persist 기능도 잘 되는지 확인하면 좋을 것 같습니다. 이런 욕심을 충족하기 위해서는 single layered test가 아니라 어느 정도 수준의 통합 테스트가 필요합니다. 번거롭더라도 state machine과 연관된 주요 서비스 로직과 기능을 검증하는 테스트 코드를 작성해 두면, 앞으로 무언가를 수정할 때 발생할지도 모를 regression에 대한 걱정을 상당 부분 내려놓을 수 있을 것입니다. 그래서 로봇딜리버리플랫폼팀은 state machine과 그 주변 컴포넌트들을 함께 검증하는 통합 테스트 코드를 추가하였습니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@EmbeddedKafka // Use embedded Kafka.
class ThothServerDeliveryFlowTest {
  private RedisServer redisServer; // Use embedded Redis.

  // Client mocks
  @MockBean private ColumbusClient mockedColumbusClient;
  @MockBean private RobotControlServicePort mockedRobotControlService;
  // ...

  @Test
  void test_normal_delivery_flow() {
    // Each of the test cases below relies on the state machine context saved from their respective previous test cases.
    // Therefore, the test cases must be run in order.
    clearStore();
    tc01_dispatch_to_transit_from_idle_to_goingToLoadingPoint();
    tc02_arrive_to_transit_from_goingToLoadingPoint_to_waitingForLoading();
    tc03_openLid_to_transit_from_waitingForLoading_to_loading();
    tc04_complete_loading_before_closingLid_to_stay_in_loading();
    tc05_complete_loading_after_openingLid_to_stay_in_loading();
    tc06_complete_loading_after_closingLid_to_transit_from_loading_to_goingToUnloadingPoint();
    tc07_arrive_to_transit_from_goingToUnloadingPoint_to_waitingForUnloading();
    tc08_openLid_to_transit_from_waitingForUnloading_to_unloading();
    tc09_complete_unloading_before_closingLid_to_stay_in_unloading();
    tc10_complete_unloading_after_openingLid_to_stay_in_unloading();
    tc11_complete_unloading_after_closingLid_to_transit_from_unloading_to_idle();
    // ...
  }

  private void tc01_dispatch_to_transit_from_idle_to_goingToLoadingPoint() {...}
  private void tc02_arrive_to_transit_from_goingToLoadingPoint_to_waitingForLoading() {
    // given:
    DeliveryStateType sourceState = DeliveryStateType.GOING_TO_LOADING_POINT;
    assertEquals(
        sourceState,
        deliveryCurrentRepository.findByRobotId(testRobotId).get().getState());

    // when:
    moveProducer.send(
        KafkaMessageV1.of(
            MoveRequest.builder()
                .robotId(testRobotId)
                .latitude(testPickupLatitude)
                .longitude(testPickupLongitude)
                .build()));

    // then:
    DeliveryStateType targetState = DeliveryStateType.WAITING_FOR_LOADING;
    // Wait for the state machine to transit states.
    Awaitility.await()
        .atMost(ASYNC_JOB_WAIT_TIMEOUT)
        .pollInterval(ASYNC_JOB_CHECK_INTERVAL)
        .until(
            () -> deliveryCurrentRepository.findByRobotId(testRobotId).get().getState(),
            equalTo(targetState));
    // Check if dispatcher module received delivery state event sent by state machine action
    verify(mockedRiderEventService, timeout(ASYNC_JOB_WAIT_TIMEOUT.toMillis()).atLeastOnce())
        .updateRiderDeliveryState(testRiderId.riderId(), sourceState, targetState);
  }
  // ...
}

위 테스트 코드에서는 외부에 의존하는 일부 빈들은 mock으로 대체하였지만, 그 외의 대부분의 실제 빈들을 로드하고 있고, Embedded Kafka와 Embedded Redis까지 구동시켰습니다. 이렇게 해서 state machine 주변의 주요 layer들을 모두 테스트에 포함시켰습니다. 그래서 각 내부 테스트 케이스 메서드 안에서 Kafka 메시지와 같은 외부 사건이 의도대로 state machine의 동작을 잘 유발하는지, 그리고 state machine의 상태 전이 때마다 해당 state context를 Redis 저장소에 제대로 저장하고 Redis로부터 잘 복구하는지까지 확인할 수 있습니다. 또한 Awaitility.await() 등을 활용하여 action과 같은 비동기 로직이 일으키는 부가 효과(side effect)를 기다리고 그 결과를 확인하고 있습니다.

위 예제 코드 중 tc02 메서드 안을 살펴보겠습니다. 먼저 ‘given’ 영역에서는 tc02의 시작 조건에 해당하는 테스트 로봇의 현재 배달 상태(픽업지로 이동 중)를 확인하고 있습니다. 그리고 ‘when’ 영역에서 테스트 로봇에게 Kafka 메시지를 통해 이동 요청을 합니다. 이것으로 state machine를 동작시킬 외부 이벤트(로봇이 도착함)의 발생을 유도한 것입니다. 발생된 테스트 로봇의 도착 이벤트는 state machine을 깨우게 되고, 그러면 state machine이 Redis 저장소에 저장되어 있던 최신 state context(배달 상태: 픽업지로 이동 중)를 읽어와 현재 메모리를 갱신합니다. 그 후 상태 전이를 수행하고 그에 동반된 action을 동작시킵니다. 전이가 완료되면 변경된 state context(배달 상태: 적재 대기 중)를 다시 Redis 저장소에 기록합니다. tc02의 ‘then’ 영역에서는 이러한 일련의 전이 과정이 완료될 때까지 기다렸다가 그 최종 상태를 확인하고 있습니다.

이렇게 state machine 및 그와 연계된 주요 비즈니스 로직을 함께 확인함으로써 regression 걱정이 줄어 마음이 한결 가벼워집니다.

마치며

Spring Statemachine 프레임워크에 대해 간단히 소개드렸지만, Spring Statemachine 프레임워크에는 아직 설명하지 않은, 그리고 안 써봤기에 설명할 수 없었던 기능들이 아직 많이 있습니다. 서두에도 말씀드렸지만, 어떤 프레임워크를 사용할 때에 그 프레임워크가 제공하는 모든 기능을 전부 사용해야 되는 것은 아니기에, 로봇딜리버리플랫폼팀에서는 꼭 필요한 핵심 기능만 잘 선택하여 활용하고자 했습니다. 앞으로 ‘로봇 배달 관리 서비스’를 개발 운영하면서 또 어떤 새로운 것을 탐색하고 활용하게 될지 궁금합니다.

로봇딜리버리플랫폼팀이 Spring Statemachine를 도입하는 과정에서 얻은 작은 경험을 공유할 수 있게 되어 감사드리며 이글이 Spring Statemachine을 처음 접할 분들에게 도움이 되었길 바랍니다.