15 June 2024

유한 상태 머신(Finite State Machine, FSM)은 소프트웨어 개발에서 자주 사용하는 패턴 중 하나입니다. 특정 사건(Event)에 의해 한 상태에서 다른 상태로 변할 수 있으며, 이를 전이(Transition)라고 합니다. 다양한 시스템의 동작을 모델링하는 데 유용합니다.

FSM을 작성하다 보면 다양한 상태와 이벤트 등을 다루게 되는데, 코드가 복잡해지는 경우가 있습니다.

예를 들어, 간단한 턴스타일(Turnstile)의 FSM을 생각해봅시다.

struct Turnstile {
  enum State {
    case locked, unlocked
  }

  enum Event {
    case insertCoin, push
  }

  private(set) var state: State = .locked

  mutating func handleEvent(_ event: Event) {
    switch (state, event) {
    case (.locked, .insertCoin):
      state = .unlocked
      print("Turnstile is now unlocked")
    case (.locked, .push):
      print("❌ Turnstile is locked. Please insert a coin.")
    case (.unlocked, .insertCoin):
      print("❌ Turnstile is already unlocked. You can push.")
    case (.unlocked, .push):
      state = .locked
      print("Turnstile is now locked")
    }
  }
}

var turnstile = Turnstile()
turnstile.handleEvent(.insertCoin) // Output: "Turnstile is now unlocked"
turnstile.handleEvent(.insertCoin) // Output: "❌ Turnstile is already unlocked. You can push."
turnstile.handleEvent(.push) // Output: "Turnstile is now locked"
turnstile.handleEvent(.push) // Output: "❌ Turnstile is locked. Please insert a coin."

위 코드에서는 상태와 이벤트를 Enum으로 정의하고, 상태 전이를 조건문(Switch-Case)으로 작성하였습니다. 이 방법에는 다음과 같은 문제점이 있습니다.

  • 확장성 부족: 새로운 상태나 이벤트를 추가할 때마다 조건문을 수정해야 합니다.
  • 유지보수 어려움: 상태 전이 로직이 분산되어 있으면 코드의 가독성과 유지보수가 어려워집니다.
  • 안전성 부족: 상태 전이가 잘못 정의되거나 빠질 경우, 예기치 않은 동작이 발생할 수 있습니다.

제네릭을 활용한 상태 전이 정의

상태 enum의 각 case를 가지지 않는 LockedUnlocked Enum 타입으로 정의하고, Turnstile은 내부에서 가지고 있던 상태를 제네릭으로 받아 상태를 런타임에서 컴파일 타임 유형으로 정의할 수 있습니다.

enum Locked {}
enum Unlocked {}

struct Turnstile<State> {}

let locked = Turnstile<Locked>()
let unlocked = Turnstile<Unlocked>()

다음으로 각 상태에서 수행할 수 있는 이벤트를 Enum이 아닌, 함수를 호출하도록 하며, 각 이벤트는 해당 상태에서만 사용할 수 있게 제한을 둡니다.

extension Turnstile where State == Locked {
  func insertCoin() -> Turnstile<Unlocked> {
    print("Turnstile is now unlocked")
    return .init()
  }
}

extension Turnstile where State == Unlocked {
  func push() -> Turnstile<Locked> {
    print("Turnstile is now locked")
    return .init()
  }
}

let locked = Turnstile<Locked>()
let unlocked = locked.insertCoin()

locked.push() // ❌ Referencing instance method 'push()' on 'Turnstile' requires the types 'Locked' and 'Unlocked' be equivalent

unlocked.push() // Output: "Turnstile is now locked"
unlocked.insertCoin() // ❌ Referencing instance method 'insertCoin()' on 'Turnstile' requires the types 'Unlocked' and 'Locked' be equivalent

각 함수에서는 변경된 상태를 타입인 Turnstile을 반환하도록 하여, 각 이벤트는 해당 상태에서만 사용할 수 있게 제약을 두어 다른 이벤트를 사용할 수 없도록 만들었습니다.

하지만 lockedunlocked는 함수 호출 뒤에도 사용할 수 있는 문제가 있습니다. 상태의 수명을 제한하고, 임의의 재사용을 지양해야 문제를 방지할 수 있습니다.

Swift 5.9의 SE-0377 - borrowing and consuming parameter ownership modifiersSE-0390 - Noncopyable structs and enums의 consuming을 이용하여 사용한 상태를 해제하는 방식을 활용할 수 있습니다.

Noncopyable를 활용한 상태 재사용 제한

Noncopyable 타입은 Struct, Enum에 추가할 수 있습니다. Noncopyable을 추가하여 자신을 복사 불가능하도록 선언하여 상태의 수명을 제한합니다.

struct Turnstile<State>: ~Copyable {}

extension Turnstile where State == Locked {
  consuming func insertCoin() -> Turnstile<Unlocked> {
    print("Turnstile is now unlocked")
    return .init()
  }
}

extension Turnstile where State == Unlocked {
  consuming func push() -> Turnstile<Locked> {
    print("Turnstile is now locked")
    return .init()
  }
}

let locked = Turnstile<Locked>() 
let unlocked = locked.insertCoin()

_ = unlocked.push()
_ = unlocked.push() // ❌ 'unlocked' consumed more than once
_ = locked.insertCoin() // ❌ 'locked' consumed more than once

consume을 사용하여 변수의 수명이 종료되어 재사용이 불가능해졌습니다. 위 코드와 같이 변수를 재사용하려고 하면 컴파일러가 에러를 발생시켜 안전한 코드를 작성할 수 있게 됩니다.

또한, var로 작성 시 기존 변수에 새로운 값을 다시 할당하는 것은 가능하지만, 기존 변수를 재사용하는 것은 여전히 불가능합니다.

var locked = Turnstile<Locked>()
var unlocked = locked.insertCoin()

locked = unlocked.push()
unlocked = locked.insertCoin()
locked = unlocked.push()
unlocked = locked.insertCoin()

unlocked = locked.insertCoin() // ❌ 'locked' consumed more than once
locked = unlocked.push() // ❌ 'locked' consumed more than once

Noncopyable을 활용하여 FSM의 안전성을 더욱 강화할 수 있습니다.

정리

Swift의 제네릭을 활용하여 FSM을 구현하는 것은 코드의 확장성과 안전성을 높이고 버그를 줄이는 데 도움이 됩니다. Noncopyable을 통해 값의 수명을 제한하여 재사용을 막음으로써 상태의 안전성을 보장할 수 있습니다. 코드의 확장성과 안전성을 높이는 데 타입 시스템을 활용하는 것은 안정성과 확장성 있는 애플리케이션을 개발하는 데 중요한 요소입니다.

참고자료