SNS 탭의 게시글을 API에서 받아오는 기능을 구현 중이었다.
현재 SNS 불러오기에는 2가지의 동작이 있다.
각 API 동작에는 3개의 상태가 있다.
각 API 별로 UI 변화를 다르게 해야한다.
또 각 API에 따라 각 상태별로 UI 변화를 다르게 해야한다.
즉, N개의 API 호출 당 3개의 상태 표현이 필요, 3 * N 개의 변화 대응을 해야한다.
본 문서는, 위 로직을 구현하며 겪은 Type과 State를 제대로 구분하여 사용을 못한 예시와, 이후 피드백을 받고 잘 구현된 예시와 함께 Type과 State에 대해 정리한다.
처음엔 'Type' 을 기준으로 상태 변화를 구분하려 했었다.
sealed class DataStatus<T>(val data: T? = null, val message: String? = null) {
class Success<T>(data: T) : DataStatus<T>(data)
class Error<T>(data: T? = null) : DataStatus<T>(data)
class Loading<T>(data: T? = null) : DataStatus<T>(data)
}
@HiltViewModel
class FeedViewModel @Inject constructor(...) : ViewModel() {
private val _feeds = MutableLiveData<DataStatus<MutableList<Feed>>>()
val feeds: LiveData<DataStatus<MutableList<Feed>>> get() = _feeds
...
}
=> MutableList<Feed>를 DataStatus로 감싼 모습.
fun fetchFeeds() {
_feeds.value = DataStatus.Loading()
viewModelScope.launch(coroutineExceptionHandler) {
fetchFeedsUseCase
.fetchFeeds()
.onSuccess { _feeds.value = DataStatus.Success(it.toMutableList()) }
.onFailure {
errorObserver.value = it
_feeds.value = DataStatus.Error()
}
}
}
=> _feeds.value 에 DataStatus.Loading(), DataStatus.Success(리스트) 을 넣고 있음
fun fetchMoreFeeds(id: Int, count: Int = 100) {
viewModelScope.launch(coroutineExceptionHandler) {
fetchFeedsUseCase
.fetchMoreFeeds(id, count)
.onSuccess { feeds ->
_feeds.value?.let {
it.data?.addAll(feeds)
_feeds.value = it
}
}
.onFailure {
errorObserver.value = it
_feeds.value = DataStatus.Error()
}
}
}
=> Loading, Success 관련 초기화가 없다
feedViewModel.feeds.observe(viewLifecycleOwner) { status ->
when (status) {
is DataStatus.Loading -> {
startShimmer()
}
is DataStatus.Success -> {
binding.feedRefreshview.isRefreshing = false
isLoading = false
status.data?.let { feeds ->
feedRecyclerviewAdapter.notifyFeeds(feeds)
stopShimmer()
binding.feedRecyclerview.visibility = View.VISIBLE
}
}
is DataStatus.Error -> {
binding.feedRefreshview.isRefreshing = false
stopShimmer()
}
}
}
=> 받아온 status의 Type을 is 로 확인하고 있다.
private fun loadMoreFeeds(id: Int) {
feedRecyclerviewAdapter.setLoadingView(true)
isLoading = true
feedViewModel.fetchMoreFeeds(id, LOAD_MORE_COUNT)
}
=> FetchMoreFeeds 관련된 UI 작업은, 직접 맨 밑에 도달했을 때 해주고 있음. (observe X)
예시를 보면서 뭔가 이상함을 느꼈을 것이다.
DataStatus라는 'Type'을 사용하여 'State'를 구분하고 있다.
FetchFeeds, FetchMoreFeeds, 두 개 혹은 그 이상의 API 호출이 일어날 수 있는데, 상태변화는 Feeds를 관찰하는 Observe 코드 하나에서 처리하고 있다.
(FetchFeeds와 FetchMoreFeeds의 Loading, Error UI는 다르다.)
비동기 작업과 관련된 Feeds 가 DataStatus에 의존적이다.
Type은 데이터타입이다. 값을 구분해준다.
Type check는 언제 하는걸까?
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is FeedNoImageContentViewHolder) {
val item = feeds[position]
holder.bind(item)
} else if (holder is FeedWithImageContentViewHolder) {
val item = feeds[position]
holder.bind(item)
}
}
이런 식으로 RecyclerView.ViewHolder 의 타입을 모를때, Type을 check 하고 holder를 사용한다.
Type을 모를 때 사용한다.
Type이 잘못되면 터지니까.
*Type check가 제네릭 내부에서도 사용되는데, 클래스 혹은 메서드에서 사용할 내부 데이터 타입을, 컴파일 시에 미리 Type check 를 한다.
기억된 정보이다.
즉 Loading, Succeess, Error, ... 이런 정보들이 State 인 것이다.
사실 If ~ else 어쩌구 하는 게 전부 state 확인하는거다.
if (State)
Statement1
else
Statement2
Type을 사용해서 State를 구분하고 있었으니 이상했던거다.
아래는 피드백을 받은 내용이다.
Type으로 State를 구분하지 말자.
각 API 비동기 호출마다 다르게 관리하자.
DataStatus는 enum class로 바꿔준다.
각각의 상수 오브젝트들이 State의 역할을 한다.
enum class DataStatus { SUCCESS, ERROR, LOADING }
데이터 관리는 feeds 하나로, State 관리는 각 비동기 처리 로직당 하나씩 관리를 한다.
fetchDataStatus, fetchMoreDataStatus 의 내부 타입은 앞서 다시 정의한 DataStatus enum class 이기 때문에, 각각 LOADING, SUCCESS, ERROR state 에 대한 분기 처리를 할 수 있다.
private val _feeds = MutableLiveData<List<Feed>>()
val feeds: LiveData<List<Feed>> = _feeds
private val _fetchDataStatus = MutableLiveData<DataStatus>()
val fetchDataStatus: LiveData<DataStatus> = _fetchDataStatus
private val _fetchMoreDataStatus = MutableLiveData<DataStatus>()
val fetchMoreDataStatus: LiveData<DataStatus> = _fetchMoreDataStatus
_fetchDataStatus.value = DataStatus.LOADING
이런 식으로
공통 관리하는 feeds의 값과는 별개로 State 변화를 fetchDataStatus를 통해 관리한다.
fun fetchFeeds() {
_fetchDataStatus.value = DataStatus.LOADING
viewModelScope.launch(coroutineExceptionHandler) {
fetchFeedsUseCase
.fetchFeeds()
.onSuccess {
_feeds.value = it.toMutableList()
_fetchDataStatus.value = DataStatus.SUCCESS
}
.onFailure {
_errorMessage.value = "Feeds 를 가져오는데 실패했습니다."
_fetchDataStatus.value = DataStatus.ERROR
}
}
}
feeds, fetchDataStatus, fetchMoreDataStatus 각각을 observe 하여
를 각각 관리할 수 있게 되었다.
feedViewModel.feeds.observe(viewLifecycleOwner) {
feedRecyclerviewAdapter.notifyFeeds(it)
}
feedViewModel.fetchDataStatus.observe(viewLifecycleOwner) {
when (it) {
DataStatus.LOADING -> {
startShimmer()
}
DataStatus.SUCCESS -> {
binding.feedRefreshview.isRefreshing = false
stopShimmer()
binding.feedRecyclerview.visibility = View.VISIBLE
}
DataStatus.ERROR -> {
binding.feedRefreshview.isRefreshing = false
stopShimmer()
}
}
}
feedViewModel.fetchMoreDataStatus.observe(viewLifecycleOwner) {
feedRecyclerviewAdapter.setLoadingView(it == DataStatus.LOADING)
}
=> ~DataStatus observe 안에서 각각 비동기 호출마다 다른 UI 수정 로직이 들어갈 수 있게 되었다!
Type 과 State는 다르다. 쓰임새도 다르다. 각각 잘 맞는 역할로 사용하자.
비동기 로직에 대한 안정적이고 제대로된 UI 처리에 대한 고민을 자주 해야할 것 같다.
삽질할 뻔한, 중요했던 내용을 짚어주고 이해하기 쉽게 피드백하고 리팩토링 해주신 Pyro 에게 감사의 말씀을 전합니다.