Android에서 Ui State 관리하는 법

cotton·2025년 3월 19일
0

Android

목록 보기
3/3

이 글은 Philipp Lackner의 ‘Sealed class for UI State are an ANTI-PATTERN' 영상을 보고 개인적인 의견과 함께 작성한 글입니다.

Sealed class

sealed class는 Kotlin에서 상속을 컨트롤 할 때 편리하게 해줍니다. 자식 클래스를 sealed claas 내부에서 제한적으로 상속할 수 있게 하며 컴파일 과정에서 sealed class 클래스에 대해서 안정성을 챙겨줍니다.

Sealed Class를 Ui State에 이용할 때의 단점

// nowinandroid TopicViewModel.kt
sealed interface NewsUiState {
    data class Success(val news: List<UserNewsResource>) : NewsUiState
    data object Error : NewsUiState
    data object Loading : NewsUiState
}

안드로이드 개발자들은 모던한 안드로이드 프로젝트를 만들면서 위와 같은 State 관리 코드를 많이 본 경험이 있을 것입니다. 안드로이드에서 공식적으로 가이드처럼 제공하는 nowinandroid 코드가 대표적입니다.

sealed class의 장점과, UI 데이터를 관리해야 한다는 목적의 Ui State를 개발함에 있어 서로 잘 어울리는 내용입니다. 하지만 실제로 자신의 프로젝트에 State를 관리하면서 sealed class를 사용하다 보면 뭔가 애매하고, 적용하기 어렵다는 생각이 들 때가 있습니다. 하나의 State만 추가하더라도 관리가 조금 어려워집니다.

Multiple UiState

// nowinandroid
sealed interface TopicUiState {
    data class Success(val followableTopic: FollowableTopic) : TopicUiState
    data object Error : TopicUiState
    data object Loading : TopicUiState
}

sealed interface NewsUiState {
    data class Success(val news: List<UserNewsResource>) : NewsUiState
    data object Error : NewsUiState
    data object Loading : NewsUiState
}

실제로 두 State를 ViewModel에서 사용하려면 두 값을 별도의 Flow로 구성해야 합니다. 특히 로딩을 관리한다고 생각해 보면, News 값에 대한 로딩과 Topic에 대한 로딩을 모두 관리해야 합니다. 물론 각 영역을 스켈레톤으로 처리하면서 먼저 들어온 데이터를 먼저 보여주는 UI에서는 장점으로 둘 수 있겠지만 모든 부분에서 장점을 가지고 있지 않습니다. 보편적으로 UI/UX에서는 모든 필수 데이터를 가져오기 이전까지는 화면 전체에 로딩을 걸어두는 경우가 대부분입니다.

// 두 가지 UiState 정의
sealed class NewsUiState {
    object Loading : NewsUiState()
    data class Success(val news: List<String>) : NewsUiState()
    object Error : NewsUiState()
}

sealed class TopicUiState {
    object Loading : TopicUiState()
    data class Success(val topics: List<String>) : TopicUiState()
    object Error : TopicUiState()
}

// 여러 State를 합쳐서 관리하는 하나의 UiState
data class CombinedUiState(
    val newsState: NewsUiState,
    val topicState: TopicUiState
)

// combine 예제
fun main() = runBlocking {

    val newsFlow = MutableStateFlow<NewsUiState>(NewsUiState.Loading)
    val topicFlow = MutableStateFlow<TopicUiState>(TopicUiState.Loading)

    val combinedFlow = combine(newsFlow, topicFlow) { news, topic ->
        CombinedUiState(news, topic)
    }

    combinedFlow.collect { combinedState ->
        println("Combined State: $combinedState")
    }

    newsFlow.value = NewsUiState.Success(listOf("News 1", "News 2"))
    topicFlow.value = TopicUiState.Success(listOf("Topic A", "Topic B"))
}

로딩 뿐만 아니라 두 개 이상의 상태를 하나로 합쳐 관리해야 하는 경우도 있습니다. 위 코드는 News 데이터와 Topic 데이터를 합쳐 하나의 UiState로 관리할 수 있도록 하는 로직이 담겨져 있습니다. 물론 두 개의 데이터 종류만을 가지고 있는 상황이지만 이런 데이터들이 다섯 개, 여섯 개가 되는 경우에는 생각보다도 더 많은 코드를 작성해야 할 것입니다.

Sealed class의 단점

또한 sealed class를 이용한 UiState를 이용하는 경우에도 생각보다 여러 단점이 있습니다. 물론 각 상태를 when 문을 이용하여 Screen에서 각각의 로직을 별개로 구현하는 것은 이해하기 쉬운 코드를 작성하기에 좋습니다. 하지만 ViewModel에서는 생각보다 가독성에서의 장점이 없습니다.

// nowinandroid
sealed interface NewsUiState {
    data class Success(val news: List<UserNewsResource>) : NewsUiState
    data object Error : NewsUiState
    data object Loading : NewsUiState
}

private val _newUiState = Flow<NewsUitState> = {
    // 데이터를 가져와 NewsUiState로 가공하는 과정 ...
}

(_newsUiState as NewsUiState.Success)?.let { success ->
    // success.news 를 이용하여 데이터를 처리하는 과정 ...
}

다시 News 데이터를 관리하는 UiState를 가져와 봤습니다. Flow 로직을 보면 NewsUiState 값에 대해서 관리하고 있습니다. 하지만 해당 NewsUiState는 여러 상태값을 가지고 있기 때문에 단순히 해당 플로우를 가져와 사용할 때, 성공했다고 100% 확신할 수 없습니다. Kotlin에서 해당 State가 데이터를 가져오는 데 성공해 Success 상태인 경우에만 데이터를 처리하기 위해서 as? 문과 ?.let 문을 사용해야 합니다. 이런 단순한 데이터를 가져오는 로직에서도 state를 캐스팅하는 등의 불편한 과정이 존재합니다.

Multi Threading Issue

우리는 유저가 기다리는 환경을 만들지 않게 하기 위해서 데이터를 캐시해서 사용하는 경우가 많습니다. 캐싱된 데이터를 먼저 보여주다가, 서버에서 데이터를 받아오는 데 성공하면 해당 데이터로 교체해서 UI에 보여줍니다. sealed class를 이용하여 UiState를 이용하는 경우 동시성 문제가 발생합니다.

private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState

fun fetchData() {
    viewModelScope.launch {
        // 캐싱된 데이터를 먼저 보여줌
        _uiState.value = UiState.Success(getCachedData())

        // 서버 데이터 가져오기
        try {
            val serverData = fetchFromServer()
            _uiState.value = UiState.Success(serverData)
        } catch (e: Exception) {
            _uiState.value = UiState.Error
        }
    }
}

// 상태를 확인하고 데이터 처리
if (_uiState.value is UiState.Success) {
    val successState = _uiState.value as UiState.Success // 스마트 캐스트 실패 가능성
    println(successState.data)
}

fetchData를 이용하여 서버에서 데이터를 가져오고 있다고 가정합니다. 그와 동시에 다른 스레드에서 State 값을 기반으로 성공 데이터를 출력하고 있다고 가정하고 있을 때 해당 state를 사용하는 곳에서 원하는 데이터로 캐스팅에 실패하거나 원하는 데이터를 정상적으로 가져오지 못할 수 있습니다.

Solution

물론 타입 관리가 간편하고 간단한 화면을 작성하는 데에 여러 UiState가 필요하지 않은 경우 매우 편하게 개발할 수 있을 것입니다. 하지만 여러 화면에서 여러 ViewModel을 가지고 있고, 각 ViewModel에서 State를 관리할 때 어느 정도의 규칙성을 가지고 개발하는 것이 좋습니다.

Data class

data class ProfileState(
    val isLoading: Boolean = false,
    val posts: List<Post> = emptyList()
)

val newState = currentState.copy(isLoading = true)

Sealed class를 다중으로 이용하는 경우 발생하는 문제 중 공통 속성을 컨트롤하기 어렵다는 단점을 해결하기 위해 하나의 State를 단일의 data class를 이용하여 처리할 수 있습니다. 쓸모 없는 캐스팅 로직 없이. 즉시 .copy() 로직을 이용하여 즉시 State를 업데이트 할 수 있습니다.

하지만 여러 상태로 나뉘는 개별 속성을 단일 data class로 이용하는 경우 단일 속성을 여러 개 넣어 해당 값을 반환하는 로직을 또 추가로 개발해야 합니다. 이를 해결하기 위해서 sealed interface를 이용하여 부분적으로 개별 속성을 공통 속성과 함께 사용할 수 있습니다.

sealed interface

data class ProfileState(
    val isLoading: Boolean = false,
    val posts: List<Post> = emptyList(),
    val details: ProfileDetails
)

sealed interface ProfileDetails
data class LocalUserDetails(val isUpdatingProfilePicture: Boolean) : ProfileDetails
data class RemoteUserDetails(val isFollowing: Boolean) : ProfileDetails

공통 속성은 data class의 이점을 그대로 사용할 수 있게 하며 개별 속성은 실제 데이터의 상태에 따라서 별도의 sealed interface로 분리하고 이를 공통 속성처럼 사용하게 하면서 data class의 장점은 챙기고 데이터 상태의 구분은 별도로 챙기는 장점을 모두 챙길 수 있습니다.

결론

sealed class는 단순한 UI State를 관리하는 데에 있어서는 여러 장점들을 가지고 있지만 고도화된 프로젝트 내에서는 여러 State를 이용하는 경우가 많아 sealed class의 단점들이 부각됩니다. 공통 속성과 개별 속성을 data class와 sealed interface로 분리하여 효율적으로 관리해 보는 것은 어떨까요?

profile
안드로이드 개발자

0개의 댓글