
찜하기 or 좋아요 기능을 구현할 때, API 호출 완료 후 UI를 갱신해야 할까?
또 그러면 찜하기 버튼은 화면 갱신이 될 때까지 block 되어야 할까?
기존 찜하기 버튼을 누를 때, API 결과가 완료될 때까지 화면 갱신 못하는 경험을 하게 될 수 있었다. 최악의 경우 설정한 timeout(현재 30초) 시간까지 사용자는 버튼 인터렉션 결과를 기다려야 하는 상황이었다. 이는 해당 액션의 이탈로 이어질 수 있다고 판단으로 이어졌다.
다른 어플로 레퍼런스를 찾아보았을 때, 인스타, 유튜브 등 좋아요를 누르면 즉시 반영되곤 하였다.
지나칠 수 없는 요소였다. 사용자 인터렉션이 실시간성으로 반영되어 사용자가 기다림 없는 UX가 되었으면 했다. 그래서 디자이너 분의 지지를 받아, 이번 청하 프로젝트에 적용 및 과정을 기록하고자 한다.
지금 찜하기 기능은 여러곳에 있지만, 홈 화면 기준으로 작성해 보려 한다.
청하 서비스의 홈 화면과 찜하기 후에 이벤트 변화이다.
| 홈 화면 | 찜하기 후 |
|---|---|
![]() | ! |
하트모양(찜하기) 버튼을 누르면 하단 스낵바가 발생하고, 목록 보러가기를 하면 찜한 목록 화면으로 이동한다.
하트 버튼을 클릭과 동시에 색상 변경 + 스낵바를 발생시킨다. 그리고 서버 요청 진행하는 방식. 실패하면 실패 스낵바 발생과 하트 색상을 원복한다.
색상 변경시, 스낵바를 발생시키는데는 문제가 없어보인다. 이게 낙관적인 UI지 하고 이후 시나리오를 구상하고 있었는데, 큰 허점이 있었다.
스낵바의 관심목록 화면으로 이동했을 때, 찜하기 API 요청이 제대로 안되었다면?
일관되지 않는 UI를 마주할 수 있을 것이다.
내부 데이터베이스 캐싱을 적절히 활용하면, 어느정도 해결이 가능해보였다. 하지만 배보다 배꼽이 너무 커지는 상황이라 이 방법은 배제하는 것으로 결정했다.
하트 버튼을 클릭할 때 색상은 즉시 변경, 스낵바는 서버통신 후 발생하는 방식, 실패시는 Senario 1과 동일
스낵바만 발생을 안시켜도 우리가 안내한 찜한 목록 화면과 싱크가 안맞는 경험을 줄일 수 있을 것이다.
바텀바에 중첩 네비게이션이 적용되어 있어 찜한 목록 화면과 홈 화면를 동시에 인스턴스가 살아 있을 수 있다.
내가 찜한 정책 화면 -> 홈 화면으로 이동 및 찜하기 클릭 후 -> 내가 찜한 정책 화면
위 시나리오로 이동했을 때 찜한 내용들이 갱신이 되어있지 않는 상태이다.
이는 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. 실패시 이전 데이터로 롤백
확장함수 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>()
위와 같은 맵을 두어서 해당상황에 데이터를 갱신 시켰다.
그리고 서버 요청 실패시에 이 데이터를 사용하도록 했다.
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))
}
}
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라고 생각된다.
이제 리팩토링 해야지..