UI의 역할은 화면에 애플리케이션 데이터를 화면에 표시하고 사용자 상호작용을 위한 주요 접점 역할을 한다.
사용자 상호작용(ex. 버튼 누르기) 또는 외부 입력(ex. 네트워크 응답)으로 인해 데이터가 변할 때마다 변경사항을 반영하도록 UI가 업데이트 되어야 한다.
사실상 UI는 데이터 레이어에서 가져온 애플리케이션 상태를 시각적으로 표현한다.
하지만 일반적으로 데이터 레이어에서 가져오는 애플리케이션 데이터는 표시해야 하는 정보와 다른 형식이다.
예를 들어 UI용으로 데이터의 일부만 필요하거나 사용자에게 관련성 있는 정보를 표시하기 위해 서로 다른 두 데이터 소스를 병합해야 할 수도 있다.
적용하는 로직과 관계없이 완전히 렌더링하는 데 필요한 모든 정보를 UI에 전달해야 한다.
UI 레이어는 애플리케이션 데이터 변경사항을 UI가 표시할 수 있는 형식으로 변환한 후에 표시하는 파이프라인이다.
UI layer는 Activity와 Fragment와 같은 UI 요소를 의미하며, 이는 데이터를 표시하기 위해 사용하는 API와는 독립적이다.
data layer의 역할은 앱 데이터를 보유하고 관리하며 접근을 제공하는 것이므로, UI 계층은 아래의 단계를 수행해야 한다.
UI는 기사 목록과 각 기사에 대한 메타데이터를 사용자에게 보여준다.
UI 상태란 앱에서 사용자에게 표시하는 정보다.
즉, 사용자가 보는 항목이 UI라면 UI 상태는 앱에서 사용자가 봐야 한다고 지정하는 항목이다.
동전의 양면과 마찬가지로 UI는 UI 상태를 시각적으로 나타낸다.
UI 상태가 변경되면 변경사항이 즉시 UI에 반영된다.
UI : 사용자가 화면에서 직접 보는 것들 (ex.뉴스 앱 : 기사 목록, 사진, 제목 등)
UI 상태 : 앱이 이 UI에 어떤 내용을 보여줄지 결정하는 백그라운드 데이터
UI 상태와 UI는 서로 연결되어 있어서, 상태가 바뀌면 화면도 자동으로 바뀐다. 예를 들어, 새로운 기사가 추가되면 그 정보가 UI 상태에 반영되고, 그 상태가 UI에 표시된다.
뉴스 앱의 요구사항을 충족하기 위해 UI를 완전히 렌더링하는 데 필요한 정보를 다음과 같이 정의된 NewsUiState 데이터 클래스에 캡슐화할 수 있다
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 상태를 관리하기 위해 사용하는 데이터 구조를 정의한 것.
뉴스 앱에서 화면에 표시될 데이터와 상태를 코드로 관리하기 위한 예제
위 데이터 클래스는 모든 속성이 val로 선언되어 있어 수정할 수 없기 때문에 Immutable이다.
UI 자체가 데이터의 유일한 소스인 경우를 제외하고 UI에서 UI 상태를 직접 수정해서는 안된다. 이 원칙을 위반하면 동일한 정보가 여러 정보 소스에서 비롯되어 데이터 불일치와 미세한 버그가 발생한다.
데이터의 유일한 출처 또는 소유자만이 자신이 노출하는 데이터를 업데이트해야 한다.
뉴스 앱에서 "북마크 여부"를 나타내는 데이터가 있다면, 이 데이터는 앱의 특정 시점에서만 고정된 값을 가지고 있어야 한다.
만약 데이터를 아무데서나 수정할 수 있다면, 동일한 정보에 대해 서로 다른 값이 존재하게 될 수 있다. 예를 들면 데이터 레이어에서는 북마크가 켜짐, UI에서는 꺼짐으로 표시되는 혼란이 생길 수 있다.
따라서 불변 상태는 앱의 데이터를 한 곳에서만 관리하고, UI는 그 데이터를 읽어서 화면에만 보여주는 것이 원칙이다.
UI 상태가 UI 렌더링에 필요한 세부정보가 포함된 immutable한 스냅샷임을 확인했다. 하지만 앱 데이터의 동적 특성에 따라 UI 상태는 시간이 지나면서 변경 될 수 있으며 이는 앱을 채우는데 사용되는 기본 데이터를 수정하는 사용자 인터페이스나 기타 이벤트로 인해 발생하기도 한다.
여기에서는 중재 요소가 각 이벤트에 적용할 로직을 정의하고 UI 상태를 만들기 위해 지원 데이터 소스에 필요한 변환을 실행하여 상호작용을 처리한다. 하지만 이에 따라 빠르게 복잡해져 다른 부분에 영향을 미칠 수 있다.
궁극적으로 UI에 주는 부담을 줄여야하며, UI 상태가 매우 단순하지 않은 이상 UI의 역할은 오직 UI 상태를 사용 및 표시하는 것이어야 한다
이 변화를 처리하는 로직이 UI 내부에 들어가면 UI가 너무 복잡해지고 역할이 늘어난다. 따라서 데이터를 관리하는 부분과 화면을 보여주는 부분을 철저히 분리하는 아키텍처 패턴인 UDF를 사용하면 된다.
UI 상태를 생성하는 역할을 담당하고 생성 작업에 필요한 로직을 포함하는 클래스다.
상태 홀더의 크기는 하단 앱 바와 같은 단일 위젯부터 전체 화면이나 네비게이션 대상에 이르기까지 관리 대상 UI 요소의 범위에 따라 다양하다.
전체 화면이나 네비게이션 대상의 경우 일반적인 구현은 ViewModel의 인스턴스이지만 애플리케이션의 요구사항에 따라 간단한 클래스도로 충분할 수도 있다.
상태 홀더는 UI 상태를 만들어내고, 그 상태를 관리하는 클래스다.
복잡한 로직(데이터를 처리하는 방법)도 여기에 포함된다.
ViewModel 유형은 Data Layer에 접근할 권한이 있고, 화면 단위의 UI 상태를 관리하는 데 권장된다. 또한 구성이 변경되어도 자동으로 유지된다. ViewModel 클래스는 앱의 이벤트에 적용할 로직을 정의하고 결과로 업데이트되는 상태를 생성한다.
ViewModel은 UI 상태를 효율적으로 관리하기 위해 필요한 로직을 처리하고, 최신 데이터를 UI에 제공하는 클래스다.
UI와 ViewModel 클래스 간의 상호작용은 주로 이벤트 입력과 그에 따른 상태 출력(state output)으로 이해될 수 있기 때문에, 이 관계는 다음 그림과 같이 표현될 수 있다.
UI Layer는 event를 ViewModel에 보냄 (이벤트 전달)
ViewModel은 데이터를 처리하고 새로운 UI 상태를 만들어 UI에 전달함 (상태 관리)
state는 아래로 향하고 event는 위로 향하는 패턴을 단방향 데이터 흐름(UDF)이라고 한다.
내비게이션 목적지나 화면의 경우, ViewModel은 repository 또는 use case 클래스와 협력하여 데이터를 가져오고, UI 상태로 변환한다. 이 과정에서 이벤트로 인해 상태 변화가 발생하면 이를 반영하여 UI 상태를 갱신한다.
ViewModel은 UI로부터 이벤트를 받아 UI가 사용할 수 있는 형태로 상태를 변경하고, 변경된 상태를 다시 UI로 전달한다.
UI은 ViewModel이 제공하는 상태를 읽어서 화면에 표시한다. 사용자의 동작(클릭, 입력 등)을 ViewModel로 전달한다.
사용자의 동작 → ViewModel 이벤트 처리 → 상태 업데이트 → UI 반영 → 다음 이벤트
ViewModel은 필요한 데이터를 repository 또는 use case에서 가져온다. 가져온 데이터를 UI가 이해할 수 있는 상태로 변환하고 이벤트의 영향을 반영하여 상태를 최신으로 유지한다.
위의 사례에는 기사 목록이 포함되며 각 기사의 제목, 설명, 출처, 작성자 이름, 게시일, 북마크 여부가 표시된다.
사용자의 기사 북마크 요청은 상태 변경을 야기할 수 있는 이벤트의 예다.
상태 생성자의 경우 UI 상태의 모든 필드를 채우고 UI가 완전히 렌더링되는 데 필요한 이벤트를 처리하기 위해 모든 필수 로직을 정의하는 역할은 ViewModel이 담당한다.
위의 다이어그램은 UDF 패턴의 실제 동작을 보여주는 예제이다.
사용자가 "북마크"와 같은 특정 이벤트를 실행했을 때, 데이터가 어떻게 흐르고 상태가 갱신되는지 나타낸다.
- UI에 표시되기 위해 Current app data는 ViewModel을 통해 처리되어 UI 상태로 변환된다.
- 사용자가 UI 요소(북마크)를 클릭하여 event가 ViewModel로 전달된다.
- ViewModel은 북마크 버튼 클릭 event를 처리하고, 이를 Data layer로 전달해서 데이터를 수정하도록 요청한다.
ViewModel은 UI 상태를 직접 수정하지 않고, Data Layer를 통해 데이터를 수정하도록 지시한다.- Data Layer는 ViewModel로부터 전달받은 요청에 따라, 어플리케이션 데이터를 업데이트 한다. (ex. 특정 기사를 북마크된 상태로 저장)
- Data Layer는 변경된 데이터를 다시 ViewModel에 전달한다. 이 때 ViewModel은 세로운 데이터로 UI 상태를 갱신할 준비를 한다.
- ViewModel은 업데이트 된 데이터를 기반으로 새로운 UI 상태를 생성하고, 이 새로운 상태는 UI 요소로 전달되어 화면을 업데이트 한다.
데이터의 단방향 흐름을 통해 UI와 데이터 관리가 분리되는 구조
- 사용자 이벤트(UI → ViewModel): 사용자의 입력(이벤트)이 ViewModel로 전달한다.
- 상태 처리(ViewModel → Data Layer): ViewModel은 데이터를 업데이트하도록 Data Layer에 요청한다.
- 업데이트된 상태(Data Layer → ViewModel → UI): 업데이트된 데이터가 ViewModel을 거쳐 UI로 전달되고, UI는 이를 반영한다.
기사를 북마크 하는 것은 비즈니스 로직의 예로, 이는 앱에 가치를 부여한다. 앱에서 정의해야 할 다양한 유형의 로직이 있다.
비즈니스 로직은 앱 데이터와 관련된 제품 요구 사항을 구현하는 것이다.
일반적으로 domain layer 또는 data layer에 위치하며, UI layer에는 절대 위치하지 않는다.
앱의 핵삼동작을 처리하는 로직으로 데이터를 관리, 저장, 수정하는 역할을 한다. (ex. 북마크를 DB에 저장)
도메인 계층이나 데이터 계층에 있어야 데이터를 중앙에서 관리할 수 있다.
UI 로직은 화면에 상태 변화를 표시하는 방법이다.
Android res를 사용해 화면에 표시할 적절한 텍스트 가져오기, 사용자가 버튼 클릭했을 때 특정 화면으로 이동하기, 화면에 메시지를 표시하기 위해 toast 또는 snackbar 사용 등이 있다.
화면과 관련된 동작을 처리하는 로직
UI 로직(context와 같은 UI 관련 타입을 포함하는 경우)은 ViewModel이 아닌 UI에 위치해야 한다.
UI가 복잡해지고 UI 로직을 다른 클래스에 위임하여 테스트 가능성과 관심사의 분리를 선호한다면, 상태 홀더 역할을 하는 간단한 클래스를 생성할 수 있다.
UI에서 생성된 간단한 클래스는 Android SDK 종속성을 가질 수 있다. 이는 해당 클래스가 UI의 생명주기를 따르기 때문이다. 반면에 ViewModel 객체는 더 긴 수명을 가지므로 이와 다르다.
UI 로직은 Android의 Context와 같은 UI 관련 타입을 사용하는 경우가 많다. ViewModel은 Context에 의존하지 않으므로, 이러한 UI 로직은 UI 계층(예: Activity나 Fragment)에 위치해야 한다.
만약 UI 로직이 너무 복잡해지면, 이를 다른 간단한 클래스에 위임해서 관리할 수 있다. 이 클래스는 UI의 생명주기를 따르기 때문에 Android SDK를 사용해도 괜찮다.
ViewModel은 UI 상태를 관리하고, 사용자 이벤트를 처리하며, UI와 데이터 계층 간의 중재자 역할을 한다. 하지만, UI 로직(화면 전환, 알림 표시)과 복잡한 비즈니스 로직(데이터 저장, 계산)은 ViewModel에서 다루지 않고, 각각 UI 계층과 데이터 계층에서 처리한다. 즉 비즈니스 로직과 UI 로직 사이에서 데이터를 변환하거나 전달하는 역할을 한다
데이터 흐름을 단순하고 명확하게 유지하며, 데이터 관리와 UI 렌더링의 역할을 분리하여 앱 구조를 개선하는 아키텍처 패턴으로 이는 데이터가 어디에서 생성되고, 어떻게 이동하며, 어디에서 소비되는지를 명확하게 구분하는 방식이다.
UDF는 상태 생성 주기를 모델링하며, 상태 변화가 시작되는 위치, 변환되는 위치, 최종적으로 소비되는 위치를 분리한다. 이러한 분리는 UI가 본연의 역할에 집중할 수 있도록 한다. 즉 상태 변화를 관찰하여 정보를 표시하고, 사용자 의도를 전달하여 상태 변화를 ViewModel에 알리는 역할을 수행한다.
UDF는 UI, 상태 관리, 데이터 처리를 분리하여 앱의 구조를 단순화하고, 데이터 일관성을 유지하며, 테스트와 유지보수를 용이하게 만든다
앱의 데이터는 단일한 위치 (ViewModel)에서 관리된다.
이로 인해 데이터 충돌이나 불일치 문제가 줄어들어 UI는 항상 최신 상태의 데이터를 보여준다.
UI와 데이터 관리가 분리되어 있어, UI를 제외하고도 데이터를 쉽게 테스트할 수 있다.
ViewModel에서 생성된 UI 상태를 별도로 테스트할 수 있다
새로운 기능을 추가하거나 수정할 때 코드를 더 쉽게 유지하고 확장할 수 있다.
상태 변화가 사용자 이벤트와 데이터 소스의 변화라는 명확한 원인에 따라 이루어지기 때문에, 코드가 구조적이고 예측 가능해진다.
UI state를 정의하고, 해당 state를 생성하는 방법을 결정한 후에는 생성된 state를 UI에 제공하는것이 다음 단계다.
UDF를 사용해서 state 생성을 관리하기 때문에, 생성된 state를 Stream으로 간주할 수 있다. 즉, 시간이 지남에 따라 state의 여러 버전이 생성된다.
이로 인해 UI state는 LiveData나 stateFlow와 같은 observable data holder를 통해 노출해야 한다. 이렇게 하면 UI가 ViewModel에서 데이터를 직접 수동으로 가져오지 않고도 state의 모든 변경 사항에 자동으로 반응할 수 있다. 또한 이러한 유형(LiveData, stateFlow)은 항상 최신 상태를 캐싱하는 이점이 있다.
이 캐싱 기능은 구성 변경(ex. 화면 회전) 후 상태를 빠르게 복원하는 데 유용하다.
UDF 패턴에서 생성된 UI 상태를 UI와 연결하는 방법
- ViewModel은 시간이 지남에 따라 새로운 UI 상태를 계속 생성한다.
ex. 사용자가 버튼을 클릭하거나 데이터가 업데이트되면 새로운 상태가 생성됨
이 "새로운 상태"를 UI에서 바로 사용할 수 있도록 스트림(데이터의 흐름) 형태로 제공해야 한다.- LiveData와 StateFlow는 관찰 가능한 데이터 타입이다. ViewModel에서 생성된 상태를 LiveData나 StateFlow로 노출하면 UI(Activity나 Fragment)가 이를 "Observe"하고, 상태가 변경될 때 자동으로 반응할 수 있다. UI가 ViewModel에서 데이터를 직접 가져오는 대신, 상태가 변경되면 자동으로 UI가 업데이트 된다. 이는 코드를 간결하고 효율적으로 유지하는 데 도움이 된다.
- LiveData와 StateFlow는 항상 최신 상태를 유지한다.
예를 들어, 화면이 회전되거나 다른 구성 변경이 발생했을 때도, 최신 상태를 빠르게 복원할 수 있다.LiveData나 StateFlow를 사용하면, ViewModel이 생성한 UI 상태를 UI에 자동으로 전달할 수 있다. 이렇게 하면 UI 상태가 변경될 때마다 UI가 자동으로 반응하며, 상태 관리와 복원이 더 쉬워진다.
이것이 UDF의 핵심 개념인 상태 변화에 반응하는 구조를 구현하는 방법 중 하나다.
UI에 노출되는 데이터가 비교적 간단한 경우에도, 데이터를 UI state 타입으로 감싸는 것이 유용하다. 이는 state holder의 데이터 발생과 해당 화면 또는 UI 요소 간의 관계를 명확히 전달하기 때문이다.
게다가 UI 요소가 점점 더 복잡해질수록, UI 상태 정의를 확장하여 UI 요소를 렌더링하는 데 필요한 추가 정보를 포함시키는 것이 더 쉽다.
UI 상태 스트림을 생성하는 일반적인 방법은, ViewModel에서 Mutable Stream을 Immutable Stream으로 노출하는 것이다.
// MutableStateFlow<UiState>를 StateFlow<UiState>로 노출하는 방식
class NewsViewModel(...) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
...
}
데이터를 단순히 ViewModel에서 UI로 바로 전달하기보다는, UI 상태 타입으로 감싸는 것이 유리하다.
관계 명확화 : UI 상태는 "이 데이터가 특정 화면 또는 UI 요소와 연결되어 있다"는 의미를 명확히 해준다
확장 가능성 : UI 요소가 점점 더 복잡해질 경우, 기존 UI 상태에 새로운
속성을 추가하기만 하면 된다
UI 상태가 시간이 지나면서 계속 업데이트되기 때문에, 데이터를 스트림 형태로 관리한다.
Mutable → Immutable로 변환 : ViewModel 내부에서는 MutableStateFlow< UiState>와 같은 **변경 가능한 상태(flow)를 사용하고, 외부(UI)로는 StateFlow< UiState>와 같은 불변 상태(flow)를 노출한다. 이렇게 하면, UI는 상태를 관찰할 수만 있고 수정은 불가능하게 된다.
ViewModel은 내부적으로 상태를 변경하고, 이를 UI가 소비할 수 있도록 업데이트를 게시하는 메서드를 노출할 수 있다.
예를 들어, 비동기 작업을 수행해야 하는 경우, viewModelScope를 사용하여 코루틴을 실행할 수 있다.그리고 작업이 완료된 후 Mutable State를 업데이트할 수 있다.
- ViewModel은 UI 상태를 관리하는 핵심 역할을 담당한다.
UI가 직접 데이터를 수정하지 않도록, ViewModel이 상태를 변경하는 메서드를 제공한다. 예를 들어, 사용자가 버튼을 클릭했을 때 ViewModel 내부 메서드가 호출되어 데이터를 처리하고 상태를 변경한다. 변경된 상태는 UI로 전달되어 화면을 업데이트한다.- 앱에서 네트워크 요청이나 DB 작업처럼 시간이 걸리는 작업은 비동기로 처리해야 한다. ViewModel에서는 Android의 viewModelScope를 사용하여 코루틴을 실행할 수 있다. 작업이 완료되면, 결과를 바탕으로 상태를 업데이트한다.
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) {
// 특정 카테고리의 뉴스를 가져와 UI 상태 업데이트
fetchJob?.cancel()
// 이전에 실행 중이던 fetchJob 작업이 있으면 취소
fetchJob = viewModelScope.launch {
// 코루틴 실행, 비동기로 뉴스 데이터를 가져옴
try {
val newsItems = repository.newsItemsForCategory(category)
// 특정 카테고리의 뉴스 데이터를 가져옴
_uiState.update {
it.copy(newsItems = newsItems)
// 가져온 데이터를 _uiState에 반영
// 기존 상태를 복사하면서 newsItems 필드만 새로운 데이터로 업데이트.
}
} catch (ioe: IOException) {
// Handle the error and notify the UI when appropriate.
_uiState.update {
val messages = getMessagesFromThrowable(ioe)
it.copy(userMessages = messages)
}
}
}
}
}
1. 사용자가 "스포츠" 카테고리를 선택
2. fetchArticles("sports") 함수 호출
3. 이전 작업이 취소되고 새로운 코루틴 실행
4. NewsRepository에서 "스포츠" 카테고리의 뉴스를 가져옴
5. 데이터를 성공적으로 가져오면 newsItems를 UI 상태에 업데이트
6. UI는 uiState를 관찰하고 업데이트된 상태를 반영하여 새로운 뉴스를 화면에 표시
위 예제에서 NewsViewModel 클래스는 특정 카테고리의 기사를 가져오려고 시도하며, 그 시도의 결과(성공 또는 실패)를 UI 상태(UI state)에 반영한다.
위 예제에서처럼 ViewModel의 함수로 상태를 변경하는 패턴은 단방향 데이터 흐름(Unidirectional Data Flow, UDF)의 가장 널리 사용되는 구현 방식 중 하나다.
서로 연관된 상태는 동일한 UI 상태 객체에서 처리해야 한다.
이렇게 하면 불일치 문제가 줄어들고, 코드가 더 이해하기 쉬워진다.
예를 들어, 뉴스 기사 목록과 북마크 수를 각각 다른 스트림에서 노출 하면, 하나는 업데이트되고 다른 하나는 업데이트되지 않는 불일치 상황이 발생할 수 있다. 하지만 하나의 스트림에서 두 요소를 함께 관리하면, 항상 두 요소가 동기화된다.
비즈니스 로직에 소스 조합이 필요 할 수 있다.
일부 비즈니스 로직은 여러 데이터 소스를 결합해야 할 수 있다.
예를 들어 북마크 버튼을 표시할 조건은 사용자가 로그인되어 있고, 프리미엄 뉴스 서비스 구독자인 경우에만 해당되는 이러한 조건 을 처리하려면, UI 상태 클래스에서 이를 모두 고려해 정의해야 한다.
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf()
)
val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
위의 선언에서 북마크 버튼의 표시 여부는 다른 두 속성의 파생 속성이다.
비즈니스 로직이 더 복잡해짐에 따라 모든 속성을 즉시 사용할 수 있는 단일 UiState 클래스의 중요성이 점차 커진다.
- 관련된 상태를 하나의 UI 상태 객체(ex. NewUiState)로 통합하면, 상태가 항상 동기화된다.
- 상태를 하나의 스트림에서 관리하면, 하나의 스트림만 관찰하면 되기 때문에 모든 관련 데이터가 항상 최신 상태로 유지된다.
- 파생 속성이란, 다른 속성을 조합해서 계산되는 값을 말한다.
예제에서 북마크 버튼의 표시 여부(canBookmarkNews)는 다음 조건에 의해 결정된다, 사용자가 로그인했는가? (isSignedIn), 사용자가 프리미엄 구독자인가? (isPremium)
이러한 설계를 통해 UI 상태 클래스에서 필요한 모든 논리를 캡슐화할 수 있다. UI는 단순히 canBookmarkNews 값을 참조하여 버튼을 표시할지 말지만 결정한다.
- 비즈니스 로직이 복잡해질수록 단일 UI 상태 클래스의 중요성이 커진다.
모든 속성을 단일 UI 상태 객체에서 정의하면, 속성 간의 관계를 명확히 할 수 있다. 예를 들어, 북마크 버튼 가시성과 관련된 조건이 추가되더라도, NewsUiState 클래스에서 한 곳에서 관리할 수 있다.
UI 상태를 단일 스트림으로 노출할지, 아니면 여러 스트림으로 나눠서 노출할지 결정하는 핵심 원칙은 발행되는 항목 간의 관계다.
단일 스트림 노출의 가장 큰 장점은 편의성과 데이터 일관성이다. 즉, 단일 스트림으로 UI 상태를 노출하면, 소비자는 항상 특정 시점에 최신 정보를 사용할 수 있다 (모든 데이터가 동기화된 상태로 제공)
하지만 다음과 같이 ViewModel 상태의 스트림이 별개일 때 적합한 경우가 있다.
관련없는 데이터 타입
UI를 렌더링하는 데 필요한 상태가 서로 독립적일 경우, 단일 스트림으로 묶는 것은 비효율적일 수 있다. 특히, 두 상태 중 하나가 더 자주 업데이트 되는 경우라면, 단일 스트림으로 묶을 때 발생하는 비용이 이점보다 클 수 있다.
UI 상태의 차이 감지
UI 상태 객체에 필드가 많아질수록, 그 중 하나의 필드가 업데이트 될 때마다 스트림이 발행된다. 그러나 뷰는 연속적으로 발행된 상태가 다른지 같은지를 이해할 수 있는 메커니즘이 없기 때문에, 모든 발행은 뷰의 업데이트를 유발한다.이를 방지하려면, Flow API나, LiveData에서 distinctUntilChanged()같은 메서드를 사용해 불필요한 업데이트를 줄여야 할 수 있다.
단일 스트림은 데이터 일관성과 사용의 편리성을 제공한다.
하지만 관련 없는 데이터 타입이 있거나, 상태 발행이 너무 잦아 UI 업데이트 성능에 영향을 준다면, 다중 스트림을 고려해야 한다.
단일 스트림
모든 상태를 하나의 객체(UI 상태 객체)로 묶어서 ViewModel에서 스트림으로 노출한다. 모든 데이터가 동기화된 상태로 제공되어 관리가 용이하지만, 불필요한 업데이트가 발생할 가능성이 있다.
다중 스트림
서로 독립적인 상태를 각각 별도의 스트림으로 관리한다. 상태 간의 의존성이 없는 경우 유용하며, 업데이트 성능을 최적화할 수 있다.
UI에서 UiState 객체 스트림을 소비하려면, 사용 중인 observable data 타입에 맞는 terminal operator를 사용해야 한다.
LiveData : observe(), Kotlin Flow : collect()
observable data를 UI에서 소비할 때는 UI의 lifecycle을 고려해야 한다.
UI가 사용자에게 표시되지 않을 때 UI 상태를 관찰하면 안된다.
LiveData를 사용할 경우, LifecycleOwner가 생명주기를 자동으로 처리한다.
Flows를 사용할 경우, 적절한 코루틴 스코프와 repeatOnLifecycle API를 사용해 생명주기를 관리하는 것이 가장 좋다.
class NewsActivity : AppCompatActivity() {
private val viewModel: NewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
// STARTED 상태일 때만 uiState 스트림 소비
viewModel.uiState.collect {
// Update UI elements
}
}
}
}
}
위 예제에서 사용된 StateFlow 객체는 활성 소비자(collector)가 없더라도 작업을 계속 수행한다. 그러나 Flows의 구현 방식에 따라 동작이 다를 수 있다.
생명주기 기반 Flow 수집(lifecycle-aware flow collection)을 사용하면, 나중에 ViewModel의 Flow 구현을 변경하더라도 UI에서 소비하는 코드에 영향을 미치지 않도록 할 수 있다.
UI 상태 소비란?
ViewModel에서 제공하는 StateFlow 또는 LiveData를 UI(Activity, Fragment 등)에서 받아 화면에 반영하는 과정이다.
UI 상태가 변경될 때마다 자동으로 화면을 업데이트할 수 있다.
UI가 보이지 않을 때 상태를 관찰하면 리소스를 낭비하거나 불필요한 작업이 발생할 수 있다. 생명주기를 기반으로 UI 상태를 소비하면, 화면이 표시될 때만 상태를 관찰하고 처리할 수 있다.
LiveData와 Flow의 차이
LiveData: 생명주기를 LifecycleOwner가 자동으로 관리한다.
Flow: 생명주기를 직접 관리해야 하며, 이를 위해 repeatOnLifecycle 같은 API를 사용한다.
repeatOnLifecycle을 사용하는 이유
UI가 특정 생명주기 상태에서만 Flow를 수집하도록 설정한다. 예를 들어, UI가 STARTED 상태일 때만 데이터를 소비하고, STOPPED 상태일 때는 작업을 중단합니다.
UI 상태 클래스(UiState)에서 로딩 상태를 나타내는 간단한 방법은 Boolean 필드를 추가하는 것이다.
data class NewsUiState(
val isFetchingArticles: Boolean = false,
...
)
isFetchingArticles
는 현재 뉴스 기사를 가져오는 작업이 진행 중인지 아닌지를 나타낸다. 이 값은 UI에서 진행 상태를 표시하는 프로그레스 바의 표시 여부를 결정한다.
class NewsActivity : AppCompatActivity() {
private val viewModel: NewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState
.map { it.isFetchingArticles }
// uiState에서 isFetchingArticles 값만 추출
.distinctUntilChanged()
// 상태 값이 실제로 변경된 경우에만 프로그레스 바 업데이트
.collect { progressBar.isVisible = it }
// isFetchingArticles 값에 따라 프로그레스 바의 가시성 업데이트
}
}
}
}
// isFetchingArticles는 ViewModel에서 관리하며, 현재 데이터가 로드 중인지 여부를 나타냄
// lifecycleScope와 repeatOnLifecycle을 사용하여 UI의 생명주기와 로딩 상태를 연동
ViewModel이 데이터를 가져오는 동안 isFetchingArticles = true로 설정
UI는 isFetchingArticles 상태를 관찰하고, 프로그레스 바 표시
데이터 로드 완료 후 isFetchingArticles = false로 변경
UI는 상태 변화를 감지하고, 프로그레스 바를 숨김
- 로딩 상태 관리 : isFetchingArticles로 로딩 상태를 단순하게 관리한다
- UI와 상태 연동 : map과 distinctUntilChanged를 사용해 불필요한 업데이트를 방지하고, 효율적으로 UI를 업데이트한다
- 생명주기 연계 : repeatOnLifecycle로 UI 생명주기에 맞게 상태를 소비한다
UI에서 오류를 표시하는 방식은 진행 중인 작업을 표시하는 방식과 비슷하다.
둘 다 해당 상태의 존재 여부를 나타내는 불리언 값으로 쉽게 표현할 수 있기 때문이다.
하지만, 오류는 사용자에게 전달할 메시지나 실패한 작업을 재시도(retry)하는 동작과 같은 부가적인 정보(metadata)를 포함할 수 있다.
따라서, 진행 중인 작업은 단순히 로딩 중 또는 로딩 중이 아님으로 표시할 수 있지만, 오류 상태는 오류의 맥락(Context)을 나타내기 위해 데이터를 포함하는 data class로 모델링해야 할 수 있다.
작업 중 오류가 발생하면, 사용자에게 무엇이 잘못되었는지 알려주는 메시지를 하나 이상 표시하는 것이 좋다. 오류 메시지는 Snackbar와 같은 UI 요소를 사용해 사용자에게 표시할 수 있다
data class Message(val id: Long, val message: String)
data class NewsUiState(
val userMessages: List<Message> = listOf(),
...
)
Message 클래스는 사용자에게 보여줄 오류 메시지를 정의한다.
userMessages 필드는 NewsUiState에서 메시지의 리스트를 관리하며,
오류가 발생하면 여기에 추가된다
작업이 실패하면 ViewModel이 userMessages에 메시지를 추가한다.
UI는 상태를 관찰하고, 새로운 메시지가 추가되면
이를 Snackbar나 다른 UI 요소로 사용자에게 표시한다.
진행 중 상태는 단순히 작업이 로드 중인지 아닌지에 대한 불리언 값으로 표현된다. 반면에, 오류 상태는 부가적인 데이터(예: 메시지, 재시도 동작 등)를 포함해야 하므로, 이를 나타내기 위해 데이터 클래스를 사용하는 것이 일반적이다.
ViewModel에서 수행되는 모든 작업은 메인 스레드에서 안전하게 실행 가능해야 한다. 이는 데이터 계층(Data Layer)과 도메인 계층(Domain Layer)이 작업을 다른 스레드로 이동시키는 책임을 지기 때문이다.
그러나 ViewModel에서 오래 실행되는 작업을 수행해야 하는 경우, 해당 로직을 백그라운드 스레드로 이동시키는 책임은 ViewModel에 있다.
Kotlin 코루틴은 동시성을 관리하는 데 매우 유용한 도구이며, Jetpack 아키텍처 구성 요소는 이를 기본적으로 지원한다.
Android의 Main Thread는 UI와 사용자 상호작용을 처리한다.
메인 스레드에서 시간이 오래 걸리는 작업을 실행하면 UI가 멈추거나 응답하지 않는 문제가 발생한다. 따라서, ViewModel은 메인 스레드에서 안전하게 호출 가능한 작업만 수행해야 한다.
네트워크 요청, 데이터베이스 쿼리와 같이 시간이 오래 걸리는 작업은 백그라운드 스레드에서 실행해야 한다. ViewModel은 이러한 작업을 처리할 때 코루틴을 사용하여 비동기적으로 실행할 수 있다.
앱 내 네비게이션 변화는 주로 이벤트와 같은 상태 변화에 의해 발생한다.
예를 들어 SignInViewModel 클래스가 로그인을 실행하면 UiState는 isSignedIn 필드를 true로 설정할 수 있다. 이러한 트리거는 위의 UI 상태 사용 섹션에 설명된 대로 사용해야 한다. 단, implementation은 Navigation 컴포넌트를 통해 처리되어야 한다
로그인 완료 등 이벤트 기반의 상태 변화는 Navigation 컴포넌트를 통해 처리해야 한다.
Paging 라이브러리는 UI에서 PagingData라는 타입으로 소비된다.
PagingData는 시간이 지나면서 항목이 변경될 수 있으므로(즉, 불변 타입이 아님), 이를 불변 UI 상태로 표현해서는 안 된다. 대신, PagingData는 ViewModel에서 독립적인 스트림으로 노출해야 한다.
상위 수준의 네비게이션 전환에서 부드럽고 매끄러운 애니메이션을 제공하려면, 두 번째 화면의 데이터가 로드된 후 애니메이션을 시작하는 것이 좋다.
Android View Framework는 프래그먼트 간 전환을 지연시키는 postponeEnterTransition() 및 startPostponedEnterTransition() API를 제공한다.
이 API는 두 번째 화면의 UI 요소(예: 네트워크에서 가져온 이미지)가 표시 준비가 완료된 후 전환 애니메이션을 시작하도록 보장한다.