Skip to content
Minimal Blog
BlueskyHomepage

Typestate Design Pattern

Programming, Design Pattern, Swift12 min read

Typestate Design Pattern

the new Design Pattern in Swift 5.9

Typestate 디자인 패턴은 객체의 상태를 타입으로 표현하고, 상태 전환을 타입 시스템으로 관리하는 강력한 디자인 패턴이다.

회전식 개찰구를 예로 들어보자.

잠긴 개찰구에 동전을 넣고 밀면 개찰구가 열린다. 한번열리면 다시 닫힘 상태로 돌아간다.

동전을 넣고 push 하는 과정을 코드로 풀어보면 다음과 같다.

struct Turnstile {
enum State {
case locked
case unlocked
}
private var state: State = .locked
private(set) var coins: Int = 0
mutating func insertCoin() {
guard state == .locked else { return }
coins += 1
state = .unlocked
}
mutating func push() {
state = .locked
}
}

var turnstile = Turnstile() // locked
turnstile.insertCoin() // unlocked
turnstile.push()

이와 같이 코드가 예쁘게만 돌아가지 않는다.
만약 돈을 넣고 밀지 않았다가, 한번더 돈 넣고 미는 상황이 발생한다면?
만약 돈을 넣지 않고 미는 상황이 발생한다면?

var turnstile = Turnstile() // locked
turnstile.insertCoin() // unlocked
turnstile.insertCoin() // ❌ ERROR: Can't insert coins when unlocked
turnstile.push() // locked
turnstile.push() // ❌ ERROR: Can't push when locked

이 경우 컴파일 에러가 나지는 않지만, 의도한 대로 동작하지는 않는다.

Typestae Driven Pattern 의 출두

로직적인 이슈가 있다면 컴파일 단에서 확인하겠다는 취지의 typestate driven pattern 이 나오게 된 것!

extension Turnstile where State == Locked {
func insertCoin() -> Turnstile<Unlocked> { // insertCoin operation can only be performed on `Turnstile<Locked>`
// guard state == .locked else { return }
return Turnstile<Unlocked>(coins: coins + 1)
}
}

위와 같이 Turnstile 을 생성하면 Unlocked 상태 일 때에만 insertCoin 함수를 호출할 수 있다. guard 문으로 감싸서 현재 상태 state 를 확인할 필요가 없다.

extension Turnstile where State == Unlocked {
func push() -> Turnstile<Locked> {
return Turnstile<Locked>(coins: coins)
}
}

마찬가지로 push 는 Locked 상태에서만 유효한 메소드가 된다.

unlocked.insertCoin() // ❌ ERROR: Can't insert coin into an unlocked turnstile
locked = unlocked.push()
locked.push() // ❌ ERROR: Can't push a locked turnstile

컴파일 에러를 발생함으로써 비정상적인 동작은 수행하지 않도록 보장할 수 있다.

하지만 여전히 문제가 있다.

하지만 여기에도 문제는 있다.

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

1. 값 타입의 복사 문제 (Copy semantics)

Turnstile은 구조체로 정의되어있기 때문에 insertCoin() 메서드를 호출할 때마다 locked 의 복사본이 생성된다. 즉, locked 의 복사본에 대한 결과를 unlocked에 할당한다. 문제는 locked 는 여전히 초기 상태의 객체로 유지 된다는 것이다.

따라서, 이후에 locked.insertCoin()을 다시 호출해도 locked는 여전히 복사된 상태로 작업하므로, 예상과 다른 동작이 발생한다.

2. 상태 전환이 제대로 적용되지 않음

Typestate 패턴의 핵심은 각 상태를 명확히 정의하고 허용된 전환만 가능하게 제한하는 것이다. 그러나 위 코드에서는 locked의 상태가 계속 바뀌지 않고 원래 상태로 남아있기 때문에 상태 전환이 제대로 적용되지 않는다.

  • locked.insertCoin() 은 새로운unlocked 를 반환하지만, locked 자체는 여전히 Locked 상태이다.
  • 다시 locked.insertCoin() 을 호출하면, locked는 여전히 Locked 상태로 시작한다.
  • 결과적으로 Locked는 상태 전환 로직이 제대로 동작하지 않으며, 코드는 실제 상태를 반영하지 못한다.

어떻게 해결해?

구조체를 클래스로 바꾸면?

직관적으로는 Turnstile 유형을 구조체 대신 클래스로 선언하는 방법이 있다. 상태를 전환할 때마다 새로운 복사본 객체를 생성하는 대신 동일한 인스턴스를 계속 가리키고 최신 상태를 관찰하는 것이다.

안타깝게도, 아래 두가지 문제가 존재한다.

  • Typestate 패턴에서는 각 상태를 독립적인 타입으로 표현해야 한다.
    • 클래스는 동일한 메모리를 참조하기 때문에, 서로 다른 타입의 상태를 한 객체로표현할 수 없다.
  • 참조 방식은 Race Condition (경합 상태) 같은 문제를 발생할 수 있다.
    • 예를 들어서 여러 스레드에서 같은 객체를 동시에 수정하면 예층 불가능한 오류가 발생할 수 있다.

actor 를 사용하면?

구조체에서 클래스나 actor 로 바꾸면 상태전환이 비동기적으로 처리되어야 하며, 시스템이 더 복잡해 질 수 있다.

그럼 어떻게 해? swift 5.9의 메모리 소유 모델

원하는 결과가 뭔지 다시 생각해보자. 상태 전환에서 locked의 상태를 업데이트하고, 이전 상태를 사용할 수 없도록 설계해야 한다.

var locked = Turnstile<Locked>()
var unlocked = locked.insertCoin()
// 이후 locked는 더 이상 유효하지 않음

이를 위해서는 상태 전환 후 이전 상태를 사용할 수 없도록 해야 한다.

이게 바로 Swift 5.9의 메모리 소유 모델이 나오게 된 이유이다! 이를 통해 Typestate 패턴에 맞는 설계를 구현할 수 있다.

Noncopyable State

swift 5.9 에서는 Noncopyable 타입 (복사 불가능한 타입)을 도입 했으며 'move-only' 타입으로 불리기도 한다. 이 타입은 구조체나 열거형에 ~Copyable ('~' : NOT 을 의미) 키워드를 사용하여 정의한다. 이렇게 정의된 타입은 값이 복사되지 않음을 컴파일러에게 알리는 역할을 한다.

Notcopyable 타입이란?

  • 특정 값이 복사되지 않도록 제한
  • 복사를 막음으로써 값을 안전하게 한 번만 사용하도록 강제

consuming 함수란?

consuming 함수란 값을 '소모' 하는 함수로, 호출 이후 원래 값을 더이상 사용할 수 없게 만든다.

func changeState(_ state: consuming State) { ... }

이 함수에 값을 전달하면, 해당 값은 함수 내부에서만 사용되고 호출 이후에는 접근할 수 없다.

Typestate 패턴에서는 상태 전환 후 이전 상태를 무효화 해야한다.

Noncopyable 타입과 consuming 함수를 활용하면, 상태 전환 후 이전 상태를 강제로 사용할 수 없도록 컴파일러가 보장한다. 이를 통해 Typestate 패턴의 안전성과 일관성을 강화할 수 있다.

Turnstile 예시로 다시 돌아와 보자.

Turnstile 을 ~Copyable 타입으로 정의한다.

struct Turnstile<State>: ~Copyable {
...
}

모든 함수를 consuming 함수로 변경한다.

extension Turnstile where State == Locked {
consuming func insertCoin() -> Turnstile<Unlocked> {
Turnstile<Unlocked>(coins: coins + 1)
}
}
extension Turnstile where State == Unlocked {
consuming func push() -> Turnstile<Locked> {
Turnstile<Locked>(coins: coins)
}
}

처음 위의 코드를 다시 실행하게 되면 컴파일 에러가 발생한다.

let locked = Turnstile<Locked>()
var unlocked = locked.insertCoin()
unlocked = locked.insertCoin() // ❌ ERROR: 'locked' consumed more than once

Unlocked 상태로 전환하자마자 locked 변수의 수명이 종료되어 재사용이 불가하기 때문이다.

정상 코드

var locked = Turnstile<Locked>()
var unlocked = locked.insertCoin()
// assigning new locked value
locked = unlocked.push()
unlocked = locked.insertCoin() // assigning next state
unlocked.coins // 2
  1. 초기 상태 설정

    var locked = Turnstile<Locked>()
  2. 동전을 넣어 상태 전환

    var unlocked = locked.insertCoin()
    • locked.insertCoin() 메서드를 호출하면, Turnstile이 Locked 상태에서 Unlocked 상태로 전환된다.
    • 이 메서드의 결과로 반환된 새 상태를 unlocked에 할당한다.
      • unlocked는 이제 Turnstile<Unlocked> 타입을 가지며, “잠금 해제된 상태”이다.
    • 이 시점에서 locked는 여전히 이전 상태(Locked)를 유지하고 있다.
  3. 다시 잠금 상태로 전환

    locked = unlocked.push()

    locked.insertCoin() 메서드를 호출하면, Turnstile이 Locked 상태에서 Unlocked 상태로 전환된다.

    • 이 메서드의 결과로 반환된 새 상태를 unlocked에 할당한다.
    • unlocked는 이제 Turnstile<Unlocked> 타입을 가지며, “잠금 해제된 상태”이다.
    • 이 시점에서 locked는 여전히 이전 상태(Locked)를 유지하고 있다.
  4. 다음 상태로 전환

    unlocked = locked.insertCoin()

체인형 operation 도 가능하다.

var turnstile = Turnstile<Locked>()
.insertCoin()
.push()
.insertCoin()
.push()
turnstile.coins // 2
turnstile
.insertCoin()
.insertCoin() // ❌ ERROR: can't insertCoin again
.push()
.push() // ❌ ERROR: can't push again

정리

  • Typestate 패턴은 객체의 상태를 명확히 정의하고, 상태에 따라 가능한 동작을 제한하는 디자인 패턴이다.
  • 특징
    • 설계 : 객체의 상태를 타입으로 표현한다.
    • 안전성 : 컴파일 타임에 허용되지 않는 동작을 방지한다.
    • 상태 전환 : 상태 변경 후 이전 상태는 더이상 사용할 수 없다. 상태 전환은 새 객체를 생성하는 방식으로 처리한다.
    • 명확한 제어 : 각 상태는 고유한 기능만 가지므로, 불필요한 복잡성을 줄인다.
  • Swift 5.9 Noncopyable 타입: 상태 전환 후 잘못된 상태 재사용을 방지한다.

참고

  • https://swiftology.io/articles/typestate/#what-is-a-typestate-design-pattern