[Akka] FSM(Finite State Machine) 1

smlee·2023년 9월 7일
0

Akka

목록 보기
17/50
post-thumbnail

PersistentFSM은 FSM으로 들어오는 메시지들을 다룰 때 사용한다. 일련의 변화에 따라 내부 상태가 유지되며, 도메인 이벤트에 의해 인용되어진다. 수신되는 메시지들과 FSM의 상태 및 변화, 도메인 이벤트의 영속성의 관계는 DSL에 의해 정의된다.

Simple Example

PersistentFSM trait의 특징을 알기 위하여 웹 스토어 고객을 나타내는 액터를 선언한다고 생각하자. WebStoreCustomerFSMActor는 다음과 같은 command를 따를 것이다.

sealed trait Command
case class AddItem(item: Item) extends Command
case object Buy extends Command
case object Leave extends Command
case object GetCurrentCart extends Command

AddItem은 쇼핑 카트인 Buy로 사용자가 선택한 아이템을 옮기는 클래스이며, 사용자가 구매를 완료하면 Leave 클래스가 실행된다. 만약 구매를 완료하지 않았다면, GetCurrentCart가 실행되어 사용자의 장바구니 상태를 저장하는 역할을 담당한다.

고객들은 다음과 같은 상태를 지닐 수 있다.

sealed trait UserState extends FSMState
case object LookingAround extends UserState {
  override def identifier: String = "Looking Around"
}
case object Shopping extends UserState {
  override def identifier: String = "Shopping"
}
case object Inactive extends UserState {
  override def identifier: String = "Inactive"
}
case object Paid extends UserState {
  override def identifier: String = "Paid"
}

위의 객체들은 고객의 상태를 나타낸 객체이다. 즉, BuyLeave, GetCurrentCart 등과 같은 상태를 문자열로 표시해주는 역할이다.

❗️ 주의해야 할 점
PersistentFSM 상태들은 PersistentFSM.FSMState trait를 상속해야 하며 def identifier:String 메서들을 Implementation해야 한다. 이들은 FSM 상태들을 직렬화를 간단하게 해준다. 또한, identifier들은 고유해야 한다.
위의 예시에서 UserState가 FSMState를 상속받은 것이 그 이유이다.

장바구니에 있는 고객 상태 데이터는 다음과 같을 것이다.

case class Item(id: String, name: String, price: Float)

sealed trait ShoppingCart {
  def addItem(item: Item): ShoppingCart
  def empty(): ShoppingCart
}
case object EmptyShoppingCart extends ShoppingCart {
  def addItem(item: Item) = NonEmptyShoppingCart(item :: Nil)
  def empty() = this
}
case class NonEmptyShoppingCart(items: Seq[Item]) extends ShoppingCart {
  def addItem(item: Item) = NonEmptyShoppingCart(items :+ item)
  def empty() = EmptyShoppingCart
}

이제 전체적인 코드를 엮으면 다음과 같을 것이다.

startWith(LookingAround, EmptyShoppingCart)

when(LookingAround) {
  case Event(AddItem(item), _) =>
    goto(Shopping).applying(ItemAdded(item)).forMax(1 seconds)
  case Event(GetCurrentCart, data) =>
    stay().replying(data)
}

when(Shopping) {
  case Event(AddItem(item), _) =>
    stay().applying(ItemAdded(item)).forMax(1 seconds)
  case Event(Buy, _) =>
    goto(Paid).applying(OrderExecuted).andThen {
      case NonEmptyShoppingCart(items) =>
        reportActor ! PurchaseWasMade(items)
        saveStateSnapshot()
      case EmptyShoppingCart => saveStateSnapshot()
    }
  case Event(Leave, _) =>
    stop().applying(OrderDiscarded).andThen {
      case _ =>
        reportActor ! ShoppingCardDiscarded
        saveStateSnapshot()
    }
  case Event(GetCurrentCart, data) =>
    stay().replying(data)
  case Event(StateTimeout, _) =>
    goto(Inactive).forMax(2 seconds)
}

when(Inactive) {
  case Event(AddItem(item), _) =>
    goto(Shopping).applying(ItemAdded(item)).forMax(1 seconds)
  case Event(StateTimeout, _) =>
    stop().applying(OrderDiscarded).andThen {
      case _ => reportActor ! ShoppingCardDiscarded
    }
}

when(Paid) {
  case Event(Leave, _) => stop()
  case Event(GetCurrentCart, data) =>
    stay().replying(data)
}

EmptyShoppingCart로 시작하여 LookingAround인 상태로 고객은 시작한다. 그리고 LookingAround를 하는 도중, 물건을 담는 이벤트가 발생하면 이를 실행할 이벤트를 담는다. 이렇게 사용자의 상태에 따라 이벤트들을 설정해주는 역할을 한다.

이때, applyEvent 메서드를 오버라이드하여 도메인 이벤트에 의해 어떻게 상태 데이터가 영향을 받는지 나타내줘야 한다.

override def applyEvent(event: DomainEvent, cartBeforeEvent: ShoppingCart): ShoppingCart = {
  event match {
    case ItemAdded(item)  => cartBeforeEvent.addItem(item)
    case OrderExecuted    => cartBeforeEvent
    case OrderDiscarded   => cartBeforeEvent.empty()
    case CustomerInactive => cartBeforeEvent
  }
}

위와 같이 이벤트 처리 역시 완료되었다면, 이벤트의 영속성에 따라 어떤 액션들이 실행될 지 andThen을 사용하여 정의할 수 있다. 이렇게 되면 로깅이나 메시지 전송과 같은 "side effect"를 편히 다룰 수 있다.

goto(Paid).applying(OrderExecuted).andThen {
  case NonEmptyShoppingCart(items) =>
    reportActor ! PurchaseWasMade(items)
}

또한, saveStateSnapshot() 메서드를 호출함으로서 상태 데이터의 스냅샷이 영속되어진다. 이 메서드는 다음과 같이 사용된다.

stop().applying(OrderDiscarded).andThen {
  case _ =>
    reportActor ! ShoppingCardDiscarded
    saveStateSnapshot()
}

이렇게 스냅샷이 저장되었다면, 회복된 상태 데이터는 가장 최근의 스냅샷 데이터에 기반해서 초기화 된다. 그리고 남아있는 도메인 이벤트들이 다시 재생되면 applyEvent를 다시 발생시킨다.

Reference

0개의 댓글