Flutter로 Cursor Pagination 구현하기 - 3. Pagination 실제로 적용해보기

이전 글

실제로 적용해보기

지금까지 Pagination에 필요한 Provider 및 CursorPagination 상태, 그리고 View를 제작했습니다.

 

지금까지 만든 것들은 모두 일반화를 위한, 추상화된 클래스들입니다. 이러한 추상화 과정을 통해서, 많은 양의 중복된 코드를 줄일 수 있습니다. 그러나 이를 실제로 적용하는 것은 또다른 문제입니다.

 

실제로 Paginate를 하기 위해서는, http Request 등 서버로의 요청을 보내야 합니다. 추상화된 코드에서는 IBasePaginationRepository에서 paginate 함수에서 request를 보낸다고 가정을 하고 있습니다. 이를 실제로 구현해야, Paginate를 사용할 수 있습니다.

 

IBasePaginationRepository는 이전 글에서 abstract interface class 로 선언해놓았습니다.

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

이번 글에서는 이 Repository를 어떻게 구현할 것인지 예시를 보이겠습니다.

 

이번 글은 순수히 예시일 뿐, 모든 것은 개별 데이터 모델마다 다릅니다.

Dio Provider

http Request를 사용할 때, 아주 강력한 flutter Library가 두 개 있습니다. 하나는 http 통신을 도와주는 Dio이고, 다른 하나는 RestAPI 통신을 도와주는 retrofit입니다. retrofit은 code generator인데, 자체적으로 dio를 탑재하고 있어 일반화된 RestAPI 통신 과정에서의 반복된 코드 작성을 줄여줍니다.

final dioProvider = Provider<Dio>((ref) {
  final dio = Dio();

  return dio;
});

위 코드를 보면, ??? 하시는 기분이 들 것입니다. dioProvider인데 아무것도 없죠? 이럴거면 그냥 Dio가 필요할 때마다 Dio() 로 객체를 새로 하나 생성해서 사용하면 되지, 굳이 Provider를 이용해서 객체를 재사용할 필요가 있을까요?

 

맞습니다. 위 코드에서는 Dio 객체를 재사용할 필요는 없습니다. 그러나 페이지네이션과는 관련이 없지만, 종종 Dio에 Interceptor 를 추가해서, http request, response, error 상황에서 별도의 동작을 수행하도록 인터셉터를 추가하는 경우가 있습니다. 이는 페이지네이션을 다루는 본 글에서는 다루지 않겠지만, 그렇기 때문에 Dio는 위와 같이 Provider로 선언을 해놓고 사용하는 것이, 장기적으로 코드의 확장 가능성을 높이는데 도움이 됩니다.

 

하다못해 로그를 찍는 로깅 인터셉터를 추가한다고 하더라도, 만약에 모든 코드에서 따로 Dio()로 객체를 별도로 생성해서 사용했다면, 이를 수정하는데 많많치 않은 비용이 들 것입니다. 그래서 지금은 아무 기능이 없더라도 Provider로 생성을 해놓겠습니다.

IModelWithId 모델 예시

여기서는, 상품(Product) 정보를 Pagination한다고 가정해보겠습니다. Product에 대한 모델 클래스를 만들어야 하는데, 이때 이 모델은 IModelWithId를 implement하는 모델이어야 합니다.

part 'product_model.g.dart';

@JsonSerializable()
class ProductModel implements IModelWithId {
  @override
  final String id;

  /// 상품 이름
  final String name;

  /// 상품 상세 정보
  final String detail;

  /// 상품 이미지 URL
  final String imgUrl;

  /// 상품 가격
  final int price;

  /// 레스토랑 정보
  final RestaurantModel restaurant;

  ProductModel({
    required this.id,
    required this.name,
    required this.detail,
    required this.imgUrl,
    required this.price,
    required this.restaurant,
  });

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

JsonSerializable로 Annotation을 추가했습니다. 이는 freezed가 상속을 지원하지 않아서, IModelWithId 를 상속받기 위해서는 JsonSerializable로 감싸야 합니다.

Repository 구현 - ProductRepository

이제, 인터페이스로만 존재했던 PaginationRepository를 실제로 구현을 할 차례입니다.

retrofit으로 RestAPI 사용하기

여기서 잠깐, retrofit에 대해서 알고 갑시다.

사진 출처:&nbsp;https://medium.com/globant/easy-way-to-implement-rest-api-calls-in-flutter-9859d1ab5396

retrofit은 RestAPI 통신 작업을 자동화할 수 있는 Code Generator입니다. 보통 웹(Web)이든 모바일 앱(App)이든, 백엔드 서버와 통신을 할 때 대부분은 (GraphQL이나 gRPC를 쓰지 않는 한) RestAPI를 사용하게 됩니다. RestAPI 엔드포인트를 한땀한땀 따는 것은, 상당히 반복적이고 지루한 작업입니다. 지루함은 둘째치고 실수를 내기에 좋다는 뜻이기도 합니다.

 

retrofit은 이러한 RestAPI를 자동화하는 도구로써, 비단 Flutter 뿐만 아니라 Android 등 다양한 플랫폼에서도 사용되고 있는 라이브러리입니다.

 

자세한 설치 방법, 사용 방법 등은 공식 문서 혹은 pub.dev Retrofit 페이지에서 확인해보실 수 있습니다. 여기서는 간략한 소개만 하고 넘어가겠습니다.

//Dynamic headers
@GET("/posts")
Future<List<Post>> getPosts(@Header("Content-Type") String contentType );

@GET("/comments")
@Headers(<String, dynamic>{ //Static header
  "Content-Type" : "application/json",
  "Custom-Header" : "Your header"
})
Future<List<Comment>> getAllComments();

@GET("/posts/{id}")
Future<Post> getPostFromId(@Path("id") int postId);

@GET("/comments?postId={id}")
Future<Comment> getCommentFromPostId(@Path("id") int postId);

@GET("/comments")
Future<Comment> getCommentFromPostIdWithQuery(@Query("postId") int postId); //This yields to "/comments?postId=postId

@DELETE("/posts/{id}")
Future<void> deletePost(@Path("id") int postId);

@POST("/posts")   
Future<Post> createPost(@Body() Post post);

GET, PUT, POST, PATCH, DELETE 등 http 메소드와 관련된 annotation들, 그리고 헤더 등 여러 http 기능에 관한 annotation들이 존재합니다. 모든 API 엔드포인트들은 요청 시 요구하는 파라미터들이 따로 존재합니다. 이를 보다 쉽게 관리할 수 있는게 Retrofit입니다.

part 'rest_client.g.dart';

@RestApi(baseUrl: kBaseUrl)
abstract class RestClient {
  factory RestClient(Dio dio, {String baseUrl}) = _RestClient;

    @GET("/posts/{id}")
  Future<Album> getPosts(@Path() int id);
}

RestClient는 abstract class로 선언이 됩니다. 그리고 자신의 이름으로 된 factory contructor를 선언해줍니다. 그리고 @RestApi라는 Annotation을 붙입니다.

 

@RestApi Annotation은 baseUrl을 집어넣을 수도 있습니다(반드시 넣어야 하는 건 아닙니다).

 

이 factory Contructor는 Dio하고, baseUrl을 파라미터로 받아야 합니다.

 

그리고 클래스 내부의 메소드로, 각각의 GET, POST 등의 메소드들을 정의하면 됩니다. URL의 경우, Annotation 안에 Parameter로 넘길 수 있습니다.

 

@Headers Annotation은 Request 시 http Body와 함께 보낼 http 헤더를 추가할 수 있습니다. 일반적으로 가장 많이 헤더가 쓰이는 곳은 인증/인가입니다. http 헤더에 세션 정보, 혹은 JWT 방식이라면 JWT 토큰을 넣어서 보낼 수 있습니다. Headers Annotation은 파라미터로 맵(딕셔너리)를 넘겨줄 수 있습니다.

retrofit으로 ProductRepository 만들기

위에서 본 방식으로 Retrofit을 이용해서 ProductRepository를 만들어보겠습니다. 이때 ProductRepository가 해야 할 게 하나 있었는데 뭐였죠? 바로 IBasePaginationRepository를 Implemenation하는 것입니다. 잊지 마세요.

@RestApi()
abstract class ProductRepository
    implements IBasePaginationRepository<ProductModel> {
  // baseUrl = http://$kBaseUrl/product
  factory ProductRepository(Dio dio, {String baseUrl}) = _ProductRepository;

  @override
  @GET('/')
  @Headers({
    'accessToken': 'true',
  })
  Future<CursorPagination<ProductModel>> paginate({
    @Queries() PaginationParams? paginationParams = const PaginationParams(),
  });
}

참고로 @Headers({'accessToken': 'true'})라 되어 있는 부분은, 인증에 관련된 부분입니다. ‘accessToken’이 true인 경우에 SecureStorage(웹이라면 쿠키)에 저장된 인증 정보를 Dio Interceptor가 가로채서 집어넣고 Request를 보내는 방식으로 동작합니다.

 

참고로, ProductRepository의 baseUrl은 /product가 될 예정입니다. 그래서 저기 @GET('/')의 URL이 ‘/’인 이유입니다.

POST도 없고, 부실하긴 하지만 어쨌든 상품 정보를 조회하는 레포지토리를 만들었습니다. 이제 이 레포지토리를 사용할 수 있도록 Provider를 만듭니다.

ProductRepositoryProvider

ProductRepository의 상태를 제공하는 Provider인, ProductRepositoryProvider입니다.

final productRepositoryProvider = Provider<ProductRepository>((ref) {
  final dio = ref.watch(dioProvider);

  return ProductRepository(
    dio,
    baseUrl: 'http://$ip/product',
  );
});

참고로 Provider는 여기서는 그 상태를 나타내는 클래스(StateNotifier 등)과 같은 파일 안에 집어넣었습니다.

ProductProvider

ProductRepository도 만들었고, 이 레포지토리를 Provide하는 ProductRepositoryProvider도 만들었습니다. 이제 남은 것은 실제 Product Model을 Provide하는 ProductProvider를 만드는 것입니다.

ProductStateNotifier

class ProductStateNotifier
    extends PaginationProvider<ProductModel, ProductRepository> {
  ProductStateNotifier({required super.repository});
}

productProvider를 만들기 위해서는 먼저 ProductStateNotifier부터 만들어야 하는데, ProductStateNotifier가 생각보다 간단하죠?

 

이는 이미 앞서 Pagination에 대한 추상화를 상당히 잘 진행해놓아서, 단지 PaginationProvider를 extends하는 것만으로도 많은 양의 보일러플레이트 코드(Boilerplate code)를 줄일 수 있습니다.

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

PaginationProvider가 기억이 안 난다면, 위와 같이 선언이 되어 있었습니다. 두 가지 제네릭을 받는데, 하나(T)는 페이지네이션을 진행할 실제 데이터 모델이고, 다른 하나(U)는 페이지네이션을 실행할 레포지토리를 받았습니다. 처음에는 복잡해보였어도 이제는 더이상 복잡해보이지 않을 것입니다.

 

PaginationProvider는 이미 paginate 함수를 기술을 해놓았고, 이 paginate 함수는 레포지토리를 외부에서 주입받아서 사용하기에, 심지어 개별 모델에 따른 Provider마다 paginate를 override를 할 필요도 없습니다. 참고로 이렇게 클래스에서 사용할 객체를 내부에서 생성하지 않고, 인터페이스 등으로 추상화한 뒤에 외부에서 생성해서 주입받는 것을 의존성 주입(Dependency Injection)이라고 합니다. 그리고 의존성 주입의 방법 중 이렇게 생성자를 통해서 외부에서 의존성을 주입하는 방식을 ‘생성자 주입’ 방식이라고 합니다.

ProductProvider

final productProvider =
    StateNotifierProvider<ProductStateNotifier, CursorPaginationBase>((ref) {
  final repository = ref.watch(productRepositoryProvider);

  return ProductStateNotifier(repository: repository);
});

class ProductStateNotifier
    extends PaginationProvider<ProductModel, ProductRepository> {
  ProductStateNotifier({required super.repository});
}

productProviderProductStateNotifier 위에 선언하겠습니다. productRepositoryProvider 를 통해 ProductRepository를 가져온 후에 이를 ProductStateNotifier에 전달(주입)하면 끝입니다.

참고로, 여전히 productProvider가 제공하는 객체의 타입은 CursorPaginationBase입니다.

 

ref.watch(productProvider) 를 했을 시 CursorPaginationBase가 반환되어야 합니다.

View 구현하기 - ProductPage

이제, 실제 위젯인 ProductPage를 구현해봅시다.

 

먼저 ProductPage는 StatelessWidget으로 구현할 것인데요, 이는 이미 전 글에서 Pagination에 대한 DefaultView을 구현하였고, 상태 관리는 이미 riverpod에게 맡기고 있기 때문에, View에서 관리할 상태는 없습니다. View에서는 비즈니스 로직을 생각하지 않고, 오직 UI를 구현하는 코드만 남기는 게 가장 이상적이고 좋은데요, 여기서도 최대한 비즈니스 로직을 제거하고 UI를 구현하는데 집중하고 있습니다.

class ProductPage extends StatelessWidget {
  const ProductPage({super.key});

  @override
  Widget build(BuildContext context) {
    return PaginationListView<ProductModel>(
      provider: productProvider,
      itemBuilder: (context, index, model) => GestureDetector(
        onTap: () => Navigator.of(context).push(
          MaterialPageRoute(
            builder: (context) => RestaurantDetailPage(
              id: model.restaurant.id,
            ),
          ),
        ),
        child: ProductCard.fromProductModel(
          model: model,
        ),
      ),
    );
  }
}

여기서 PaginationListView<ProductModel>를 리턴하고 있는데요, PaginationListView는 이전 글에서 구현한 일반화된 페이지네이션 뷰입니다.

class PaginationListView<T extends IModelWithId>
    extends ConsumerStatefulWidget {
  final StateNotifierProvider<PaginationProvider, CursorPaginationBase>
      provider;
  final PaginationWidgetBuilder<T> itemBuilder;

  const PaginationListView({
    required this.provider,
    required this.itemBuilder,
    super.key,
  });

  @override
  ConsumerState<PaginationListView> createState() =>
      _PaginationListViewState<T>();
}

PaginationListView는 두 개의 파라미터를 받고 있습니다.

  • provider: 여기서는 당연히 위에서 구현한 productProvider를 injection합니다.
  • itemBuilder: 여기서는 자신이 만들고 싶은 컴포넌트를 직접 구현하면 됩니다.
itemBuilder: (context, index, model) => GestureDetector(
  onTap: () => Navigator.of(context).push(
    MaterialPageRoute(
      builder: (context) => RestaurantDetailPage(
        id: model.restaurant.id,
      ),
    ),
  ),
  child: ProductCard.fromProductModel(
    model: model,
  ),
),

여기서는 GestureDetector를 주입하고 있습니다. 그리고, ProductCardGestureDetector 안에 넣고 있구, ProductCard는 따로 구현한 UI입니다. UI는 이 글의 관심사가 아니기에 생략하겠습니다.

이렇게 구현이 되는 것을 확인할 수 있습니다.

 

참고로, 여기서는 사용자가 빠르게 스크롤을 하게 되면, 밑에 로딩바가 나오는 것을 확인할 수 있는데요, 착각하면 안 되는 게 저 로딩바는 사용자가 끝까지 내리면 나오는 게 아니라, 끝에서 450픽셀 위에서부터 데이터를 요청하고 있습니다.

 

만약에 스크롤을 느리게 한다면, 미리 다음 페이지네이션을 진행하므로 사용자는 저 로딩바를 보지 않을 수 있습니다.

이렇게요.

근데 ProductCard 컴포넌트 자체가 height가 낮아서, 450픽셀 정도로는 충분치 않은 것 같습니다. 이때는 좀 더 늘려주는 게 낫겠습니다.

정리

product
├── component
│   └── product_card.dart
├── model
│   ├── product_model.dart
│   └── product_model.g.dart
├── provider
│   └── product_provider.dart
├── repository
│   ├── product_repository.dart
│   └── product_repository.g.dart
└── view
    └── product_page.dart

product 모델의 폴더 구조는 이렇게 되어 있습니다.

 

글을 정리하자면,

  1. product_model이 만듭니다. 이 모델은 IModelWithId를 implements합니다.
  2. product_repository를 만듭니다. 이 레포지토리는 IBasePaginationRepository 를 implements합니다.
  3. product_provider를 만듭니다. 이 프로바이더는 ProductStateNotifier를 StateNotifier로 이용하며, 이 StateNotifier는 PaginationProvider를 extends합니다.
  4. 그리고 view를 만듭니다. 이 view는 내부적으로 PaginationListView를 이용합니다.

📚 참고 자료