[Android] State Machine

Daemon·2025년 12월 14일

Android

목록 보기
9/13
post-thumbnail

들어가며

모바일 앱 개발을 하다 보면 단순한 페이드 인/아웃을 넘어서는 복잡한 애니메이션 시퀀스를 구현해야 할 때가 있다. 여러 단계를 거쳐야 하고, 각 단계가 완료되어야 다음 단계로 넘어가며, 때로는 이전 상태에 따라 다른 경로를 택해야 하는 경우다.

예를 들어, 음료 주문 앱에서 사용자가 주문을 완료하면 이런 시퀀스가 필요하다:

  1. 주문 접수 애니메이션 (체크마크 등장)
  2. 포인트 적립 카운팅 애니메이션
  3. 등급 업그레이드가 있다면 배지 반짝임
  4. 쿠폰 발급 애니메이션
  5. 완료 메시지 표시

이런 시퀀스를 if-else와 콜백 체인으로 관리하면 금방 스파게티 코드가 된다.
이때 오토마타 이론의 State Machine(상태 기계) 원리를 활용하면 우아하고 유지보수하기 쉬운 코드를 작성할 수 있다.

State Machine(상태 기계)이란?

State Machine은 컴퓨터 과학의 오토마타 이론에서 나온 개념이다. 시스템이 유한한 개수의 상태 중 하나에만 존재할 수 있고, 정의된 규칙에 따라서만 상태 전환이 일어나는 모델이라고 보면 된다.

핵심 개념

대학교에서 오토마타 이론 수업을 들었다면 익숙할 개념들이다:

  1. 상태(States): 시스템이 가질 수 있는 모든 가능한 상태들
  2. 전환(Transitions): 한 상태에서 다른 상태로 이동하는 규칙
  3. 이벤트(Events): 전환을 트리거하는 입력이나 조건
  4. 초기 상태(Initial State): 시스템이 시작할 때의 상태
  5. 최종 상태(Final States): 시스템이 종료될 수 있는 상태들

왜 State Machine인가?

일반적인 접근 방식부터 보자.

문제가 되는 코드:

var step = 0
var isAnimating = false

fun onAnimationComplete() {
    when (step) {
        0 -> {
            if (hasPoints) {
                step = 1
                startPointAnimation()
            } else if (hasUpgrade) {
                step = 2
                startUpgradeAnimation()
            } else {
                step = 3
                showComplete()
            }
        }
        1 -> {
            if (hasUpgrade) {
                step = 2
                startUpgradeAnimation()
            } else {
                step = 3
                showComplete()
            }
        }
        // 계속 증식하는 중...
    }
}

이 코드의 문제는 뭘까?

  • step 변수가 정확히 어떤 의미인지 알기 어렵다
  • 각 단계에서 어떤 일이 일어나는지 추적하기 힘들다
  • 조건문이 중첩되면서 복잡도가 기하급수적으로 증가한다

State Machine 방식:

sealed interface OrderState {
    data object Idle : OrderState
    data class OrderConfirmed(val orderId: String) : OrderState
    data class PointsEarning(val points: Int) : OrderState
    data class UpgradeAchieved(val newTier: String) : OrderState
    data class CouponIssued(val coupon: String) : OrderState
    data class Complete : OrderState
}

fun transition(event: AnimationEvent): OrderState {
    return when (val current = currentState) {
        is Idle -> OrderConfirmed(event.orderId)
        is OrderConfirmed ->
            if (hasPoints) PointsEarning(event.points)
            else if (hasUpgrade) UpgradeAchieved(event.tier)
            else Complete
        // 명확한 전환 규칙
    }
}

차이가 보이는가? 각 상태가 명확하게 정의되어 있고, 전환 규칙도 한눈에 파악할 수 있다.

Compose에서 State Machine 구현하기

1. 상태 정의 (Sealed Interface/Class)

Kotlin의 sealed class/interface를 사용하면 컴파일 타임에 모든 가능한 상태를 보장받을 수 있다. 이게 핵심이다.

sealed interface OrderAnimationState {
    // 각 상태가 필요한 데이터를 포함한다
    data object Idle : OrderAnimationState

    data class OrderConfirmed(
        val orderId: String,
        val timestamp: Long
    ) : OrderAnimationState

    data class PointsAnimating(
        val startPoints: Int,
        val endPoints: Int,
        val animationProgress: Float = 0f
    ) : OrderAnimationState

    data class BadgeUpgrading(
        val previousTier: String,
        val newTier: String,
        val showSparkles: Boolean = true
    ) : OrderAnimationState

    data class CouponRevealing(
        val couponCode: String,
        val discount: Int
    ) : OrderAnimationState

    data class AllComplete(
        val totalPoints: Int,
        val tier: String
    ) : OrderAnimationState
}

여기서 중요한 건 각 상태가 자신에게 필요한 데이터를 직접 갖고 있다는 것이다. PointsAnimating은 시작 포인트와 끝 포인트를 알아야 하고, BadgeUpgrading은 이전 등급과 새 등급을 알아야 한다.

2. State Controller 구현

상태 전환 로직을 캡슐화하는 컨트롤러를 만든다. 여기가 State Machine의 핵심이다.

class OrderAnimationController(
    initialState: OrderAnimationState = OrderAnimationState.Idle
) {
    private val _state = MutableStateFlow<OrderAnimationState>(initialState)
    val state: StateFlow<OrderAnimationState> = _state.asStateFlow()

    // 현재 상태를 다음 상태로 전환
    fun onOrderConfirmed(orderId: String) {
        _state.value = OrderAnimationState.OrderConfirmed(
            orderId = orderId,
            timestamp = System.currentTimeMillis()
        )
    }

    // 애니메이션 완료 시 호출되는 핸들러들
    fun onConfirmationAnimationComplete(hasPoints: Boolean, hasUpgrade: Boolean) {
        val current = _state.value
        if (current !is OrderAnimationState.OrderConfirmed) return

        _state.value = when {
            hasPoints -> OrderAnimationState.PointsAnimating(
                startPoints = 0,
                endPoints = 50
            )
            hasUpgrade -> OrderAnimationState.BadgeUpgrading(
                previousTier = "Silver",
                newTier = "Gold"
            )
            else -> OrderAnimationState.AllComplete(
                totalPoints = 0,
                tier = "Silver"
            )
        }
    }

    fun onPointsAnimationComplete(hasUpgrade: Boolean, totalPoints: Int) {
        val current = _state.value
        if (current !is OrderAnimationState.PointsAnimating) return

        _state.value = if (hasUpgrade) {
            OrderAnimationState.BadgeUpgrading(
                previousTier = "Silver",
                newTier = "Gold"
            )
        } else {
            OrderAnimationState.AllComplete(
                totalPoints = totalPoints,
                tier = "Silver"
            )
        }
    }

    fun onBadgeAnimationComplete(hasCoupon: Boolean) {
        val current = _state.value
        if (current !is OrderAnimationState.BadgeUpgrading) return

        _state.value = if (hasCoupon) {
            OrderAnimationState.CouponRevealing(
                couponCode = "WELCOME10",
                discount = 10
            )
        } else {
            OrderAnimationState.AllComplete(
                totalPoints = 150,
                tier = current.newTier
            )
        }
    }

    fun onCouponAnimationComplete() {
        _state.value = OrderAnimationState.AllComplete(
            totalPoints = 150,
            tier = "Gold"
        )
    }

    fun reset() {
        _state.value = OrderAnimationState.Idle
    }
}

각 핸들러 함수가 "현재 이 상태인가?"를 먼저 확인하는 게 보이는가? 이것이 바로 State Machine의 핵심적인 안전장치다.

3. Composable에서 사용

이제 실제로 화면에서 어떻게 쓰는지 보자.

@Composable
fun OrderSuccessScreen(
    orderId: String,
    hasPoints: Boolean,
    hasUpgrade: Boolean,
    hasCoupon: Boolean,
    onDismiss: () -> Unit
) {
    val controller = remember { OrderAnimationController() }
    val currentState by controller.state.collectAsState()

    // 초기 애니메이션 시작
    LaunchedEffect(orderId) {
        controller.onOrderConfirmed(orderId)
    }

    Box(modifier = Modifier.fillMaxSize()) {
        when (val state = currentState) {
            is OrderAnimationState.Idle -> {
                // 초기 상태 - 아무것도 표시하지 않음
            }

            is OrderAnimationState.OrderConfirmed -> {
                OrderConfirmationAnimation(
                    orderId = state.orderId,
                    onComplete = {
                        controller.onConfirmationAnimationComplete(
                            hasPoints = hasPoints,
                            hasUpgrade = hasUpgrade
                        )
                    }
                )
            }

            is OrderAnimationState.PointsAnimating -> {
                PointsCountingAnimation(
                    startPoints = state.startPoints,
                    endPoints = state.endPoints,
                    onComplete = {
                        controller.onPointsAnimationComplete(
                            hasUpgrade = hasUpgrade,
                            totalPoints = state.endPoints
                        )
                    }
                )
            }

            is OrderAnimationState.BadgeUpgrading -> {
                BadgeUpgradeAnimation(
                    previousTier = state.previousTier,
                    newTier = state.newTier,
                    onComplete = {
                        controller.onBadgeAnimationComplete(hasCoupon)
                    }
                )
            }

            is OrderAnimationState.CouponRevealing -> {
                CouponRevealAnimation(
                    couponCode = state.couponCode,
                    discount = state.discount,
                    onComplete = {
                        controller.onCouponAnimationComplete()
                    }
                )
            }

            is OrderAnimationState.AllComplete -> {
                CompleteScreen(
                    totalPoints = state.totalPoints,
                    tier = state.tier,
                    onDismiss = onDismiss
                )
            }
        }
    }
}

when 표현식 하나로 모든 상태를 처리한다. Kotlin의 sealed class 덕분에 모든 케이스를 처리했는지 컴파일러가 체크해준다. 새로운 상태를 추가하면? 컴파일러가 "여기도 처리해야 해요" 하고 알려준다.

4. 개별 애니메이션 Composable 예시

각 상태에 대응하는 애니메이션 컴포넌트를 만들면 된다.

@Composable
fun PointsCountingAnimation(
    startPoints: Int,
    endPoints: Int,
    onComplete: () -> Unit
) {
    var currentPoints by remember { mutableIntStateOf(startPoints) }

    LaunchedEffect(endPoints) {
        // 0.9초 동안 카운팅 애니메이션
        val duration = 900L
        val steps = 30
        val increment = (endPoints - startPoints) / steps

        repeat(steps) {
            delay(duration / steps)
            currentPoints = (startPoints + increment * (it + 1))
                .coerceAtMost(endPoints)
        }

        // 완료 후 다음 상태로 전환
        delay(300)
        onComplete()
    }

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "+$currentPoints",
            style = MaterialTheme.typography.displayLarge,
            color = Color(0xFFFFD700)
        )
        Text(
            text = "포인트 적립!",
            style = MaterialTheme.typography.titleMedium
        )
    }
}

@Composable
fun BadgeUpgradeAnimation(
    previousTier: String,
    newTier: String,
    onComplete: () -> Unit
) {
    var showSparkles by remember { mutableStateOf(false) }

    LaunchedEffect(Unit) {
        delay(200)
        showSparkles = true
        delay(1500)
        onComplete()
    }

    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Text(
                text = "$previousTier$newTier",
                style = MaterialTheme.typography.displayMedium
            )

            if (showSparkles) {
                Text(text = "✨", fontSize = 48.sp)
            }
        }
    }
}

각 컴포넌트가 자기가 해야 할 애니메이션만 신경 쓴다. 전체 시퀀스는? Controller가 알아서 관리한다. 책임이 명확하게 분리된다.

State Machine의 장점

1. 명확한 상태 관리

불가능한 상태를 원천 차단할 수 있다.

// 나쁜 예: 플래그 조합으로 상태 관리
var isAnimating = false
var hasCompletedPoints = false
var hasCompletedBadge = false
var showingCoupon = false

// 좋은 예: 명시적 상태
sealed interface State {
    data object PointsAnimating : State
    data object BadgeAnimating : State
    data object CouponShowing : State
}

플래그 조합은 2^n 가지 상태가 가능하지만, 대부분은 의미 없는 조합이다. State Machine은 정의한 상태만 존재할 수 있다.

2. 불가능한 상태 방지

// 이런 모순적 상태가 가능해진다
isAnimating = true
isComplete = true

// Sealed class는 이런 상황을 원천 차단
sealed interface State {
    data object Animating : State
    data object Complete : State
    // 둘 중 하나만 가능!
}

3. 테스트 용이성

상태 전환을 독립적으로 테스트할 수 있다.

class OrderAnimationControllerTest {
    @Test
    fun `포인트가 있을 때 확인 완료 후 포인트 애니메이션으로 전환`() {
        val controller = OrderAnimationController()

        controller.onOrderConfirmed("ORDER-123")
        controller.onConfirmationAnimationComplete(
            hasPoints = true,
            hasUpgrade = false
        )

        assertTrue(controller.state.value is OrderAnimationState.PointsAnimating)
    }

    @Test
    fun `포인트와 업그레이드가 모두 있을 때 포인트 먼저 표시`() {
        val controller = OrderAnimationController()

        controller.onOrderConfirmed("ORDER-123")
        controller.onConfirmationAnimationComplete(
            hasPoints = true,
            hasUpgrade = true
        )

        // 포인트 애니메이션이 먼저
        assertTrue(controller.state.value is OrderAnimationState.PointsAnimating)

        controller.onPointsAnimationComplete(hasUpgrade = true, totalPoints = 50)

        // 그 다음 배지 업그레이드
        assertTrue(controller.state.value is OrderAnimationState.BadgeUpgrading)
    }
}

UI 없이 로직만 테스트할 수 있다.

4. 디버깅 편의성

로그 하나면 현재 상태를 정확히 알 수 있다.

controller.state.collect { state ->
    Timber.d("Current animation state: ${state::class.simpleName}")
    // "Current animation state: PointsAnimating"
}

플래그 조합으로 관리하면 모든 플래그 값을 다 찍어봐야 알 수 있다. State Machine은 상태 이름 하나면 끝이다.

5. 시각화 가능

State Machine의 가장 큰 장점은 다이어그램으로 그릴 수 있다는 것이다.

[Idle]
  ↓ (주문 확인)
[OrderConfirmed]
  ↓ (hasPoints?)
  ├─ Yes → [PointsAnimating]
  │          ↓ (hasUpgrade?)
  │          ├─ Yes → [BadgeUpgrading]
  │          └─ No → [AllComplete]
  └─ No → (hasUpgrade?)
           ├─ Yes → [BadgeUpgrading]
           └─ No → [AllComplete]

이 다이어그램을 기획자나 디자이너에게 보여주면? 바로 이해한다. 코드를 설명할 필요가 없다.

실전 패턴과 Best Practice

1. 상태 전환 검증

잘못된 상태에서 전환을 시도하면 에러를 내는 것이 좋다.

fun onPointsAnimationComplete(hasUpgrade: Boolean, totalPoints: Int) {
    val current = _state.value

    // 현재 상태가 예상한 상태인지 검증
    require(current is OrderAnimationState.PointsAnimating) {
        "Cannot complete points animation from state: ${current::class.simpleName}"
    }

    _state.value = if (hasUpgrade) {
        OrderAnimationState.BadgeUpgrading(
            previousTier = "Silver",
            newTier = "Gold"
        )
    } else {
        OrderAnimationState.AllComplete(
            totalPoints = totalPoints,
            tier = "Silver"
        )
    }
}

이렇게 하면 버그가 생겨도 어디서 잘못됐는지 정확히 알 수 있다.

2. 타임아웃 처리

애니메이션이 무한정 기다리면 안 된다. 타임아웃을 걸어두는 게 좋다.

class OrderAnimationController {
    private val timeoutJob = MutableStateFlow<Job?>(null)

    fun onOrderConfirmed(orderId: String) {
        _state.value = OrderAnimationState.OrderConfirmed(orderId)

        // 타임아웃 설정 (10초 후 강제 완료)
        timeoutJob.value?.cancel()
        timeoutJob.value = viewModelScope.launch {
            delay(10_000)
            Timber.w("Animation timeout, forcing completion")
            forceComplete()
        }
    }

    fun onAnimationComplete() {
        timeoutJob.value?.cancel()
        // 정상 완료 처리
    }
}

실제로 프로덕션에서 애니메이션이 멈춰버리는 버그를 겪은 적이 있다. 타임아웃 하나로 해결됐다.

3. 상태 히스토리 추적

디버깅할 때 "어떤 경로로 이 상태에 도달했지?"를 알고 싶을 때가 많다.

class OrderAnimationController {
    private val stateHistory = mutableListOf<OrderAnimationState>()

    private fun setState(newState: OrderAnimationState) {
        stateHistory.add(_state.value)
        _state.value = newState
        Timber.d("State transition: ${stateHistory.last()::class.simpleName}${newState::class.simpleName}")
    }

    fun canGoBack(): Boolean = stateHistory.isNotEmpty()

    fun goBack() {
        if (stateHistory.isNotEmpty()) {
            _state.value = stateHistory.removeLast()
        }
    }
}

히스토리를 남기면 사용자가 뒤로가기를 눌렀을 때도 대응할 수 있다.

주의사항

1. 상태 폭발 (State Explosion)

State Machine을 처음 쓰면 상태를 너무 세밀하게 나누고 싶어진다.

// 과도한 상태 분리
sealed interface BadgeState {
    data object BadgeIdle : BadgeState
    data object BadgeScaling : BadgeState
    data object BadgeRotating : BadgeState
    data object BadgeGlowing : BadgeState
    data object BadgeBouncing : BadgeState
    data object BadgeFading : BadgeState
}

// 적절한 추상화
sealed interface BadgeState {
    data object Idle : BadgeState
    data class Animating(
        val progress: Float,
        val effects: Set<Effect>
    ) : BadgeState {
        enum class Effect { SCALE, ROTATE, GLOW, BOUNCE }
    }
}

경험상 상태가 10개를 넘어가면 뭔가 잘못됐다는 신호다. 더 높은 추상화 레벨을 찾아봐야 한다.

2. ViewModel과의 통합

Controller를 ViewModel에서 관리하는 게 일반적이다.

class OrderViewModel : ViewModel() {
    private val animationController = OrderAnimationController()
    val animationState = animationController.state.asStateFlow()

    fun completeOrder(orderId: String) {
        viewModelScope.launch {
            try {
                val result = orderRepository.submitOrder(orderId)

                animationController.onOrderConfirmed(
                    orderId = result.orderId,
                    hasPoints = result.earnedPoints > 0,
                    hasUpgrade = result.tierUpgrade != null
                )
            } catch (e: Exception) {
                // 에러 처리
            }
        }
    }
}

ViewModel이 비즈니스 로직을, Controller가 애니메이션 시퀀스를 담당한다. 책임이 명확해진다.

3. 메모리 누수 방지

화면이 닫힐 때 리소스를 정리해야 한다.

@Composable
fun OrderSuccessScreen() {
    val controller = remember { OrderAnimationController() }

    DisposableEffect(Unit) {
        onDispose {
            controller.reset()
            controller.cleanup()
        }
    }
}

정리하자면

State Machine 패턴으로 복잡한 애니메이션을 관리하면서 느낀 점들을 정리해본다.

장점:

  1. 예측 가능성: 모든 상태와 전환이 명시적이다
  2. 안정성: 불가능한 상태를 컴파일 타임에 방지한다
  3. 유지보수성: 새로운 상태나 전환 추가가 쉽다
  4. 테스트 용이성: 각 상태와 전환을 독립적으로 테스트할 수 있다
  5. 디버깅: 현재 상태를 명확히 파악할 수 있다

단점:

  1. 초기 보일러플레이트가 많다
  2. 간단한 애니메이션에는 오버엔지니어링이다
  3. 팀원들이 패턴을 이해하는 데 시간이 걸린다

무엇보다도 기획이 바뀌어서 "여기에 애니메이션 하나 더 끼워넣어주세요" 할 때, State Machine이 없었으면 온갖 if문을 다 뜯어고쳐야 했을 것이다. State Machine 덕분에 상태 하나 추가하고 전환 규칙만 수정하면 끝이었다.

특히 Kotlin의 sealed class/interface와 Compose의 상태 기반 렌더링은 State Machine 패턴과 완벽하게 맞아떨어진다. when 표현식 하나로 모든 상태를 처리하고, 컴파일러가 빠뜨린 케이스를 잡아준다.

0개의 댓글