Skip to content

✏️ 박명범_Room 에서 Flow 로 반환받아서 StateFlow 로 사용하기

박명범 edited this page Dec 14, 2022 · 1 revision

Flow Room 에서 Flow 로 반환 받기

Room 에서 Flow 로 반환하는 것을 지원하고 있다. Flow 라서 가능한 것이 아니라 Room 내부에서 Flow 를 지원하도록 구현했기 때문에 가능한 것이다. 우리는 간단하게 Flow<> 블록으로 감싸주기만 하면 된다.

GifticonDao(Data Layer)

@Query("SELECT * FROM $GIFTICON_TABLE WHERE id = :id")
fun getGifticon(id: String): Flow<GifticonEntity>

삡은 Data <-> Domain <-> Presentation 모듈로 나누어져 있고, Data 레이어에 속하는 Flow 를 Domain 을 거쳐 Presentation(UI) 레이어까지 가지고 와서 화면에 보여주어야 하는데 이때 다양한 상태가 존재한다

  • 요청 하기 전
  • 요청 중 (로딩 중)
  • 성공 (정상적으로 받아 온 경우)
  • 성공 (정상적으로 받아왔지만 빈 값인 경우)
  • 실패

이러한 상태를 Data 레이어에서 바로 처리해버린다면 앱을 사용하는 사용자는 현재 어떤 상태인지, 기능이 동작을 안 한다면 어떤 문제가 발생했는지 모르기 때문에 개발자는 적당한 UI 요소로 알려야 할 필요가 있다.

그래서 이러한 상태를 가지는 wrapper class 를 만들게 되었다

wrapper class

DbResult(Domain Layer)

sealed class DbResult<out T : Any> {
    data class Success<out T : Any>(val data: T) : DbResult<T>()
    object Loading : DbResult<Nothing>()
    object Empty : DbResult<Nothing>()
    data class Failure(val throwable: Throwable) : DbResult<Nothing>()
}

위에선 5가지 상태에 대해서 소개했는데(물론 상황에 따라 더 있을 수 있다), 나는 요청 전 상태는 제외했다. 그리고 이 Flow 를 요청하는 부분의 초기 값을 Loading 상태로 주었다.

DbResult 가 Domain 레이어에 존재하는 이유는 Data 레이어에서 발생한 Flow 를 감싸서 Presentation 으로 보내야하고, Presentation 레이어에서는 Domain 레이어 만을 알고 있기 때문에 Data 레이어와 Presentation 레이어와의 통신을 위해 둘 다 알고 있는 Domain 레이어에 정의되었다.

wrapper class 로 Flow 감싸기

GifticonRepositoryImpl(Data Layer)

// ...
override fun getGifticon(id: String): Flow<DbResult<Gifticon>> = flow {
    emit(DbResult.Loading)
    gifticonLocalDataSource.getGifticon(id).collect {
        emit(DbResult.Success(it))
    }
}.catch { e ->
    emit(DbResult.Failure(e))
}
// ...

GifticonRepository(Domain Layer)

interface GifticonRepository {
    fun getGifticon(id: String): Flow<DbResult<Gifticon>>
    // ...
}

Data 레이어에 있는 RepositoryImpl 에서 새로운 Flow 를 만들고 있다. 여기서 방출하는 Flow 는 DbResult 를 가지고 있다.

초기 값으로는 위에서 언급했듯이 Loading 상태로 만들었는데 UI 계층이 아닌 Data 계층에서 로딩 상태를 가지는 것에 대해 이상하게 생각할 수도 있다. 팀원끼리도 이에 대해 얘기를 나눠봤는데 결론은 Data 레이어에서 로딩 상태를 표현해도 괜찮다는 쪽으로 모아졌다.

이상하게 생각됐던 이유는 아마도 이전에 우리가 ViewModel 에서 loading 상태를 가지고 있고, 이 loading 값에 따라 프로그레스바를 띄워주거나 하는 식으로 처리했기 때문이 아닐까라는 생각이 들었다.

DB 나 Remote 에서 데이터를 가져올 때도 보통 비동기적으로 가져오기 때문에 로딩 상태가 있는 것은 어쩌면 자연스러운 상태라고 할 수 있다.

이후 DataSource 에서 Gifticon 을 가져오는 동작을 실행시키고, 성공했다면 DbResult.Success 에 담겨서 방출될 것이고, 문제가 발생했다면 catch 블록에서 잡혀 DbResult.Failure 에 Throwable 이 담겨서 방출될 것이다.

Presentation 레이어에서는 Domain 레이어에 있는 GifticonRepository 인터페이스에게 요청을 하고, 실제로는 Data 레이어에서 실제로 담겨서 반환되게 된다.

UseCase 을 써야할까?

UseCase 를 사용했을 때의 장점

  1. ViewModel 에서 UseCase 들을 가지고 있기 때문에 어떠한 동작을 수행하게 될 지 파악하기 쉽다
  2. 여러 동작이 복합적으로 이루어져야 할 때 UseCase 를 사용하면 한 번에 분산된 Repository 들에서 각각의 동작을 수행할 수 있다
  3. 일관적이고, 직관적이다

UseCase 를 사용했을 때의 단점

  1. 단순히 Repository 의 동작을 수행하는 정도면 필요 없는 UseCase 가 늘어나게 된다
  2. 코드 양이 많아져서 유지보수가 오히려 힘들어질 수 있다
  3. 구글의 가이드가 명확하지 않다. 공식 문서나 샘플 코드에서는 Repository 가 다른 Repository 를 가지는 구조와 UseCase 가 다른 UseCase 를 가지는 구조를 복합적으로 사용하고 있다. 그렇기 때문에 개발자마다 개발 스타일이 달라지고 호불호가 생기게 된다

우리 팀은 어떨까?

우리는 UseCase 를 사용하고 있다. 단순히 Repository 의 메서드 하나만 실행하고 끝나지는 않았다는 점, 그리고 코드의 일관성 등을 고려해서 결정한 사항이다.

GetGifticonUseCase(Domain Layer)

class GetGifticonUseCase @Inject constructor(
    private val gifticonRepository: GifticonRepository
) {
    operator fun invoke(id: String): Flow<DbResult<Gifticon>> {
        return gifticonRepository.getGifticon(id)
    }
}

GetGifticonUseCase 에서는 단순히 기프티콘의 정보를 호출하는 동작만 하기 때문에 Repository 에서 하나의 메서드만을 호출하고 있다.

Flow 를 StateFlow 로 바꿔보자

지금까지는 Room 에서 반환 받은 Flow 를 DbResult 에 감싸주기만 했을 뿐, 여전히 Flow(cold) 로 가져오고 있었다. 하지만 기프티콘의 정보는 계속해서 화면에 보여줘야 하기 때문에 SharedFlow(hot) 으로 변환해서 가지고 있어야 했다. (기존의 LiveData 와 비슷한 포지션을 한다고 생각한다. 또한 LiveData 처럼 XML 에 직접 바인딩 할 수도 있다)

GifticonDetailViewModel(Presentaion Layer)

@HiltViewModel
class GifticonDetailViewModel @Inject constructor(
    stateHandle: SavedStateHandle,
    getGifticonUseCase: GetGifticonUseCase,
    // ...
) : ViewModel() {

    private val gifticonId = stateHandle.get<String>(KEY_GIFTICON_ID) ?: error("Gifticon id is null")
    private val gifticonDbResult =
        getGifticonUseCase(gifticonId).stateIn(viewModelScope, SharingStarted.Eagerly, DbResult.Loading)
    // ...
}

stateHandle 로 부터 기프티콘의 고유 ID 를 가져오고 있는데 일단 이 부분에 대한 설명은 패스하도록 하겠다.

이 ID 를 통해서 기프티콘을 요청하게 된다. 위에서 소개한 GetGifticonUseCase 를 통해서 Flow<DbResult<Gifticon>> 형태로 가져올 수 있다.

Flow 를 StateFlow 로 변환하는 것은 stateIn 이라는 것을 사용하면 간편하게 가능하다. 이때 2 가지 방법이 있다.

  1. 초기 값과 함께 바로 사용
  2. 초기 값 없이(scope 만 지정) suspend function 내부에서 사용

기프티콘 상세 화면을 표현하기 위해서는 화면이 생성되자 마자 기프티콘 정보를 호출해야 했고, 바로 멤버 필드로 담아주기 위해서 첫 번째 방법을 선택했다.

초기 값을 설정하는 부분을 보면 SharingStarted 라는 옵션이 보이는데 여기는 3 가지 옵션이 있다

SharingStarted

  1. Eagerly
  • 첫 번째 구독자가 등장하기 전부터 빠르게 방출한다
  1. Lazily
  • 첫 번째 구독자가 등장한 순간부터 업스트림으로부터 Flow 가 시작된다
  1. WhileSubscribed
  • 첫 번째 구독자가 등장한 순간부터 업스트림으로부터 Flow 가 시작된다. 마지막 구독자가 사라지면 기본적으로 즉시 멈추고, 이 시간을 지정할 수도 있다

나는 아까도 말했듯이 화면이 띄워지자 마자 기프티콘 정보를 불러와야 하기 때문에 Eagerly 옵션을 사용했다. 그리고 그 전까지는 null 로 받아왔다.

그 결과 gifticonDbResult 의 타입은 SharedFlow<DbResult<Gifticon?>> 이 된다

tranform 으로 DbResult 에서 꺼내기

위에서 받아온 SharedFlow<DbResult<Gifticon?>> 는 바로 사용할 수 없다. DbResult 에 성공, 실패 등의 다양한 상태를 담고 있기 때문에 바로 XML 에 바인딩 할 수 없고, 성공 여부를 판단한 후에 넣어줄 수 있게 된다.

그래서 사용한 방법이 바로 tranform 을 사용하는 것이다.

tranform 은 Flow 의 확장 함수로 형태를 변환해서 사용할 수 있다.

GifticonDetailViewModel(Presentaion Layer)

@HiltViewModel
class GifticonDetailViewModel @Inject constructor(
    stateHandle: SavedStateHandle,
    getGifticonUseCase: GetGifticonUseCase,
    // ...
) : ViewModel() {

    // ...
    val gifticon: StateFlow<Gifticon?> = gifticonDbResult.transform {
        if (it is DbResult.Success) {
            emit(it.data)
        }
    }.stateIn(viewModelScope, SharingStarted.Eagerly, null)
    // ...
}

여기서는 gifticonDbResult 를 transform 해서 Success 상태일 때만 StateFlow 로 만들도록 했다. 그렇다면 Success 가 아닐 때는 어떻게 동작할까? 바로 null 이 된다. 이유는 간단하다. stateIn 에서 초기 값으로 null 을 줬기 때문이다.

결국 DbResult 를 벗겨내어 StateFlow<Gifticon?> 형태를 가지게 되었다. 이 상태에서는 바로 XML 에 바인딩 할 수 있게 된다.

마지막! XML 에서 사용해보자

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="vm"
            type="com.lighthouse.presentation.ui.detailgifticon.GifticonDetailViewModel" />
    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <EditText
            android:id="@+id/tv_product_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{vm.gifticon.name}"
            tools:text="딸기마카롱설빙" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

위와 같이 ViewModel 의 gifticon.name 에 바로 접근하여 데이터를 바인딩 해 줄 수 있게 된다.

예외 처리

GifticonDetailViewModel(Presentaion Layer)

val failure = gifticonDbResult.transform {
    if (it is DbResult.Failure) {
        emit(it.throwable)
    }
}

GifticonDetailActivity(Presentation Layer)

repeatOnStarted {
    viewModel.failure.collect {
        showInvalidDialog()
    }
}

ViewModel 에 failure 라는 변수를 앞서 소개한 방식과 동일한 방식으로 만들고, Activity 에서 collect 를 하면 적절한 UI 로 표시한다. 나는 5초 뒤 자동으로 닫히는 AlertDialog 를 구현해 잘못된 상태임을 알렸다

오류 정상

✍️ BEEP Tech Blog

박명범

양수진

김명석

이지훈

👾 BEEP

🗣 Ground Rule

✏️ Conventions

⚙️ Setting

🌱 Daily Scrum

week 1
week 2
week 3
week 4
week 5
week 6
Clone this wiki locally