[텃텃] Flow를 활용한 FireStore 실시간 데이터 Fetching (with Hilt)

안승우·2024년 5월 5일
0

텃텃

목록 보기
4/6

FireStore에 Flow를 적용해볼까?

텃텃을 개발하면서 데이터 Fetching에 Flow를 공부하며 적극적으로 활용하는 것이 하나의 목표였다. 그래서 Flow를 활용해 단방향 데이터 흐름을 실시간으로 UI Layer까지 전달하는 적합한 방법에 대한 고민이 있었다.

텃텃은 Domain Layer가 생략된 클린 아키텍처로 구성했다. 이전 프로젝트에서 Layer별 모듈로 나눠 개발했을 때, 유지 보수하기 괜찮은 아키텍처라 느껴 이번에도 채택했다. 다만, Version 1.0에선 프로젝트 사이즈가 크지 않기 때문에 Domain Layer는 생략했다.

내가 찾은 방법은

  1. Data Layer의 각 Repository에 Flow를 활용한 실시간 데이터 흐름을 만든다.

  2. UI LayerViewModel에서 해당 Flow를 StateFlow<UiState>으로 관리한다.

  3. UI LayerScreen(Composable)에서 UiState을 수집하여 데이터 매핑, 로딩 등을 처리한다.

이제 단계별로 따라가 보자

Step 👆 Hilt 모듈 만들기

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 패턴 등을 통해 인스턴스를 생성해야 하는 경우에 사용


Step ✌ 실시간 데이터 Flow 만들기

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의 코드가 간결해지는 것은 덤이다.


Step 🤟 ViewModel에서 StateFlow & UiState로 Flow 받기

이제 만든 데이터 흐름을 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를 구독하여 항상 최신 데이터를 받게 된다.

이런 방식의 장점은

  1. 백킹 프로퍼티를 사용해서 UiState을 관리할 필요가 없다

  2. init 블록 에서 Flow를 수집하지 않아도 된다.

  3. initialValue를 이용해 초기 UiState은 Loading으로 설정할 수 있다.


Step ✋ Composable에 데이터 매핑시키기

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를 선택한 사람에게 도움이 됐으면 좋겠다!

profile
안드로이드 개발자

0개의 댓글

관련 채용 정보