ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Flutter] 무한스크롤 성능 최적화를 해보자 feat. Lazy Loading, Throttle #3
    Flutter/project 2024. 2. 13. 23:57

    *이 글의 페이지네이션 로직은 코드팩토리님의 강의 내용을 응용하여 제작되었습니다.

     

    이전 글에서 계속 이어집니다. 필요하시면 무한스크롤 1편, 2편을 참고해주세요
     

    모바일 환경에서 무한스크롤을 사용하는 이유가 뭘까? feat. cursor based pagination #1

    유저에게 보여줘야할 데이터 목록이 많은 경우, 한번에 모든 데이터를 보여주는 것은 DB 조회비용도 많이 들 뿐더러서버 및 클라이언트에 부하를 일으켜 성능저하로 이어집니다. 그렇기때문에

    nomal-dev.tistory.com

     

    [Flutter] 무한 스크롤 구현해보기 feat. cursor based pagination #2

    *이 글의 페이지네이션 로직은 코드팩토리님의 강의 내용을 응용하여 제작되었습니다. 모바일 환경에서 무한스크롤을 사용하는 이유가 뭘까? feat. cursor based pagination #1 유저에게 보여줘야할 데

    nomal-dev.tistory.com

     

     

    클라이언트에서 무한스크롤 적용할때, 어떻게하면 유저 입장에서 좀 더 빠릿한 느낌을 줄 수 있을까요?

    우리는 보통 로딩 시간이 적을수록 빠르다는 인상을 받습니다. 그렇다고 api가 실행되고 네트워크 통신시 소요되는 시간을 클라이언트에서 관리할 순 없는 노릇입니다.

     

    실제 로딩 속도를 줄일 수 없다면 클라이언트에서 할수있는 일은 불필요한 api 호출을 줄이고 데이터를 미리 받아와 로딩이 빠르다는 인상을 줄수있도록 하는 것입니다. 이렇게 함으로써 유저에게 더 나은 사용자 경험을 제공 할 수 있습니다.

     

     

    ✓ 지연 로딩(Lazy Loading)

    무한 스크롤에서 유저에게 좀 더 빠르게 데이터를 전달 할 수 있는 방법은 무엇일까요? 바로 스크롤의 끝에 도달하기 전에 다음 데이터를 미리 불러오는 것입니다.

     

     

    예를들어 유튜브 콘텐츠를 불러올때 데이터를 3개씩 보여주고 3개의 데이터를 모두 보여줬을때 쯤에 다음 데이터를 불러온다고 가정하겠습니다. 만약 로딩시 2초가 걸린다고 한다면 유저는 다음 데이터를 불러올때까지 2초간 Loading Indicator를 보면서 기다려야 할 겁니다.

    유저입장에서는 2초 기다림이 굉장히 답답하다고 느낄 수 있습니다.

     

     

    그렇기때문에 많은 앱에서는 유저의 스크롤이 현재 컨텐츠들의 끝에 도달하기전에 미리 다음 데이터를 불러오도록 설계합니다.

     

    flutter에서는 scroller의 위치를 알수있는 방법이 있습니다. 이걸 활용하면 fetchMore 요청을 위와같이 현재 스크롤의 몇 픽셀 전에 API를 요청하도록 구성할 수 있습니다. 바로 코드와 함께 알아보도록 하겠습니다.

     

     

    📌 Code

    아래 코드들은 이전 글에서 이어집니다.

     

    ✍🏼 PaginationUtils class

    class PaginationUtils {
      static void paginate({
        required ScrollController controller,
        required PaginationController provider,
      }) {
        if (controller.offset > controller.position.maxScrollExtent - 300) {
          provider.paginate(fetchMore: true);
        }
      }
    }

     

    class PaginationUtils의 paginate() 메서드는 현재 ScrollController의 길이를 측정하고 전체길이의 '-300px'에 도달했을 시 PaginationControllerpaginate()fetchMoretrue로 바꿔 실행하는 역할을 합니다.

     

    🤷🏻‍♂️ "적절한 api호출 타이밍은 어떻게 설정해야하나요?"

     

    List의 card 크기나 로딩 시간 등을 고려해야합니다. 그렇기때문에 절대적인 기준은 없으며 수치를 조금씩 변경시키면서 맞춰가는 수 밖에 없습니다. 저의 경우는 가장 하단에서 300px정도 전에 호출하는것이 가장 자연스러워서 이대로 진행했습니다.

     

     

    ✍🏼 StorePaginationListView + Lazy-Loading

    typedef StorePaginationWidgetBuilder<T extends IModelWithId> = Widget Function(
        BuildContext context, int index, T model);
    
    class StorePaginationListView<T extends IModelWithId>
        extends ConsumerStatefulWidget {
      final StateNotifierProvider<PaginationController, PaginationBase> provider;
      final StorePaginationWidgetBuilder<T> itemBuilder;
      final TabController tabController;
      const StorePaginationListView({
        required this.provider,
        required this.itemBuilder,
        required this.tabController,
        super.key,
      });
    
      @override
      ConsumerState<StorePaginationListView> createState() =>
          _StorePaginationListViewState<T>();
    }
    
    class _StorePaginationListViewState<T extends IModelWithId>
        extends ConsumerState<StorePaginationListView> {
      final ScrollController controller = ScrollController();
      @override
      void initState() {
        ref
            .read(activeLocationForStoreListControllerProvider.notifier)
            .locationBasePaginate();
        super.initState();
        // 스크롤이 될때마다 이벤트를 감지하고 listener를 실행한다.
        controller.addListener(listener);
      }
      
      void listener() {
        PaginationUtils.paginate(
          controller: controller,
          provider: ref.read(widget.provider.notifier),
        );
      }
    
      @override
      void dispose() {
        controller.removeListener(listener);
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        final state = ref.watch(widget.provider);
    
        return CustomRefreshIndicator(
          onRefresh: () async => await ref.read(widget.provider.notifier).paginate(
                forceRefetch: true,
              ),
          child: CustomScrollView(
            physics: const BouncingScrollPhysics(
                parent: AlwaysScrollableScrollPhysics()),
            controller: controller,
            slivers: [
              StoreListTabBarWidget(
                tabController: widget.tabController,
              ),
              if (state is PaginationLoading) const CustomLoadingCircleWidget(),
              if (state is PaginationNothing)
                StoreNothingWidget(
                  errorMapper: state.mapper,
                ),
              if (state is PaginationError)
                SliverToBoxAdapter(
                  child: CustomErrorWidget(
                    errorMapper: state.mapper,
                    errorFun: () async {
                      return await ref
                          .read(widget.provider.notifier)
                          .paginate(forceRefetch: true);
                    },
                  ),
                ),
              if (state is Pagination<T>)
                SliverPadding(
                  padding: const EdgeInsets.symmetric(horizontal: 16.0),
                  sliver: SliverList(
                    delegate: SliverChildBuilderDelegate(
                      (BuildContext context, int index) {
                        if (index == state.data.length) {
                          if (state is PaginationFetchingMore) {
                            return Padding(
                              padding: const EdgeInsets.symmetric(
                                horizontal: 16.0,
                                vertical: 8.0,
                              ),
                              child: Center(
                                child: state is PaginationFetchingMore
                                    ? const CircularProgressIndicator(
                                        color: AchromaticColors
                                            .IMPACT_COLOR_LIGHT_GRAY,
                                      )
                                    : null,
                              ),
                            );
                          } else {
                            return null; 
                          }
                        }
    
                        final pItem = state.data[index];
                        return widget.itemBuilder(
                          context,
                          index,
                          pItem,
                        );
                      },
                      childCount: state.data.length + 1,
                    ),
                  ),
                ),
            ],
          ),
        );
      }
    }

     

     

    주목해야할 코드는 다음과 같습니다.

    • void listener() {
          PaginationUtils.paginate(
            controller: controller,
            provider: ref.read(widget.provider.notifier),
          );
        }
      •   PaginationUtils.paginate()를 실행할 함수를 만들어 놓는다.
    •   void initState() {
          ref
              .read(activeLocationForStoreListControllerProvider.notifier)
              .locationBasePaginate();
          super.initState();
          controller.addListener(listener);
        }
      • 스크롤 이벤트를 감지하고 특정 임계값에 도달했을때 listener를 실행한다.

     

    ✓ 중복요청 해결

    앞서 현재 스크롤 길이-300px일때 fetchMore 요청을 넣는 로직을 만들었습니다. 이렇게 스크롤의 길이를 측정해서 다음 데이터를 가져오는 무한스크롤에선 중복요청이 일어날 수 있습니다. 아래의 시나리오를 보겠습니다.

     

    ⭐️ 유저가 굉장히 빠른 속도로 스크롤하고 있는 상황

    1. listner()가 실행되고 api 요청을 넣는다.
    2. 서버로부터 데이터를 반환을 받는다.
    3. 새로운 데이터가 view에 반영되기전 찰나의 순간에 listner가 실행되고 또다시 같은 요청이 들어간다.

    이렇게 이전 요청이 아직 완료되기도 전에 새로운 요청으로 인해 같은 요청이 중복으로 발생 할 수 있습니다. 물론 중복으로 요청한다고 해서 view에서는 문제가 발생하지않습니다. 하지만 불필요한 요청을 하는 것 자체가 리소스 낭비이므로 해결할 필요성이 있습니다.

    이럴때 우리는 throttle의 적용을 고려할 수 있습니다.

     

     

    📌 Throttle

    Throttle 이벤트를 제어하는 방법중 하나이며, 첫 번째 API 요청 이후에 지정된 시간 동안 동일한 요청이 발생하면 번째 요청 이외의 나머지 요청을 무시합니다.

     

    예를들어 그림과 같이 throttle을 3초로 설정한다면 첫 요청이 발생한 순간부터 3초 이내에 발생하는 동일한 api 호출의 중복된 요청은 모두 무시해서 결국 처음 요청만 실행되게 됩니다.

     

    찰나의 순간에 두번째 요청이 발생될 수 있는 무한 스크롤에 적용하기 적합한 방법입니다.

    이를 사용하면 서버 및 클라이언트의 부하를 줄이고 더 나은 사용자 경험을 제공할 수 있습니다. 

     

    ✍🏼 PaginationController + Throttle

     

    debounce_throttle package - All Versions

    Pub is the package manager for the Dart programming language, containing reusable libraries & packages for Flutter and general Dart programs.

    pub.dev

    class PaginationInfo {
      final int fetchCount;
      final bool fetchMore;
      final bool forceRefetch;
      final String firstCursor;
      PaginationInfo({
        this.fetchCount = 20,
        this.fetchMore = false,
        this.forceRefetch = false,
        this.firstCursor = '0000000000000000',
      });
    }
    
    class PaginationController<T extends IModelWithId,
        U extends IBasePaginationService<T>> extends StateNotifier<PaginationBase> {
      final U service;
      final PaginationType type;
      final paginationThrottle = Throttle(
        const Duration(microseconds: 100),
        initialValue: PaginationInfo(),
        checkEquality: false,
      );
    
      PaginationController({
        required this.service,
        required this.type,
      }) : super(PaginationLoading()) {
        paginate();
        paginationThrottle.values.listen((state) {
          throttlePagination(state);
        });
      }
    
      Future<void> paginate({
        int fetchCount = 20,
        // 추가 데이터 가져오기 true: ++데이터, false: 새로고침(현재상태를 덮어씌움)
        bool fetchMore = false,
        // 강제 리로딩
        bool forceRefetch = false,
        String firstCursor = '0000000000000000',
      }) async {
        paginationThrottle.setValue(PaginationInfo(
          fetchCount: fetchCount,
          fetchMore: fetchMore,
          firstCursor: firstCursor,
          forceRefetch: forceRefetch,
        ));
      }
    
      throttlePagination(PaginationInfo info) async {
        int fetchCount = info.fetchCount;
        bool fetchMore = info.fetchMore;
        bool forceRefetch = info.forceRefetch;
        String firstCursor = info.firstCursor;
        if (state is Pagination && !forceRefetch) {
          final pState = state as Pagination;
          if (!pState.meta.hasNextData) {
            return;
          }
        }
    
        final isLoading = state is PaginationLoading;
        final isRefetching = state is PaginationRefetching;
        final isFetchingMore = state is PaginationFetchingMore;
    
        if (fetchMore && (isLoading || isRefetching || isFetchingMore)) {
          return;
        }
    
        if (type == PaginationType.storeDist ||
            type == PaginationType.point ||
            type == PaginationType.notice ||
            type == PaginationType.myReview) {
          firstCursor = '0000000000000000';
        } else {
          firstCursor = '9999999999999999';
        }
        PaginationParams? paginationParams = PaginationParams(
          take: fetchCount,
          customCursor: firstCursor,
        );
    
        if (fetchMore) {
          final pState = state as Pagination<T>;
          state = PaginationFetchingMore(
            meta: pState.meta,
            data: pState.data,
          );
          paginationParams = paginationParams.copyWith(
            customCursor: pState.meta.customCursor,
          );
        } else {
          if (state is Pagination && !forceRefetch) {
            final pState = state as Pagination<T>;
            state = PaginationRefetching<T>(
              data: pState.data,
              meta: pState.meta,
            );
          } else {
            state = PaginationLoading(); 
          }
        }
    
        final result =
            await service.paginate(type: type, paginationParams: paginationParams);
        final value = switch (result) {
          Success(value: final value) => value,
          Failure(exception: final e) => e,
        };
    
        if (value is CustomExceptions) {
          value.maybeWhen(
            orElse: () {
              state = PaginationError(mapper: ErrorMapper());
            },
    
            notingStore: (message) {
              state = PaginationNothing(
                  mapper: ErrorMapper(
                errorIllust: ImagePathIllust.illust09,
                message: '현재위치에서 영업중인 매장이 없어요',
              ));
              if (type == PaginationType.favorite) {
                state = PaginationNothing(
                    mapper: ErrorMapper(
                  errorIllust: ImagePathIllust.illust12,
                  message: '아직 관심매장이 없어요',
                ));
              }
            },
          );
        }
    
        if (state is PaginationFetchingMore) {
          final pState = state as PaginationFetchingMore<T>;
          // 기존데이터에 새로운 데이터 추가
          if (value is Pagination<T>) {
            state = value.copyWith(data: [
              ...pState.data,
              ...value.data,
            ]);
          }
        } else {
          if (value is Pagination<T>) {
            if (value.data.isEmpty) {
              if (type == PaginationType.history) {
                state = PaginationNothing(
                    mapper: ErrorMapper(
                        errorIllust: ImagePathIllust.history_empty_illust,
                        message: '아직 주문내역이 없어요'));
              }
              if (type == PaginationType.myReview) {
                state = PaginationNothing(
                    mapper: ErrorMapper(
                        errorIllust: ImagePathIllust.review_empty_illust,
                        message: '작성한 리뷰가 없어요'));
              }
              return;
            }
            print('페이지네이션 상태: $value');
            state = value;
          }
        }
      }
    }

     

    쓰로틀에 관련된 코드만 정리 해보겠습니다.

    • class PaginationInfo {
        final int fetchCount;
        final bool fetchMore;
        final bool forceRefetch;
        final String firstCursor;
        PaginationInfo({
          this.fetchCount = 20,
          this.fetchMore = false,
          this.forceRefetch = false,
          this.firstCursor = '0000000000000000',
        });
      }
      • Throttle()의 initialValue를 설정하기위해 만든 class
      • paginationThrottle.setValue()에도 사용되어야하는데 하나의 값만 넣을 수있기때문에 필요한 모든 값을 하나의 클래스에 선언하여 객체화하였음 
    •  final paginationThrottle = Throttle(
          const Duration(microseconds: 100),
          initialValue: PaginationInfo(),
          checkEquality: false,
        );
      • 쓰로틀에 관한 설정을 하는 부분
      • initialValue: 처음함수를 실행할때 사용될 초기값을 의미한다. 이전에 만든 PaginationInfo() 객체를 넣어두었음
      • checkEquality: 똑같은 값을 넣었을때 실행 유무를 정의하는 것입니다. paginate()는 연속적으로 실행되어야하므로 false를 넣어준다.
    •   PaginationController({
          required this.service,
          required this.type,
        }) : super(PaginationLoading()) {
          paginate();
          paginationThrottle.values.listen((state) {
            throttlePagination(state);
          });
        }
      •  paginate()을 통해 초기 데이터를 가져오고 이후부터는 paginationThrottle()이 실행된다.
    • paginationThrottle.setValue(PaginationInfo(
            fetchCount: fetchCount,
            fetchMore: fetchMore,
            firstCursor: firstCursor,
            forceRefetch: forceRefetch,
          ));
      • 넘겨받은 파라미터를 통해 쓰로틀이 적용된 페이지네이션 메서드가 실행되는 부분이다.

     

    ✍🏼  무한스크롤 최적화 적용 모습

     

    약 500개 정도의 데이터를 take=10으로 테스트한 모습입니다. 리스트의 가장 하단에 로딩인디케이터가 보이기도 전에 다음 데이터를 불러와서 유저입장에서 빠르다는 인식을 줄수있으며, Throttle을 도입해 불필요한 데이터요청을 하지않아 네트워크 통신 리소스를 아꼈습니다.

     


     

    페이지네이션에 관한 모든 이야기가 드디어 끝났습니다. 페이지네이션은 많이 사용되고 TabBarView와 같이 사용하면서 구현하기도 복잡했던 부분이라 정리하는게 좋겠다는 생각이 들어 글을 썼는데 생각보다 많은 분량으로 부득이하게 3단계로 나눠서 작성했습니다.

     

    이밖에도 페이지네이션을 최적화를 할수있는 수단으로는 상세 화면을 위한 데이터를 미리 캐싱을 하거나 가상화 리스트를 통해 화면에 보이는 항목만 렌더링 하는 등의 방법이 있겠지만 어떠한 도메인에도 기본적으로 적용 할 수 있는 최적화방법만 정리 해 보았습니다.

     

    적으면서도 과연 읽는 분들이 잘 읽힐지 의문이 들었지만 이런게 부족한 개발 실력때문이지않은가싶어 부끄러워졌던 집필 기간이였습니다.

    (마지막에 개선 사항들을 수치화 못한게 많이 아쉽습니다 🥲)

    부족하지만 긴글 읽어주셔서 감사합니다.

     

    이 글이 꼭 정답은 아닙니다. 잘못된 부분이나 부족한 부분을 알려주시면 학습 후 수정하겠습니다.
Designed by Tistory.