Skip to main content

Command Palette

Search for a command to run...

유한 오토마타는 신이야

우리는 상태머신을 왜 사용해야하는가

Updated
9 min read
유한 오토마타는 신이야

상태머신이란 무엇인가?

다른 단어로 유한 오토마타(Finite Automata)라고도 이야기하는데, 단순하게 해석하면 유한한 상태를 가지는 기계라고 이해하면 편하다.

우리는 많은 경우에 상태값을 가지고서 행동하게 된다.

대기 ──이벤트 1──▶ 진행중 ──이벤트 2──▶ 완료 or 취소

위 flow를 다음과 같이 해석할 수 있다.

  • 대기는 '시작 상태'다.
  • 상태가 변화하는 것은 특정한 이벤트가 일어날 때이다.
  • 완료취소는 '끝 상태'이다.

이와 같이 우리는 언젠가 저 기계가 '끝 상태'에 도달할 것이라고 예상한다. 이것을 사람들은 '유한한 상태를 가진다'라고 표현하며, 유한 오토마타라고 부르게 된 것이다.

사실 나는 지금 굉장히 허술하게 이 상태머신을 설명하고 있지만, 컴퓨터공학에서 말하는 모델과 수학에서 이야기하는 모델로 나뉘는 편이다. 자세한 이야기는 하단의 위키백과를 참고하면 된다.

유한 상태 기계 - 위키백과


코드로 보는 상태머신

말로만 하면 와닿지 않을 수 있다. 상태머신 없이 상태를 관리하는 코드부터 살펴보자.

상태머신 없이 관리하는 경우

class OrderService(
    private val orderRepository: OrderRepository,
) {
    fun approve(orderId: Long) {
        val order = orderRepository.findById(orderId)

        // 상태 검증을 개발자가 직접 해야 한다
        if (order.status != OrderStatus.PENDING) {
            throw IllegalStateException("승인은 PENDING 상태에서만 가능합니다. 현재: ${order.status}")
        }

        order.status = OrderStatus.APPROVED
        orderRepository.save(order)
    }

    fun ship(orderId: Long) {
        val order = orderRepository.findById(orderId)

        if (order.status != OrderStatus.APPROVED) {
            throw IllegalStateException("배송은 APPROVED 상태에서만 가능합니다. 현재: ${order.status}")
        }

        order.status = OrderStatus.SHIPPED
        orderRepository.save(order)
    }

    fun cancel(orderId: Long) {
        val order = orderRepository.findById(orderId)

        // PENDING과 APPROVED에서만 취소 가능... 맞나?
        if (order.status != OrderStatus.PENDING && order.status != OrderStatus.APPROVED) {
            throw IllegalStateException("취소할 수 없는 상태입니다. 현재: ${order.status}")
        }

        order.status = OrderStatus.CANCELLED
        orderRepository.save(order)
    }
}

이 코드의 문제는 명확하다.

  • 상태 전이 규칙이 각 메서드에 흩어져 있다. 전체 흐름을 파악하려면 모든 메서드를 뒤져봐야 한다.
  • if 분기를 하나라도 빠뜨리면 비정상 전이가 허용된다.
  • 상태가 추가될 때마다 모든 메서드를 수정해야 한다.

상태머신으로 관리하는 경우

val orderMachine = stateMachine<OrderStatus, OrderEvent, Order> {
    from(OrderStatus.PENDING) {
        on<OrderEvent.Approve>() goto OrderStatus.APPROVED
        on<OrderEvent.Cancel>() goto OrderStatus.CANCELLED
    }
    from(OrderStatus.APPROVED) {
        on<OrderEvent.Ship>() goto OrderStatus.SHIPPED
        on<OrderEvent.Cancel>() goto OrderStatus.CANCELLED
    }
    from(OrderStatus.SHIPPED) {
        on<OrderEvent.Deliver>() goto OrderStatus.DELIVERED
    }
}

이 코드 한 블록만 보면 전체 상태 흐름을 한눈에 파악할 수 있다. 정의되지 않은 전이(예: SHIPPED에서 Cancel)는 상태머신이 자동으로 거부한다. 서비스 레이어는 이렇게 간결해진다.

class OrderService(
    private val orderRepository: OrderRepository,
    private val orderMachine: StateMachine<OrderStatus, OrderEvent, Order>,
) {
    fun approve(orderId: Long) {
        val order = orderRepository.findById(orderId)
        val result = orderMachine.fire(order, OrderEvent.Approve)
        orderRepository.save(result.context)
    }

    fun ship(orderId: Long) {
        val order = orderRepository.findById(orderId)
        val result = orderMachine.fire(order, OrderEvent.Ship)
        orderRepository.save(result.context)
    }
}

상태 검증 로직이 사라졌다. 상태머신이 대신 검증해주기 때문이다.


언제 어떻게 사용하는 것이 좋을까?

지금까지 장황하게 상태머신에 대해서 이야기했다. 그러면 이걸 언제 사용해야 하는지에 대해서 모른다면 굳이 상태머신의 정의를 알 필요가 없을 것이다.

이럴 때 사용하면 좋다

  • 상태값을 2개 이상 관리해야 하는 순간부터 사용하는 것이 좋다.
  • 이벤트 기반 아키텍처를 추구한다면 사용하면 좋다.
  • 자신이 속한 비즈니스가 너무 복잡해서 어딘가 명세가 있었으면 좋겠다고 생각한다면 사용하는 것이 좋다.

대표적인 사용 사례

도메인상태 예시이벤트 예시
주문 관리PENDING → APPROVED → SHIPPED → DELIVEREDApprove, Ship, Deliver, Cancel
문서 관리DRAFT → REVIEW → PUBLISHED → ARCHIVEDSubmit, Approve, Publish, Archive
결재 시스템작성 → 1차 승인 → 2차 승인 → 완료Submit, Approve, Reject, Cancel
CI/CD 파이프라인Build → Test → Deploy → RunningTrigger, Pass, Fail, Rollback

내가 생각하는 상태머신의 장점과 단점

장점

  • 이벤트를 기반으로 상태의 변화에 대한 명세를 관리하기 때문에 데이터의 흐름을 파악하거나 비즈니스를 이해하기에 수월하다.
  • 개발자의 실수를 상태머신이 어느 정도 보완해주며 막아주기 때문에 프레임워크로서의 역할을 해준다.
  • 특정 상태에서의 비정상(기대하지 않는) 이벤트에 대한 검증을 상태머신이 대신 해준다.
// ARCHIVED 상태에서 Publish 이벤트를 보내면?
val archived = Document(DocumentStatus.ARCHIVED, "content")
machine.canFire(archived, DocumentEvent.Publish) // false

// fire()를 호출하면 InvalidTransitionException이 발생한다
machine.fire(archived, DocumentEvent.Publish)
// → InvalidTransitionException: No valid transition from state 'ARCHIVED' with event 'Publish'

단점

  • 아주 간단한 작업(특정 필드의 값을 수정)에 대해서도 이벤트를 정의하고 상태에 대한 명세를 작성해야 하기 때문에 약간 번거롭다.
  • 이벤트를 추가하면 삭제하기가 어렵다.
  • 버전 관리를 하기 힘들다. 이미 운영 중인 상태머신의 전이 규칙을 변경하면 기존 데이터와의 정합성 문제가 발생할 수 있다.

위 내용을 종합해 보면, 상태머신은 프레임워크로서 강력한 상태 및 이벤트 관리를 보장하지만 그만큼 기존 flow를 변경하는 것은 고비용이라는 것을 알 수 있다.


내가 상태머신을 직접 만든 이유

나는 상태머신을 직접 만들어서 사용하는 편인데, 이런저런 복잡한 사정이 있다.

관심이 있는 분들은 아래의 GitHub을 이용해 주세요. (contribute 대환영)

https://github.com/Tianea2160/statemachine

일단 나는 웬만하면 직접 만들어서 사용하지는 않는 편이다. 왜냐하면 Spring이라는 거대한 생태계가 내가 필요로 하는 모든 것을 거의 대부분 지원해주고 있기 때문이다.

그런데 상태머신에 대해서는 그다지 만족스럽지 못하다.

spring-statemachine은 일단 굉장히 잘 만든 프레임워크라는 것을 먼저 이야기하고 싶다. 그렇지만 이걸 사용하지 않는 이유는 다음과 같다.

1. 최신 Spring Boot 버전에 대한 지원이 느리다

현재(2026.02.08)를 기준으로 Spring Boot 4가 나왔고 Spring Framework 7을 지원하지만, 아직 spring-statemachine은 Spring 7을 지원하지 않는다.

회사라면 이렇게 빠른 버전을 바로 사용하지는 않을 것이라서 괜찮겠지만, 개인적으로 사용할 때는 이것 때문에 버전을 낮춰야 하는데, 그만큼의 메리트가 있느냐고 생각할 때에는 '아니다'라고 말할 것이다.

Spring Statemachine - GitHub

2. 가볍게 사용하고 싶다

나는 spring-statemachine-core를 사용하는데, 솔직히 인메모리 상태머신만 사용하는 상황에서 스프링 프레임워크가 주는 부수적인 기능들은 아무것도 필요하지 않았다.

내가 필요한 것은 딱 이것뿐이었다.

  • 상태와 이벤트를 정의한다.
  • 전이 규칙을 선언한다.
  • 이벤트를 보내면 상태가 바뀐다.
  • 잘못된 전이는 거부한다.

3. 상태머신에 비즈니스 로직을 넣지 않는 것이 더 낫다

spring-statemachine의 Action에 비즈니스 로직을 넣어도 봤는데, 그렇게 안 넣는 것이 오히려 더 유지보수하기 편하고 가독성도 올라간다.

이와 관련해서는 말보다는 코드로 이야기하겠다.

spring-statemachine에 비즈니스 로직을 넣는 경우

// Spring StateMachine 방식 - Action에 비즈니스 로직이 들어간다
@Configuration
class OrderStateMachineConfig : StateMachineConfigurerAdapter<OrderStatus, OrderEvent>() {

    override fun configure(transitions: StateMachineTransitionConfigurer<OrderStatus, OrderEvent>) {
        transitions
            .withExternal()
            .source(OrderStatus.PENDING).target(OrderStatus.APPROVED)
            .event(OrderEvent.APPROVE)
            .action { context ->
                // 비즈니스 로직이 여기에...
                val order = context.getExtendedState().get("order", Order::class.java)
                order.approvedAt = Instant.now()
                order.approvedBy = SecurityContextHolder.getContext().authentication.name
                notificationService.sendApprovalNotification(order)
                inventoryService.reserve(order.items)
                // 점점 비대해진다
            }
    }
}

이 방식은 상태머신 설정 안에 서비스 호출, 알림, 재고 처리 등이 뒤섞여서 상태 전이 규칙이 비즈니스 로직에 묻혀버린다.

상태머신은 상태 관리만, 비즈니스 로직은 서비스에서

// 상태머신은 순수하게 전이 규칙만 정의
val orderMachine = stateMachine<OrderStatus, OrderEvent, Order> {
    from(OrderStatus.PENDING) {
        on<OrderEvent.Approve>() goto OrderStatus.APPROVED
        on<OrderEvent.Cancel>() goto OrderStatus.CANCELLED
    }
    from(OrderStatus.APPROVED) {
        on<OrderEvent.Ship>() goto OrderStatus.SHIPPED
    }
}

// 비즈니스 로직은 서비스 레이어에서 명확하게 분리
class OrderService(
    private val orderMachine: StateMachine<OrderStatus, OrderEvent, Order>,
    private val orderRepository: OrderRepository,
    private val notificationService: NotificationService,
) {
    fun approve(orderId: Long) {
        val order = orderRepository.findById(orderId)

        // 1. 상태 전이 (상태머신이 검증 + 전이)
        val result = orderMachine.fire(order, OrderEvent.Approve)

        // 2. 비즈니스 로직 (서비스가 담당)
        notificationService.sendApprovalNotification(result.context)

        // 3. 저장
        orderRepository.save(result.context)
    }
}

이렇게 하면 상태머신 정의만 보면 전체 상태 흐름이 보이고, 비즈니스 로직은 서비스 레이어에서 읽으면 된다. 각자의 책임이 명확하다.


직접 만든 라이브러리 소개

위와 같은 이유로 직접 만든 라이브러리를 간단히 소개한다.

설치

// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        mavenCentral()
        maven { url = uri("https://jitpack.io") }
    }
}

// build.gradle.kts
dependencies {
    implementation("com.github.Tianea2160:statemachine:v1.0.0")
}

기본 사용법

상태, 이벤트, 도메인 모델을 정의한다.

// 1. 상태 정의
enum class DocumentStatus : State {
    DRAFT, PUBLISHED, ARCHIVED
}

// 2. 이벤트 정의
sealed interface DocumentEvent : Event {
    data object Publish : DocumentEvent
    data object Archive : DocumentEvent
}

// 3. 도메인 모델 - Stateful 인터페이스를 구현
data class Document(
    override val state: DocumentStatus,
    val content: String,
) : Stateful<DocumentStatus, Document> {
    override fun withState(newState: DocumentStatus): Document =
        copy(state = newState)
}

상태머신을 선언하고 사용한다.

// 4. 상태머신 정의
val machine = stateMachine<DocumentStatus, DocumentEvent, Document> {
    from(DocumentStatus.DRAFT) {
        on<DocumentEvent.Publish>() goto DocumentStatus.PUBLISHED
        on<DocumentEvent.Archive>() goto DocumentStatus.ARCHIVED
    }
    from(DocumentStatus.PUBLISHED) {
        on<DocumentEvent.Archive>() goto DocumentStatus.ARCHIVED
    }
}

// 5. 사용
val doc = Document(DocumentStatus.DRAFT, "Hello World")
val result = machine.fire(doc, DocumentEvent.Publish)

println(result.previousState)  // DRAFT
println(result.newState)       // PUBLISHED
println(result.stateChanged)   // true

Guard - 조건부 전이

특정 조건을 만족할 때만 전이를 허용할 수 있다.

val machine = stateMachine<DocumentStatus, DocumentEvent, Document> {
    from(DocumentStatus.DRAFT) {
        on<DocumentEvent.Publish>() goto DocumentStatus.PUBLISHED guardedBy {
            it.content.isNotBlank()  // 내용이 비어있으면 발행 불가
        }
    }
}

val emptyDoc = Document(DocumentStatus.DRAFT, "")
machine.canFire(emptyDoc, DocumentEvent.Publish)  // false

val validDoc = Document(DocumentStatus.DRAFT, "Hello World")
machine.canFire(validDoc, DocumentEvent.Publish)   // true

Guard는 조합할 수 있다. and, or, not 연산자를 지원한다.

val notBlank: Guard<Document> = Guard { it.content.isNotBlank() }
val longEnough: Guard<Document> = Guard { it.content.length >= 10 }

// AND 조합 - 둘 다 만족해야 전이
val publishable = notBlank and longEnough

// OR 조합 - 하나만 만족해도 전이
val archivable = notBlank or Guard { it.state == DocumentStatus.PUBLISHED }

// NOT - 부정
val notArchived = !Guard<Document> { it.state == DocumentStatus.ARCHIVED }

Action - 전이 시 컨텍스트 변환

전이가 일어날 때 컨텍스트를 변환할 수 있다. 모든 것이 불변이므로 copy를 사용한다.

val machine = stateMachine<DocumentStatus, DocumentEvent, Document> {
    from(DocumentStatus.DRAFT) {
        on<DocumentEvent.Publish>() goto DocumentStatus.PUBLISHED guardedBy {
            it.content.isNotBlank()
        } action { doc, _ ->
            doc.copy(publishedAt = System.currentTimeMillis())
        }
    }
}

Action도 then으로 체이닝할 수 있다.

val setTimestamp: Action<Document, DocumentEvent> = Action { doc, _ ->
    doc.copy(publishedAt = System.currentTimeMillis())
}
val normalizeContent: Action<Document, DocumentEvent> = Action { doc, _ ->
    doc.copy(content = doc.content.trim().lowercase())
}

// 순차 실행: setTimestamp → normalizeContent
val publishAction = setTimestamp then normalizeContent

onTransition - 전이 콜백

모든 전이에 대해 로깅이나 이벤트 발행 같은 횡단 관심사를 처리할 수 있다.

val machine = stateMachine<DocumentStatus, DocumentEvent, Document> {
    from(DocumentStatus.DRAFT) {
        on<DocumentEvent.Publish>() goto DocumentStatus.PUBLISHED
        on<DocumentEvent.Archive>() goto DocumentStatus.ARCHIVED
    }
    from(DocumentStatus.PUBLISHED) {
        on<DocumentEvent.Archive>() goto DocumentStatus.ARCHIVED
    }

    onTransition { from, event, to ->
        println("[$from] --${event::class.simpleName}--> [$to]")
        // [DRAFT] --Publish--> [PUBLISHED]
    }
}

핵심 API 정리

컴포넌트타입설명
Stateinterface상태를 나타내는 마커 인터페이스. enum class로 구현
Eventinterface이벤트를 나타내는 마커 인터페이스. sealed interface로 구현
Stateful<S, C>interface상태를 가진 도메인 모델이 구현하는 인터페이스
Guard<C>fun interface전이 조건 (C) -> Boolean. and, or, not 조합 가능
Action<C, E>fun interface전이 시 실행되는 동작 (C, E) -> C. then으로 체이닝 가능
fire(model, event)method이벤트를 발행하고 전이 실행. 유효하지 않으면 예외 발생
canFire(model, event)method전이 가능 여부를 예외 없이 확인
availableEvents(model)method현재 상태에서 가능한 모든 이벤트 목록 반환

마무리

상태머신은 결국 "이 상태에서 이 이벤트가 오면 저 상태로 간다"는 규칙을 명시적으로 선언하는 도구다.

if-else로 흩어져 있던 상태 검증 로직을 한곳에 모으고, 정의되지 않은 전이는 프레임워크가 알아서 거부해 준다. 그 대신 모든 변경을 이벤트로 정의해야 하는 비용이 따른다.

상태가 2개 이상일때 도입하면 편하고 4개 이상이 되면 거의 필수다. 자신의 도메인에서 상태 흐름이 복잡해지기 시작했다면, 상태머신 도입을 고려해 보길 바란다.


참고 자료

21 views

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ