
어떤 프로그램이든 개발하다 보면 그 규모가 커집니다. 그렇다 보니 필연적으로 상태(State)를 관리하면서 머리가 어지러워지는 경험을 하게 됩니다.
개발 초창기에는 괜찮을지 몰라도, 앱이 성장하고 비즈니스 로직이 고도화되면 상황은 달라집니다. 특히 상태가 단순히 특정 이벤트에 의해서만 결정되지 않고, 네트워크 연결 여부, 유저의 멤버십 등급, 토큰 만료 여부 등 다른 '외부의 상태'들이 UI 상태 전이에 끊임없이 개입하기 시작합니다.
오늘은 단순히 상태를 정의하는 것을 넘어, 왜 우리가 유한 상태 머신(Final State Machine, FSM)을 설계해야만 하는가에 대한 근본적인 이유와, 외부 변수의 개입으로 시스템이 비대해질 때 발생하는 복잡성을 어떻게 아키텍처 레벨에서 제어할 것인가에 대해 알아보도록 하겠습니다.
2^n의 복잡도를 감당할 수 있을까?
처음 프로그램을 개발하다 보면, 초창기에는 그 복잡도가 낮다 보니 상태를 '독립적인 변수들의 집합'으로 바라보고 개발을 진행해도 무리가 없을 것입니다.
class VideoViewModel {
var isLoading: Boolean = false
var isPlaying: Boolean = false
var isError: Boolean = false
var isBuffering: Boolean = false
// 변수가 늘어날수록, 관리해야 할 조합은 기하급수적으로 증가
}
이 방식의 치명적인 문제는 상태의 독립성에 있습니다. 개발자의 의도는 로딩, 재생, 에러 셋 중 하나를 보여주고자 하는 것입니다. 하지만 컴퓨터 입장에서 위 코드는, 아래 예시처럼 로딩과 에러를 동시에 띄우는 다소 황당한 상황을 허용합니다.
isLoading = true인 동시에isError = true?
변수가 4개면 가능한 상태 조합은 2^4 = 16가지입니다. 하지만 실제로 유효한 상태는 그보다 훨씬 적은 4개뿐입니다(로딩, 재생, 에러, 버퍼링). 즉, 나머지 10개의 논리적으로 불가능해야 할 상태가 코드 상에서 방치되고 있습니다. 이것이 버그로 이어질 수 있는 것입니다. 이것을 해결하기 위해 활용할 수 있는 것이 유한 상태 머신(Finite State Machine)입니다.
FSM(Finite State Machine)을 도입하는 주요 목적은 이러한 '불가능한 상태'를 코드 레벨에서 원천 봉쇄하는 것입니다.
아래와 같이, (sealed) interface를 활용하여 상태들을 상호 배타적으로 정의할 수 있습니다.
sealed interface VideoState {
object Idle : VideoState // 대기
object Loading : VideoState // 로딩 중
object Playing : VideoState // 재생 중
data class Error(val msg: String) : VideoState // 에러 발생
}
이제 Loading이면서 동시에 Error일 수 있는 방법은 없습니다. 컴파일러 수준에서 상태의 유일성을 보장해 주기 때문입니다. 복잡했던 16가지의 조합이, 우리가 정의한 4개의 명확한 상태로 정리됩니다.
이렇게 정의하면, 상호 배타적인 각 상태를 깔끔하게 소비할 수 있습니다. 특히 조건문을 사용하면 모든 상태를 처리했는지 컴파일러가 검사해 주므로, 특정 상태 처리를 누락하는 실수도 방지할 수 있습니다. 이것이 FSM이 필요한 가장 기초적이고 근본적인 이유입니다.
fun render(state: VideoState) {
when (state) {
is VideoState.Idle -> { /* ... */ }
is VideoState.Loading -> { /* ... */ }
is VideoState.Playing -> { /* ... */ }
is VideoState.Error -> { /* ... */ }
}
}
앞 예시를 통해 상태를 명확하게 정의할 수 있었습니다. 하지만 앱의 규모가 커지면 '상태 전이' 과정에서 문제가 발생합니다.
앞서 정의한 VideoState를 기능에서 자체적으로 사용되는 '내부 상태'로 정의해 보겠습니다. 이 내부 상태는 비디오 재생 기능과 관련된 특정 이벤트에 의해 전이될 수 있습니다(예: 비디오 데이터를 불러왔다면 Loading → Playing으로 변화). 하지만 상태를 변경하려는 이벤트가 발생했음에도, 이를 가로막는 '외부 요인'들이 존재할 수 있습니다.
예를 들어, Idle(대기) 상태에서 Playing(재생) 상태로 넘어가려는 상황을 가정해 봅시다. 단순히 상태만 바꾸는 것은 위험할 것입니다. 외부에서 영향을 받는 요소도 고려해야 하기 때문입니다.
이 모든 조건을 단순히 상태 전이 함수 안에 추가하기만 한다면, 추후에는 매우 복잡한 코드를 유지보수해야 할 것입니다.
fun reduce(event: Event): VideoState {
if (event is PlayClicked) {
// 1. 네트워크 체크
if (!NetworkManager.isConnected) return VideoState.Error("No Network")
// 2. 인증 체크 (네트워크 체크보다 먼저 해야 하나? 나중에 해야 하나?)
if (!AuthManager.isLoggedIn) return VideoState.Error("Authorization needed")
// 3. 권한 체크
if (!PermissionManager.hasPermission) return VideoState.Error("No Permission")
// ... 조건이 계속 추가됨 ...
return VideoState.Playing // 모든 관문을 통과해야 비로소 재생
}
// ...
}
이 코드는 SRP(단일 책임 원칙)와 OCP(개방 폐쇄 원칙)를 위반하며, 검증 로직의 순서가 하드코딩 되어 있어 유지보수가 매우 어려워집니다.
개발자가 실수로 '네트워크 체크'와 '인증 체크'의 순서를 바꾸는 상황을 가정해 보겠습니다. 이 상황에서, 네트워크가 끊겼는데 로그인을 먼저 체크하려다 크래시가 발생할 수도 있습니다. 비즈니스 로직의 중요도가 if 문의 라인 순서에 암시적으로 숨겨져 있기 때문입니다.
이 복잡성을 해결하기 위해 복잡성을 더하는 if문들을 제거하고, 각 검증 로직을 독립된 객체(Interceptor)로 분리하고 이를 체인(Chain) 형태로 관리하는 방식을 고려해볼 수 있습니다.
각 검사 로직이 구현해야 할 공통 규약입니다. 결과를 Pass 혹은 Block으로 정의합니다.
sealed interface InterceptResult {
object Pass : InterceptResult // 통과
data class Block(val fallbackState: VideoState) : InterceptResult // 차단 및 대체 상태 반환
}
interface StateInterceptor {
fun check(event: Event, currentState: VideoState): InterceptResult
}
이제 각 클래스는 자기 할 일만 합니다. 코드가 훨씬 깔끔해집니다.
class NetworkInterceptor(val netManager: NetworkManager) : StateInterceptor {
override fun check(event: Event, state: VideoState): InterceptResult {
// 네트워크가 없으면 Error 상태로 차단
return if (netManager.isConnected) InterceptResult.Pass
else InterceptResult.Block(VideoState.Error("Network Disconnected"))
}
}
class AuthInterceptor(val authManager: AuthManager) : StateInterceptor {
override fun check(event: Event, state: VideoState): InterceptResult {
// 로그인이 안 되어 있으면 AuthRequired 상태로 차단
return if (authManager.isLogged) InterceptResult.Pass
else InterceptResult.Block(VideoState.Error("Authorization Needed"))
}
}
이제 비즈니스 로직의 우선순위가 코드 라인 속에 숨어 있는 게 아니라, 리스트로 명시화됩니다.
class VideoStateMachine {
// 순서를 바꾸고 싶으면 리스트 순서만 변경하면 됨
private val interceptorChain = listOf(
NetworkInterceptor(netManager), // 1순위: 네트워크
AuthInterceptor(authManager), // 2순위: 인증
// ...
)
fun dispatch(event: VideoEvent) {
val currentState = this.state
// 체인을 순회하며 검사 (가로채기 시도)
for (interceptor in interceptorChain) {
val result = interceptor.check(event, currentState)
if (result is InterceptResult.Block) {
// 검증 실패 시, 원래 가려던 곳이 아닌 대체 상태(Fallback)로 전이
transitionTo(result.fallbackState)
return
}
}
// 모든 관문을 통과했다면 정상적인 상태 전이 로직 수행
val finalState = reduce(currentState, event)
transitionTo(finalState)
}
}
이제 "어떤 검사가 먼저인가?"가 명확해졌습니다. 새로운 조건이 추가되어도 reduce 함수를 건드릴 필요 없이, 새로운 Interceptor를 만들어서 리스트에 끼워 넣기만 하면 됩니다.
계층형 상태 머신(Hierarchical FSM)은 '상태 개수의 폭발'을 해결합니다.
앱이 커짐에 따라 상태가 늘어나는 것은 필연적입니다. 이때 "어떤 상태에 있든 상관없이 발생하는 이벤트" 처리가 문제가 됩니다.
예를 들어, Playing, Paused, Buffering 등 비디오 화면의 어떤 상태에 있든 '토큰 만료' 이벤트가 오면 강제로 LoggedOut 상태로 가야 한다고 가정해 봅시다. 이 상황에서는 모든 상태 분기마다 Logout 코드를 중복해서 넣는 대신, 계층형 상태 머신의 활용을 고려해볼 수 있습니다.
상태를 폴더 구조처럼 계층화하여, 부모 상태가 공통 이벤트를 처리하게 합니다. 상속/구현 개념을 상태 머신에 적용한 것입니다.
sealed interface AppState {
// 1. 최상위 부모 상태: 로그인된 상태 (Container)
sealed interface LoggedIn : AppState {
// 자식 상태들
object Playing : LoggedIn
object Paused : LoggedIn
object Buffering : LoggedIn
// LoggedIn 내부의 공통 동작 정의
fun handleGlobalEvent(event: Event): AppState? {
return if (event is Event.TokenExpired) LoggedOut else null
}
}
// 2. 또 다른 최상위 상태: 로그아웃 상태
object LoggedOut : AppState
}
이벤트를 처리할 때, 현재 상태(자식)에서 처리를 못 하면 부모 상태에게 기회를 주는 원리를 활용하여 아래와 같이 코드를 구성할 수 있습니다.
fun reduce(state: AppState, event: Event): AppState {
// 1. 현재 상태가 LoggedIn 그룹에 속해 있다면 공통 로직 먼저 확인
if (state is AppState.LoggedIn) {
val nextState = state.handleGlobalEvent(event)
if (nextState != null) return nextState // 부모가 처리했으면 즉시 반환 (인터셉트)
}
// 2. 자식 상태별 개별 로직 수행
return when(state) {
is AppState.LoggedIn.Playing -> { /* 재생 관련 로직 */ }
is AppState.LoggedIn.Paused -> { /* 일시정지 관련 로직 */ }
else -> state
}
}
이렇게 하면 하위 상태(Playing)는 토큰 만료 같은 전역적인 문제에 신경 쓸 필요가 없게 됩니다.
특정 기능이 커지는 것을 대비하여, FSM 활용을 고려할 수 있습니다. 잘 사용하면 복잡한 비즈니스 로직을 유연하고 견고하게 만들 수 있기 때문입니다.
처음에는 상태들을 별도의 객체로 나누는 과정이 번거롭게 느껴질 수도 있습니다. 하지만 FSM은 서비스가 성장하고 예외 케이스가 늘어날수록 그 진가를 발휘하기 때문에 추후 유지보수의 용이성을 고려하고 싶다면 좋은 선택지가 될 것입니다.