안드로이드 앱 클린 아키텍처 - UI 레이어

이윤설·2024년 9월 20일

안드로이드 연구소

목록 보기
2/33

UI 레이어

UI 레이어 개요

UI 레이어

UI의 역할
UI는 애플리케이션 데이터를 화면에 표시하고, 사용자와의 상호작용을 처리하는 중요한 역할을 한다. 사용자가 버튼을 클릭하거나 외부 입력(예: 네트워크 응답)이 있을 때, UI는 데이터의 변경사항을 반영하여 업데이트된다. 따라서 UI는 데이터 레이어에서 가져온 애플리케이션 상태를 시각적으로 표현하는 역할을 한다.

데이터 형식의 차이
데이터 레이어에서 가져오는 데이터는 UI에서 표시해야 하는 형식과 다를 수 있다. 예를 들어, UI에서 필요한 정보의 일부만 사용할 수 있으며, 여러 데이터 소스를 결합해야 할 경우도 있다. UI는 이러한 데이터를 사용자가 이해할 수 있는 형태로 변환한 후 표시하는 파이프라인 역할을 한다.

UI 아키텍처의 구성
일반적인 아키텍처에서 UI 요소는 상태 홀더에 의존하고, 상태 홀더는 데이터 레이어의 클래스나 선택적 도메인 레이어의 클래스에 의존한다.

기본 우수 사례
가령, 뉴스 기사를 표시하는 앱을 생각해보자. 사용자는 기사를 읽고, 카테고리별로 탐색하며, 로그인 후 특정 기사를 북마크할 수 있다. 이 앱은 다음과 같은 기능을 제공한다:

  • 읽을 수 있는 기사 보기
  • 카테고리별 기사 탐색
  • 로그인 후 기사 북마크
  • 일부 프리미엄 기능 접근

이 예제는 단방향 데이터 흐름의 원칙을 이해하고, UI 레이어의 앱 아키텍처에서 해결할 수 있는 문제를 설명하는 데 도움을 준다.

UI 레이어 아키텍처

UI는 사용하는 API(뷰 또는 Jetpack Compose)에 관계없이 데이터를 표시하는 활동이나 프래그먼트 같은 UI 요소를 지칭한다. 데이터 레이어는 애플리케이션 데이터를 보유하고 관리하며, UI 레이어에서 다음과 같은 작업을 수행해야 한다:

  1. 앱 데이터를 UI에서 쉽게 렌더링할 수 있는 형태로 변환한다.
  2. 변환된 데이터를 UI 요소로 변환하여 사용자에게 표시한다.
  3. UI 요소에서 발생하는 사용자 입력 이벤트를 처리하고, 그 결과를 UI 데이터에 반영한다.
  4. 위 단계를 필요에 따라 반복한다.

이 가이드에서는 UI 상태를 정의하고 관리하는 방법, 단방향 데이터 흐름(UDF)의 원칙, 관찰 가능한 데이터 유형으로 UI 상태를 노출하는 방법 등을 설명한다.

UI 상태 정의

사용자가 보는 항목이 UI라면, UI 상태는 사용자에게 보여줘야 하는 항목이다. UI 상태가 변경되면, 변경사항이 즉시 UI에 반영된다.

불변성
UI 상태 정의는 변경할 수 없는 구조로 설계되어야 한다. 불변 객체의 장점은 애플리케이션의 상태를 안정적으로 유지하는 것이다. UI는 상태를 읽고, 이를 기반으로 UI 요소를 업데이트하는 역할에 집중해야 한다. UI가 UI 상태를 직접 수정해서는 안 되며, 이는 데이터 불일치와 버그를 유발할 수 있다.

단방향 데이터 흐름으로 상태 관리

UI 상태는 시간이 지나면서 변경될 수 있으며, 사용자 상호작용이나 다른 이벤트에 의해 영향을 받을 수 있다. 이를 처리하기 위해 중재 요소가 필요하다. UI는 UI 상태를 사용하여 상태 변경 사항을 관찰하고, 사용자 이벤트를 ViewModel에 전달한다.

상태 홀더


UI 상태를 생성하는 역할을 담당하고 생성 작업에 필요한 로직을 포함하는 클래스를 상태 홀더라고 한다. 상태 홀더의 크기는 하단 앱 바와 같은 단일 위젯부터 전체 화면이나 탐색 대상에 이르기까지 관리 대상 UI 요소의 범위에 따라 다양하다.

전체 화면이나 탐색 대상의 경우 일반적인 구현은 ViewModel의 인스턴스이지만 애플리케이션의 요구사항에 따라 간단한 클래스로도 충분할 수 있다. 예를 들어 우수사례의 뉴스 앱은 NewsViewModel 클래스를 상태 홀더로 사용하여 섹션에 표시되는 화면의 UI 상태를 생성한다.

UI와 상태 생성자 간의 상호 종속을 모델링하는 방법은 다양하다.
하지만 UI와 ViewModel 클래스 사이의 상호작용은 대체로 이벤트 입력입력의 후속 상태인 출력으로 간주될 수 있으므로 관계는 위 다이어그램과 같다.

데이터 흐름은 데이터 레이어에서 ViewModel로 향하고,
UI 상태 흐름은 ViewModel에서 UI 요소로 향하고,
이벤트 흐름은 UI 요소에서 다시 ViewModel로 향한다.

  • ViewModel이 UI에 사용될 상태를 보유하고 노출한다. UI 상태는 ViewModel에 의해 변환된 애플리케이션 데이터이다.
  • UI가 ViewModel에 사용자 이벤트를 알린다.
  • ViewModel이 사용자 작업을 처리하고 상태를 업데이트한다.
  • 업데이트된 상태가 렌더링할 UI에 다시 제공된다.
  • 상태 변경을 야기하는 모든 이벤트에 위의 작업이 반복된다.

UDF의 이점

단방향 데이터 흐름(UDF)은 다음과 같은 이점을 제공한다.

  • 데이터 일관성: UI의 정보 소스는 하나로 유지된다.
  • 테스트 가능성: UI와 상태 소스가 분리되어 독립적으로 테스트할 수 있다.
  • 유지 관리성: 상태 변경이 사용자 이벤트와 데이터 소스 모두에 의해 영향을 받는다.

UI 상태 노출

UI 상태를 정의하고 생성 관리를 설정한 후, 해당 상태를 UI에 표시하는 과정으로 넘어간다. UDF(단방향 데이터 흐름)를 통해 상태 생성을 관리하므로, 생성된 상태는 시간에 따라 여러 버전으로 나타난다. 이를 위해 UI 상태는 LiveData나 StateFlow와 같은 관찰 가능한 데이터 홀더에 노출되어야 한다. 이렇게 하면 ViewModel에서 데이터를 직접 가져오지 않고도 UI가 상태 변경에 반응할 수 있다. 또한, 이러한 데이터 홀더는 항상 최신 UI 상태를 캐시하므로, 구성 변경 후 빠른 상태 복원에 유용하다.

ViewModel 예시

class NewsViewModel(...) : ViewModel() {
    val uiState: NewsUiState = ...
}

LiveData에 관한 자세한 내용은 Codelab을 참고하고, Kotlin 흐름에 대한 정보는 Android의 Kotlin 흐름 문서를 참조하라.

Jetpack Compose 앱에서는 Compose의 관찰 가능한 상태 API(예: mutableStateOf 또는 snapshotFlow)를 활용할 수 있다. 간단한 UI 상태를 다룰 때, UI 상태 유형으로 데이터를 래핑하는 것이 유리하다. 이는 UI 요소와 상태 홀더 간의 관계를 명확하게 전달하기 때문이다. UI 요소가 복잡해질 경우, 상태 정의를 추가하여 필요한 정보를 더 포함할 수 있다.

일반적으로 ViewModel에서 지원되는 변경 가능한 스트림을 불변 스트림으로 노출하는 방식이 많이 사용된다. 예를 들어, MutableStateFlow<UiState>StateFlow<UiState>로 노출하는 방식이다.

class NewsViewModel(...) : ViewModel() {
    var uiState by mutableStateOf(NewsUiState())
        private set

    ...
}

이렇게 하면 ViewModel은 상태를 내부적으로 변경하는 메서드를 노출하여 UI가 사용할 수 있도록 업데이트를 게시할 수 있다. 비동기 작업을 실행해야 할 경우, viewModelScope를 사용하여 코루틴을 실행하고, 작업이 완료되면 상태를 업데이트할 수 있다.

class NewsViewModel(
    private val repository: NewsRepository,
    ...
) : ViewModel() {
    var uiState by mutableStateOf(NewsUiState())
        private set

    private var fetchJob: Job? = null

    fun fetchArticles(category: String) {
        fetchJob?.cancel()
        fetchJob = viewModelScope.launch {
            try {
                val newsItems = repository.newsItemsForCategory(category)
                uiState = uiState.copy(newsItems = newsItems)
            } catch (ioe: IOException) {
                // 오류 처리 및 UI에 알림
                val messages = getMessagesFromThrowable(ioe)
                uiState = uiState.copy(userMessages = messages)
            }
        }
    }
}

이 예시에서 NewsViewModel 클래스는 특정 카테고리의 기사를 가져온 후 결과에 따라 UI 상태를 업데이트하여 UI가 적절히 반응할 수 있게 한다.

UI와 스트림

Android 앱 개발에서 상태는 일반적으로 Kotlin의 스트림 기법으로 구현하는 것이 권장된다.

코틀린의 스트림

그렇다면 스트림은 무엇일까?
코틀린의 스트림(Stream)은 데이터의 흐름을 다루는 일련의 작업이다. 아이스크림 주문 앱을 예로 들어 설명해보겠다.

사용자가 앱에서 아이스크림을 주문하면, 주문 데이터는 앱의 여러 단계(주문 접수, 결제 처리, 배송 준비 등)를 거친다. 각 단계에서 발생하는 데이터 흐름을 스트림이라고 할 수 있다.

예를 들어, 주문 상태가 "주문 접수", "결제 완료", "배송 준비"로 변할 때마다 스트림을 통해 그 정보를 앱에 전달하게 된다. 코틀린에서는 FlowLiveData 같은 스트림 API를 사용하여 이러한 데이터를 비동기적으로 처리하고, UI는 이 상태 변화를 관찰하며 실시간으로 업데이트된다.

따라서, 사용자가 아이스크림을 주문하면 주문이 진행되는 상태 변화(예: "주문 접수됨", "결제 완료됨", "배송 준비 중")를 스트림으로 전달하고, UI에서 이를 실시간으로 반영하는 방식이다.

data class IceCream(val id: Int, val name: String, val price: Int)

data class OrderUiState(
    val availableIceCreams: List<IceCream> = emptyList(),
    val selectedIceCream: IceCream? = null,
    val orderInProgress: Boolean = false
)

class OrderViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(OrderUiState())
    val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()

    init {
        loadIceCreams()
    }

    private fun loadIceCreams() {
        viewModelScope.launch {
            // 실제 앱에서는 네트워크나 데이터베이스에서 불러옴
            val iceCreams = listOf(
                IceCream(1, "Vanilla", 3000),
                IceCream(2, "Chocolate", 3500),
                IceCream(3, "Strawberry", 3200)
            )
            _uiState.update { currentState ->
                currentState.copy(availableIceCreams = iceCreams)
            }
        }
    }

    fun selectIceCream(iceCream: IceCream) {
        _uiState.update { currentState ->
            currentState.copy(selectedIceCream = iceCream)
        }
    }

    fun placeOrder() {
        if (_uiState.value.selectedIceCream != null) {
            viewModelScope.launch {
                _uiState.update { it.copy(orderInProgress = true) }
                // 실제 주문 처리 로직 (예: 네트워크 호출)
                kotlinx.coroutines.delay(2000) // 주문 처리 중
                _uiState.update { it.copy(orderInProgress = false) }
            }
        }
    }
}

// Composable: UI에서 아이스크림 목록을 표시하고 선택한 후 주문할 수 있는 화면
@Composable
fun IceCreamOrderScreen(
    orderViewModel: OrderViewModel = viewModel()
) {
    val uiState by orderViewModel.uiState.collectAsState()

    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Choose your Ice Cream", style = MaterialTheme.typography.h6)

        Spacer(modifier = Modifier.height(16.dp))

        // 아이스크림 목록 표시
        uiState.availableIceCreams.forEach { iceCream ->
            Button(
                onClick = { orderViewModel.selectIceCream(iceCream) },
                modifier = Modifier.fillMaxWidth().padding(8.dp)
            ) {
                Text("${iceCream.name} - ${iceCream.price} 원")
            }
        }

        Spacer(modifier = Modifier.height(16.dp))

        // 선택된 아이스크림 표시
        uiState.selectedIceCream?.let { selected ->
            Text("Selected: ${selected.name}")
        }

        Spacer(modifier = Modifier.height(16.dp))

        // 주문 버튼과 진행 상황 표시
        if (uiState.orderInProgress) {
            CircularProgressIndicator()
        } else {
            Button(
                onClick = { orderViewModel.placeOrder() },
                modifier = Modifier.fillMaxWidth().padding(8.dp),
                enabled = uiState.selectedIceCream != null
            ) {
                Text("Place Order")
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun PreviewIceCreamOrderScreen() {
    IceCreamOrderScreen()
}
  1. 상태 정의와 관리
private val _uiState = MutalbeStateFlow(OrderUiState())
val uiState: StateFlow<OrderUiState> = _uiState.asStateFlow()
  • ViewModel에서 MutableStateFlow를 사용하여, 내부적으로 변경 가능한 상태를 관리한다.
  • 외부에는 일긱 전용인 StateFlow로 노출하여 캡슐화를 유지한다.
  1. 상태 업데이트
fun selectIceCream(iceCream: IceCream) {
  _uiState.update { currentState -> 
     currentState.copy(selectedIceCream = iceCream)
  }
}
  • update 함수를 사용해 상태를 안전하게 업데이트한다.
  • 이 업데이트는 스트림을 통해 자동으로 UI에 전파된다.
  1. UI에서의 상태 사용
@Composable
fun IceCreamOrderScreen(orderViewModel: OrderViewModel = viewModel()) {
  val uiState by orderViewModel.uiState.collectAsState()
  // UI 구성
}
  • collectAsState()를 사용해 Stateflow를 Compose의 상태로 변환한다.
  • 이를 통해 UI는 상태 변화를 자동으로 관찰하고 반영할 수 있다.
  1. UI 반응성
uiState.availableIceCreams.forEach { iceCream ->
    Button(
        onClick = { orderViewModel.selectIceCream(iceCream) },
        // ...
    ) {
        Text("${iceCream.name} - ${iceCream.price} 원")
    }
}
  • UI 요소들은 uiState의 현재 값을 바탕으로 렌더링된다.
  • 상태가 변경되면 UI는 자동으로 재구성된다.
  1. 비동기 작업과 상태 업데이트
fun placeOrder() {
  if (_uiState.value.selectedIcecream != null) {
    viewModelScope.launch {
      _uiState.update {it.copy(orderInProgress = true) }
      // 실제 주문 처리 로직
      kotlinx.couroutins.delay(2000)
      _uiState.update {it.copy(orderInProgress = false) }
  • 비동기 작업(주문 처리)도 상태 업데이트를 통해 UI에 반영된다.
  • 로딩 상태의 변화가 UI에 즉시 반영된다.

ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ
이 플래그의 값은 UI에 진행률 표시줄의 존재 여부를 나타낸다.

@Composable
fun LatestNewsScreen(
    modifier: Modifier = Modifier,
    viewModel: NewsViewModel = viewModel()
) {
    Box(modifier.fillMaxSize()) {
        if (viewModel.uiState.isFetchingArticles) {
            CircularProgressIndicator(Modifier.align(Alignment.Center))
        }
        // 기타 UI 요소 추가. 예: 목록
    }
}

화면에 오류 표시

오류 표시는 진행 중인 작업 표시와 유사하게 불리언 값으로 표현할 수 있다. 그러나 오류에는 사용자에게 전달할 메시지나 다시 시도할 작업이 포함될 수 있다. 따라서 오류 상태를 모델링하기 위해 오류 상황에 대한 메타데이터를 포함하는 데이터 클래스를 사용하는 것이 좋다.

data class Message(val id: Long, val message: String)

data class NewsUiState(
    val userMessages: List<Message> = listOf(),
    ...
)

오류 메시지는 스낵바와 같은 UI 요소로 사용자에게 표시될 수 있다. 이와 관련된 자세한 내용은 UI 이벤트 페이지를 참조하라.

스레딩 및 동시 실행

ViewModel에서 실행되는 모든 작업은 기본 스레드에서 안전하게 호출해야 한다.
데이터 레이어와 도메인 레이어가 작업을 다른 스레드로 옮기는 역할을 담당하기 때문이다. 장기 실행 작업은 ViewModel에서 백그라운드 스레드로 옮기는 것이 중요하다.

Kotlin 코루틴은 동시 실행 작업을 관리하는 좋은 방법이며, Jetpack 아키텍처 구성요소는 이를 기본적으로 지원한다.

탐색

앱 탐색의 변경사항은 주로 이벤트 같은 내보내기에 의해 이루어진다. 예를 들어 SignInViewModel 클래스가 로그인을 실행하면 UiStateisSignedIn 필드를 true로 설정할 수 있다. 이러한 트리거는 UI 상태 사용 섹션에서 설명된 대로 사용해야 하며, 구현 시 탐색 구성요소를 지연해야 한다.

Paging

Paging 라이브러리는 UI에서 PagingData라는 유형과 함께 사용된다. PagingData는 시간에 따라 변경될 수 있는 항목을 나타내므로, 변경 불가능한 UI 상태로 표현되면 안 된다. 대신 ViewModel의 데이터를 독립적으로 노출해야 한다.

애니메이션

부드럽고 원활한 화면 전환을 위해 다음 화면의 데이터가 로드될 때까지 기다린 후 애니메이션을 시작하는 것이 좋다. Android 뷰 프레임워크는 postponeEnterTransition()startPostponedEnterTransition() API를 제공하여 프래그먼트 간의 전환을 지연할 수 있다. 이러한 API를 사용하면 다음 화면의 UI 요소가 준비되었을 때 전환을 애니메이션 처리할 수 있다.


UI 이벤트

UI 이벤트 개요

UI 이벤트는 UI 레이어에서 UI 또는 ViewModel로 전달되어 처리해야 하는 작업을 의미한다.
가장 일반적인 이벤트 유형은 사용자 이벤트다.
사용자는 화면을 터치하거나 제스처를 통해 앱과 상호작용하며 사용자 이벤트를 발생시킨다.
이러한 이벤트는 UI에서 onClick() 리스너와 같은 콜백을 통해 처리된다.

핵심 용어

  • UI: 사용자 인터페이스를 구성하는 뷰 또는 Compose 코드.
  • UI 이벤트: UI 레이어에서 처리해야 하는 작업.
  • 사용자 이벤트: 사용자가 앱과 상호작용할 때 발생하는 이벤트.

ViewModel은 일반적으로 사용자가 발생시킨 이벤트에 따른 비즈니스 로직을 처리한다.
예를 들어 사용자가 데이터를 새로고침하는 버튼을 클릭하면, ViewModel이 비즈니스 로직을 처리한다.
그러나 일부 이벤트는 UI 동작 로직을 통해 UI에서 직접 처리할 수 있다.
예를 들어 다른 화면으로의 이동이나 Snackbar 표시 같은 작업이 이에 해당한다.

비즈니스 로직은 앱의 여러 플랫폼이나 폼 팩터에서 동일하게 유지되지만, UI 동작 로직은 플랫폼에 따라 달라질 수 있다.

  • 비즈니스 로직: 결제 처리나 사용자 설정 저장과 같은 상태 변경과 관련된 작업. 일반적으로 도메인 및 데이터 레이어에서 처리한다.
  • UI 동작 로직: 상태 변경을 UI에 반영하는 로직. 예를 들어, 탐색 로직이나 사용자에게 메시지를 표시하는 방법 등이 이에 해당한다.

UI 이벤트 결정 트리

이벤트가 발생하는 위치에 따라 처리 방식이 달라진다. 이벤트가 ViewModel에서 발생한 경우, UI 상태를 업데이트한다. 이벤트가 UI에서 발생하고 비즈니스 로직을 필요로 할 경우, ViewModel에 위임한다. 이벤트가 UI에서 발생하고 UI 동작 로직이 필요한 경우, UI에서 직접 상태를 수정한다.

사용자 이벤트 처리

UI 요소의 상태 변경과 관련된 이벤트는 UI에서 직접 처리할 수 있다. 반면, 비즈니스 로직을 실행해야 하는 이벤트는 ViewModel이 처리한다.

@Composable
fun LatestNewsScreen(viewModel: LatestNewsViewModel = viewModel()) {

    // 더 많은 세부 정보를 보여줄지 여부를 나타내는 상태
    var expanded by remember { mutableStateOf(false) }

    Column {
        Text("Some text")
        if (expanded) {
            Text("More details")
        }

        Button(
            // 이 버튼 클릭 이벤트는 UI 내부 상태를 변경하여 처리
            onClick = { expanded = !expanded }
        ) {
            val expandText = if (expanded) "Collapse" else "Expand"
            Text("$expandText details")
        }

        Button(
            // 이 버튼 클릭 이벤트는 ViewModel에서 비즈니스 로직을 처리
            onClick = { viewModel.refreshNews() }
        ) {
            Text("Refresh data")
        }
    }
}

RecyclerView의 사용자 이벤트 처리

RecyclerView 항목에서 발생하는 이벤트는 ViewModel이 처리해야 한다. 예를 들어, 뉴스 항목의 북마크 기능을 처리할 때 ViewModel은 뉴스 항목의 ID와 함께 해당 기능을 제공해야 한다.

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    val publicationDate: String,
    val onBookmark: () -> Unit
)

class LatestNewsViewModel(
    private val formatDateUseCase: FormatDateUseCase,
    private val repository: NewsRepository
) {
    val newsListUiItems = repository.latestNews.map { news ->
        NewsItemUiState(
            title = news.title,
            body = news.body,
            bookmarked = news.bookmarked,
            publicationDate = formatDateUseCase(news.publicationDate),
            // 비즈니스 로직을 UI에서 호출할 수 있도록 람다 함수로 전달
            onBookmark = {
                repository.addBookmark(news.id)
            }
        )
    }
}

이처럼 RecyclerView 어댑터는 ViewModel과 직접 상호작용하지 않고 필요한 데이터만 사용한다. ViewModel을 어댑터에 직접 전달하지 않으면 UI와 비즈니스 로직이 분리되어 유지보수성과 테스트 가능성이 향상된다.

사용자 이벤트 함수의 네이밍 규칙

이벤트를 처리하는 ViewModel 함수는 그 역할에 맞는 동사를 포함하여 이름을 지정한다. 예를 들어, addBookmark(id)logIn(username, password)와 같이 명확한 동사를 포함하는 방식으로 명명한다.

ViewModel 이벤트 처리

ViewModel에서 발생하는 이벤트는 항상 UI 상태 업데이트로 이어져야 한다. 이는 단방향 데이터 흐름의 원칙을 따르며, 구성 변경 후에도 이벤트를 재현할 수 있도록 한다.

data class LoginUiState(
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val isUserLoggedIn: Boolean = false
)

이 UI 상태는 isUserLoggedIn 값을 기반으로 화면 전환을 처리할 수 있다.

@Composable
fun LoginScreen(
    viewModel: LoginViewModel = viewModel(),
    onUserLogIn: () -> Unit
) {
    val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)

    LaunchedEffect(viewModel.uiState) {
        if (viewModel.uiState.isUserLoggedIn) {
            currentOnUserLogIn()
        }
    }

    // 로그인 화면의 나머지 UI 구성
}

위 코드는 LoginUiState에 따라 사용자가 로그인되면 자동으로 다른 화면으로 전환되도록 한다.

이와 같이 ViewModel에서 발생하는 모든 이벤트는 UI 상태를 통해 처리되며, 비즈니스 로직과 UI 로직이 명확하게 분리되어 관리된다.

탐색 이벤트

탐색 이벤트란?

탐색 이벤트(Navigation Event)는 UI에서 특정 동작이나 사용자 입력에 따라 다른 화면으로 이동해야 할 때 발생하는 이벤트를 말한다. 예를 들어, 사용자가 로그인 버튼을 클릭하면 로그인이 완료되었을 때 다른 화면으로 전환하는 것이 탐색 이벤트다. 이때 사용자가 원하는 화면으로 적절히 이동하는 것이 탐색 이벤트의 목적이다.

탐색 이벤트는 단순히 DB에서 데이터를 검색하는 것이 아니라, 앱의 UI 흐름을 제어하는 방식이다. 버튼 클릭, 특정 조건이 만족될 때 다른 화면으로 이동해야 하는 경우가 여기에 포함된다. 탐색은 주로 NavController 또는 onUserLogIn과 같은 함수에 의해 처리된다.

아래 글도 정리하여 설명한다.

이벤트 소비 시 상태 업데이트 가능

ViewModel에서 UI 이벤트를 소비하면 다른 UI 상태 업데이트를 트리거할 수 있다.
예를 들어, 화면에 메시지를 표시할 때, 해당 메시지가 소비되면 상태가 변경되어 ViewModel이 이를 인지하고 처리해야 한다. UI 상태는 화면에 표시되는 내용과 긴밀하게 연결되어 있으며, 사용자 메시지를 표시하거나 닫았을 때 이를 UI 상태로 반영하여 업데이트하는 것이 중요하다.

코드 설명

data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessage: String? = null
)

class LatestNewsViewModel : ViewModel() {
    var uiState by mutableStateOf(LatestNewsUiState())
        private set

    fun refreshNews() {
        viewModelScope.launch {
            if (!internetConnection()) {
                uiState = uiState.copy(userMessage = "No Internet connection")
            }
        }
    }

    fun userMessageShown() {
        uiState = uiState.copy(userMessage = null)
    }
}

LatestNewsUiState 데이터 클래스는 UI에서 사용되는 상태를 정의하며, 화면에 표시할 내용을 담고 있다.
LatestNewsViewModel에서는 이 UI 상태를 관리한다.
refreshNews 함수는 네트워크 연결이 없을 경우 userMessage에 "No Internet connection" 메시지를 설정하여 사용자에게 알림을 보낸다.

그 후 UI에서 이 메시지를 소비하면 userMessageShown() 함수가 호출되어 userMessage 값을 null로 설정해 상태를 초기화한다.

위 코드에서 상태는 UI와 긴밀히 연결되어 있다. 메시지를 화면에 표시해야 하는지 여부는 userMessage에 의해 결정되며, 메시지가 소비되면(즉, 사용자가 확인했거나 닫았을 때) userMessage를 null로 설정하여 더 이상 표시되지 않도록 한다.

탐색 이벤트 처리

UI에서 사용자가 버튼을 탭하거나 특정 작업을 하면 ViewModel이 상태를 업데이트하고, 이에 따라 UI에서 탐색 이벤트를 처리할 수 있다. 이 경우 탐색 로직은 UI에서 처리되고, 상태 업데이트는 ViewModel에서 처리된다. 예를 들어, 사용자가 로그인을 시도할 때 ViewModel이 유효성을 검사하고, 성공 시 UI가 탐색을 진행한다.

코드 설명

로그인 화면에서 사용자가 로그인을 시도하면 login() 메서드가 호출되고, 로그인이 완료되면 탐색 이벤트가 트리거된다. 아래는 이를 처리하는 코드다:

@Composable
fun LoginScreen(onUserLogIn: () -> Unit, viewModel: LoginViewModel = viewModel()) {
    Button(onClick = { viewModel.login() }) {
        Text("Log in")
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    LaunchedEffect(viewModel, lifecycle) {
        snapshotFlow { viewModel.uiState }
            .filter { it.isUserLoggedIn }
            .flowWithLifecycle(lifecycle)
            .collect {
                onUserLogIn()
            }
    }
}

화면 간 탐색 이벤트 처리

ViewModel에서 특정 화면 간의 탐색을 처리할 때, 추가 로직을 통해 UI 상태와 탐색 상태를 관리할 수 있다. 예를 들어, 생년월일 확인 화면에서 사용자가 날짜를 입력하고 유효성 검사를 통과하면 다음 화면으로 자동으로 이동하는 로직을 추가할 수 있다.

코드 설명

생년월일 확인 화면에서 유효성 검사가 진행 중일 때, 날짜가 유효하면 다음 화면으로 이동하는 로직을 구현한 예시다.

@Composable
fun DobValidationScreen(onNavigateToNextScreen: () -> Unit, viewModel: DobValidationViewModel = viewModel()) {
    var validationInProgress by rememberSaveable { mutableStateOf(false) }

    Button(onClick = {
        viewModel.validateInput()
        validationInProgress = true
    }) {
        Text("Continue")
    }

    if (validationInProgress) {
        val lifecycle = LocalLifecycleOwner.current.lifecycle
        LaunchedEffect(viewModel, lifecycle) {
            snapshotFlow { viewModel.uiState }
                .filter { it.isDobValid }
                .flowWithLifecycle(lifecycle)
                .collect {
                    validationInProgress = false
                    onNavigateToNextScreen()
                }
        }
    }
}
  1. Composable 함수 정의:
@Composable
fun DobValidationScreen(onNavigateToNextScreen: () -> Unit, 
viewModel: DobValidationViewModel = viewModel())
  • 이 함수는 생년월일(Date of Birth, DOB) 검증 화면을 구성한다.
  • onNavigateToNextScreen: 다음 화면으로 이동하는 콜백 함수
  • viewModel: 화면의 로직을 처리하는 ViewModel
  1. 로컬 상태 관리:
var validationInProgress by rememberSaveable { mutableStateOf(false) }
  • validationInProgress: 검증 진행 중인지 여부를 나타내는 로컬 상태
  • rememberSaveable을 사용해 구성 변경 시에도 상태를 유지한다.
  1. 버튼 구현:
Button(onClick = {
    viewModel.validateInput()
    validationInProgress = true
}) {
    Text("Continue")
}
  • 사용자가 "Continue" 버튼을 클릭하면 입력을 검증하고 validationInProgress를 true로 설정한다.
  1. 검증 결과 처리:
if (validationInProgress) {
    val lifecycle = LocalLifecycleOwner.current.lifecycle
    LaunchedEffect(viewModel, lifecycle) {
        snapshotFlow { viewModel.uiState }
            .filter { it.isDobValid }
            .flowWithLifecycle(lifecycle)
            .collect {
                validationInProgress = false
                onNavigateToNextScreen()
            }
    }
}
  • if (validationInProgress) { ... }:
    이 부분은 현재 유효성 검사가 진행 중일 때만 내부 코드를 실행한다는 뜻이다.

  • val lifecycle = LocalLifecycleOwner.current.lifecycle:
    현재 화면(또는 프래그먼트)의 생명주기를 가져온다. 이는 앱의 상태 변화(예: 화면 회전, 백그라운드로 전환 등)를 추적하는 데 사용된다.

  • LaunchedEffect(viewModel, lifecycle) { ... }:
    이 부분은 Compose에서 사용되는 특별한 함수다. viewModel이나 lifecycle이 변경될 때마다 내부 코드를 실행한다.

  • snapshotFlow { viewModel.uiState }:
    viewModeluiState를 관찰 가능한 흐름(Flow)으로 변환한다. 이렇게 하면 uiState가 변경될 때마다 알림을 받을 수 있다.

  • .filter { it.isDobValid }:
    uiStateisDobValid가 true인 경우만 통과시킨다. 즉, 생년월일이 유효할 때만 다음 단계로 진행한다.

  • .flowWithLifecycle(lifecycle):
    이 흐름을 앱의 생명주기와 연결한다. 앱이 백그라운드에 있을 때는 불필요한 작업을 하지 않도록 한다.

  • .collect { ... }:
    모든 조건이 충족되면 이 부분이 실행된다.

  • 내부 코드:

   validationInProgress = false
   onNavigateToNextScreen()

유효성 검사가 완료되었음을 표시하고, 다음 화면으로 이동하는 함수를 호출한다.

간단히 말해, 이 코드는 사용자의 생년월일이 유효한지 계속 확인하다가, 유효하다고 판단되면 다음 화면으로 넘어가는 로직이다. 이 과정에서 앱의 생명주기를 고려하여 효율적으로 동작하도록 설계되어 있다.

4. 상태와 이벤트의 분리

UI는 주로 탐색과 이벤트 소비를 처리하고, ViewModel은 비즈니스 로직을 담당한다.
이벤트가 발생하는 위치와 그 처리를 분명히 하여, ViewModel과 UI 간의 역할을 명확히 분리해야 한다. 이러한 패턴을 따르지 않으면 앱의 구조가 복잡해지고 예상치 못한 버그가 발생할 수 있다.


상태 홀더 및 UI 상태

UI 레이어 가이드

UI 레이어 가이드에서는 UI 상태를 생성하고 관리하는 방법으로 단방향 데이터 흐름(UDF)을 제안하고 있다. 데이터는 데이터 레이어에서 UI로 단방향으로 흐르며, 이를 상태 홀더라는 특수 클래스에서 관리할 수 있다. 상태 홀더는 ViewModel 또는 일반 클래스로 구현된다.

이 문서에서는 상태 홀더와 UI 레이어에서의 역할을 살펴보겠다.
이 문서를 통해 다음 내용을 이해할 수 있다.

  • UI 상태의 유형
  • UI 상태를 처리하는 로직의 종류
  • 상태 홀더를 적절히 구현하는 방법

UI 상태 생성 파이프라인의 요소


  1. UI 상태
    UI 상태는 UI를 설명하는 속성이다. 두 가지 유형으로 나뉜다:

    • 화면 UI 상태: 화면에 표시되는 정보(예: 뉴스 기사)를 나타낸다. 이는 보통 데이터 레이어와 연결된다.
    • UI 요소 상태: UI 요소의 특성을 나타낸다(예: 표시 여부, 글꼴 크기 등).
      Android의 뷰(View)에서는 이러한 상태를 관리하는 메서드를 제공하며, Jetpack Compose에서는 상태를 컴포저블 외부로 호이스팅할 수 있다.
  2. 로직
    UI 상태는 정적이지 않으며, 시간에 따라 애플리케이션 데이터나 사용자 이벤트에 의해 변한다. 비즈니스 로직은 앱 데이터의 요구사항을 처리하며, UI 로직은 화면에 UI 상태를 표시하는 방법을 다룬다.

UI 상태 생성 로직

애플리케이션의 로직은 UI 상태 생성에 중요한 역할을 한다. 로직은 비즈니스 로직과 UI 로직으로 나뉜다.

  • 비즈니스 로직은 데이터 레이어에서 처리하며, 사용자가 버튼을 눌러 기사를 북마크하는 것과 같은 작업을 담당한다.
  • UI 로직은 화면 상호작용을 처리하며, 예를 들어 사용자가 카테고리를 선택할 때 올바른 힌트를 표시하는 작업을 포함한다.

UI 수명 주기와의 관계

UI 수명 주기

UI 수명 주기는 UI 컴포넌트(예: 액티비티, 프래그먼트, 뷰)가 생성되고, 활성화되며, 비활성화되고, 파괴되는 과정이다. Android에서 이는 onCreate(), onStart(), onResume(), onPause(), onStop(), onDestroy() 등의 메서드로 표현된다.

UI 상태와 로직은 UI의 수명 주기에 따라 달라진다.

a) UI 수명 주기와 무관한 부분:
데이터 레이어와 비즈니스 로직은 대체로 UI 수명 주기와 독립적으로 동작한다.
이 부분은 구성 변경(예: 화면 회전)에 영향을 받지 않고 지속된다.

ex)데이터베이스에서 사용자 정보 조회, 네트워크 요청을 통한 날씨 데이터 가져오기, 사용자의 설정 정보 저장

b) UI 수명 주기에 종속된 부분:
UI 로직은 주로 UI 수명 주기에 종속된다.
이 부분은 UI 컴포넌트의 생성, 소멸과 함께 변화하며, 런타임 권한이나 시스템 리소스에 영향을 받는다.

ex) 카메라 권한 요청 및 처리, 화면에 표시될 뷰 요소들의 초기화 및 설정, 사용자 입력에 따른 UI 업데이트

상태 홀더

상태 홀더는 애플리케이션에서 UI 상태를 관리하고 저장하는 역할을 하는 구성 요소다. 주로 UI와 비즈니스 로직 간의 연결 고리 역할을 하며, 데이터 흐름을 효율적으로 관리하도록 돕는다.

주요 기능

  1. 상태 저장: 상태 홀더는 UI 상태를 저장하고 관리한다. 이는 사용자가 애플리케이션을 사용하는 동안 발생하는 다양한 변화에 대응하기 위한 것이다.

  2. 로직 중개: 상태 홀더는 필요한 경우 비즈니스 로직을 호스팅하는 데이터 소스에 대한 접근을 제공한다. 이를 통해 UI는 복잡한 로직을 직접 처리하지 않고, 상태 홀더를 통해 간접적으로 처리할 수 있다.

  3. 상태 업데이트: 사용자의 입력이나 이벤트에 따라 UI 상태를 업데이트하는 역할을 한다. 이는 UI가 항상 최신 상태를 반영하도록 보장한다.

장점

  • 유지보수성: UI 로직과 비즈니스 로직이 분리되어 코드의 유지보수가 용이하다.
  • 테스트 용이성: 상태 홀더를 사용하면 UI와 비즈니스 로직을 독립적으로 테스트할 수 있다.
  • 가독성: 코드가 더 명확하게 구성되어, 다른 개발자가 상태 홀더의 역할을 쉽게 이해할 수 있다.

유형

상태 홀더는 두 가지 주요 유형으로 나눌 수 있다:
1. 비즈니스 로직 상태 홀더: 사용자 이벤트를 처리하고 데이터 레이어에서 UI 상태로 변환한다.
2. UI 로직 상태 홀더: UI 요소의 상태를 관리하며, UI 자체에서 제공하는 데이터에 작동한다.

상태 홀더는 애플리케이션의 상태를 효율적으로 관리하고, UI와 비즈니스 로직 간의 상호작용을 매끄럽게 만들어주는 중요한 구성 요소다.

상태 홀더 혼합 가능

상태 홀더는 종속 항목의 수명 주기가 같거나 더 짧을 경우 다른 상태 홀더에 종속될 수 있다. 예를 들어, UI 로직 상태 홀더는 다른 UI 로직 상태 홀더에 종속될 수 있으며, 화면 수준의 상태 홀더는 UI 로직 상태 홀더에 종속될 수 있다.

예제코드

다음은 Jetpack Compose에서 티켓 환불 상태를 관리하는 간단한 상태 홀더 예제다.
이 예제는 사용자가 환불 요청 버튼을 클릭하면 환불 상태를 업데이트하고, 해당 상태를 UI에 반영하는 구조로 되어 있다.

// 상태 홀더
class RefundViewModel : ViewModel() {
    var refundState by mutableStateOf(RefundState.IDLE)
        private set

    fun requestRefund() {
        refundState = RefundState.PROCESSING

        // Simulate refund process
        // Replace this with actual refund logic
        // For example, make a network request
        // After processing:
        refundState = RefundState.SUCCESS // or RefundState.FAILURE based on result
    }

    enum class RefundState {
        IDLE,
        PROCESSING,
        SUCCESS,
        FAILURE
    }
}
----------------------------------------
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RefundScreen() {
    val viewModel: RefundViewModel = viewModel()
    val refundState by viewModel.refundState

    Scaffold { paddingValues ->
        Column(modifier = Modifier.padding(paddingValues)) {
            when (refundState) {
                RefundViewModel.RefundState.IDLE -> {
                    Button(onClick = { viewModel.requestRefund() }) {
                        Text("티켓 환불 요청")
                    }
                }
                RefundViewModel.RefundState.PROCESSING -> {
                    CircularProgressIndicator()
                    Text("환불 처리 중...")
                }
                RefundViewModel.RefundState.SUCCESS -> {
                    Text("환불이 완료되었습니다!", color = MaterialTheme.colorScheme.primary)
                }
                RefundViewModel.RefundState.FAILURE -> {
                    Text("환불 요청에 실패했습니다.", color = MaterialTheme.colorScheme.error)
                }
            }
        }
    }
}
  1. UI 레이어:

    • RefundScreen() 함수 전체가 UI 레이어에 해당한다. 이 함수는 Jetpack Compose를 사용하여 화면을 구성하고, 사용자의 입력을 받아 UI를 업데이트한다. Button, Text, CircularProgressIndicator 등과 같은 컴포저블 요소들이 UI를 구성한다.
  2. 데이터 레이어:

    • viewModel.requestRefund() 메서드는 데이터 레이어와 상호작용하는 부분이다.
      이 메서드는 환불 요청을 처리하는 비즈니스 로직을 포함하고 있으며, 상태(refundState)를 업데이트하여 UI 레이어에 알린다.
    • RefundViewModel 내의 로직과 상태 정의도 데이터 레이어의 일환이다. 이 ViewModel은 환불 요청의 상태를 관리하고, 필요한 데이터를 가져오는 역할을 한다.

상태 생성

현대의 UI는 정적이지 않다. 사용자는 UI와 끊임없이 상호작용하며, 앱은 외부 데이터에 반응해 UI를 업데이트한다. 이러한 UI 상태는 적절하게 생성되고 관리되어야 하며, 단방향 데이터 흐름의 원칙을 따르는 것이 중요하다.

이 문서에서는 UI 상태 생성과 관련된 주요 개념을 다룬다. 여기서는 상태 생성에 필요한 API를 설명하고, 상태 변경 소스에 따라 상태 홀더에서 상태를 처리하는 방법을 제시한다. 또한 시스템 리소스를 고려한 상태 생성 범위 설정과 UI에 상태를 노출하는 방법도 다룬다.

UI 상태 생성 파이프라인

Android 앱에서 UI 상태 생성은 다음과 같은 처리 파이프라인으로 설명할 수 있다.

입력: 상태 변경의 원천으로, UI 상호작용 또는 외부 데이터 소스로부터 이벤트가 발생한다.
상태 홀더: 입력된 이벤트에 비즈니스 로직이나 UI 로직을 적용하여 상태를 생성하고 관리한다.
출력: 처리된 상태를 UI에서 사용할 수 있도록 노출하고, UI는 이 상태를 기반으로 렌더링된다.

이벤트와 상태의 차이

이벤트: 일시적이며 예측 불가능하다. 사용자가 버튼을 누르거나 시스템에서 새로운 데이터가 발생할 때처럼 특정 시점에서 발생하는 트리거이다.

상태: 항상 존재하고 앱의 UI를 나타낸다. 이벤트가 발생하면 상태는 변화하지만, 상태 자체는 지속적으로 존재한다.

이벤트는 상태의 입력이며, 상태는 항상 존재하고 변화한다.

상태 생성 파이프라인 어셈블러

상태 생성 파이프라인의 핵심은 이벤트와 상태 간의 변환을 관리하는 어셈블러 역할을 하는 부분이다. 이 파이프라인은 다음 단계를 포함한다:

  1. 입력 처리: 이벤트를 처리하여 상태 변경을 유발하는 단계.
  2. 로직 적용: 비즈니스 로직이나 UI 로직을 적용하여 상태를 생성하거나 업데이트.
  3. 출력 상태 노출: 변경된 상태를 UI에서 사용할 수 있도록 노출.

상태 생성 파이프라인에서 이벤트를 처리하는 방식에 따라 원샷 API와 스트림 API가 나뉜다.

상태 생성 파이프라인의 입력

  1. 원샷 API: 단일 이벤트를 처리하고 끝나는 API이다. 사용자가 버튼을 누르거나 단일 네트워크 요청을 처리하는 경우가 이에 해당한다.

    • 예: suspend 함수, LiveDatasetValue 메서드 등.
  2. 스트림 API: 지속적으로 여러 이벤트를 처리할 수 있는 API이다. 데이터 스트림을 처리하거나 WebSocket, Flow, RxJava와 같은 비동기 스트림 소스를 처리할 때 사용된다.

    • 예: StateFlow, Flow, RxJavaObservable 등.
  3. 원샷 및 스트림 API: 경우에 따라 원샷 이벤트와 지속적인 스트림 처리가 동시에 필요한 경우도 있다. 예를 들어, 네트워크 요청의 성공/실패 여부는 원샷으로 처리하고, 그 후 데이터를 지속적으로 모니터링할 수 있다.

상태 생성 파이프라인의 출력 유형

상태 생성 후, UI에서 사용할 수 있도록 상태를 노출하는 방식도 중요하다. Android에서 사용하는 상태 출력 방식은 수명 주기 인식이 필요하다.

  • StateFlow: Kotlin의 상태 흐름 API로, 수명 주기를 고려한 상태 관리가 가능하다.
  • LiveData: Android에서 널리 사용되는 상태 관리 API로, 수명 주기 인식을 내장하고 있다.

이 두 API는 UI 상태가 변화할 때, UI를 자동으로 다시 렌더링하도록 한다.

상태 생성 파이프라인의 초기화

상태 생성 파이프라인의 초기화는 사용자의 요구에 맞게 상태를 처음 설정하는 과정이다.
초기화는 앱의 초기 상태를 결정하며, 보통 ViewModel에서 이루어진다.

초기화 시점에서는 다음과 같은 작업들이 수행된다:

  1. 기본 상태 설정: 앱이 처음 실행될 때 기본적으로 가져야 할 UI 상태를 설정한다. 예를 들어, 화면이 처음 열릴 때 로딩 상태로 시작하는 경우가 이에 해당한다.
  2. 초기 이벤트 처리: 네트워크 호출이나 데이터베이스 조회와 같은 초기 작업이 포함될 수 있다.

이 과정이 완료되면 상태는 UI에 노출되어 사용자와의 상호작용을 통해 변화하게 된다.


https://developer.android.com/topic/architecture/ui-layer?hl=ko&_gl=1*ne2n05*_up*MQ..*_ga*NjU2MDc3Ni4xNzI3MDA0NDQ5*_ga_6HH9YJMN9M*MTcyNzAwNDQ0OC4xLjAuMTcyNzAwNDQ0OC4wLjAuMTE5OTAzMjM0Nw..

profile
화려한 외면이 아닌 단단한 내면

0개의 댓글