도메인 계층에서 Flow를 합쳐 UI에 바로 쓸 모델로 만들기
도메인 계층에서 Flow를 합쳐 UI에 바로 쓸 모델로 만들기
대부분의 안드로이드 앱은 결국 같은 모양에 다다릅니다. 네트워크나 데이터베이스에서 불러온 무언가의 목록이 있고, 그 무언가에 대한 사용자의 선택이 어딘가 다른 곳에 저장되어 있습니다. 토픽을 보여 주는 화면은 사용자가 어떤 토픽을 팔로우하는지도 알아야 합니다. 아티클을 보여 주는 화면은 그중 무엇이 북마크됐는지도 알아야 합니다. 두 반응형 소스를 하나로 합치는 도구는 이미 알고 계실 겁니다. 바로 combine입니다. 더 깊은 질문은, 그 합침이 어디에 살아야 하고 무엇을 만들어 내야 하느냐입니다. 구글의 Now in Android 샘플은 이를, 사용자 설정을 원본 데이터에 녹여 넣어 무엇이 팔로우됐는지를 이미 아는 스트림을 UI에 건네주는 작은 도메인 계층 유스케이스(use case)로 답합니다.
이 글에서는 도메인 계층 유스케이스가 두 개의 독립적인 flow를 하나의 UI용 스트림으로 어떻게 조합하는지를 깊이 있게 파고듭니다. 이를 떠받치는 Room 기반 토픽 flow와 DataStore 기반 설정 flow, 이 둘을 잇는 combine 연산자, 각 항목에 설정을 녹여 넣는 장식(decoration) 단계, 각 ViewModel이 stateIn으로 적용하는 콜드에서 핫으로의 전환, 같은 모양을 재사용하는 검색 변형, 그리고 이 조합이 코드베이스 전반에서 실제로 어디에 자리하는지에 대한 솔직한 비일관성까지 두루 살펴봅니다.
근본적인 문제: ViewModel마다 중복되는 반응형 조합
Interests 화면을 떠올려 보세요. 모든 토픽을 보여 주고, 그중 사용자가 팔로우하는 것들을 표시합니다. 가장 직접적인 구현은 두 리포지토리를 ViewModel에 주입해 그 자리에서 바로 합치는 것입니다.
class InterestsViewModel @Inject constructor(
topicsRepository: TopicsRepository,
userDataRepository: UserDataRepository,
) : ViewModel() {
val uiState = combine(
topicsRepository.getTopics(),
userDataRepository.userData,
) { topics, userData ->
topics.map { topic ->
FollowableTopic(topic, isFollowed = topic.id in userData.followedTopics)
}
}
}
이 방법도 동작하고, 화면 하나뿐이라면 합리적입니다. 문제는 더 많은 화면이 같은 데이터를 필요로 할 때 드러납니다. Now in Android에서는 Interests 목록과 For You 온보딩 선택기가 모두 팔로우 상태가 짝지어진 토픽을 원하고, Search 결과는 같은 장식을 다른 모양으로 녹여 넣기를 원합니다. ViewModel마다 자기 combine과 자기 장식 루프를 짠다면, 같은 로직이 UI 계층 곳곳에 복사됩니다. 정렬 옵션을 추가하는 날, 혹은 장식에 두 번째 설정 필드가 필요해지는 날, 여러분은 모든 복사본을 하나하나 고치면서 빠뜨린 게 없기를 바라야 합니다. 이 조합은 도메인 동작의 한 조각인데도, UI 계층 여기저기에 흩어져 있습니다.
해법은 그 동작에 이름을 붙이고 자리를 하나 마련해 주는 것입니다. 이를 해내는 유스케이스를 보기 전에, 그 유스케이스가 소비하는 두 스트림을 먼저 이해해야 합니다. 조합이 그렇게 동작하는 이유가 바로 이 두 스트림의 성질에 있기 때문입니다.
두 상류 flow: 원본 데이터와 사용자 설정
조합의 입력은 정확히 두 개입니다. 하나는 로컬 데이터베이스에서 온 토픽의 원본 목록을 실어 나릅니다. 다른 하나는 설정 저장소에서 온 사용자의 선택을 실어 나릅니다. 둘 다 뒷받침하는 저장소가 바뀔 때마다 다시 방출하는 콜드 Flow이며, 바로 이 반응형 성질이 이 패턴 전체를 살아 움직이게 합니다.
Room에서 오는 토픽
원본 토픽은 Room 데이터베이스에서 비롯됩니다. Room은 쿼리 결과를 Flow로 노출하는데, 이는 수집 시점에 현재 행들을 방출하고, 그다음부터는 바탕 테이블이 바뀔 때마다 새 목록을 다시 방출한다는 뜻입니다. DAO가 바로 그것을 선언합니다.
@Dao
interface TopicDao {
@Query(value = "SELECT * FROM topics")
fun getTopicEntities(): Flow<List<TopicEntity>>
}
getTopicEntities()는 Flow<List<TopicEntity>>를 반환합니다. 새로고침하려고 이를 두 번 호출할 일은 없습니다. Room이 topics 테이블을 관찰하다가, 삽입·수정·삭제가 있을 때마다 새 목록을 flow로 밀어 넣습니다. 이 함수가 방출하는 엔티티는 도메인 모델이 아니라 데이터베이스 행이므로, 리포지토리가 이를 한 계층 위로 매핑합니다.
internal class OfflineFirstTopicsRepository @Inject constructor(
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
) : TopicsRepository {
override fun getTopics(): Flow<List<Topic>> =
topicDao.getTopicEntities()
.map { it.map(TopicEntity::asExternalModel) }
}
이름이 오프라인 우선이라고 말하는데, 읽기 경로가 이를 증명합니다. getTopics()는 오직 topicDao에서만 읽습니다. network 의존성은 별도의 동기화 동안에만 건드리며, 그때 Room에 새 데이터를 씁니다. 나중에 만들 조합 스트림은 언제나 로컬 데이터베이스를 반영할 뿐, 네트워크를 직접 읽는 일은 없습니다. 다만 이를 소비하는 유스케이스는 이 클래스에 의존하지 않습니다. 인터페이스에 의존합니다.
interface TopicsRepository : Syncable {
fun getTopics(): Flow<List<Topic>>
fun getTopic(id: String): Flow<Topic>
}
OfflineFirstTopicsRepository가 아니라 TopicsRepository에 의존하므로, 도메인 계층은 Room의 존재를 모른 채로 남습니다. 고정된 목록을 반환하는 페이크로 갈아 끼워도 유스케이스는 똑같이 동작하며, 바로 이 점이 유스케이스를 따로 떼어 테스트할 수 있게 해 줍니다.
DataStore에서 오는 설정
두 번째 스트림은 사용자의 선택을 실어 나르며, Room이 아니라 Proto DataStore에 자리합니다. 데이터 소스는 proto를 읽어, 필드가 평범한 코틀린 Set으로 된 도메인 모델로 매핑합니다.
class NiaPreferencesDataSource @Inject constructor(
private val userPreferences: DataStore<UserPreferences>,
) {
val userData = userPreferences.data
.map {
UserData(
bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys,
viewedNewsResources = it.viewedNewsResourceIdsMap.keys,
followedTopics = it.followedTopicIdsMap.keys,
// theme, dark config, dynamic color, 온보딩 필드는 생략
)
}
}
proto는 각 설정을 맵으로 저장하고, 매핑은 그 keys만 남겨 followedTopicIdsMap을 팔로우한 토픽 id의 Set<String>으로 바꿉니다. Room과 마찬가지로, DataStore.data는 수집 시점에 현재 값을 방출하고 쓰기가 있을 때마다 다시 방출하는 flow입니다. 리포지토리는 이 스트림을 그대로 넘기면서 쓰기 쪽을 더합니다.
internal class OfflineFirstUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val analyticsHelper: AnalyticsHelper,
) : UserDataRepository {
override val userData: Flow<UserData> = niaPreferencesDataSource.userData
override suspend fun setTopicIdFollowed(followedTopicId: String, followed: Boolean) {
niaPreferencesDataSource.setTopicIdFollowed(followedTopicId, followed)
analyticsHelper.logTopicFollowToggled(followedTopicId, followed)
}
}