Android에서 Optimistic UI 적용하기 (feat: 찜하기)

covy·2024년 8월 1일
post-thumbnail

서론

찜하기 or 좋아요 기능을 구현할 때, API 호출 완료 후 UI를 갱신해야 할까?
또 그러면 찜하기 버튼은 화면 갱신이 될 때까지 block 되어야 할까?

기존 찜하기 버튼을 누를 때, API 결과가 완료될 때까지 화면 갱신 못하는 경험을 하게 될 수 있었다. 최악의 경우 설정한 timeout(현재 30초) 시간까지 사용자는 버튼 인터렉션 결과를 기다려야 하는 상황이었다. 이는 해당 액션의 이탈로 이어질 수 있다고 판단으로 이어졌다.

다른 어플로 레퍼런스를 찾아보았을 때, 인스타, 유튜브 등 좋아요를 누르면 즉시 반영되곤 하였다.

지나칠 수 없는 요소였다. 사용자 인터렉션이 실시간성으로 반영되어 사용자가 기다림 없는 UX가 되었으면 했다. 그래서 디자이너 분의 지지를 받아, 이번 청하 프로젝트에 적용 및 과정을 기록하고자 한다.

현재 구조

지금 찜하기 기능은 여러곳에 있지만, 홈 화면 기준으로 작성해 보려 한다.
청하 서비스의 홈 화면과 찜하기 후에 이벤트 변화이다.

홈 화면찜하기 후
!

하트모양(찜하기) 버튼을 누르면 하단 스낵바가 발생하고, 목록 보러가기를 하면 찜한 목록 화면으로 이동한다.

어떻게 적용할 수 있을까?

Senario 1. -> 배제

하트 버튼을 클릭과 동시에 색상 변경 + 스낵바를 발생시킨다. 그리고 서버 요청 진행하는 방식. 실패하면 실패 스낵바 발생과 하트 색상을 원복한다.

색상 변경시, 스낵바를 발생시키는데는 문제가 없어보인다. 이게 낙관적인 UI지 하고 이후 시나리오를 구상하고 있었는데, 큰 허점이 있었다.

스낵바의 관심목록 화면으로 이동했을 때, 찜하기 API 요청이 제대로 안되었다면?
일관되지 않는 UI를 마주할 수 있을 것이다.

내부 데이터베이스 캐싱을 적절히 활용하면, 어느정도 해결이 가능해보였다. 하지만 배보다 배꼽이 너무 커지는 상황이라 이 방법은 배제하는 것으로 결정했다.

Senario 2. -> 결정

하트 버튼을 클릭할 때 색상은 즉시 변경, 스낵바는 서버통신 후 발생하는 방식, 실패시는 Senario 1과 동일

스낵바만 발생을 안시켜도 우리가 안내한 찜한 목록 화면과 싱크가 안맞는 경험을 줄일 수 있을 것이다.

Senario 2의 단점 및 해결

바텀바에 중첩 네비게이션이 적용되어 있어 찜한 목록 화면과 홈 화면를 동시에 인스턴스가 살아 있을 수 있다.

내가 찜한 정책 화면 -> 홈 화면으로 이동 및 찜하기 클릭 후 -> 내가 찜한 정책 화면

위 시나리오로 이동했을 때 찜한 내용들이 갱신이 되어있지 않는 상태이다.
이는 pull to Refresh만 적용시켜 사용자가 직접 새로고침을 할 수 있게 유도해도 충분하다고 생각되었다.

코드로 적용해보기

현재 기술스택은 Compose, Flow를 쓰고 있다.
그러면 고려해야 할 점은?

위 화면에서 Bookmark를 어떻게 Flow로 구현할 수 있을까?

기존 아래와 같은 PolicyUiModel을 collect하여 view를 변경하고 있었다.

data class PolicyUiModel(
	val id: String,
    val isBookmarked: Boolean,
    ...
)
class PolicyViewModel: ViewModel() {
	val _policyUiModel = mutableStateFlow<PolicyUiModel>(PolicyUiModel(...))
    val policyUiModel = _policyUiModel.asStateFlow()
}

Optimistic UI의 핵심은 찜하기 클릭시에 isBookmarked를 선반영한다. 그리고 API 호출 결과에 따라 그대로 두거나 되돌리는 작업을 한다.

그러면 위 데이터와 별도로, API를 호출 및 취소될 결과 반영을 위한 flow가 있어야겠다.

여기서는 추가적인 조건이 있다.

같은 아이디에 대해서 네트워크 요청은 debounce를 적용해야한다.

이를 위해 flow는 각 정책 ID와 찜하기 여부를 가지고 있어야 할 것이다.

핵심 로직은 아래와 같다
1. bookmark 버튼 클릭
2. State를 통한 UI 변경
3. api 발생을 위한 flow 값 방출
4. 방출한 값 ID별로 debounce 적용
5. 방출된 값에 대해서 api 호출
6. 실패시 이전 데이터로 롤백

ID별로 debounce 하는 방법

확장함수 groupBy를 통해 id별로 flow로 분류한 후 각 flow에 대해서 debounce를 붙여주면 될 것이다.

data class BookmarkInfo(
    val id: String,
    val isBookmarked: Boolean,
)

class PolicyViewModel: ViewModel() {
// 생략...

private val debounceFlow =
        MutableSharedFlow<BookmarkInfo>()
// 생략...
}

북마크 이벤트를 받을 debounce를 만들어주고 확장함수를 만들었다. main()를 통해서 적용예시를 만들어보았다.

suspend fun main() {
    val source: Flow<BookmarkInfo> = flow {
        emit(BookmarkInfo("0", false))
        emit(BookmarkInfo("0", false))
        emit(BookmarkInfo("1", true))
        emit(BookmarkInfo("0", true))
        delay(400L)
        emit(BookmarkInfo("0", false))

    }

    source.groupBy { it.id }
        .flatMapMerge { it.second.debounceFlow(300L) }
        .collect { value ->
            println(value.id + " is " + value.isBookmarked)
        }


}

fun <T, K> Flow<T>.groupBy(getKey: (T) -> K): Flow<Pair<K, Flow<T>>> = flow {
    val storage = mutableMapOf<K, SendChannel<T>>()
    try {
        collect { t ->
            val key = getKey(t)
            storage.getOrPut(key) {
                Channel<T>(32).also { emit(key to it.consumeAsFlow()) }
            }.send(t)
        }
    } finally {
        storage.values.forEach { chan -> chan.close() }
    }
}

실패시 어떻게 롤백할 것인가?

정말 만약에 실패할 경우에는 이전 데이터로 돌려 놓아야 할 것이다.

그러면 마지막으로 API 호출을 성공한 데이터가 정합성에 맞다고 생각되었다.
그래서

private val lastByWhetherSuccessOfBookmarks = mutableMapOf<String, Boolean>()

위와 같은 맵을 두어서 해당상황에 데이터를 갱신 시켰다.

  • policy 리스트를 불러올 때
  • 북마크 서버 통신 성공시

그리고 서버 요청 실패시에 이 데이터를 사용하도록 했다.

최종 코드

viewModel.bookmark()

fun bookmark(id: String, isChecked: Boolean) {
        _bookmarkedPolicies.update { state ->
            (state as? BookmarkedPolicyUIState.Success)?.let { successState ->
                successState.copy(
                    youthPolicies = successState.youthPolicies.map { policy ->
                        policy.takeIf { it.id == id }?.copy(isBookmarked = isChecked) ?: policy
                    },
                )
            } ?: state
        }
        viewModelScope.launch {
            debounceFlow.emit(BookmarkInfo(id, isChecked))
        }
    }

viewModel.init 블럭

debounceFlow.groupBy { it.id }
                .flatMapMerge { data ->
                    data.second.debounce(300L)
                }.collect { bookmarkInfo ->
                    bookmarkPolicyUseCase(
                        bookmarkInfo.id, bookmarkInfo.isBookmarked,
                        onError = {
                            _bookmarkedPolicies.update { state ->
                                (state as? BookmarkedPolicyUIState.Success)?.let { successState ->
                                    successState.copy(
                                        youthPolicies = successState.youthPolicies.map { policy ->
                                            policy.takeIf { it.id == bookmarkInfo.id }
                                                ?.copy(isBookmarked = lastByWhetherSuccessOfBookmarks[policy.id]?: !policy.isBookmarked)
                                                ?: policy
                                        },
                                    )
                                } ?: state
                            }
                            _uiEvent.send(PolicyBookmarkUiEvent.BookmarkFailure)
                        },
                    ).collect {
                        lastByWhetherSuccessOfBookmarks[it.id] = it.isBookmarked
                        if (it.isBookmarked) {
                            _uiEvent.send(PolicyBookmarkUiEvent.BookmarkSuccess)
                        } else {
                            _uiEvent.send(PolicyBookmarkUiEvent.UnBookmarkSuccess)
                        }

                    }
                }

결과

후기

슈뢰딩거의 고양이같은 로직이다.

개발자 입장에서 사용자가 버튼을 눌렀을 때 찜하기 되었는지 안되었는지 상태를 알 수 없으므로 case by case를 일일이 정해주는게 복잡했다.

UX를 중요시하는 서비스나, 어느정도 서비스가 완성되고 사용자 경험을 끌어올리는 상황이라면 충분히 사용을 고려할만한 UX라고 생각된다.

이제 리팩토링 해야지..

작동 테스트 해볼려면?

0개의 댓글