Android

[Android] 앱 아키텍처 - UI 레이어란?

참깨빵위에참깨빵_ 2024. 5. 6. 22:13
728x90
반응형

https://developer.android.com/topic/architecture/ui-layer?hl=ko

 

UI 레이어  |  Android 개발자  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. UI 레이어 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. UI의 역할은 화면에 애플리케이션 데이터를

developer.android.com

 

 

UI(User Interface)의 역할은 앱 데이터를 표시하고 사용자 상호작용(버튼 클릭 등)의 기본 지점으로도 기능하는 것이다.

사용자 상호작용 또는 외부 입력(네트워크 응답 등)으로 데이터가 바뀔 때마다 이를 반영하도록 UI가 업데이트돼야 한다.

 

그러나 데이터 레이어에서 가져온 데이터는 표시해야 하는 정보와 다른 형식이다. 또는 서로 다른 2개의 데이터 소스를 합쳐야 하는 경우도 있다.

 

기본 우수사례

 

뉴스 기사 앱이 있다고 가정한다. 앱에는 기사를 표시하는 기사 화면이 있고 로그인하면 북마크할 수 있다. 아래는 앱에서 유저가 할 수 있는 작업이다.

 

  • 읽을 수 있는 기사 보기
  • 카테고리 별 기사 둘러보기
  • 로그인해서 특정 기사 북마크
  • 자격이 있다면 일부 프리미엄 기능에 접근

 

이 예를 우수사례로 사용해서 단방향 데이터 흐름 원칙을 소개하고 이런 원칙이 UI 레이어의 앱 아키텍처 관련해서 해결 가능한 문제를 설명한다.

 

UI 레이어 아키텍처

 

UI는 사용하는 API에 상관없이 데이터를 표시하는 액티비티, 프래그먼트 같은 UI 요소를 말한다.

UI 레이어에선 아래 단계들을 실행해야 한다.

 

  1. 앱 데이터를 사용하고 UI에서 쉽게 렌더링할 수 있는 데이터로 변환
  2. UI 렌더링 가능 데이터를 사용하고 유저에게 표시할 UI 요소로 변환
  3. 이렇게 조합된 UI 요소의 유저 입력 데이터를 사용하고 입력 이벤트 결과를 필요에 따라 UI 데이터에 반영
  4. 1~3단계를 필요한 만큼 반복

 

UI 레이어 정보 가이드에선 아래 작업, 개념을 설명한다.

 

  • UI 상태를 정의하는 방법
  • UI 상태를 생성, 관리하기 위한 단방향 데이터 흐름(UDF)
  • UDF 원칙에 따라 관찰 가능한 데이터 유형으로 UI 상태를 노출하는 법
  • 관찰 가능한 UI 상태를 사용하는 UI의 구현 방법

 

UI 상태 정의

 

UI에는 각 기사의 일부 메타데이터와 같이 기사 목록이 표시된다. 앱에서 유저에게 표시하는 이 정보가 UI 상태다.

유저가 보는 항목이 UI라면 UI 상태는 앱에서 유저가 봐야 한다고 지정하는 항목이다. UI 상태가 변경되면 변경사항이 즉시 UI에 반영된다.

 

 

뉴스 앱의 요구사항을 충족하기 위해 UI를 완전 렌더링하는 데 필요한 정보를 아래처럼 data class에 캡슐화할 수 있다.

 

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 상태 정의는 바꿀 수 없다. 불변성의 주요 이점은 변경 불가능한 객체가 순간의 앱 상태를 보장한다는 것이다.

그래서 UI는 상태를 읽어서 UI 요소를 업데이트하는 한 가지 역할에 집중할 수 있다. 따라서 UI 자체가 데이터의 유일한 소스인 경우를 제외하고 UI에서 UI 상태를 직접 바꿔선 안 된다. 이 원칙을 위반하면 같은 정보가 여러 정보 소스에서 비롯돼 데이터 불일치, 미세 버그가 발생한다.

예를 들어 우수사례에서 UI 상태의 NewsItemUiState 객체에 있는 bookmarked 플래그가 Activity 클래스에서 업데이트되면 이 플래그는 북마크된 기사 상태의 소스로서 데이터 레이어와 경합한다. 변경 불가능한 data class는 이런 안티패턴을 방지하는 데 유용하다.

 

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

 

앱 데이터의 동적 특성에 따라 상태는 시간이 지나면서 바뀔 수 있다. 이는 기본 데이터를 수정하는 유저 상호작용, 기타 이벤트로 발생할 수도 있다.

중재 요소가 각 이벤트에 적용할 로직을 정의하고 UI 상태를 만들기 위해 지원 데이터 소스에 필요한 변환을 실행해서 상호작용을 처리한다는 이점이 있을 수 있다. 상호작용과 이에 따른 로직이 UI 자체에 포함될 수도 있지만, UI가 이름에서 알 수 있는 것 이상의 역할(데이터 소유자, 생성자, 변환자 등)을 담당하기 시작하면 빠르게 복잡해질 수 있다.

궁극적으로 UI에 주는 부담을 줄여야 한다. UI 상태가 매우 단순한 게 아닌 이상 UI의 역할은 오직 UI 상태를 사용, 표시하는 것이어야 한다.

 

상태 홀더

 

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

전체 화면, 탐색 대상의 경우 일반적인 구현은 뷰모델의 인스턴스지만 앱 요구사항에 따라 간단한 클래스로도 충분할 수 있다.

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

 

 

상태가 아래로 향하고 이벤트는 위로 향하는 패턴을 단방향 데이터 흐름이라고 한다. 이 패턴이 앱에 주는 영향은 아래와 같다.

 

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

 

탐색 대상이나 화면의 경우 뷰모델은 Repository, UseCase 클래스와 함께 작동해서 데이터를 가져와 UI로 변환하는동시에 상태 변경을 야기할 수 있는 이벤트 효과를 통합한다.

뉴스 앱에서 유저의 기사 북마크 요청은 상태 변경을 야기할 수 있는 이벤트의 예다. 상태 생성자는 UI 상태의 모든 필드를 채우고 UI가 완전히 렌더링되는 데 필요한 이벤트를 처리하기 위해 모든 필수 로직을 정의하는 건 뷰모델이 담당한다.

 

 

로직의 유형

 

  • 비즈니스 로직 : 앱 데이터에 대한 제품 요구사항의 구현. 뉴스 앱의 기사 북마크를 예로 들 수 있다. 이 로직은 일반적으로 도메인 or 데이터 레이어에 배치되지만 UI 레이어에는 배치되지 않는다
  • UI 동작 로직 or UI 로직 : 화면에 상태 변경사항을 표시하는 방법. Android Resources를 써서 화면에 표시할 텍스트를 가져오거나 버튼 클릭 시 특정 화면 이동, 토스트 / 스낵바를 써서 화면에 유저 메시지를 표시한다

 

특히 컨텍스트 같은 UI 타입의 경우 UI 로직은 뷰모델이 아닌 UI에 있어야 한다. 테스트 가능성을 높이고 문제 구분에 도움이 되도록 UI 로직을 다른 클래스에 위임하고자 하며, UI가 점점 복잡해지는 경우 간단한 클래스를 상태 홀더로 만들 수 있다. UI에서 생성된 간단한 클래스는 UI 생명주기를 따르기 때문에 Android SDK 종속 항목을 쓸 수 있다.

 

UI 상태 노출

 

UI 상태를 정의하고 이 상태의 생성을 관리할 방법을 결정한 후에는 생성된 상태를 UI에 표시하는 단계를 진행한다.

UDF를 써서 상태 생성을 관리하므로 생성된 상태를 스트림으로 간주할 수 있다. 따라서 LiveData, StateFlow 같이 관찰 가능한 데이터 홀더에 UI 상태를 노출해야 한다.

이유는 뷰모델에서 데이터를 직접 가져오지 않고도 UI가 상태 변경사항에 반응할 수 있게 하기 위해서다. 이런 유형은 항상 최신 버전의 UI 상태를 캐시한다는 이점도 있다. 이는 구성 변경 후 빠른 상태 복원에 유용하다.

 

class NewsViewModel(...) : ViewModel() {
    val uiState: StateFlow<NewsUiState> = …
}

 

UI에 표시될 데이터가 비교적 간단하면 UI 상태 유형으로 데이터를 래핑하는 게 좋은 경우가 많다. 내보낸 상태 홀더와 관련 UI 요소 간의 관계를 전달하기 때문이다.

UiState 스트림을 만드는 일반적인 방법은 뷰모델에서 지원하는 변경 가능한 스트림을 변경 불가능한 스트림으로 노출하는 것이다. MutableStateFlow<UiState>를 StateFlow<UiState>로 노출하는 것이 예시다.

 

class NewsViewModel(...) : ViewModel() {

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

    ...

}

 

그 후 뷰모델은 상태를 내부적으로 바꾸는 메서드를 노출해 UI에 쓰이도록 업데이트를 게시한다.

비동기 작업을 해야 하는 경우 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) {
        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 상태 사용

 

UI에서 UiState 객체의 스트림을 사용하려면 사용 중인 관찰 가능한 데이터 유형에 터미널 연산자를 사용한다.

LiveData는 observe()를 사용하고 Flow라면 collect()나 이 메서드의 변형을 사용한다.

UI에서 관찰 가능한 데이터 홀더를 사용할 때는 UI 생명주기를 고려해야 한다. 이유는 유저에게 뷰가 표시되지 않을 때 UI가 UI 상태를 관찰해선 안 되기 때문이다.

LiveData를 사용하면 LifecycleOwner가 생명주기 문제를 암시적으로 처리한다. Flow를 사용할 때는 적절한 코루틴 범위, repeatOnLifecycle API로 처리하는 게 가장 좋다.

 

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}

 

위의 StateFlow 객체는 활성 수집기가 없을 때 작업 실행을 중지하지 않지만 Flow를 써서 작업하는 경우 Flow의 구현 방법을 모를 수 있다. 생명주기를 인식하는 Flow 수집을 사용하면 나중에 다운스트림 수집기 코드를 재검토하지 않아도 뷰모델 Flow를 이런 방식으로 바꿀 수 있다.

 

진행 중인 작업 표시

 

UiState 클래스의 로드 상태를 나타내는 간단한 방법은 Boolean 필드를 쓰는 것이다.

 

data class NewsUiState(
    val isFetchingArticles: Boolean = false,
    ...
)

 

이 값은 UI에 프로그레스 바가 존재하는지를 나타낸다.

 

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Bind the visibility of the progressBar to the state of isFetchingArticles.
                viewModel.uiState
                    .map { it.isFetchingArticles }
                    .distinctUntilChanged()
                    .collect { progressBar.isVisible = it }
            }
        }
    }
}

 

화면에 오류 표시

 

UI에서의 오류 표시는 진행 중인 작업 표시와 비슷하다. 두 작업은 모두 존재 여부를 나타내는 Boolean 값으로 쉽게 표현되기 때문이다.

하지만 오류엔 유저에게 다시 전달하는 관련 메시지 or 실패 작업을 재시도하는 작업이 포함될 수 있다.

따라서 진행 중인 작업을 로드하거나 로드하지 않는 동안 오류 컨텍스트에 적절한 메타데이터를 호스팅하는 data class를 써서 오류 상태를 모델링해야 할 수 있다.

뉴스 기사를 가져오는 작업에서 오류가 발생하면 오류 상황을 자세히 설명하는 메시지를 하나 이상 유저에게 표시하는 게 좋다.

 

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

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

 

반응형