Flutter로 Cursor Pagination 구현하기 - 1. 상태 모델 및 Provider 구현

Flutter에서 Cursor Base Pagination 구현하기

이전 글:

이전 글에서 페이지네이션의 개념에 대해서 알아보았고, 모바일에서는 주로 커서 기반 페이지네이션이 많이 사용된다는 것도 배웠습니다. 이제 직접 Flutter에서 커서 기반 페이지네이션을 구현해봅시다.

 

본 글은 코드팩토리 님의 플러터 중급 강의를 듣고 이를 저의 방식을 곁들여서 정리한 글입니다.

 

본 글은 페이지네이션을 일반화시킨 방법으로 적용할 것입니다. 그래서 모든 클래스와 메소드들이 굉장히 높은 차원으로 추상화되어 있습니다. 그렇기 때문에 왜 이렇게 되어 있는지 추적하기가 어려울 수 있습니다. 다음 글에서 이를 직접 각 모델에 맞추어서 적용함으로써 왜 이렇게 설계되었는지를 확인한다면, 좀 더 이해하기 쉬울 것입니다.

사용할 패키지

  • riverpod
    • 상태 관리 패키지입니다. GetX에 관한 글을 올린 적이 있었는데, riverpod은 이와는 비슷하지만 조금은 다른 방식으로 상태 관리를 수행합니다.
    • Provider의 확장판입니다. Provider의 개발자들이 새롭게 만든 상태 관리 패키지로, riverpod 또한 Provider 단어를 재조합한 애너그램(Anagram)입니다.
  • Dio
    • http를 위한 통신 패키지입니다
  • Retrofit
    • RestAPI 통신을 위한 패키지입니다. Dio와 궁합이 잘 맞습니다
  • freezed
    • Code Generation 관련 패키지로, 모델 클래스에 대해 fromJson, toJson이나 copyWith 등의 메소드를 제공합니다

이 글 자체에서는 freezed와 riverpod만 사용할 것이고, 다음 글부터 Dio랑 Retrofit을 사용해서 실제 Paginate Request를 보내볼 것입니다.

커서 페이지네이션 상태 모델

데이터 구조 정의하기

먼저 커서 페이지네이션을 만들기 위해서는, 페이지네이션을 할 데이터의 구조부터 정의해야 합니다.

 

이때 데이터를 추상화하여 재사용성을 높이기 위해서는, 데이터가 일관된 형태로 구조화되어야 합니다. 이는 클라이언트 단의 문제가 아니라, 서버 개발자들과도 긴밀히 이야기를 해서, 일관된 형태의 데이터 구조를 완성할 수 있도록 해야 합니다. 페이지네이션은 백엔드, 프론트엔드 어느 한 쪽의 문제가 아니라 다 같이 협력해야 하는 문제입니다.

 

API를 통해서 받아오는 데이터가 이러한 형태를 갖도록 설계합니다. (구조적인 형태를 보기 위해서 yaml 파일로 표현했을 뿐, 실제로 넘어오는 데이터 형태는 json입니다)

### RestaurantModel
meta: # 데이터의 메타 정보
  count: 20 # 데이터의 개수
  hasMore: true # 추가 데이터가 있는지
data:
  - id: "1952a209-7c26-4f50-bc65-086f6e64dbbd"
    name: "우라나라에서 가장 맛있는 짜장면집"
    thumbUrl: "/img/thumb.png"
    tags:
      - "신규"
      - "세일중"
    priceRange: "cheap"
    ratings: 4.89
    ratingsCount: 200
    deliveryTime: 20
    deliveryFee: 3000
### ProductModel
meta:
  count: 20
  hasMore: true
data:
  - id: "1952a209-7c26-4f50-bc65-086f6e64dbbd"
    restaurant:
      id: "1952a209-7c26-4f50-bc65-086f6e64dbbd"
      name: "우라나라에서 가장 맛있는 짜장면집"
      thumbUrl: "/img/thumb.png"
      tags:
        - "신규"
        - "세일중"
      priceRange: "cheap"
      ratings: 4.89
      ratingsCount: 200
      deliveryTime: 20
      deliveryFee: 3000
    name: "마라맛 코팩 떡볶이"
    imgUrl: "/img/img.png"
    detail: "서울에서 두번째로 맛있는 떡볶이집! 리뷰 이벤트 진행중~"
    price: 8000

위와 같은 데이터 모델에서, meta 컨테이너가 따로 있고 실제 정보가 들어있는 data 컨테이너가 따로 존재합니다. 즉 Pagination 모델에 관해서 무조건 아래의 형태가 보장됩니다.

meta:
    count:
    hasMore:
data:
    # ... 

이를 통해서 적절하게 페이지네이션 모델을 추상화할 수 있습니다.

페이지네이션 모델 정의하기

메타 모델 추가

import 'package:freezed_annotation/freezed_annotation.dart';

part 'cursor_pagination_meta.freezed.dart';
part 'cursor_pagination_meta.g.dart';

@freezed
class CursorPaginationMeta with _$CursorPaginationMeta {
  const factory CursorPaginationMeta({
    required int count,
    required bool hasMore,
  }) = _CursorPaginationMeta;

  factory CursorPaginationMeta.fromJson(Map<String, dynamic> json) =>
      _$CursorPaginationMetaFromJson(json);
}

먼저, 메타 정보를 담는 메타 모델의 경우는 이렇게 정의할 수 있습니다.

 

메타 정보의 경우는 데이터의 정보를 담는 count와 추가 정보가 있는지 여부인 hasMore 밖에 없습니다. 만약에 메타 정보에 추가적인 데이터가 담기는 경우, 이에 맞추어서 메타 정보를 수정하면 됩니다.

 

메타 정보는 따로 추상화를 할 필요는 없습니다.

CursorPaginationBase 모델 추가

여기에서 한 가지 중요한 트릭을 셋업하고 시작하겠습니다. 그것은 바로 CursorPaginationBase이라는 추상 클래스를 만드는 것입니다.

 

이 베이스 클래스를 만드는 이유는 나중에 더 확실히 알게 되는데, 커서 페이지네이션에서 페이지네이션 상태를 관리할 때, 개별 상태(로딩 중, 데이터 존재 등의 상태)를 클래스로 만들어서 관리할 예정입니다.

각 상태를 한 번에 묶는 베이스 클래스로써 이 CursorPaginationBase가 동작하게 됩니다.

abstract class CursorPaginationBase {}

대체 왜 이런 방식을 사용하는 것인지 이해가 안 된다면 당연합니다. 저도 처음에 ? 했어요. 그러나 이후에 이 방식의 정체를 알게 될 것입니다.

커서 페이지네이션 모델 추가

그리고서, 커서 페이지네이션 모델을 아래와 같이 정의합니다.

@JsonSerializable(genericArgumentFactories: true)
class CursorPagination<T> extends CursorPaginationBase {
  final CursorPaginationMeta meta;
  final List<T> data;

  CursorPagination({
    required this.meta,
    required this.data,
  });

  CursorPagination copyWith({
    CursorPaginationMeta? meta,
    List<T>? data,
  }) {
    return CursorPagination<T>(
      meta: meta ?? this.meta,
      data: data ?? this.data,
    );
  }

  factory CursorPagination.fromJson(
    Map<String, dynamic> json,
    T Function(Object? json) fromJsonT,
  ) =>
      _$CursorPaginationFromJson(json, fromJsonT);
}

이때, freezed는 공식적으로 상속을 지양하기에 여기서는 어쩔 수 없이 JsonSerializable을 사용해야 합니다. 그래서 copyWith 함수는 따로 정의를 해주어야 합니다.

 

이때 중요한 포인트는, 바로 데이터 모델을 추상화하였다는 점입니다!

class CursorPagination<T> extends CursorPaginationBase {
  final CursorPaginationMeta meta;
  final List<T> data;

  CursorPagination({
    required this.meta,
    required this.data,
  });
}

이 부분이 가장 중요합니다. data는 T 제네릭(템플릿) 타입 객체를 리스트 형태로 담고 있습니다. 또한 메타 정보 역시 담고 있습니다. 이는 앞에서 보았던 커서 페이지네이션 모델의 형태와 동일합니다.

 

따라서 T 값만 조정해주면, 모든 모델에 대해서 커서 페이지네이션을 적용할 수 있게 설계할 수 있습니다.

 

여기 T에는 ProductModel이 들어갈 수도, RestaurantModel이 들어갈 수도, RatingModel이 들어갈 수도 있습니다.

커서 페이지네이션의 상태 추가

스크롤 뷰 형태의 어플리케이션을 사용할 때를 생각해봅시다. 이때 어디에서 임계 조건이 발생하는 지를 잘 따져보아야 합니다.

  1. 처음 데이터를 요청할 때 로딩 중의 상태 : CursorPaginationLoading
  2. 새로고침을 하는 상태 : CursorPaginationRefetching
  3. 추가 데이터 요청 상태 : CursorPaginationFetchingMore
  4. 에러 상태 : CursorPaginationError
  5. 데이터가 있는 상태(일반 상태) : CursorPaginationBase

이 상태를 구분하는 이유는, 바로 캐시 관리를 위해서입니다.

 

가장 쉬운 5번은 이미 위에서 구현을 해놓았습니다(CursorPaginationBase)

맨 처음의 로딩 상태

먼저, 처음 데이터를 요청할 때는 가지고 있는 데이터가 없습니다. 따라서 아래와 같이 빈 깡통으로 구현합니다.

// 첫 로딩
class CursorPaginationLoading extends CursorPaginationBase {}

새로고침 상태

두 번째, 새로고침을 하는 상태입니다. 이때 주의해야 할 것은, 새로고침을 한다는 것은, 이미 데이터를 불러왔고, 여기에서 새로고침을 한다는 의미입니다. 따라서 새로고침 상태는 일종의 CursorPagination의 하위 상태로 봐야 합니다. 따라서 CursorPagination 을 상속받는 형태로 구현할 수 있습니다.

// 위로 당겨서 새로 고침할 때 사용
class CursorPaginationRefetching<T> extends CursorPagination<T> {
  CursorPaginationRefetching({
    required super.meta,
    required super.data,
  });
}

추가 데이터 요청 상태

세 번째, 추가 데이터를 요청하는 경우도 마찬가지입니다. 이때에도, 이미 기존의 데이터를 갖고 있는 상태이고, 이 역시 CursorPagination의 하위 상태로 간주합니다.

// 리스트의 맨 아래로 내려서 추가 데이터를 요청하는 상태
// CursorPaginationLoading은 데이터가 없기에, 사용할 수는 없음
class CursorPaginationFetchingMore<T> extends CursorPagination<T> {
  CursorPaginationFetchingMore({
    required super.meta,
    required super.data,
  });
}

참고로 CursorPaginationFetchingMore와 CursorPaginationLoading의 차이점은 무엇일까요? CursorPaginationFetchingMore의 경우 데이터를 추가 요청하는 상태로써, 이미 데이터를 갖고 있고, 유저가 화면 밑으로 스크롤을 다 해서 새로운 데이터를 요청하게 될 때의 상황입니다.

 

반면 CursorPaginationLoading은 맨 처음 시작할 때 로딩 중의 상태로써, 갖고 있는 데이터가 없습니다. 이러한 차이가 있습니다.

에러 상태

네 번째로, 에러가 발생한 상황이 있을 수 있습니다. 여기서는 에러가 발생할 시 에러 메시지를 갖고 있다고 가정해보겠습니다.

class CursorPaginationError extends CursorPaginationBase {
  final String message;

  CursorPaginationError({
    required this.message,
  });
}

실제로는 에러 메시지 외에, 에러 상태 코드 등을 추가로 가질 수 있으며, 이는 팀과 소통해서 정해야 하는 문제같습니다.

 

당연히 에러 발생 시 갖고 있는 데이터는 없어야 합니다. 그렇기 때문에 일반 CursorPaginationBase를 상속받습니다.

정리

정리하면 아래와 같이 상태를 표현할 수 있습니다.

// 첫 로딩
class CursorPaginationLoading extends CursorPaginationBase {}

// 위로 당겨서 새로 고침할 때 사용
class CursorPaginationRefetching<T> extends CursorPagination<T> {
  CursorPaginationRefetching({
    required super.meta,
    required super.data,
  });
}

// 리스트의 맨 아래로 내려서 추가 데이터를 요청하는 상태
// CursorPaginationLoading은 데이터가 없기에, 사용할 수는 없음
class CursorPaginationFetchingMore<T> extends CursorPagination<T> {
  CursorPaginationFetchingMore({
    required super.meta,
    required super.data,
  });
}

// 에러 발생 시 사용
class CursorPaginationError extends CursorPaginationBase {
  final String message;

  CursorPaginationError({
    required this.message,
  });
}

이렇게 함으로써, 우리는 커서 페이지네이션의 상태를 구분할 수 있게 되었습니다.

 

선술했듯이 이렇게 상태를 클래스로 구분하는 이유는, 바로 캐시 관리를 위해서입니다.

IModelWithId - 모델 인터페이스 정의하기

‘커서’ 페이지네이션에서 놓치지 말아야 하는 것이 하나 있습니다. 커서를 정의하기 위해서, 모든 데이터 모델에는 id라는 값이 존재한다는 것입니다(id를 식별자로 할 지는 프로젝트마다 다르며, 여기서는 그렇다고 가정). 그렇기 때문에 이 데이터 모델도 추상화가 가능합니다.

abstract interface class IModelWithId {
  final String id;

  IModelWithId({
    required this.id,
  });
}

이렇게 Id를 갖는 모델의 추상 인터페이스를 만들어놓으면, IModelWithId를 상속받는 모든 모델을 반드시 id를 갖는다는 것을 보장할 수 있습니다.

 

참고로 interface는 dart 3에 등장한 modifier로써, dart 2 버전대라면 interface를 없애도 무방합니다.

Repository

PaginationParams 모델 정의하기

@freezed
class PaginationParams with _$PaginationParams {
  const factory PaginationParams({
    String? after,
    int? count,
  }) = _PaginationParams;

  factory PaginationParams.fromJson(Map<String, dynamic> json) =>
      _$PaginationParamsFromJson(json);
}

PaginationParams는 클라이언트가 서버로 데이터를 요청할 때, 페이지네이션에 관련된 파라미터를 정의하는 모델입니다.

 

먼저, 이전 글에서 Cursor Pagination에서는 2가지 정보를 클라이언트가 서버에 보내야 한다고 설명했습니다.

  1. Cursor: 마지막 아이템의 식별자, 서버에서는 이 아이디 후부터의 데이터를 전송한다
  2. 갯수: Cursor 초과로부터 얼마나 많은 데이터를 가져올 지 개수

따라서 PaginationParams는 두 가지 필드, String? after, int? count 가 필요합니다. 두 필드가 모두 nullable로 선언되어 있는데, 이는 두 필드가 주어지지 않을 경우, Default 값으로 요청을 처리하기 위해서입니다.

 

이때는 클라이언트에서 디폴트 값을 정할 수도 있는데, 보통 서버에서 값들이 안 주어졌을 때 디폴트로 데이터를 보낼 수 있어야 합니다. 여기에서 서버는 after와 count가 주어지지 않을 경우 after는 첫 데이터, 그리고 count는 20으로 응답을 보냅니다.

기본 페이지네이션 레포지토리 인터페이스 - IBasePaginationRepository

abstract interface class IBasePaginationRepository<T extends IModelWithId> {
  Future<CursorPagination<T>> paginate({
    PaginationParams? paginationParams = const PaginationParams(),
  });
}

이제, 페이지네이션 레포지토리의 인터페이스를 생성합니다.

 

이 인터페이스는 IModelWithId 계열의 데이터를 페이지네이션하는 레포지토리를 완성합니다.

 

이 인터페이스는 paginate 라는 메소드를 갖고 있습니다. 따라서 이 인터페이스를 구현할 때 paginate 함수를 구현해야 합니다. paginate는 실제로 API에 요청을 보내서, CursorPagination<T extends IModelWithId> 를 반환합니다.

그리고 paginate 메소드는 PaginationParams라는 파라미터를 인수로 받습니다. 이 페이지네이션 파라미터를 실제 페이지네이션 요청 시 같이 인수로 넘기게끔 구현을 해야 합니다.

PaginationProvider

위에서 레포지토리 인터페이스를 만들었습니다. 이제는 이 인터페이스를 상속받는 레포지토리를 통해서, 실제 요청을 보내고 그 결과를 상태로 갖고 있을 Provider를 만들 차례입니다.

 

이 Provider는 실제로 값을 들고 있어야 합니다. 즉 CursorPaginationBase 상태를 들고 있는 Provider입니다.

천천히 가보겠습니다.

Provider 클래스 원형

class PaginationProvider<T extends IModelWithId,
        U extends IBasePaginationRepository<T>>
    extends StateNotifier<CursorPaginationBase>

PaginationProvider는, 두 개의 제네릭을 받습니다.

  • T: IModelWithId를 상속받는 제네릭입니다. 실제 데이터 모델이 들어갑니다(Restaurant, Product 등 페이지네이션되는 실제 데이터)
  • U: IBasePaginationRepository<T>를 상속받는 제네릭입니다. 따라서 U는 레포지토리가 됩니다. 또한, 레포지토리를 구현할 때, IModelWithId를 상속받는 제네릭을 또한 받았었습니다. 이를 T로 그대로 넣어줍니다.

그리고, PaginationProvider는 riverpod의 Provider이기에 StateNotifier를 상속받습니다. StateNotifier를 상속받게 되면, 자동으로 state 라는 상태를 다룰 수 있게 됩니다.

 

여기에서, StateNotifier는 무엇의 상태를 다룰 지 그 타입을 제네릭으로 받습니다. 예를 들어서 아래와 같은 형식입니다.

/// int 상태를 다루는 StateNotifier
/// 여기서 [state]는 int 타입
class Counter extends StateNotifier<int> {
  Counter(): super(0); // state = 0 으로 초기화

  void increment() => state++;
  void decrement() => state--;
}

PaginationProvider는 어떤 타입을 상태로 다루냐면, CursorPaginationBase를 상태로 다룹니다. 이때 CursorPagination이 아닌 이유는, paginate 함수의 결과가 CursorPagination이 아닐 수도 있기 때문입니다. CursorPaginationError, CursorPaginationLoading 의 상태일 수도 있습니다. 이들은 CursorPagination이 아닌, CursorPaginationBase만을 상속받습니다. 그래서 StateNotifier의 제네릭이 Base가 들어갑니다.

Constructor

class PaginationProvider<T extends IModelWithId,
        U extends IBasePaginationRepository<T>>
    extends StateNotifier<CursorPaginationBase> {
    final U repository;

    /// state = CursorPaginationLoading()으로 초기 상태 정의
  PaginationProvider({required this.repository})
      : super(CursorPaginationLoading()) {
    paginate();
  }

생성자 부분입니다. 먼저, 레포지토리의 경우는 외부에서 Injection으로 받아줄 겁니다. 외부에서는, 레포지토리에 실제로 어떤 모델(Restaurant, Product 등)에 대해서 페이지네이션을 요청할 건지 레포지토리를 직접 만들어서 주입시켜줘야 합니다.

 

또한, PaginationProvider의 초기 상태(initial state)는 항상 정해져 있습니다. CursorPaginationLoading입니다. 왜일까요? 그야 당연한게, 요청을 하기 전 맨 처음 Provider가 생성되었을 경우에는, 당연히 맨 처음의 로딩 상태가 되어야 하기 때문입니다. 따라서 super(CursorPaginationLoading())으로 첫 상태를 초기화해줍니다.

 

그리고, paginate라는, Provider 자체의 함수를 생성 시에 자동 호출할 것입니다.

 

이것은 클라이언트의 편의상의 기능인데, PaginationProvider를 생성한다는 것은 필연적으로 첫 페이지네이션 호출을 하게 된다는 의미입니다. 따라서 생성자에서 자동으로 호출을 진행합니다.

 

이제, Provider 자체의 paginate 함수를 정의하러 갑시다. 이때 참고로 provider에서 state를 변경하는 paginate 함수와 실제 Request를 보내는 Repository의 paginate 함수는 구분해야 합니다!

paginate 함수 원형

/// 페이지네이션을 실행하는 함수입니다.
///
/// - [fetchCount]는 한 번에 가져올 데이터 개수로써, 기본값은 20입니다.
/// - [fetchMore]는 추가로 데이터를 더 가져올 건지의 여부로써, 기본값은 false입니다.
///   - 이때 [fetchMore]가 `false`이면, 현재 상태를 덮어씌우는 새로 고침을 의미한다.
/// - [forceRefetch]는 강제로 다시 로딩할 건지의 여부로써, 기본값은 false입니다.
///   - 이때 [forceRefetch]가 `true`로 주어지면, 현재 상태와 상관 없이 강제 새로 고침한다.
///   - 그리고 `CursorPaginationLoading()` 상태 혹은 `CursorPaginationFetchingMore()` 상태가 된다.
Future<void> paginate({
  int fetchCount = 20,
  bool fetchMore = false,
  bool forceRefetch = false,
}) async

원형은 이렇게 되어 있습니다.

  • 먼저, fetchCount는 한 번에 가져올 데이터 개수로, 기본값이 20으로 되어 있습니다.
  • fetchMore는 추가로 더 데이터를 가져올 건지에 대한 여부입니다. 이게 만약에 false인데 paginate 함수가 불렸다는 것은, 추가적인 데이터를 가져오는 것이 아니라 현재 상태를 덮어씌우는 새로고침 혹은, 첫 로딩을 의미합니다.
  • forceRefetch는 강제로 다시 로딩할 건지의 여부로 기본값은 false입니다.

paginate 함수 구현

여기에서, CursorPaginationBase state의 5가지 가능한 상태를 다시 살펴봅시다.

  1. CursorPagination() - 정상적으로 데이터가 존재하는 상태
  2. CursorPaginationLoading() - 데이터를 가져오는 중 (현재 캐시 없음)
  3. CursorPaginationError() - 데이터를 가져오는 중 에러 발생
  4. CursorPaginationRefetching() - 첫 번째 페이지부터 다시 데이터를 가져올 때
  5. CursorPaginationFetchMore() - 추가 데이터를 paginate 해오라는 요청을 받을 때

이를 기반으로, 가능한 시나리오를 하나씩 정리합니다.

가정 1 - 바로 반환이 가능한 상황

함수가 꽤 복잡하기에, 가장 먼저 쉬운 케이스 - 바로 반환이 가능한 케이스부터 Early return으로 생각해줍니다.

 

먼저 hasMore == false인 상황이 있습니다. 이미 다음 데이터가 없다는 것을 알고 있다면, 굳이 paginate를 더 진행하지 않고 반환해도 괜찮습니다. 또한 기존 데이터가 이미 있다는 것은, state가 CursorPagination이라는 것을 의미합니다.

/// 기존 데이터가 이미 존재하고, forceRefetch가 false일 때
/// hasMore가 false라면 그대로 종료
if (state is CursorPagination && forceRefetch == false) {
  final pState = state as CursorPagination;

  if (pState.meta.hasMore == false) {
    return;
  }
}

두 번째로, 이미 state가 로딩 중인 상황이 있습니다. 이 상황은 무엇일까요? 예를 들어 앱을 맨 아래까지 스크롤하여, 추가 데이터를 가져오는 중일때, 중복으로 한 번 더 paginate 함수가 호출이 될 수 있습니다.

 

이때는 이미 로딩 중인 상황이므로, paginate 함수를 더 실행시키면 안 됩니다. 따라서 이때에도 Early return이 가능합니다.

/// 처음 로딩 시
final isLoading = state is CursorPaginationLoading;

/// 새로 고침
final isRefetching = state is CursorPaginationRefetching;

/// 추가 데이터 가져오기
final isFetchingMore = state is CursorPaginationFetchingMore;

if (fetchMore && (isLoading || isFetchingMore || isRefetching)) {
  return;
}

단, 이때 fetchMore == true인지를 꼭 확인해주어야 합니다. 왜냐하면 fetchMore == false일 경우, 추가 데이터를 가져오려는 의도가 아니라 강제 Refetching, 즉 새로고침의 의도가 있을 수 있기 때문입니다. 새로고침의 경우, 현재 상태가 로딩 중이여도 새로고침을 할 수 있도록 하는 게 자연스러워 보입니다 (이는 프로젝트에 따라서 달라질 수 있습니다. 로딩 중에 새로고침을 허용하지 않고 싶은 경우, 로직을 수정하면 됩니다).

 

fetchMoreisFetchingMore 변수가 헷갈릴 수 있는데, fetchMore는 파라미터로써 추가로 데이터를 더 가져올 건지 의도를 나타내는 파라미터입니다. isFetchingMore는 현재 상태가 CursorPaginationFetchingMore인지 나타내는 boolean 값입니다.

가정 2 - 데이터를 추가로 가져올 때(fetchMore)

이제 Early return 조건은 완료했고, 본격적으로 구현을 해봅시다.

 

우선, PaginationParams를 생성해줍니다. PaginationParams를 미리 생성하는 이유는, 이제부터는 paginate 함수에서 실제로 Repository의 paginate 함수를 호출해서 요청을 보낼 것이기 때문입니다.

// paginationParams 생성
PaginationParams paginationParams = PaginationParams(
  count: fetchCount,
);

그리고 가장 먼저 생각해볼 사항은, 데이터를 추가로 가져올 때, 즉 fetchMore 상황입니다.

 

이때는 한 가지 보장을 하나 할 수 있습니다. 데이터를 추가로 가져온다는 말은, 이미 데이터가 존재한다는 의미입니다. 따라서 state는 무조건 CursorPagination<T>의 상황일 수밖에 없습니다(그게 아니라면 에러를 내보는 게 맞습니다!).

// paginationParams 생성
PaginationParams paginationParams = PaginationParams(
  count: fetchCount,
);

// fetchMore
// 데이터를 추가로 더 가져오는 상황
if (fetchMore) {
  // fetchMore가 실행되는 상황은 무조건 이미 화면에 데이터가 존재하는 상태
  final pState = state as CursorPagination<T>;

  state = CursorPaginationFetchingMore(
    meta: pState.meta,
    data: pState.data,
  );

    // after로 cursor 설정하기
  paginationParams = paginationParams.copyWith(
    after: pState.data.last.id,
  );
}

참고로, after: pState.data.last.id에서 last에 null이 들어오는 상황은 절대 일어나지 않는다고 보장할 수 있습니다. 왜냐면 이미 앞에서 hasMore가 false일 때 필터링을 한 번 했고, fetchMore는 반드시 데이터가 이미 있다는 것을 보장하는 상황이기에, data는 무조건 하나 이상의 값이 있다는 것을 확신할 수 있습니다.

가정 3 - 데이터를 처음부터 가져올 때

만약에 데이터를 처음부터, 가져오는 상황이라면 어떨까요?

 

이때는 state가 적어도 CursorPagination라는 것을 보장할 수는 없을 것입니다.

 

일단, state가 CursorPagination 이 아니라면, 확실한 것은 그건 처음부터 데이터가 아예 존재하지 않는 상태입니다. 혹은, forceRefetch == true 일 때도, 그냥 데이터가 아예 없었던 것처럼 취급을 해도 괜찮습니다. 참고로 forceRefetch==true라는 말은 강제로 새로고침한다는 의미로, 기존의 데이터를 보존하지 않고 그냥 새로고침으로 띄울 것입니다.

// fetchMore
// 데이터를 추가로 더 가져오는 상황
if (fetchMore) {
  // ...
}
// 데이터를 처음부터 가져오는 상황
else {
  // 만약에 데이터가 있는 상황이라면
  // 기존 데이터를 보존한 채로 Fetch 진행
  if (state is CursorPagination && !forceRefetch) {
    // 기존 데이터를 유지한 채로, 새로운 데이터를 가져오기
    final pState = state as CursorPagination<T>;
    state = CursorPaginationRefetching<T>(
      meta: pState.meta,
      data: pState.data,
    );
  } else {
    // forceRefetch true일 때 (나머지 상황, 데이터 유지 필요 X)
    state = CursorPaginationLoading();
  }
}

대부분의 상황에서는, 이미 데이터가 있는 상황에서는 굳이 데이터를 삭제하지 않고, 보존했다가 새롭게 데이터가 들어오면 교체를 하는게, 유저에게 앱이 좀 더 빠르다는 인상을 줄 수 있게 됩니다. 그래서 캐시의 데이터를 유지한 채로, CursorPaginationRefetching 을 진행합니다. state에 CursorPaginationRefetching 을 추가한 이유입니다.

Repository에 Paginate 실행하기(실제 요청 보내기)

상황 2, 상황 3에서 각각의 시나리오를 가정해서, PaginationParams와 state 설정을 했습니다. 네 맞습니다. 앞선 상항은 엄밀히 이야기하면 PaginationParams를 설정하기 위한 과정이었습니다. 어차피 상황 2, 상황 3 모두 request를 보내야 하는 것은 동일하고, PaginationParams에서의 디테일 차이가 있을 뿐입니다. 또한 앱의 상태를 표시하기 위한 과정일 뿐입니다.

// paginationParams 생성
PaginationParams paginationParams = PaginationParams(
  count: fetchCount,
);

// fetchMore
// 데이터를 추가로 더 가져오는 상황
if (fetchMore) {
  // ...
}
// 데이터를 처음부터 가져오는 상황
else {
  // ...
}

final response = await repository.paginate(
  paginationParams: paginationParams,
);

맨 마지막에, repository의 paginate 함수를 실행해줍니다.

데이터를 캐시에 저장하기

repository의 paginate 함수를 실행시켜서, 그 요청의 결과를 response에 저장했습니다. 이제 이를 캐시에 저장하는 것을 잊으면 안 되겠죠.

 

우선 캐시에 저장하기 전에, 두 가지 상황으로 또 나뉩니다. 추가로 데이터를 가져오는 상황일 때(CursorPaginationFetchingMore), 기존의 데이터를 보존하면서 추가 데이터를 집어넣어야 합니다. 그 상황이 아니라면, 기존의 데이터는 무시되어도 상관 없습니다.

 

위 케이스를 코드로 표현해보겠습니다.

final response = await repository.paginate(
  paginationParams: paginationParams,
);

// 캐시(state)에 값 저장하기
if (state is CursorPaginationFetchingMore) {
  final pState = state as CursorPaginationFetchingMore;

  // 로딩 끝
  state = response.copyWith(
    data: [
      ...pState.data, // 기존에 있던 데이터
      ...response.data, // 추가로 가져온 데이터
    ],
  );
} else {
  state = response;
}

이때 state에 기존 데이터를 보존하면서, 추가 데이터를 저장하는 방법은 Dart 언어에서 다양한 방법이 있습니다. add 함수를 사용할 수 있고, 또 위에처럼 ... (Cascading) 연산으로 풀어 사용할 수도 있습니다. 이는 팀내에서 정한 코딩 스타일에 맞추면 될 것 같습니다.

가정 4 - 예외 처리하기

다 끝난 줄 알았지만, 마지막으로 남은 게 하나 있습니다. 바로 예외(Exception)이 발생했을 때 상황입니다. 만약에 서버가 죽거나, 네트워크 등이 끊키거나(특히 모바일 환경은 언제나 네트워크가 끊어질 수 있다는 것을 염두해두어야 합니다), API 연결에 문제가 생겼거나 해서 예외가 발생했을 때 이를 처리할 수 있어야 합니다.

 

이는 간단하게, 지금까지 작성한 코드를 모두 try ... catch ... 문 안에 감싸서 해결하겠습니다.

Future<void> paginate({
  int fetchCount = 20,
  bool fetchMore = false,
  bool forceRefetch = false,
}) async {
  try {
        if (state is CursorPagination && forceRefetch == false) {
      final pState = state as CursorPagination;

      if (pState.meta.hasMore == false) {
        return;
      }
    }
        // ...

    final response = await repository.paginate(
      paginationParams: paginationParams,
    );

    if (state is CursorPaginationFetchingMore) {
      final pState = state as CursorPaginationFetchingMore;

      // 로딩 끝
      state = response.copyWith(
        data: [
          ...pState.data, // 기존에 있던 데이터
          ...response.data, // 추가로 가져온 데이터
        ],
      );
    } else {
      state = response;
    }
  } catch (e, stack) {
    print(e);
    print(stack);

    state = CursorPaginationError(
      message: '데이터를 가져오지 못 했습니다.',
    );
  }

던져진 에러는, 나중에 적절하게 처리를 하면 됩니다. 일단 이때는 미리 만들어둔, CursorPaginationError로 state를 변경합니다.

코드 결론

Future<void> paginate({
  int fetchCount = 20,
  bool fetchMore = false,
  bool forceRefetch = false,
}) async {
  try {
        /////////////////////
        // ## Early return //
        /////////////////////
    if (state is CursorPagination && forceRefetch == false) {
      final pState = state as CursorPagination;

      if (pState.meta.hasMore == false) {
        return;
      }
    }

    /// 처음 로딩 시
    final isLoading = state is CursorPaginationLoading;

    /// 새로 고침
    final isRefetching = state is CursorPaginationRefetching;

    /// 추가 데이터 가져오기
    final isFetchingMore = state is CursorPaginationFetchingMore;

    if (fetchMore && (isLoading || isFetchingMore || isRefetching)) {
      return;
    }

        /////////////////////////
        // ## 실제 요청 보내는 상황 //
        /////////////////////////

    // paginationParams 생성
    PaginationParams paginationParams = PaginationParams(
      count: fetchCount,
    );

    // fetchMore
    // 데이터를 추가로 더 가져오는 상황
    if (fetchMore) {
      // fetchMore가 실행되는 상황은 무조건 이미 화면에 데이터가 존재하는 상태
      final pState = state as CursorPagination<T>;

      state = CursorPaginationFetchingMore(
        meta: pState.meta,
        data: pState.data,
      );

      paginationParams = paginationParams.copyWith(
        after: pState.data.last.id,
      );
    }
    // 데이터를 처음부터 가져오는 상황
    else {
      // 만약에 데이터가 있는 상황이라면
      // 기존 데이터를 보존한 채로 Fetch 진행
      if (state is CursorPagination && !forceRefetch) {
        // 기존 데이터를 유지한 채로, 새로운 데이터를 가져오기
        final pState = state as CursorPagination<T>;
        state = CursorPaginationRefetching<T>(
          meta: pState.meta,
          data: pState.data,
        );
      } else {
        // forceRefetch true일 때 (나머지 상황, 데이터 유지 필요 X)
        state = CursorPaginationLoading();
      }
    }

    final response = await repository.paginate(
      paginationParams: paginationParams,
    );

    if (state is CursorPaginationFetchingMore) {
      final pState = state as CursorPaginationFetchingMore;

      state = response.copyWith(
        data: [
          ...pState.data, // 기존에 있던 데이터
          ...response.data, // 추가로 가져온 데이터
        ],
      );
    } else {
      state = response;
    }
  } catch (e, stack) {
    print(e);
    print(stack);

    state = CursorPaginationError(
      message: '데이터를 가져오지 못 했습니다.',
    );
  }
}

정리

이제 CursorPaginationBase를 만든 이유를 아시겠나요? 커서 페이지네이션의 상태와 관련된 최상위 루트 클래스를 만들었더니, state가 하위 클래스로 자유롭게 캐스팅이 가능합니다. 이때문에 paginate 단계에서 자유롭게 상태를 표현할 수 있습니다.

 

아직 많은 게 남아 있습니다. 이제 실제 Request를 보낼 Dio를 작성해야 하고, View 역시 구현해야 합니다. 다만 여기서부터는 각 프로젝트에 맞추어서 구현을 진행하면 될 것 같습니다.

Reference