배경
많은 개발자 분들은 UiState를 같이 사용하시고 있습니다. 저 역시도 UiState를 사용하곤 하는데 실제로 잘 사용하고 있는 것인지 의문이 들 때가 많아서 이번 기회에 제대로 정리해서 사용해볼까 합니다. UiState를 정리하면서 함께 UI Layer에 대해서도 깊게 고민해보겠습니다.
UI 레이어
UI는 다들 아시다시피 사용자와 가장 밀접하게 데이터를 표시하고 사용자와 상호작용을 하는 기본 지점이라고 볼 수 있습니다.
그리고 이러한 데이터들은 보통 데이터 레이어에서 가져오게 되는데, 데이터 레이어에서 가져오는 애플리케이션 데이터는 표시해야 하는 정보와 다른 형식입니다.
예를 들어 UI용으로 데이터의 일부만 필요하거나 사용자에게 관련성 있는 정보를 표시하기 위해 서로 다른 두 데이터 소스를 병합해야 할 수도 있습니다. 적용하는 로직과 관계없이 완전히 렌더링하는 데 필요한 모든 정보를 UI에 전달해야 합니다. UI 레이어는 애플리케이션 데이터 변경사항을 UI가 표시할 수 있는 형식으로 변환한 후에 표시하는 파이프라인입니다.
UI 레이어 아키텍처와 UiState
데이터 레이어의 역할은 앱 데이터를 보유하고 관리하며 앱 데이터에 액세스할 권한을 제공하는 것이었다면 Ui레이어는 다음 단계를 실행해야 합니다.
- 앱 데이터를 사용하고 Ui에서 쉽게 렌더링할 수 있는 데이터로 변환
- UI 렌더링 가능 데이터를 사용하고 사용자에게 표시할 UI 요소로 변환
- 이렇게 조합된 UI요소의 사용자 입력 이벤트를 사용하고 입력 이벤트의 결과를 필요에 따라 UI 데이터에 반영
- 1~3단계를 필요한 만큼 반복
즉, 사용자가 보는 항목이 UI라면 UI 상태는 앱에서 사용자가 봐야 한다고 지정하는 항목입니다. 동전의 양면과 마찬가지로 UI는 UI 상태를 시각적으로 나타냅니다. UI 상태가 변경되면 변경사항이 즉시 UI에 반영됩니다.
여기서부터 UiState의 개념이 접목되게 됩니다. UI를 완전히 렌더링하는 데 필요한 정보를 UiState 데이터 클래스에 캡슐화할 수 있습니다.
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf(),
val userMessages: List<Message> = listOf()
)
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
...
)
Ui 상태 정의는 불변성을 가지기 때문에 UiState를 통해 상태를 읽고 이에 따라 UI 요소를 업데이트하는 한 가지 역할에 집중할수 있게 됩니다. 따라서 UI 자체가 데이터의 유일한 소스인 경우를 제외하고 UI에서 UI 상태를 직접 수정해서는 안 됩니다. 이 원칙을 위반하면 동일한 정보가 여러 정보 소스에서 비롯되어 데이터 불일치와 미세한 버그가 발생합니다.
이름 규칙: 기능+ UiState
하지만 앱 데이터의 동적 특성에 따라 상태는 시간이 지나면서 변경될 수 있으며 이는 앱을 채우는 데 사용되는 기본 데이터를 수정하는 사용자 상호작용이나 기타 이벤트로 인해 발생하기도 합니다. 따라서 무조건적으로 불변성을 가져야 한다! 라는 생각은 조금 위험할 수도 있습니다.
각 이벤트에 적용할 로직을 정의하고 UI 상태를 만들기 위해 지원 데이터 소스에 필요한 변환을 실행하여 상호작용을 처리한다는 이점이 있을 수 있습니다. 하지만 UI가 이름에서 알 수 있는 것 이상의 역할을 담당하기 시작하면 복잡해질 수 있습니다.
이렇게 복잡해지면 결과 코드가 뚜렷한 경계 없이 긴밀하게 결합된 혼합체가 되므로 테스트 가능 여부에 영향을 미칠 수 있습니다. 궁극적으로 UI에 주는 부담을 줄여야 합니다. UI 상태가 매우 단순하지 않은 이상 UI의 역할은 오직 UI 상태를 사용 및 표시하는 것이어야 합니다.
단방향 데이터 흐름(UDF)으로 상태 관리
UI 상태를 생성하는 역할을 담당하고 생성 작업에 필요한 로직을 포함하는 클래스를 State Holders(상태 홀더)라고 합니다. State Holders의 크기는 하단 앱 바와 같은 단일 위젯부터 전체 화면이나 탐색 대상에 이르기까지 관리 대상 UI 요소의 범위에 따라 다양합니다.
UI와 상태 생성자 간의 상호 종속을 모델링하는 방법은 다양합니다. 하지만 UI와 ViewModel 클래스 사이의 상호작용은 대체로 이벤트 입력과 입력의 후속 상태인 출력으로 간주될 수 있으므로 관계는 다음 다이어그램과 같습니다.
이렇게 상태가 아래로 향하고 이벤트는 위로 향하는 패턴을 단방향 데이터 흐름 (UDF)이라고 합니다. 이 패턴이 앱 아키텍처에 미치는 영향은 다음과 같습니다.
- ViewModel이 UI에 사용될 상태를 보유하고 노출합니다. UI 상태는 ViewModel에 의해 변환된 애플리케이션 데이터입니다.
- UI가 ViewModel에 사용자 이벤트를 알립니다.
- ViewModel이 사용자 작업을 처리하고 상태를 업데이트합니다.
- 업데이트된 상태가 렌더링할 UI에 다시 제공됩니다.
- 상태 변경을 야기하는 모든 이벤트에 위의 작업이 반복됩니다.
탐색 대상이나 화면의 경우 ViewModel은 저장소 또는 사용 사례 클래스와 함께 작동하여 데이터를 가져와 UI 상태로 변환하는 동시에 상태 변경을 야기할 수 있는 이벤트 효과를 통합합니다.
안드로이드에서 제시하는 우수사례 중 해당 UI에 대한 상태 변경의 예시를 그림으로 설명합니다.
0. 현재 app data가 데이터 레이어에서 ViewModel로 전달됩니다.
1. 현재 UI State로 UI를 변경합니다.
2. User가 아티클을 북마크 하는 이벤트가 발생하여 ViewModel로 전달합니다.
3. ViewModel이 데이터 레이어로 State가 변경되었음을 알립니다.
4. Data Layer가 application data를 업데이트 합니다.
5. 북마크된 아티클을 포함한 새로운 정보가 ViewModel로 전달됩니다.
6. 새로운 UI State를 UI에 전달합니다.
UDF를 사용하는 이유
UDF는 상태 생성 주기를 모델링합니다. 또한 상태 변경이 발생하는 위치, 변환되는 위치, 최종적으로 사용되는 위치를 구분합니다. 이렇게 구분하면 UI가 이름에 드러난 의미 그대로 동작할 수 있습니다. 즉, 상태 변경사항을 관찰하여 정보를 표시하고 변경사항을 ViewModel에 전달하여 사용자 인텐트를 전달합니다.
- 데이터 일관성: UI용 정보 소스가 하나입니다.
- 테스트 가능성: 상태 소스가 분리되므로 UI와 별개로 테스트할 수 있습니다.
- 유지 일관성: 상태 변경은 잘 정의된 패턴을 따릅니다. 즉, 변경은 사용자 이벤트 및 데이터를 가져온 소스 모두의 영향을 받습니다.
UI 상태 노출
UI 상태를 정의하고 이 상태의 생성을 관리할 방법을 결정한 후에는 생성된 상태를 UI에 표시하는 단계를 진행합니다. UDF를 사용하여 상태 생성을 관리하므로 생성된 상태를 스트림으로 간주할 수 있습니다. 즉, 시간 경과에 따라 여러 버전의 상태가 생성되며 LiveData 또는 StateFlow와 같이 관찰 가능한 데이터 홀더에 UI 상태를 노출해야 합니다. 이유는 ViewModel에서 데이터를 직접 가져오지 않고도 UI가 상태 변경사항에 반응할 수 있도록 하기 위해서입니다.
class NewsViewModel(
private val repository: NewsRepository,
...
) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
private var fetchJob: Job? = null
fun fetchArticles(category: String) {
fetchJob?.cancel()
fetchJob = viewModelScope.launch {
try {
val newsItems = repository.newsItemsForCategory(category)
_uiState.update {
it.copy(newsItems = newsItems)
}
} catch (ioe: IOException) {
// Handle the error and notify the UI when appropriate.
_uiState.update {
val messages = getMessagesFromThrowable(ioe)
it.copy(userMessages = messages)
}
}
}
}
}
UI 상태를 노출할 때 다음 사항을 고려해야 합니다.
1. UI 상태 객체는 서로 관련성 있는 상태를 처리
이렇게 하면 불일치가 줄어들고 코드를 이해하기가 더 쉽습니다. 서로 다른 두 스트림에 노출하면 한 스트림이 업데이트되고 다른 스트림은 업데이트되지 않은 상황이 발생할 수 있습니다. 단일 스트림을 사용하면 두 요소가 모두 최신 상태로 유지됩니다. 또한 일부 비즈니스 로직에는 소스 조합이 필요할 수 있습니다.
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf()
)
val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
2. UI 상태: 단일 스트림인지 여러 스트림인지 확인하세요
단일 스트림 노출의 가장 큰 장점은 편의성과 데이터 일관성입니다. 즉, 상태 사용자자가 언제나 즉시 최신 정보를 확인할 수 있습니다. 하지만 다음과 같이 ViewModel 상태의 스트림이 별개일 때 적합한 경우가 있습니다.
1) 관련 없는 데이터 유형: UI를 렌더링하는 데 필요한 일부 상태는 서로 완전히 별개일 수 있습니다. 이때 서로 다른 상태를 함께 번들로 묶는 데 드는 비용이 이점보다 더 클 수 있으며 이는 상태 중 하나가 다른 상태보다 더 자주 업데이트되는 경우에 특히 그렇습니다.
2) UiState 비교: 객체에 필드가 많을수록 필드 중 하나를 업데이트하면 스트림이 내보내질 가능성이 큽니다. 뷰에는 연속적으로 이루어 지는 내보내기가 같은지 다른지 파악하는 비교(diff) 메커니즘이 없으므로 내보내기할 때마다 뷰가 업데이트됩니다. 따라서 Flow API 또는 LiveData의 distinctUntilChanged()와 같은 메서드를 사용한 완화 작업이 필요할 수 있습니다.
정리
이렇게 정리하다보니 제가 UiState에 대한 제대로된 지식이 아닌 단순히 Network Success, Fail 등과 같은 처리를 위해 사용하고 있었다고 생각이 들었습니다. 다음 글에서는 UiState를 통해 어떻게 동작하면 좋을지 살펴보려고 합니다.
'Android' 카테고리의 다른 글
[Android] 데이터 레이어 (0) | 2023.09.29 |
---|---|
[Android] UI 레이어 - 이벤트와 상태 (0) | 2023.09.29 |
[Android-Test] Android Compose Test 도입하기 - 실전 (0) | 2023.09.27 |
[Android-Test] Android Compose Test 도입하기 - 이론 (0) | 2023.09.26 |
[Android-Test] Android Test코드 작성하기5 - Hilt (0) | 2023.09.26 |
댓글