pagination
페이지네이션은 대량의 데이터를 여러 페이지로 나누어 사용자가 더 쉽게 접근하고 탐색할 수 있도록 하는 기법을 말한다. 데이터베이스 쿼리나 웹 애플리케이션에서 대량의 항목을 한 번에 모두 표시하지 않고, 일정한 개수씩 나누어 순차적으로 보여줌으로써 성능을 최적화하고 사용자 경험을 향상시킬 수 있다.
페이지네이션은 크게 오프셋 기반 방식과 커서 기반 방식으로 나뉜다. 오프셋 기반 방식의 경우 데이터 목록을 정렬한 후 앞에서부터 몇 개의 데이터를 건너뛰고(offset) 조회할 지를 결정한다. 오프셋 기반 방식은 비교적 간단하게 구현할 수 있고, 특정 페이지로의 직접 접근이 용이합니다. 그러나 데이터베이스의 크기가 커질수록 오프셋의 성능 저하가 발생할 수 있다.
커서 기반 방식의 경우 데이터 목록을 정렬한 후 특정한 커서를 기준으로 그 이후의 데이터를 조회한다. 따라서 커서 기반 방식은 오프셋 기반 방식에 비해 성능이 우수하며, 특히 대량의 데이터셋에서 효율적이다. 커서 기반 방식은 주로 다음 페이지의 시작 지점을 특정 데이터의 고유 식별자나 정렬된 열의 값으로 설정하여 구현된다.
일장일단이 있는 만큼 어떤 방식이 더 적합할 지 고민해보고 적용하면 되겠다.
오프셋 기반 방식
간단하게 쿼리스트링으로 orderBy, offset, limit을 받아서 처리해주는 코드를 작성해보았다. 만약 orderBy 등에 대한 비즈니스 로직이 필요하다면 이를 service에서 처리하고 레포지토리로 내려보내주면 되겠다. 나는 totalCount를 뭉탱이로 클라이언트에 보내주고, 클라이언트 쪽에서 skip을 계산해서 알아서 보내주는 편을 더 선호한다.
// src/user/product.repository.ts import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { GetProductsDto } from './dto/GetProducts.dto'; import { Product } from '@prisma/client'; @Injectable() export class ProductRepository { constructor(private prisma: PrismaService) {} async getProducts(getProductsDto: GetProductsDto): Promise<Product[]> { const products = await this.prisma.product.findMany({ orderBy: { createdAt: orderBy ?? 'desc' }, skip: parseInt(offset), take: parseInt(limit), }); return products } async countProuducts(): Promise<number> { const products = await this.prisma.product.count() return products } }
// src/user/product.service.ts import { Injectable } from '@nestjs/common'; import { ProductRepository } from './product.repository'; import { GetProductsDto } from './dto/GetProducts.dto'; import { Product } from '@prisma/client'; @Injectable() export class ProductService { constructor(private repository: ProductRepository) {} async getProducts(getProductsDto: GetProductsDto): Promise<{totalCount: number, products: Product[]}> { // const [products, totalCount] = await promise.all([ // this.repository.getProducts(getProductsDto), // this.repository.countProuducts() // ]) const products = await this.repository.getProducts(getProductsDto) const totalCount = await this.repository.countProuducts() returns { totalCount, products } } }
// src/user/product.controller.ts import { Controller, Get, Param, } from '@nestjs/common'; import { ProductService } from './product.service'; import { GetProductsDto } from './dto/GetProducts.dto'; import { Product } from '@prisma/client'; @Controller('products') export class ProductController { constructor(private service: ProductService) {} @Get() getProducts(@Param() getProductsDto: GetProductsDto): Promise<{totalCount: number, products: Product[]}> { return this.service.getProducts(getProductsDto) } }
커서 기반 방식
거시적으로 본다면 offset 대신 cursor를 받는다는 것 말고는 큰 차이가 없다. 다만 이 cursor를 클라이언트에 보내주기 위해 getNextCursor와 같은 특별한 종류의 helper 함수가 필요할 뿐이다. 나는 이러한 helper들을 따로 모듈로 분리하여 관리하고 있다.
// src/user/helper.helper.ts import { Injectable } from '@nestjs/common'; import { GetProductsDto } from './dto/GetProducts.dto'; import { Product } from '@prisma/client'; @Injectable() export class Helper { async getNextCursor<T extends { [key in 'id']: string }>( contents: Array<T>, getDto: GetProductsDto | GetArticlesDto queryFn: (getDto: GetProductsDto | GetArticlesDto) => Promise<Array<T>> ) { const { take, orderBy } = getDto let nextCursor = ''; // 만약 length가 take보다 작으면 다음 커서는 확정적으로 없음 if (contents.length === Number(take)) { const content = contents.pop(); // 레포지토리의 메소드를 호출 const nextContent = await queryFn({ orderBy, cursor: content.id, take, }); // nextContent.length는 0이거나 그보다 크다 if (nextContent.length) nextCursor = content.id; } return nextCursor; } }
// src/user/product.repository.ts import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { GetProductsDto } from './dto/GetProducts.dto'; import { Product } from '@prisma/client'; @Injectable() export class ProductRepository { constructor(private prisma: PrismaService) {} async getProducts({ orderBy, cursor, limit }): Promise<Product[]> { const products = await this.prisma.product.findMany({ orderBy, cursor, take: parseInt(limit), }); return products } }
// src/user/product.service.ts import { Injectable } from '@nestjs/common'; import { ProductRepository } from './product.repository'; import { Helper } from '../helper/helper.helper'; import { GetProductsDto } from './dto/GetProducts.dto'; import { Product } from '@prisma/client'; @Injectable() export class ProductService { constructor( private repository: ProductRepository, private helper: Helper, ) {} async getProducts(getProductsDto: GetProductsDto): Promise<{ ... }> { const getProducts = this.repository.getProducts const products = await getProducts(getProductsDto) const nextCursor = await this.Helper.getNextCursor(products, getProductsDto, getProducts) returns { nextCursor, products } } }
getNextCursor 함수를 좀 더 예쁘게 가다듬어보고 싶지만 내 머리로는 이 정도가 한계인듯 싶다.
+ 커서 기반으로 페이지네이션을 구현할 때, getNextCursor 함수나 nextCursor 프로퍼티 없이 배열의 마지막 아이템의 id를 커서로 사용하고, 서버에서는 skip 1을 하는 방식도 있는 듯하다. 무한 스크롤을 구현하거나 할 때라면 이런 방식을 써도 괜찮겠다 싶다가도, 프론트 작업하던 기억을 떠올려보면, 지금의 방식이 나는 조금 더 만족스럽다.
블로그의 정보
Ayden's journal
Beard Weard Ayden