비동기 작업 관리 회고, Type 과 State

Nathan Kim·2022년 2월 17일
1

서론

SNS 탭의 게시글을 API에서 받아오는 기능을 구현 중이었다.
현재 SNS 불러오기에는 2가지의 동작이 있다.

  1. 가장 최신 100개 불러오기 (FetchFeeds)
  2. ID값 부터 시작해서 Count 값만큼의 게시글 불러오기 (FetchMoreFeeds)

각 API 동작에는 3개의 상태가 있다.

  • 로딩
  • 성공
  • 실패

각 API 별로 UI 변화를 다르게 해야한다.
또 각 API에 따라 각 상태별로 UI 변화를 다르게 해야한다.

즉, N개의 API 호출 당 3개의 상태 표현이 필요, 3 * N 개의 변화 대응을 해야한다.

본 문서는, 위 로직을 구현하며 겪은 Type과 State를 제대로 구분하여 사용을 못한 예시와, 이후 피드백을 받고 잘 구현된 예시와 함께 Type과 State에 대해 정리한다.

본론

안 좋은 예시

처음엔 'Type' 을 기준으로 상태 변화를 구분하려 했었다.


Lodaing, Success, Error class 를 DataStatus sealed class 로 선언

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)
}

ViewModel 의, SNS 게시글 관리하는 DataStatus Type의 feeds

@HiltViewModel
class FeedViewModel @Inject constructor(...) : ViewModel() {

    private val _feeds = MutableLiveData<DataStatus<MutableList<Feed>>>()
    val feeds: LiveData<DataStatus<MutableList<Feed>>> get() = _feeds
    
    ...
}

=> MutableList<Feed>를 DataStatus로 감싼 모습.

전체 게시글 불러오는 fetchFeeds()

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(리스트) 을 넣고 있음

게시글 더 가져오는 fetchMoreFeeds()

  
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 관련 초기화가 없다

feeds observe

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)

뭐가 잘못된걸까?

예시를 보면서 뭔가 이상함을 느꼈을 것이다.

  1. DataStatus라는 'Type'을 사용하여 'State'를 구분하고 있다.

  2. FetchFeeds, FetchMoreFeeds, 두 개 혹은 그 이상의 API 호출이 일어날 수 있는데, 상태변화는 Feeds를 관찰하는 Observe 코드 하나에서 처리하고 있다.
    (FetchFeeds와 FetchMoreFeeds의 Loading, Error UI는 다르다.)

  3. 비동기 작업과 관련된 Feeds 가 DataStatus에 의존적이다.


Type

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 를 한다.

State

기억된 정보이다.

즉 Loading, Succeess, Error, ... 이런 정보들이 State 인 것이다.

사실 If ~ else 어쩌구 하는 게 전부 state 확인하는거다.

if (State)
   Statement1
else
   Statement2

참고로 컴퓨터는 전부 'State'로 이루어져 있다.


Type을 사용해서 State를 구분하고 있었으니 이상했던거다.

어떻게 해야할까?

아래는 피드백을 받은 내용이다.

  1. Type으로 State를 구분하지 말자.

  2. 각 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 하여

  • 데이터
  • fetch feed 상태
  • fetch more feed 상태

를 각각 관리할 수 있게 되었다.

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 에게 감사의 말씀을 전합니다.

1개의 댓글

comment-user-thumbnail
약 1시간 전

멋져요!

답글 달기