Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

루비로 상태 머신 마스터하기: 실전 가이드

상태 머신(state machine)은 어떤 대상이 가질 수 있는 모든 상태와, 그 상태들 사이에서 허용된 전환(transition)을 담아낼 수 있습니다. 예를 들어 문(door)의 상태 머신은 단 두 가지 상태(openclosed)와 두 가지 전환(openingclosing)만 가집니다.

반면 복잡한 상태 머신은 여러 다양한 상태와 그 사이를 잇는 수백 개의 전환을 가질 수도 있습니다. 실제로 주위를 둘러보면 어디에나 유한 상태 머신(finite state machine)이 존재한다는 것을 알게 됩니다. 웹사이트에서 물건을 구매할 때, 자판기에서 과자를 뽑을 때, 심지어 ATM에서 현금을 인출할 때도 말이죠.

이 글에서는 루비(Ruby)에서 상태 머신을 구성하는 방법과 state_machines 젬(gem)을 활용하는 방법을 살펴보겠습니다.

개발에서의 상태 머신

그렇다면 개발에서 상태 머신은 언제 필요할까요? 간단히 답하면, 상태 전환에 대한 여러 규칙을 모델링하거나 특정 전환이 일어날 때 부수 효과(side-effect)를 수행하고 싶을 때입니다. 핵심은 애플리케이션 중 상태 머신의 이점을 얻을 수 있는 부분을 잘 식별하는 것입니다. 저에게 늘 잘 통했던 좋은 예는 이커머스 애플리케이션의 Order(주문)입니다.

온라인으로 상품을 판매하는 간단한 애플리케이션에서 주문은 다음과 같은 여러 상태 중 하나일 수 있습니다.

  • created
  • processing
  • ready
  • shipped
  • delivered
  • void

허용되는 전환 관계는 아래 다이어그램에서 확인할 수 있습니다.

루비로 상태 머신 마스터하기: 실전 가이드

주문을 상태 머신의 형태로 시각화하면, 상품 주문부터 배송 완료까지 전체 흐름을 한눈에 명확하게 파악할 수 있습니다. 그리고 개발자 입장에서는 해당 흐름의 특정 지점에서 무엇을 할 수 있고, 무엇을 할 수 없는지에 대한 명확한 규칙을 세울 수 있게 됩니다.

다만 주의할 점도 있습니다. 지나치게 광범위한 문제에 상태 머신을 적용하려다 보면 수백 개의 상태로 불어나서 추론하고 따라가기 어려운 함정에 빠지기 쉽습니다. 따라서 구현에 들어가기 전에 반드시 최상위 수준의 개념과 상태 차트(state chart)를 먼저 준비하세요.

루비로 만드는 첫 번째 상태 머신

OrderStateMachine을 루비로 직접 구현해 보겠습니다.

class OrderStateMachine
  def initialize(order)
    @order = order
  end

  def process
    return false unless @order.state == "created"
    @order.update!(state: "processing")
  end

  def mark_ready
    return false unless @order.state == "processing"
    @order.update!(state: "ready")
  end

  def ship
    return false unless @order.state == "ready"
    @order.update!(state: "shipped")
  end

  def deliver
    return false unless @order.state == "shipped"
    @order.update!(state: "delivered")
  end
end

충분히 간단한 구현입니다. 상태 머신의 각 전환마다 새로운 메서드를 정의하고, 그 안에서 유효성 검사를 거친 후 전환을 수행합니다.

이 구현의 가장 큰 장점은 모든 것이 명시적(explicit)이라는 점입니다. 새로 합류한 개발자도 상태 머신의 전체 범위를 아주 빠르게 이해할 수 있습니다.

이번에는 전환에 부수 효과를 추가해 보겠습니다. 포장이 완료되어 배송 가능한 상태인 주문은 자동으로 출하 처리하도록 만들겠습니다.

def mark_ready
  return false unless @order.state == "processing"
  @order.update!(state: "ready")
  ship if @order.packed? && @order.deliverable?
end

이런 순진한(naïve) 구현도 충분히 잘 동작하지만, 전환과 조건이 많아지면 코드가 지나치게 장황해진다는 단점이 있습니다.

Ruby Toolbox를 검색해 보면 상태 머신 관련 젬이 여러 개 등장합니다. 그중 state_machinesaasm이 가장 인기 있는 선택지이며, 둘 다 Rails에서 사용할 경우를 대비해 ActiveRecord 어댑터를 함께 제공합니다. 두 젬 모두 충분히 검증되어 프로덕션 환경에서 바로 사용할 수 있으니, 상태 머신을 구현해야 한다면 꼭 확인해 보시기 바랍니다.

루비에서 state_machines 젬 사용하기

이 글에서는 state_machines 젬을 사용해 Order의 상태 머신을 모델링하는 방법을 소개하겠습니다.

class Order
  state_machine initial: :created do
    event :process do
      transition created: :processing
    end

    event :mark_ready do
      transition processing: :ready
    end

    event :ship do
      transition ready: :shipped
    end

    event :deliver do
      transition shipped: :delivered
    end

    event :fail_delivery do
      transition shipped: :ready
    end

    event :void do
      transition [:created, :processing] => :void
    end
  end
end

위 클래스는 간단한 상태 머신과 그에 속한 모든 전환을 정의합니다. 여기에 더해, 젬은 주문 객체에 다음과 같은 유용한 헬퍼 메서드들을 자동으로 제공합니다.

order = Order.new
order.created?       # => true
order.can_process?   # => true
order.process!       # 상태가 processing으로 전환됨
order.processing?    # => true
order.ship!          # => false, 아직 ready 상태가 아니므로 전환 불가

state_machines 젬으로 부수 효과 실행하기

실제 시스템에서 그렇듯이, 상태 머신의 많은 전환에는 부수 효과가 따릅니다. state_machines 젬은 이를 손쉽게 정의하고 실행할 수 있도록 도와줍니다. 두 가지 부수 효과를 추가해 보겠습니다.

  1. ready 상태로의 모든 전환에서, 주문이 deliverable하다면 배송 화물(consignment)을 생성합니다.
  2. fail_delivery 이벤트가 발생하면, 사용자에게 이메일을 보내 해당 사실을 알립니다.
class Order
  state_machine initial: :created do
    before_transition to: :ready do |order|
      order.create_consignment if order.deliverable?
    end

    after_transition on: :fail_delivery do |order|
      DeliveryMailer.delivery_failed(order).deliver_later
    end

    # 이벤트 정의는 앞선 예제와 동일
  end
end

전환은 평소와 같은 방식으로 수행하면 되며, 정의된 훅이 자동으로 실행됩니다.

order = Order.create!
order.process!        # created -> processing
order.mark_ready!     # processing -> ready, 배송 가능 시 consignment 생성
order.ship!           # ready -> shipped
order.fail_delivery!  # shipped -> ready, 사용자에게 알림 이메일 발송

젬이 제공하는 모든 옵션을 학습하려면 state_machines 젬의 GitHub 페이지를 직접 확인해 보시길 권합니다.

Rails에서 ActiveRecord와 함께 state_machines 젬 사용하기

앞서 언급했듯이 state_machinesaasm은 모두 ActiveRecord를 지원합니다. ActiveRecord와 함께 state_machines 젬을 사용하더라도 구현 방식에는 큰 변화가 없으며, 이 글 앞부분에서 다룬 내용 대부분을 그대로 활용할 수 있습니다.

여기에 더해 다음과 같은 추가 기능들을 얻을 수 있습니다.

  1. 트랜잭션 안에서 실행되는 부수 효과: 전환 훅 안에서 데이터베이스 작업을 수행했는데 트랜잭션이 실패하면, 상태 변경 역시 커밋되지 않습니다. 이 동작을 비활성화하고 싶다면 use_transactions 옵션을 확인하세요.
  2. 속성 업데이트 시점 주의: 다른 모든 ActiveRecord 콜백과 마찬가지로, 전환 과정에서 현재 모델의 속성을 업데이트하려면 before_transition 콜백 안에서 수행해야 합니다. after_transition 안에서 속성을 변경하려면 모델을 다시 저장해야 합니다. 아래 예제를 참고하세요.
class Order < ApplicationRecord
  state_machine initial: :created do
    before_transition any => :shipped do |order|
      order.shipped_at = Time.current  # 저장 전이므로 함께 커밋됨
    end

    after_transition any => :shipped do |order|
      order.touch(:notified_at)        # after에서는 별도 저장 필요
    end
  end
end
  1. 상태 필터링 스코프 자동 제공: 젬은 상태별로 모델을 필터링할 수 있는 스코프를 자동으로 만들어 줍니다. 예를 들어 Order.with_state(:processing)으로 처리 중인 모든 주문을 조회하거나, Order.without_state(:processing)으로 처리 중이 아닌 주문을 조회할 수 있습니다.
  2. 유효하지 않은 전환에 대한 에러 처리: 정의되지 않은 전환을 시도하면(예: processing에서 delivered로 직접 이동), 레코드 저장이 실패하고 유효성 검사 오류(validation error)에 에러가 추가됩니다. 생성된 오류 메시지를 국제화(i18n)하려면 설정 파일(예: en.yml)에 상태와 이벤트에 대한 번역 키를 추가하면 됩니다.
en:
  activerecord:
    state_machines:
      order:
        states:
          created: 'Created'
          processing: 'Processing'
          ready: 'Ready'
          shipped: 'Shipped'
          delivered: 'Delivered'
          void: 'Void'
        events:
          ship: 'Ship'
          deliver: 'Deliver'

참고: ActiveRecord 성능 문제를 진단하고 해결하는 방법이 궁금하다면 관련 글을 함께 확인해 보세요.

마무리: 루비로 상태 머신 만들기

이 글에서는 개발에서 상태 머신을 사용하는 이유를 살펴보고, 간단한 상태 머신을 직접 구현해 보았습니다. 마지막으로 루비와 Ruby on Rails 환경에서 state_machines 젬을 활용하는 방법까지 알아보았습니다.

이제 상태 머신에 대한 기본적인 이해를 갖추었으니, 주변에서 이미 상태 머신을 사용하고 있는 사례나 상태 머신을 도입하면 큰 도움이 될 만한 시나리오를 스스로 발견하게 될 것입니다.

다음 글에서 다시 만나요. 상태 머신 구축을 응원합니다!

P.S. Ruby Magic의 글을 발행 즉시 읽어보고 싶다면 Ruby Magic 뉴스레터를 구독하세요. 어떤 글도 놓치지 않을 수 있습니다!

루비로 상태 머신 마스터하기: 실전 가이드

Pulkit Goyal

게스트 저자 Pulkit은 시니어 풀스택 엔지니어이자 컨설턴트입니다. 여가 시간에는 자신의 경험을 블로그에 기록하고 있습니다.