모바일 앱 개발을 하다 보면 단순한 페이드 인/아웃을 넘어서는 복잡한 애니메이션 시퀀스를 구현해야 할 때가 있다. 여러 단계를 거쳐야 하고, 각 단계가 완료되어야 다음 단계로 넘어가며, 때로는 이전 상태에 따라 다른 경로를 택해야 하는 경우다.
예를 들어, 음료 주문 앱에서 사용자가 주문을 완료하면 이런 시퀀스가 필요하다:
이런 시퀀스를 if-else와 콜백 체인으로 관리하면 금방 스파게티 코드가 된다.
이때 오토마타 이론의 State Machine(상태 기계) 원리를 활용하면 우아하고 유지보수하기 쉬운 코드를 작성할 수 있다.
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()
}
}
// 계속 증식하는 중...
}
}
이 코드의 문제는 뭘까?
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
// 명확한 전환 규칙
}
}
차이가 보이는가? 각 상태가 명확하게 정의되어 있고, 전환 규칙도 한눈에 파악할 수 있다.
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은 이전 등급과 새 등급을 알아야 한다.
상태 전환 로직을 캡슐화하는 컨트롤러를 만든다. 여기가 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의 핵심적인 안전장치다.
이제 실제로 화면에서 어떻게 쓰는지 보자.
@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 덕분에 모든 케이스를 처리했는지 컴파일러가 체크해준다. 새로운 상태를 추가하면? 컴파일러가 "여기도 처리해야 해요" 하고 알려준다.
각 상태에 대응하는 애니메이션 컴포넌트를 만들면 된다.
@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가 알아서 관리한다. 책임이 명확하게 분리된다.
불가능한 상태를 원천 차단할 수 있다.
// 나쁜 예: 플래그 조합으로 상태 관리
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은 정의한 상태만 존재할 수 있다.
// 이런 모순적 상태가 가능해진다
isAnimating = true
isComplete = true
// Sealed class는 이런 상황을 원천 차단
sealed interface State {
data object Animating : State
data object Complete : State
// 둘 중 하나만 가능!
}
상태 전환을 독립적으로 테스트할 수 있다.
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 없이 로직만 테스트할 수 있다.
로그 하나면 현재 상태를 정확히 알 수 있다.
controller.state.collect { state ->
Timber.d("Current animation state: ${state::class.simpleName}")
// "Current animation state: PointsAnimating"
}
플래그 조합으로 관리하면 모든 플래그 값을 다 찍어봐야 알 수 있다. State Machine은 상태 이름 하나면 끝이다.
State Machine의 가장 큰 장점은 다이어그램으로 그릴 수 있다는 것이다.
[Idle]
↓ (주문 확인)
[OrderConfirmed]
↓ (hasPoints?)
├─ Yes → [PointsAnimating]
│ ↓ (hasUpgrade?)
│ ├─ Yes → [BadgeUpgrading]
│ └─ No → [AllComplete]
└─ No → (hasUpgrade?)
├─ Yes → [BadgeUpgrading]
└─ No → [AllComplete]
이 다이어그램을 기획자나 디자이너에게 보여주면? 바로 이해한다. 코드를 설명할 필요가 없다.
잘못된 상태에서 전환을 시도하면 에러를 내는 것이 좋다.
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"
)
}
}
이렇게 하면 버그가 생겨도 어디서 잘못됐는지 정확히 알 수 있다.
애니메이션이 무한정 기다리면 안 된다. 타임아웃을 걸어두는 게 좋다.
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()
// 정상 완료 처리
}
}
실제로 프로덕션에서 애니메이션이 멈춰버리는 버그를 겪은 적이 있다. 타임아웃 하나로 해결됐다.
디버깅할 때 "어떤 경로로 이 상태에 도달했지?"를 알고 싶을 때가 많다.
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()
}
}
}
히스토리를 남기면 사용자가 뒤로가기를 눌렀을 때도 대응할 수 있다.
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개를 넘어가면 뭔가 잘못됐다는 신호다. 더 높은 추상화 레벨을 찾아봐야 한다.
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가 애니메이션 시퀀스를 담당한다. 책임이 명확해진다.
화면이 닫힐 때 리소스를 정리해야 한다.
@Composable
fun OrderSuccessScreen() {
val controller = remember { OrderAnimationController() }
DisposableEffect(Unit) {
onDispose {
controller.reset()
controller.cleanup()
}
}
}
State Machine 패턴으로 복잡한 애니메이션을 관리하면서 느낀 점들을 정리해본다.
장점:
단점:
무엇보다도 기획이 바뀌어서 "여기에 애니메이션 하나 더 끼워넣어주세요" 할 때, State Machine이 없었으면 온갖 if문을 다 뜯어고쳐야 했을 것이다. State Machine 덕분에 상태 하나 추가하고 전환 규칙만 수정하면 끝이었다.
특히 Kotlin의 sealed class/interface와 Compose의 상태 기반 렌더링은 State Machine 패턴과 완벽하게 맞아떨어진다. when 표현식 하나로 모든 상태를 처리하고, 컴파일러가 빠뜨린 케이스를 잡아준다.