유한 오토마타는 신이야
우리는 상태머신을 왜 사용해야하는가

상태머신이란 무엇인가?
다른 단어로 유한 오토마타(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 → DELIVERED | Approve, Ship, Deliver, Cancel |
| 문서 관리 | DRAFT → REVIEW → PUBLISHED → ARCHIVED | Submit, Approve, Publish, Archive |
| 결재 시스템 | 작성 → 1차 승인 → 2차 승인 → 완료 | Submit, Approve, Reject, Cancel |
| CI/CD 파이프라인 | Build → Test → Deploy → Running | Trigger, 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 대환영)
일단 나는 웬만하면 직접 만들어서 사용하지는 않는 편이다. 왜냐하면 Spring이라는 거대한 생태계가 내가 필요로 하는 모든 것을 거의 대부분 지원해주고 있기 때문이다.
그런데 상태머신에 대해서는 그다지 만족스럽지 못하다.
spring-statemachine은 일단 굉장히 잘 만든 프레임워크라는 것을 먼저 이야기하고 싶다. 그렇지만 이걸 사용하지 않는 이유는 다음과 같다.
1. 최신 Spring Boot 버전에 대한 지원이 느리다
현재(2026.02.08)를 기준으로 Spring Boot 4가 나왔고 Spring Framework 7을 지원하지만, 아직 spring-statemachine은 Spring 7을 지원하지 않는다.
회사라면 이렇게 빠른 버전을 바로 사용하지는 않을 것이라서 괜찮겠지만, 개인적으로 사용할 때는 이것 때문에 버전을 낮춰야 하는데, 그만큼의 메리트가 있느냐고 생각할 때에는 '아니다'라고 말할 것이다.
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 정리
| 컴포넌트 | 타입 | 설명 |
State | interface | 상태를 나타내는 마커 인터페이스. enum class로 구현 |
Event | interface | 이벤트를 나타내는 마커 인터페이스. 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개 이상이 되면 거의 필수다. 자신의 도메인에서 상태 흐름이 복잡해지기 시작했다면, 상태머신 도입을 고려해 보길 바란다.

