텃텃
을 개발하면서 데이터 Fetching에 Flow
를 공부하며 적극적으로 활용하는 것이 하나의 목표였다. 그래서 Flow
를 활용해 단방향 데이터 흐름
을 실시간으로 UI Layer까지 전달하는 적합한 방법에 대한 고민이 있었다.
텃텃은 Domain Layer가 생략된 클린 아키텍처로 구성했다. 이전 프로젝트에서 Layer별 모듈로 나눠 개발했을 때, 유지 보수하기 괜찮은 아키텍처라 느껴 이번에도 채택했다. 다만, Version 1.0에선 프로젝트 사이즈가 크지 않기 때문에 Domain Layer는 생략했다.
내가 찾은 방법은
Data Layer
의 각 Repository에 Flow
를 활용한 실시간 데이터 흐름을 만든다.
UI Layer
의 ViewModel
에서 해당 Flow를 StateFlow<UiState>
으로 관리한다.
UI Layer
의 Screen(Composable)
에서 UiState을 수집하여 데이터 매핑, 로딩 등을 처리한다.
이제 단계별로 따라가 보자
Retrofit
을 사용할 때, @Provides
어노테이션을 이용해 인스턴스를 생성했었다.
FireStore
에서는 단지
Firebase.firestore.collection("컬렉션 명")
형식으로 컬렉션에 접근하여 사용하면 된다.
하지만 난 FireStore용 모듈을 만들어 좀 더 편하게 사용할 수 있게 만들고 싶었다.
FireBaseModule.kt
@Module
@InstallIn(SingletonComponent::class)
class FireBaseModule {
@Provides
@Singleton
@Named("usersRef")
fun provideUsersRef() = Firebase.firestore.collection(FireBaseKey.USERS)
@Provides
@Singleton
@Named("gardensRef")
fun provideGardensRef() = Firebase.firestore.collection(FireBaseKey.GARDENS)
...
}
각 메서드는 모두 CollectionReference
를 반환한다. 그래서 Hilt에서 각 요소를 구분하기 위해 @Named
어노테이션을 추가한다. FireBaseModule에서 제공하는 CollectionReference는 필요한 Repository에서 사용할 것이다.
그림은 텃텃의 FireStore 구조이다. 각 컬렉션 별로 Repository를 분리하여 관리하기로 했다. 그래서 Repository의 종속성을 삽입하는 모듈이 필요하다.
DataModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
@Singleton
internal abstract fun bindAuthRepository(
authRepository: AuthRepositoryImpl
) : AuthRepository
@Binds
@Singleton
internal abstract fun bindGardenRepository(
gardenRepository: GardenRepositoryImpl
) : GardenRepository
...
}
Repository 종속성을 삽입하는 DataModule에선 각 Repository를 @Binds
어노테이션을 이용한다.
DataModule.kt (@Provides)를 이용한 경우
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Singleton
@Provides
fun provideAuthRepository(
@Named("usersRef") usersRef: CollectionReference,
@Named("gardensRef") gardensRef: CollectionReference
): AuthRepository = AuthRepositoryImpl(usersRef, gardensRef)
@Singleton
@Provides
fun provideGardenRepository(
@Named("gardensRef") gardensRef: CollectionReference
): GardenRepository = GardenRepositoryImpl(gardensRef)
}
이런 식으로도 사용이 가능하지만, @Binds를 이용했을 때 코드가 더 간결해지고 interface
인 Repository의 종속성을 주입하기 때문에 @Binds가 @Provides보다 적합하다.
@Binds
constructor를 가질 수 없는 인터페이스에 대한 종속성 삽입의 경우에 사용
@Provides
외부 라이브러리에서 제공되는 클래스이므로 프로젝트 내에서 소유할 수 없는 경우 또는 Builder 패턴 등을 통해 인스턴스를 생성해야 하는 경우에 사용
FireStore 데이터를 가져오는 방법은 이전 포스트를 참고하면 된다.
핵심은 데이터의 실시간 업데이트 수신 대기를 위해서는 addSnapShotListener()
를 활용해야한다.
CropsRepositoryImpl.kt
class CropsRepositoryImpl @Inject constructor(
@Named("gardensRef") val gardenRef: CollectionReference,
) : CropsRepository {
override fun getDocumentPath(gardenId: String, cropsId: String): DocumentReference
= gardenRef.document(gardenId).collection(FireBaseKey.CROPS).document(cropsId)
override fun getCropsDetail(gardenId: String, cropsId: String): Flow<Crops> = callbackFlow {
val registration = getDocumentPath(gardenId, cropsId).addSnapshotListener { snapshot, exception ->
if (exception != null) {
cancel()
return@addSnapshotListener
}
if (snapshot != null && snapshot.exists()) {
val data = snapshot.toObject(Crops::class.java)
trySend(data)
}
}
awaitClose { registration.remove() }
}
...
}
addSnapShotListener
콜백 안에서 flow를 전달하기 위해 callbackFlow
를 사용해 실시간 데이터 흐름을 만들어준다.
작업이 완료됐을 때 메모리 누수를 방지하기 위해 awaitClose
블록에서 리스너를 제거한다.
이제 해당 로직을 제네릭으로 만들어 Flow Extension으로 사용할 수 있다.
FlowExtensions.kt
fun <T> DocumentReference.asSnapShotFlow(
dataType: Class<T>
): Flow<T> = callbackFlow {
val registration = addSnapshotListener { snapshot, exception ->
if (exception != null) {
cancel()
return@addSnapshotListener
}
if (snapshot != null && snapshot.exists()) {
val data = snapshot.toObject(dataType) as T
trySend(data)
}
}
awaitClose { registration.remove() }
}
CropsRepositoryImpl.kt
class CropsRepositoryImpl @Inject constructor(
@Named("gardensRef") val gardenRef: CollectionReference,
) : CropsRepository {
override fun getDocumentPath(gardenId: String, cropsId: String): DocumentReference
= gardenRef.document(gardenId).collection(FireBaseKey.CROPS).document(cropsId)
override fun getCropsDetail(gardenId: String, cropsId: String): Flow<Crops>
= getDocumentPath(gardenId, cropsId).asSnapShotFlow(Crops::class.java)
...
}
이제 제네릭으로 선언돼 확장성을 갖는 asSnapShotFlow
를 이용해 다양한 상황에서 Flow를 제공할 수 있다.
Repository의 코드가 간결해지는 것은 덤이다.
이제 만든 데이터 흐름을 ViewModel에서 받아 사용해보자
로딩 상태, 데이터 매핑 등을 위해 UiState
을 사용할 것이고, StateFlow
로 Flow를 수집해 사용할 것이다.
CropsDetailUiState.kt
sealed interface CropsDetailUiState {
data object Loading : CropsDetailUiState
data class Success(
val crops: Crops
) : CropsDetailUiState
}
when 문에서 깔끔하게 사용할 수 있도록 sealed interface
으로 선언해주자
CropsDetailViewModel.kt
@HiltViewModel
class CropsDetailViewModel @Inject constructor(
private val cropsRepo: CropsRepository,
...
): BaseViewModel() {
...
val uiState: StateFlow<CropsDetailUiState>
= cropsRepo.getCropsDetail(
gardenId = gardenId,
cropsId = crops.id
).map(CropsDetailUiState::Success)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = CropsDetailUiState.Loading
)
...
}
Repository에서 제공하는 Flow를 UiState
으로 변환한 것을 StateFlow
로 수집해준다.
UI는 생성된 StateFlow를 구독하여 항상 최신 데이터를 받게 된다.
이런 방식의 장점은
백킹 프로퍼티
를 사용해서 UiState을 관리할 필요가 없다
init 블록
에서 Flow를 수집하지 않아도 된다.
initialValue
를 이용해 초기 UiState은 Loading으로 설정할 수 있다.
Compose에서는 collectAsStateWithLifecycle()
를 이용해 StateFlow를 State으로 사용할 수 있다.
이를 통해 StateFlow에서 다른 값이 방출될 때마다 리컴포지션을 시킨다. 그래서 실시간으로 데이터가 변할 때마다 UI를 갱신할 수 있다.
CropsDetailScreen.kt
@Composable
fun CropsDetailRoute(
modifier: Modifier = Modifier,
scope: CoroutineScope,
onBack: () -> Unit,
...
viewModel: CropsDetailViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (uiState) {
CropsDetailUiState.Loading -> TutTutLoadingScreen()
is CropsDetailUiState.Success -> {
val crops = (uiState as CropsDetailUiState.Success).crops
CropsDetailScreen(
modifier = modifier,
crops = crops,
...
)
}
...
}
collectAsStateWithLifecycle()
를 이용해 ViewModel의 UiState(StateFlow)
를 uiState(State)
로 수집한다.
이 uiState을 바탕으로 when 문
내에서 상태에 따른 다른 UI를 보여줄 수 있다.
텃텃을 개발하며 FireStore 데이터를 Flow로 제공하는 적합한 방법
에 대해 고민하고 적용한 방법이다.
개발 당시 FireStore + Flow + Hilt 조합의 참고할만한 프로젝트가 찾기 어려워 텃텃이 해당 조합의 표준이 되고 싶었다. 개선의 여지가 있기 때문에 다음 버전에선 개선된 방법을 고민해보고 공유하려 한다.
개인 프로젝트에서 나처럼 FireStore를 선택한 사람에게 도움이 됐으면 좋겠다!