Masonry List에서 발생한 무한 로딩 이슈 - Debouncing으로 해결하기

Jessie·2024년 10월 18일

지난주 클라이언트 분께 전달드린 산출물에 대한 피드백을 받았다. "무한 로딩", "멈춤 현상"이라는 단어가 몇번 중복되어 눈에 띄었다. 특정 페이지에서 무한 로딩이 자꾸 발생하고, 앱이 멈춰 사용이 불편하다는 것이었다. 다른 마이너한 이슈들은 다음 산출물 전달일까지 고치면 되겠지만, 해당 이슈는 빨리 처리를 해야할 것 같아 코드를 열어보았다.

문제 상황

새로 추가한 '검색 페이지'에서 이슈가 발생하였다. 검색 페이지로 이동하여 검색창에 키워드를 입력하고, 결과에 대한 첫 10개의 데이터를 받기까지는 문제가 없다. 하지만 스크롤을 조금만이라도 내리면 무한 로딩이 시작되었다.

문제 분석 과정

(1) 스크롤을 내릴때 (2) 로딩이 발생 - 이 두가지 상황을 보니 무한 스크롤을 구현하기 위해 사용한 useInfiniteQuery와 관련된 문제라는 생각이 들었다. 우선 useInfiniteQueryQueryFn 속성에 넣은 함수가 API 요청을 하고 있어 QueryFn 안에 로그를 찍었다. 그리고 다음 페이지를 요청하거나 리로딩을 할 때도 QueryFn이 호출되니 fetchNextPagerefetch가 호출되는 부분에도 로그를 찍었다.

초기 데이터를 가져오거나, 쿼리키가 바뀌어 데이터가 불러와지는 상황은 전혀 문제가 없었다. 리로딩을 하는 상황도 마찬가지로 API가 한번만 호출되었다. 문제는 fetchNextPage였다. 스크롤을 움직일때마다 무수히 많은 스크롤이벤트가 발생하며 QueryFn을 호출하고있었다.

무한 스크롤을 사용하는 페이지가 여러개 있었는데, 다른 페이지에서는 해당 문제가 전혀 없었다. 왜 이 페이지에서만 문제가 생겼을까 하고 차이점을 찾아보니 다른 페이지들은 <FlatList />를 사용하고 있었고, 문제가 발생한 페이지에서는 <MasonryList /> 라는 태그를 사용하고 있었다.

FlatList vs Masonry List

Masonry List란 Masonry 레이아웃을 구현할 수 있도록 해주는 커스텀 컴포넌트를 말한다. 일종의 그리드 시스템인데, 일반적인 그리드 시스템을 사용하는 FlatList와는 차이가 있다.

  • FlatList: 일정한 그리드 레이아웃을 사용한다. 모든 아이템이 동일한 높이와 너비를 가지기 때문에 아이템의 크기가 다를 경우 높이와 너비가 맞춰지며 빈 공간이 생길 수 있다.
  • Masonry List: 아이템의 각 열이 서로 다른 높이로 자연스럽게 정렬된다. 아이템이 동일하지 않은 높이를 가져도 각 열의 높이를 유동적으로 맞춰주어 빈 공간이 생기지 않는다.

열의 높이가 정해지는 방식은 다르다는 점만 빼면 목록을 보여줄때 사용된다는 쓰임새가 비슷하기도 하고, 내가 사용한 react-native-masonry-list라는 라이브러리를 보면 태그의 속성이 FlatList와 완전히 동일하다. 그래서 당연히 Masonry List가 FlatList를 확장해서 만든것이라고 생각했던 것이 나의 큰 실수였다.

// 다른 페이지
<FlatList
    data={data}
    keyExtractor={item => item.id}
    numColumns={2}
    renderItem={renderItem}
    onEndReachedThreshold={0.5}
    onEndReached={() => handleFetchNextPage()}
/>

// 검색 페이지
<MasonryList
    data={data}
    keyExtractor={item => item.id}
    numColumns={2}
    renderItem={renderItem}
    onEndReachedThreshold={0.5}
    onEndReached={() => handleFetchNextPage()}
/>

이렇게 비슷하게 생겼어도, 각각의 리스트가 스크롤을 감지하는 방법은 너무나도 달랐다.

FlatList는 단순한 그리드 레이아웃을 사용하기 때문에 스크롤 뷰의 전체 높이와 현재 스크롤 위치를 비교하는 방식으로 스크롤의 끝에 도달했는지를 판단한다. 이 방식은 상대적으로 단순하기 때문에 위치를 정확하게 감지할 수 있다. 그리고 FlatList는 onEndReached 이벤트를 한 번만 트리거하도록 설계되어서 스크롤 위치가 다시 바뀌지 않는 한 함수를 추가로 호출하지 않는다. 이러한 설계가 직관적인 계산 방식과 맞물려 스크롤이 마지막에 도달했을때 함수를 중복없이 한번만 호출하게 된다.

Masonry List는 비대칭 그리드 레이아웃을 사용하기 때문에 스크롤 계산 방식이 훨씬 더 복잡하다. 열마다 높이가 다르기 때문에 각 열의 끝을 계산하는 과정이 FlatList보다는 덜 정확하고, 스크롤 뷰의 크기가 일정하지 않아 스크롤 끝에 도달했다고 하는 판단하는 시점이 매번 달라지기 때문에 onEndReached가 중복되어 호출될 가능성이 높아진다. 또, 한 열은 끝에 도달했는데 다른 열은 끝에 도달하지 않는 상황도 생길 수 있는데, 이런 FlatList에서는 일어나지 않을 예외적인 상황에서도 중복 호출이 될 수 있다.

react-native-masonry-list 라이브러리의 깃허브 페이지에는 onEndReached 속성이 FlatList 방식과 똑같이 동작한다고 했지만, 나의 케이스는 어찌되었든 예외 상황이었다. 어떤 방식으로 스크롤 위치를 감지하는지 정확히는 모르겠지만 여러 상황을 종합해봤을때 FlatList의 속성을 상속받거나 작동 원리가 완전하게 동일하지는 않은 것 같아 그냥 안전 장치를 추가하기로 했다.

문제 해결: Debouncing 적용

스크롤 이벤트가 여러번 발생해도 API 호출을 딱 한번만 하도록 하기 위해 Debouncing을 적용하기로 했다. 스크롤 이벤트가 계속 발생하다가 마지막 이벤트가 발생한 뒤 일정 시간이 지나면 API 호출이 발생할 것이다. 스크롤에 손가락을 얹고 스크롤을 떼기까지 마지막 이벤트 단 한번만 호출이 되는 것이다. 따라서 사용자 입장에서는 FlatList와 큰 차이를 못 느끼게 된다.

lodash 라이브러리를 사용할수도 있었겠지만, 한번 사용하자고 라이브러리를 설치하기는 싫어서 그냥 간단하게 함수로 구현했다.

function debounce(func: Function, delay: number) {
    let timeout: NodeJS.Timeout;
    
    return (...args: any[]) => {
        if (timeout) clearTimeout(timeout);  // 기존 타이머를 제거
        timeout = setTimeout(() => {         // 새로운 타이머 시작
            func(...args);                   // 지연 시간이 지나면 원래 함수 호출
        }, delay);
    };
}
  • timeout 변수에는 setTimeout으로 반환된 타이머 Id를 저장. 이 값은 기존의 타이머를 취소하거나 새로 시작할 때 사용.
  • 이미 timeout 변수에 할당된 값이 있으면 해당 Id값을 사용하여 기존 타이머를 제거
  • 기존 타이머 제거 후 새로운 타이머 시작
  • 만약 지정한 시간동안 debounce 함수가 호출되지 않는다면 콜백함수를 실행
const handleFetchNextPage = useCallback(
    debounce(() => {
        if (!hasNextPage) return;

        if (!isFetchingNextPage) {
            fetchNextPage();
        }
    }, 200), [isFetchingNextPage, fetchNextPage, hasNextPage],
);

문제가 되는 fetchNextPage() 함수를 감싸주었다. 그리고 혹시라도 페이지의 다른 상태들이 변경되면서 리렌더링이 발생하여 함수가 재생성되면 타이머가 꼬일 수 있기 때문에 useCallback을 사용하였다. 최신 상태가 반영되어야 하는 isFetchingNextPage, fetchNextPage, hasNextPage 값만 의존성 배열에 넣어주었다.


처음 피드백을 받았을때는 살짝 당황했었는데, 생각보다 금방 문제를 해결해서 다행이었다. 잘 몰랐던 Masonry List에 대한 개념도 알게되었고, 아무생각없이 UI 라이브러리를 사용하는 것에 대해 경각심을 다시 깨우친 계기가 되었다. UI와 관련된 라이브러리는 지원이 멈출 경우 호환성 문제도 있고, 내가 직접 원하는대로 커스텀하기에는 한계가 있어 사용을 최대한 안하려고 한다. 하지만 직접 구현하지 못하는 부분이 분명 있기에 사용할때 더 잘 알아보고 사용하도록 노력해야겠다.

profile
주니어 프론트엔드 개발자입니다 😎

0개의 댓글