여러 해 동안 안드로이드 개발자들 사이에서 반응형 구조(Reactive Architecture)는 꾸준히 인기있던 주제였는데 보통 RxJava를 예시로 설명합니다(아래의 Rx 섹션 참조). 반응형 프로그래밍은 데이터가 흐르는 방식과 변경 내용의 전파와 관련된 패러다임으로, 이를 통해 앱을 빌드하고 비동기 작업에서 얻는 데이터를 표시하는 과정을 단순화할 수 있습니다.
반응형 개념 중 일부를 구현하기 위한 한 가지 도구가 바로 LiveData 입니다. LiveData는 관찰자의 생명주기(Lifecycle)를 알고 있는 간단한 observable입니다. 데이터 소스나 저장소(Repository)에서 LiveData를 노출시키면 간단하게 반응형 구조를 만들 수 있지만, 몇 가지 함정이 있을 가능성이 있습니다.
이 글을 통해 LiveData를 사용해 더욱 반응적인 아키텍처를 빌드하는 데 도움이 되는 패턴을 사용하는 방법을 알아보고, 함정을 피하는 방법 또한 함께 알아보겠습니다.
LiveData의 목적
Android에서는 거의 언제든 액티비티, 프래그먼트 및 뷰를 제거할 수 있으므로, 이러한 컴포넌트 중 하나를 참조하게 된다면 메모리 누수 또는 NullPointerException를 발생시킬 수 있습니다.
LiveData는 관찰자(Observer) 패턴을 구현함으로써 View 컨트롤러(액티비티, 프래그먼트 등)와 UI 데이터의 소스(보통은 ViewModel) 사이의 통신을 허용하도록 설계되었습니다. LiveData로 이 통신이 더 안전해집니다. 수명 주기를 알고 있으므로 데이터가 활성화될 경우 View만 데이터를 수신하게 됩니다.
요컨대, View와 ViewModel 사이에서 구독을 수동으로 취소할 필요가 없다는 점이 장점입니다.
View와 ViewModel의 상호작용
ViewModel너머의 LiveData
observable 패러다임은 View 컨트롤러와 ViewModel 사이에서 매우 효과적으로 작동하며, 이를 사용하여 앱의 다른 컴포넌트를 관찰하고 생명주기를 관리할 때 사용할 수 있습니다. 이에 해당하는 예는 다음과 같습니다.
모든 것이 함께 연결되어 있으므로 데이터 변경 시 UI가 자동으로 업데이트 되는 것이 이 패러다임의 장점입니다.
단점은 LiveData에는 Rx처럼 데이터 스트림을 결합하거나 스레드를 관리하기 위한 도구 키트가 함께 제공되지 않는다는 점입니다.
일반적인 앱의 모든 계층에 LiveData를 사용한다면 다음과 같은 모습이 될 것입니다.
LiveData를 사용하는 일반적인 앱 아키텍처
컴포넌트 사이에 데이터를 전달하려면 매핑하고 결합할 방법이 필요합니다. 이를 위해 MediatorLiveData가 다음과 같은 Transformations 클래스의 도우미와 함께 사용됩니다.
View가 제거되면 View의 수명 주기가 다운스트림에서 후속 구독으로 전파되므로 이러한 구독을 해체할 필요가 없습니다.
패턴
일대일 정적 변환 — 맵
ViewModel은 특정 타입의 데이터를 관찰하고 있고, 이를 다른 형식의 데이터로 노출합니다
위의 예시에서, ViewModel은 저장소에서 데이터를 UI 모델로 변환하는 뷰로 데이터를 전달할 뿐입니다. 저장소에 새로운 데이터가 있을 때마다 ViewModel은 단순히 해당 데이터를 매핑합니다.
# See : https://medium.com/media/06f6fe684ab4ddb408a3f1350a95c705/href
class MainViewModel {
val viewModelResult = Transformations.map(repository.getDataForUser() { data ->
convertDataToMainUIModel(data)
}
}
이 변환은 무척 단순합니다. 하지만 사용자가 변경될 수 있는 경우 switchMap이 필요합니다.
일대일 동적 변환 — switchMap
사용자를 노출하는 사용자 관리자를 관찰 중이면서, 저장소를 관찰하기 위해 각 사용자의 ID를 필요로 하는 사례를 생각해 보세요.
사용자 관리자는 저장소가 결과를 노출하기 위해 필요한 사용자 ID 제공
사용자 ID를 즉시 사용할 수 없으므로 ViewModel 초기화 시 이것을 연결할 수 없지만,
switchMap으로 이를 구현할 수 있습니다.
# See : https://medium.com/media/d7bdd631d37720e718ad621dcea59577/href
class MainViewModel {
val repositoryResult = Transformations.switchMap(userManager.user) { user ->
repository.getDataForUser(user)
}
}
switchMap은 내부적으로 MediatorLiveData를 사용하므로 MediatorLiveData에 익숙해지는 것이 중요합니다. LiveData의 여러 소스를 결합하려는 경우 사용해야 하기 때문입니다.
일대다 종속성 — MediatorLiveData
MediatorLiveData를 사용하면 한 개 또는 여러 개의 데이터 소스를 단일 LiveData observable에 추가할 수 있습니다.
# See : https://medium.com/media/0321806e465aac3256282c8c61685d98/href
val liveData1: LiveData = ...
val liveData2: LiveData = ...
val result = MediatorLiveData()
result.addSource(liveData1) { value ->
result.setValue(value)
}
result.addSource(liveData2) { value ->
result.setValue(value)
}
문서 에서 발췌한 이번 예시에서는 소스 중 일부라도 변경되면 결과를 업데이트합니다. 데이터가 자동으로 결합되지는 않는다는 점에 유의하세요 . MediatorLiveData는 단지 알림을 처리할 뿐입니다.
샘플 앱에서 변환을 구현하려면 두 가지 다른 LiveData를 하나로 결합해야 합니다.
MediatorLiveData는 두 데이터 소스의 결합에 사용됨
MediatorLiveData를 사용하여 데이터를 결합하는 한 가지 방법은 소스를 추가하고 다른 메서드에서 값을 설정하는 것입니다.
# See : https://medium.com/media/26f689e7abeb2f23e2d496e070df1c68/href
fun blogpostBoilerplateExample(newUser: String): LiveData {
val liveData1 = userOnlineDataSource.getOnlineTime(newUser)
val liveData2 = userCheckinsDataSource.getCheckins(newUser)
val result = MediatorLiveData()
result.addSource(liveData1) { value ->
result.value = combineLatestData(liveData1, liveData2)
}
result.addSource(liveData2) { value ->
result.value = combineLatestData(liveData1, liveData2)
}
return result
}
데이터의 실제 결합은 combineLatestData 메서드에서 수행됩니다.
# See : https://medium.com/media/6927d24dddb01f3303e0f512ad994968/href
private fun combineLatestData(
onlineTimeResult: LiveData,
checkinsResult: LiveData
): UserDataResult {
val onlineTime = onlineTimeResult.value
val checkins = checkinsResult.value
// Don't send a success until we have both results
if (onlineTime == null || checkins == null) {
return UserDataLoading()
}
// TODO: Check for errors and return UserDataError if any.
return UserDataSuccess(timeOnline = onlineTime, checkins = checkins)
}
값이 준비되어 있거나 올바른지 확인하고 결과( loading , error 또는 success )를 내놓습니다.
아래의 보너스 섹션을 보면 Kotlin의 확장 함수로 이것을 정리하는 방법을 알 수 있습니다.
LiveData를 사용하지 않는 경우
“반응형으로 구성”하고 싶더라도 먼저 그 장점을 파악한 후에 모든 곳에 LiveData를 추가해야 합니다. 앱의 컴포넌트에 UI에 대한 연결이 없는 경우 아마 LiveData가 필요하지 않을 것입니다.
앱의 사용자 관리자가 인증 공급자(예: Firebase Auth)의 변경을 수신 대기하고 고유의 토큰을 서버로 업로드하는 경우를 예로 들 수 있습니다.
토큰 업로더와 사용자 관리자 사이의 상호작용이 반응형이어야 할까요?
토큰 업로더는 사용자 관리자를 관찰할 수 있지만, 누구의 Lifecycle로 관찰할 수 있을까요? 이 작업은 View와는 전혀 관계가 없습니다. 게다가 View가 제거되는 경우 사용자 토큰이 아예 업로드되지 않을 수도 있습니다.
또 다른 옵션은 토큰 업로더에서 observeForever ()를 사용하고 어떤 식으로든 사용자 관리자의 수명 주기에 연결하여 완료 시 구독을 제거하는 것입니다.
하지만 모든 것을 observable로 만들 필요는 없습니다. 사용자 관리자가 토큰 업로더를 직접 호출하도록 하세요. 또는 아키텍처에서 타당한 것이라면 무엇이든 상관없습니다.
UI와 관련 없는 액션은 LiveData를 사용할 필요 없음 앱의 일부가 UI에 영향을 주지 않으면 아마 LiveData가 필요하지 않을 것입니다.
Antipattern: LiveData의 인스턴스 공유
클래스가 LiveData를 다른 클래스에 노출할 때 동일한 LiveData 인스턴스나 다른 인스턴스를 노출하고 싶은지 신중하게 생각하세요.
# See : https://medium.com/media/f1a623a675fae26f3408cc1b27c956db/href
class SharedLiveDataSource(val dataSource: MyDataSource) {
// Caution: this LiveData is shared across consumers
private val result = MutableLiveData()
fun loadDataForUser(userId: String): LiveData {
result.value = dataSource.getOnlineTime(userId)
return result
}
}
이 클래스가 싱글톤인 경우(즉, 이 클래스의 인스턴스가 하나만 있는 경우), 항상 같은 LiveData를 반환할 수 있습니다. 맞나요? 하지만 꼭 그럴 필요는 없습니다. 이 클래스의 소비자가 여럿 있을 수 있기 때문입니다.
예를 들어 다음을 고려해 보세요.
# See : https://medium.com/media/2101e961637a6aa6377e80c86c88ffe4/href
sharedLiveDataSource.loadDataForUser("1").observe(this, Observer {
// Show result on screen
})
두 번째 소비자 역시 그 클래스를 사용합니다.
# See : https://medium.com/media/267bd0bfa347e383b0109a2d79faa442/href
sharedLiveDataSource.loadDataForUser("2").observe(this, Observer {
// Show result on screen
})
첫 번째 소비자는 사용자 “2”에 속한 데이터를 포함한 업데이트를 받습니다.
이 클래스를 한 소비자에게서만 사용 중이라고 생각할지라도, 결국 이 패턴을 사용하는 버그로 끝날 수 있습니다. 예를 들어 액티비티의 한 인스턴스에서 다른 인스턴스로 이동할 때 새 인스턴스가 잠깐 이전 인스턴스로부터 데이터를 수신할 수도 있습니다 . LiveData는 최근 값을 새 관찰자에게 디스패치한다는 점을 기억하세요. 또한 Lollipop에 액티비티 전환이 도입되었는데 두 가지 액티비티가 활성 상태 에 있는 흥미롭고 극단적인 상황을 일으킵니다. 즉, LiveData의 유일한 소비자 인스턴스가 두 개 있을 수 있고 그중 하나는 아마 잘못된 데이터를 표시할 것이라는 의미입니다.
# See : https://medium.com/media/382a3960f55184cf4b29ed401cd738db/href
class SharedLiveDataSource(val dataSource: MyDataSource) {
fun loadDataForUser(userId: String): LiveData {
val result = MutableLiveData()
result.value = dataSource.getOnlineTime(userId)
return result
}
}
이 문제의 해결책은 단순히 각 소비자에 대해 새 LiveData를 반환하는 것입니다.
깊이 잘 생각한 후에 소비자들 사이에 LiveData 인스턴스를 공유하세요.
MediatorLiveData의 문제: 초기화 범위 밖에서 소스 추가
(MVP 아키텍처에서 통상적으로 하듯이) View에 대한 참조를 유지하기보다는 관찰자 패턴을 사용하는 것이 더 안전합니다. 하지만 그렇다고 해서 누수에 대해 잊어도 괜찮다는 건 아닙니다!
다음 데이터 소스를 고려해 보세요.
# See : https://medium.com/media/b4fa2a3913b6a3b80ebc784365317768/href
class SlowRandomNumberGenerator {
private val rnd = Random()
fun getNumber(): LiveData {
val result = MutableLiveData()
// Send a random number after a while
Executors.newSingleThreadExecutor().execute {
Thread.sleep(500)
result.postValue(rnd.nextInt(1000))
}
return result
}
}
이 데이터 소스는 500ms 후에 임의의 값과 함께 새로운 LiveData를 반환할 뿐입니다. 여기에 잘못된 점은 없습니다.
ViewModel에서는 생성기에서 생성되는 숫자를 받는 randomNumber 속성을 노출할 필요가 있습니다. 이를 위해 MediatorLiveData를 사용하는 것은 이상적인 방법이 아닙니다. 새 숫자가 필요할 때마다 소스를 추가해야 하기 때문입니다.
# See : https://medium.com/media/a1d9527cd1787077fd31471258e73bfa/href
val randomNumber = MediatorLiveData()
/**
* *Don't do this.*
*
* Called when the user clicks on a button
*
* This function adds a new source to the result but it doesn't remove the previous ones.
*/
fun onGetNumber() {
randomNumber.addSource(numberGenerator.getNumber()) {
randomNumber.value = it
}
}
사용자가 버튼을 클릭할 때마다 MediatorLiveData에 소스를 추가하면 앱이 의도한 대로 작동합니다. 하지만 더 이상 업데이트를 보내지 않을 이전의 LiveData가 전부 누출되므로 낭비입니다.
소스를 새로 추가하기 전에 소스에 대한 참조를 저장한 다음 제거할 수 있습니다. (스포일러: 이것이 바로 Transformations.switchMap의 역할입니다! 아래 해결책을 확인해 보세요.)
MediatorLiveData를 사용하는 대신 Transformation.map으로 이 문제의 해결을 시도하고 실패해 봅시다.
Transformation의 문제: 초기화 범위 밖의 Transformation
이 경우 이전의 예시를 사용해서는 효과가 없을 것입니다.
# See : https://medium.com/media/7c3d330360f75c4f1e59a41f4f06e95d/href
var lateinit randomNumber: LiveData
/**
* Called on button click.
*/
fun onGetNumber() {
randomNumber = Transformations.map(numberGenerator.getNumber()) {
it
}
}
여기서 이해해야 할 중요한 문제가 있습니다. 그건 바로 Transformation은 호출 시 새로운 LiveData를 만든다는 점입니다(map과 switchMap 둘 다). 이 예시에서 randomNumber는 View에 노출되지만 사용자가 버튼을 클릭할 때마다 재할당됩니다. 관찰자는 구독하는 순간에 var에 할당된 LiveData에 대한 업데이트만 받을 것이라는 점 을 놓치는 일이 비일비재합니다.
# See : https://medium.com/media/6aefce903175b2f61e48bbee5337e1fa/href
viewmodel.randomNumber.observe(this, Observer { number ->
numberTv.text = resources.getString(R.string.random_text, number)
})
이 구독은 onCreate()에서 발생하므로 viewmodel.randomNumber LiveData 인스턴스가 나중에 바뀌는 경우 관찰자가 다시는 호출되지 않을 것입니다.
다시 말하자면,
var에 Livedata를 사용하지 마세요. 초기화 시 Transformation을 연결하세요.
해결책: 초기화 중에 Transformation 연결
노출된 LiveData를 Transformation으로 초기화하세요.
# See : https://medium.com/media/6e6e0159d0140035f401fbd128edba16/href
private val newNumberEvent = MutableLiveData>()
val randomNumber: LiveData = Transformations.switchMap(newNumberEvent) {
numberGenerator.getNumber()
}
LiveData에서 Event 를 사용하여 언제 새로운 숫자를 요청할 지 지정합니다.
# See : https://medium.com/media/86d3fb0f3b98d5c4a9ca142f23eed578/href
/**
* Notifies the event LiveData of a new request for a random number.
*/
fun onGetNumber() {
newNumberEvent.value = Event(Unit)
}
보너스 섹션
Kotlin으로 정리
위의 MediatorLiveData 예시에서는 반복되는 코드를 보여주므로 Kotlin의 확장 함수를 활용할 수 있습니다.
이제 저장소가 훨씬 더 깔끔해 보입니다.
# See : https://medium.com/media/15e23e3a1c0cca9331d26eb948e0a664/href
fun getDataForUser(newUser: String?): LiveData {
if (newUser == null) {
return MutableLiveData().apply { value = null }
}
return userOnlineDataSource.getOnlineTime(newUser)
.combineAndCompute(userCheckinsDataSource.getCheckins(newUser)) { a, b ->
UserDataSuccess(a, b)
}
}
LiveData와 RxJava
마지막으로, 불편한 진실에 대해 얘기해봅시다. LiveData는 View가 ViewModel을 관찰할 수 있도록 고안된 것입니다. 이 문제를 해결하려면 반드시 LiveData를 사용하세요! 이미 Rx를 사용하고 있더라도 둘 다 LiveDataReactiveStreams *로 통신할 수 있습니다.
프레젠테이션 계층을 벗어나 LiveData를 사용하려는 경우 MediatorLiveData에는 RxJava가 제공하는 것과 같은 데이터 스트림을 결합하고 이에 대한 작업을 하기 위한 도구 키트가 없다는 사실을 알아차리실지 모르겠습니다. 하지만 Rx는 습득하기 무척 어렵습니다. LiveData 변환(그리고 Kotlin 매직)의 결합이 해결하려는 문제에 충분할 수도 있겠지만, 개발자와 개발자 팀이 RxJava를 익히는 데 이미 많은 것을 투자한 상태라면 아마도 LiveData가 필요하지 않을 것입니다.
* auto-dispose 를 사용하는 경우 이 문제에 LiveData를 사용하는 건 중복 투자가 될 수 있습니다.
댓글 없음 :
댓글 쓰기