[Akka] FSM(Finite State Machine)

smlee·2023년 11월 7일
0

Akka

목록 보기
48/50
post-thumbnail

우리는 이전 포스트들에서 context.become을 사용하여 상태(핸들러)를 변환했었다. 하지만, context.become은 매우 복잡한 메서드이므로 이를 대체할 수 있는 FSM(Finite State Machine)에 대해 알아보려 한다.

FSM은 Finite State Machine의 준말로, 직역하자면 한정된 상태 머신이다. 즉 여러 상태로 바뀔 수 있는(context.become) 액터를 이벤트 단위로 다루는 것이다. 따라서 직접 message를 다루지 않는다. 직접 다루는 것은 event이며, 이 event는 message와 data를 포함하고 있다. 따라서 핸들러가 존재하지 않는다.


시나리오

실생활에서 볼 수 있는 간단하지만 여러 번의 상태 변화가 있는 액터를 작성해보려고 한다. VendingMachine이라는 액터로, Initialized(inventory: Map[String,Int], prices: Map[String,Int])를 통하여 VendingMachine을 초기화 한다.

그리고 초기화가 완료되었다면 RequestProduct(product:String)을 통하여 사용자가 주문할 물품에 대한 메시지를 해당 액터로 보낸다. 그 후, Instruction(instruction: String)이라는 메시지를 통해 VendingMachine이 사용자로 하여금 어떠한 행동을 해야하는지 띄워준다.

그리고 ReceiveMoney(amount: Int)를 통해 얼만큼의 돈을 받았는지 처리하고, 물건을 사용자에게 전달하는 메시지로는 Deliver(product: String)이 된다. 만약 잔돈 역시 발생하였다면 GiveBackChange(amount:Int)를 사용자에게 추가로 보내준다.

이 과정들에서 발생할 수 있는 에러는 타임아웃 에러와 올바르지 않은 메시지가 온 경우이다. 이런 경우를 위해 ReceiveMoneyTimeoutVendingError(reason: String)를 사용자에게 보내준다.

1. FSM을 사용하지 않은 경우

이제 위의 시나리오 대로 코드를 작성해보려고 한다.

object VendingMachine {

  val INITIALIZE_ERROR = "Machine Not Initialized"
  val PRODUCT_AVAILABLE_ERROR = "Product Not Available"
  val REQUEST_TIMEOUT = "Request Timed out"
  def INSERT_MONEY_INSTRUCTION(price: Int) = s"Please Insert $price"

  case class Initialize(inventory: Map[String, Int], prices: Map[String, Int])
  case class RequestProduct(product: String)

  case class Instruction(instruction: String) 
  case class ReceiveMoney(amount: Int)
  case class Deliver(product: String)
  case class GiveBackChange(amount: Int)

  case class VendingError(reason: String)
  case object ReceiveMoneyTimeout
}

class VendingMachine extends Actor {
	  import VendingMachine._

  implicit val executionContext = context.dispatcher

  override def receive: Receive = idle

  private def idle: Receive = {
    case Initialize(inventory, prices) =>
      context.become(operational(inventory, prices))
    case _ => sender() ! VendingError(INITIALIZE_ERROR)
  }

  private def operational(inventory: Map[String, Int], prices: Map[String, Int]): Receive = {
    case RequestProduct(product) => inventory get (product) match {
      case None | Some(0) => sender() ! VendingError(PRODUCT_AVAILABLE_ERROR)
      case Some(_) =>
        val price = prices(product)
        sender() ! Instruction(INSERT_MONEY_INSTRUCTION(price))
        context.become(waitForMoney(inventory, prices, product, 0, startReceiveMoneyTimeoutSchedule, sender()))
    }
  }

  private def waitForMoney(
                          inventory: Map[String, Int], prices: Map[String, Int],
                          product: String, money: Int, moneyTimeoutSchedule: Cancellable,
                          requester: ActorRef
                          ): Receive = {
    case ReceiveMoneyTimeout =>
      requester ! VendingError(REQUEST_TIMEOUT)

      if(money > 0) requester ! GiveBackChange(money)
      context.become(operational(inventory, prices))

    case ReceiveMoney(amount) =>
      moneyTimeoutSchedule.cancel()

      val price = prices(product)

      if(money + amount >= price) {
        requester ! Deliver(product)

        if(money + amount > price)
          requester ! GiveBackChange(money + amount - price)

        val newStock = inventory(product) - 1
        val newInventory = inventory + (product -> newStock)

        context.become(operational(newInventory, prices))
      }

      else {
        val remainingMoney = price - money - amount
        requester ! Instruction(INSERT_MONEY_INSTRUCTION(remainingMoney))

        context.become(waitForMoney(inventory, prices, product, money + amount, startReceiveMoneyTimeoutSchedule, requester))
      }
  }

  private def startReceiveMoneyTimeoutSchedule = context.system.scheduler.scheduleOnce(1 second){
    self ! ReceiveMoneyTimeout
  }
}

코드가 복잡해 보이지만 차근차근 보면 어렵지 않다. 가장 먼저 idle 상태로 시작한다. 이는 자판기 내의 인벤토리 및 가격에 대한 정보가 있지 않은, 미초기화 상태로 시작한다. 따라서 해당 정보들을 담은 Initialize(inventory, prices)라는 메시지를 받아 처리해야 한다. 만약 초기화 메시지 외의 다른 부류의 메시지가 도착하면 에러를 띄운다.

초기화 메시지를 받았다면, 이제 작동 가능한 상태인 operational(inventory, prices)상태가 된다. 당연히 이 과정은 context.become으로 바뀐 것이다. operational에서는 요청 받은 상품이 존재하는 지 여부를 확인하고, 존재한다면 결제 처리를 하는 waitForMoney로 상태를 바꾼다.

waitForMoney에서는 일정 시간(여기서는 startReceiveMoneyTimeoutSchedule에서 스케줄링 된 1초) 동안 돈이 들어오지 않는다면 타임아웃 에러를 발생시키고, 물건 가격 이상의 돈이 들어왔을 경우 물건과 거스름돈 메시지를 보낸다. 만약 돈이 부족한 경우 남은 돈을 1초 동안 기다린다.

위의 코드는 상태가 조금이라도 바뀔 때마다 context.become을 사용한다. 따라서 복잡해질 가능성이 높다. 그렇다면 FSM을 사용하면 어떨까

2. FSM을 사용한 경우

FSM은 상태와 데이터 2가지가 존재한다. 따라서 동반 객체에 상태와 데이터에 대한 선언을 한다. 그리고 동반 클래스는 Actor를 확장하는 것이 아닌 FSM[상태 타입, 데이터 타입]을 확장시킨다. 코드를 통해 자세히 알아보자.

object VendingMachineFSM {

  def vendingMachineFSMProps: Props = Props[VendingMachineFSM]

  val COMMAND_ERROR = "Command Not Found"

  trait VendingState

  case object Idle extends VendingState
  case object Operational extends VendingState
  case object WaitForMoney extends VendingState

  /////////////////////////////////////////
  // data
  
  trait VendingData

  case object Uninitialized extends VendingData
  case class Initialized(inventory: Map[String, Int], prices: Map[String, Int]) extends VendingData
  case class WaitForMoneyData(inventory: Map[String, Int], prices: Map[String, Int], product: String, money: Int, requester: ActorRef) extends VendingData

}

class VendingMachineFSM extends FSM[VendingState, VendingData]{
  import VendingMachineFSM._
  import VendingMachine._

  startWith(Idle, Uninitialized)

  when(Idle) {
    case Event(Initialize(inventory, prices), Uninitialized) => goto(Operational) using Initialized(inventory, prices)
    case _ =>
      sender() ! VendingError(INITIALIZE_ERROR)
      stay()
  }

  when(Operational){
    case Event(RequestProduct(product), Initialized(inventory, prices)) => inventory.get(product) match {
      case None | Some(0) =>
        sender() ! VendingError(PRODUCT_AVAILABLE_ERROR)
        stay()
      case Some(_) =>
        val price = prices(product)
        sender() ! Instruction(INSERT_MONEY_INSTRUCTION(price))
        goto (WaitForMoney) using WaitForMoneyData(inventory, prices, product, 0, sender () )
    }
  }

  when(WaitForMoney, stateTimeout = 1 second){
    case Event(StateTimeout, WaitForMoneyData(inventory, prices, product, money, requester)) =>
      requester ! VendingError(REQUEST_TIMEOUT)

      if (money > 0) requester ! GiveBackChange(money)

      goto(Operational) using Initialized(inventory, prices)

    case Event(ReceiveMoney(amount), WaitForMoneyData(inventory, prices, product, money, requester)) =>
      val price = prices(product)

      if (money + amount >= price) {
        requester ! Deliver(product)

        if (money + amount > price)
          requester ! GiveBackChange(money + amount - price)

        val newStock = inventory(product) - 1
        val newInventory = inventory + (product -> newStock)

        goto(Operational) using Initialized(newInventory, prices)
      }

      else {
        val remainingMoney = price - money - amount
        requester ! Instruction(INSERT_MONEY_INSTRUCTION(remainingMoney))

        stay() using WaitForMoneyData(inventory, prices, product, money + amount, requester)
      }

  }

  whenUnhandled{
    case Event(_, _) =>
      sender() ! VendingError(COMMAND_ERROR)
      stay()
  }

  onTransition{
    case stateA -> stateB => log.info(s"Transitioning from $stateA to $stateB")
  }

  initialize()
}

우리는 FSM을 사용하지 않는 VendingMachine Actor에서 idle, operational, waitForMoney라는 핸들러들(상태들)을 사용했었다. 하지만 FSM은 메시지가 아닌 이벤트를 다룬다. 따라서 핸들러로 사용되었던 것들은 상태가 된다. 그리고, 핸들러 파라미터들은 데이터가 된다. 따라서 동반 객체에는 데이터와 상태를 선언해준다.

그리고, FSM[VendingState, VendingData]를 확장한다. 그리고 클래스 내부에 동반 객체인 VendingMachineFSM 뿐만 아니라 1번의 VendingMachine 액터의 동반 객체인 VendingMachine을 import시킨다. FSM은 메시지를 직접 다루는 것이 아닌 이벤트를 다루는 것이 맞지만, 이벤트가 메시지를 포함하고 있으므로 메시지 정보 역시 필요하다.

가장 먼저 startWith라는 메서드를 통해 초기 상태를 지정한다. Idle이 최초 상태이므로 startWith(Idle)이 된다.

그리고 while(상태)에는 해당 state일 때 FSM이 해야할 행위를 정의한다. 이때, 상태가 바뀐다면 goto(바뀔 상태) using 새 상태가 사용할 데이터 형식으로 사용한다. 만약 현재 상태를 유지한다면 stay()를 사용한다.

그리고 initialize()를 통해 초기화시키면 된다.

0개의 댓글