App 개발 일지/Nutri Capture

Nutri Capture 프론트엔드 - 무한 스크롤 로직 리팩토링

interfacer_han 2024. 11. 16. 01:34

#1 문제

#1-1 문제 상황

정렬된 아이템이기에, 각 아이템의 시각은 위에서 아래로 갈수록 작아져야한다. 그러나, mealId 41인 아이템과 mealId 50인 아이템을 보면 이상한 점이 보인다. 바로 mealId 50인 아이템의 시각은 18:01:14...로, 17:09:03...인 mealId 50보다 큰 것이다.

 

#1-2 문제 코드

...

@Composable
fun NutrientScreen(
    ...
) {
    LaunchedEffect(key1 = true) {
        ...
    }

    LaunchedEffect(key1 = true) {
        ...
    }

    LaunchedEffect(key1 = viewModel.isInitialized.value) {
        if (viewModel.isInitialized.value) {
            // 무한 스크롤
            snapshotFlow { listState.layoutInfo.visibleItemsInfo }.collect { visibleItemsInfo ->
                val totalMaxIndex = listState.layoutInfo.totalItemsCount - 1
                val firstVisibleItemIndex = listState.firstVisibleItemIndex
                val visibleItemCount = visibleItemsInfo.size

                if (totalMaxIndex <= firstVisibleItemIndex + visibleItemCount) {
                    viewModel.onEvent(NutrientViewModelEvent.LoadMoreItemsAfterLastDayMeal)
                }
            }
        }
    }

    LazyColumn(
        ...
    ) {
        ...
    }
}

시행착오를 겪으며 알게된 문제의 원인은, viewModel.onEvent()가 동시다발적으로 실행되는 것이었다. viewModel.onEvent()는, NutrientViewModelEvent.LoadMoreItemsAfterLastDayMeal를 인수로 받으면 코루틴(viewModelScope)으로 데이터를 로드해 Item을 추가해준다. 코루틴은 비동기적으로 실행되기에, 데이터 로드 요청이 완료되기 전에 또 로드 요청이 들어온 경우 먼저번의 요청와 이후의 요청 간 (완료에 대한) 순차성이 보장되지 않는다. 그래서 View가 의도와 다른 이상한 화면을 출력했던 것이다.

 

해결 방법이 크게 2가지 떠오른다. 먼저, 데이터 로드 요청에 동기성을 부여하는 것이다. 그리고 또 하나는 viewModel.onEvent(NutrientViewModelEvent.LoadMoreItemsAfterLastDayMeal) 이하 '데이터 로드 요청'이 여러 번 수행되지 않게 막는 방법이다.

 

전자의 방법이 가장 확실해보이지만, 구현이 쉽지 않아 보인다. (내 생각일 뿐이지만) 코드의 가독성도 나빠질 것 같다. 따라서 우선 후자의 방법을 시도해본다. 다만, 무슨 짓을 해도 문제가 해결되지 않는다면 전자의 방법을 시도해야 할 것이다.

 

#2 해결

#2-1 원인: 너무 자주 변경되는 State

'데이터 로드 요청'이 연달아 유발되는 원인은, snapshotFlow { ... }에 listState.layoutInfo.visibleItemsInfo를 넣은 데에 있다. listState.layoutInfo.visibleItemsInfo는 LazyColumn에 보여지고 있는 아이템 정보로 사용자가 스크롤할 때마다 끊임없이 변한다. 실제로 snapshotFlow { listState.layoutInfo.visibleItemsInfo }.collect { visibleItemsInfo -> ... }의 ... 부분에 Log.i( ... )를 넣으면 어마어마한 양의 Log 메시지가 출력됨을 확인할 수 있다.

 

#2-2 snapshotFlow를 적용할 State 변경

// 무한 스크롤
snapshotFlow {
    val totalMaxIndex = listState.layoutInfo.totalItemsCount - 1
    val firstVisibleItemIndex = listState.firstVisibleItemIndex
    val visibleItemCount = listState.layoutInfo.visibleItemsInfo.size
    totalMaxIndex <= firstVisibleItemIndex + visibleItemCount
}.collect { shouldLoadMoreData ->
    if (shouldLoadMoreData) {
        viewModel.onEvent(NutrientViewModelEvent.LoadMoreItemsAfterLastDayMeal)
    }
}

위와 같이 코드를 변경한다. 이제, collect()가 이전보다 훨씬 덜 빈번히 호출될 것이다.

 

#2-3 찝찝함

#2-2의 변경 사항을 적용하면, #1-1에서 발생했던 문제 상황이 말끔히 해결된다. 하지만, 약간의 찝찝함이 남는다. 만약, shouldLoadMoreData가 연달아 true인 경우가 존재한다고 가정해보자. 그 경우 프로그래머는 '데이터 로드 요청'이 (본 게시글의 목적과는 반대로) 연달아 실행되기를 바라고 있을 테다. 하지만, shouldLoadMoreData가 변하지 않기에 그런 일은 일어나지 않을 것이다. 나는 이게 마음에 들지 않는다.

 

#2-4 Flow.combine() 활용

// 무한 스크롤
val shouldLoadMoreData = snapshotFlow {
    val totalMaxIndex = listState.layoutInfo.totalItemsCount - 1
    val firstVisibleItemIndex = listState.firstVisibleItemIndex
    val visibleItemCount = listState.layoutInfo.visibleItemsInfo.size
    totalMaxIndex <= firstVisibleItemIndex + visibleItemCount
}

val totalItemCount = snapshotFlow {
    listState.layoutInfo.totalItemsCount
}

val loadMoreData = combine(
    shouldLoadMoreData,
    totalItemCount
) { shouldLoadMoreDataValue, totalItemCountValue ->
    Pair(shouldLoadMoreDataValue, totalItemCountValue)
}

loadMoreData.collect { (shouldLoadMoreData, _) ->
    if (shouldLoadMoreData) {
        viewModel.onEvent(NutrientViewModelEvent.LoadMoreItemsAfterLastDayMeal)
    }
}

Flow.combine()을 이용해, #2-3의 찝찝함을 해소한다. 기존에는 위 코드에서 shouldLoadMoreData의 변화만을 감지해 collect()했지만, 이제는 totalItemCount의 변화까지 감지한다. shouldLoadMoreData가 true로 일정하게 유지되어도, totalItemCount가 변하면 '데이터 로드 요청'이 유발되는 것이다.

 

'데이터 로드 요청'이 이상적인 방향으로 연달아 실행되는 시나리오는, 먼저 번의 '데이터 로드 요청'이 완료되어 View에 Load한 데이터를 무사히 붙인 직후 다시 이후의  '데이터 로드 요청'이 실행되는 상황이다. 위 코드대로라면, totalItemCount는 '데이터 로드 요청'이 완료됨과 동시에 변화한다. 따라서 이전 '데이터 로드 요청'과 이후 '데이터 로드 요청'완료에 대한 순차성이 보장된다.

 

#3 요약

무한 스크롤 로직이 꼬이지 않게 리팩토링했다.

 

#4 완성된 앱

#4-1 스크린샷

위 화면 속의 아이템들 뿐만 아니라 모든 아이템에 대해 정렬이 잘 유지된다.

 

#4-2 이 게시글 시점의 Commit

 

GitHub - Kanmanemone/nutri-capture-new

Contribute to Kanmanemone/nutri-capture-new development by creating an account on GitHub.

github.com

 

#4-3 본 프로젝트의 가장 최신 Commit

 

GitHub - Kanmanemone/nutri-capture-new

Contribute to Kanmanemone/nutri-capture-new development by creating an account on GitHub.

github.com