Home
프로필

페이지네이션 기초

image

💡 페이지네이션의 필요

페이지네이션이란 많은 데이터를 부분적으로 나눠 불러오는 기술이다.

  • 데이터베이스에 수천 수만개의 상품이 있다고 하자. 클라이언트는 그 모두를 한번에 받을 필요가 없다.
  • 왜냐, 사용자가 그 많은 상품을 다 보지도 않을 뿐더러
  • 데이터 전송에는 돈이 들기 때문이다.
  • 돈이 차고 넘치더라도 많은 데이터를 한번에 보내면 메모리에 과부하가 걸린다.
  • 이는 속도 저하를 초래하고 최악의 경우 서버를 터트린다.

💡 페이지네이션 방법

페이지네이션에는 크게 두 가지 방법이 있다.
하나는 이름에서 보듯 페이지를 기반으로 하는 전통적인 방식이다. 게시판형 커뮤니티에서 많이 볼 수 있는 모습이며 일반적으로 숫자로 구획되어 있다.

  • 페이지 기반 페이지네이션
    • 페이지를 기준으로 데이터를 컷팅(skip)한다.
    • 페이지 숫자를 누르면 다음 페이지로 이동하는 UI로 구현된다.
    • 요청을 보낼 때 원하는 데이터 개수와 몇 번째 페이지인지를 명시한다.
    • 이동하는 도중 데이터가 추가되거나 삭제되는 경우 데이터의 누락이나 중복이 일어날 수 있다.
    • 알고리즘이 간단한 편이다.

하지만 스마트폰 보급과 함께 스크롤 기반의 페이지네이션이 유행했다. 스크롤을 내리며 바닥에 도달할 때까지 이른바 무한 스크롤을 구현하는 방식이다.

  • 스크롤(커서) 기반 페이지네이션
    • 가장 최근에 가져온 데이터를 기준으로 다음 데이터를 가져온다
    • 마지막 데이터의 기준값(id)으로 원하는 데이터 개수를 명시한다.
    • 스크롤 형태의 UI에서 자주 사용된다.
    • 최근 데이터의 기준값을 기반으로 쿼리가 작성되기 때문에 데이터가 누락되거나 중복될 확률이 적다.

무한 스크롤(infinite scroll)
프론트엔드 쪽에서 스크롤의 높이를 계산하여 바닥에 닿기 전에 데이터를 요청한다(UX를 고려해 적당히 일찍 요청을 보내도록 한다). 그러면 서버가 응답하여 추가 데이터를 보내고, 프론트엔드는 이를 다시 아래쪽 UI 끝에다가 붙인다. 이 과정이 반복되면 데이터가 마르지 않는 한 스크롤이 계속해서 이어진다. 이런 작동방식 때문에 그에 무한 스크롤이라는 별칭이 붙게 됐다.
뇌피셜

사실 두 경우 모두 일상에서 자주 보는 형태다. 하나가 더 우월하다 아니다로 쉽게 나눌 수 없고, 서비스에 따라 적절한 방식을 택하는 게 좋을 것이다. 물론 공부하는 입장에서야 두 가지 방식을 다 챙겨야겠지만.

그런데 까놓고 얘기하자. 솔직히 말해 이 구역의 주인은 스크롤 기반의 페이지네이션이라는 생각을 지울 수 없다. 더 많은 실력과 경험을 요하기 때문이기도 하고, 이를 구현할 줄 안다면 페이지 기반의 페이지네이션을 공존시키는 것은 그리 어려운 일이 아니기 때문이다.

💡 쿼리 파라미터의 형태

/posts?order__createdAt=ASC&where__id_more_than=3&take=20

  • 쿼리를 어떤 형태로 작성할 것인가는 개발자, 회사 마음이다.
  • 보통 split() 메써드를 활용하여 쿼리값을 분리하기 때문에, 잘 쓰이지 않는 문자를 구분자로 쓰는 것이 좋다.
  • 가령 이런 규칙을 따른다고 하자. {property}__{filter}
  • where__id_more_than=3
    • where은 리포지토리 로직에 조건으로 들어가게 된다.
    • 거기에 id_more_than이라는 필터를 걸겠다는 의미다. 그리고 그 값은 3이다.
    • 만약 find()에 쓰였다면 id가 3보다 큰 데이터를 찾아 응답으로 보낸다.

이보듯 언더바 두개로 프로퍼티와 필터를 구분함으로써 쿼리에 담긴 내용을 쉽게 분리할 수 있다.

💡 페이지네이션 구현

  1. getAll... 금지
  • 서두에서 말한 것처럼 데이터를 한번에 요청하는 것은 좋은 습관이 아니다.
  • 데이터가 적은 개인 사이트면 큰 문제가 없겠지만, 실무에서 쓴다면..? 🤔
  • 그러니 페이지네이션, 즉 분할 로드하는 기법으로 GET 요청을 대신하도록 하자.
  • 페이지 기반이라면 다음 페이지를 눌렀을 때 데이터 요청이 들어갈 것이고,
  • 스크롤 기반이라면 특정 높이만큼 스크롤을 내렸을 때 데이터 요청이 들어갈 것이다.

  1. PaginateDTO
  • 페이지네이션 요청은 쿼리 파라미터로 이뤄진다. 이 쿼리 파라미터에 대한 DTO를 먼저 작성한다.
  • 왜그러냐면, 요청에 실려오는 데이터(쿼리나 파람 바디 등)를 먼저 검증하고 (필요에 따라)재가공하는 일이 필요하기 때문이다. DTO가 그런 역할이라고 했다.
    paginate-post.dto.ts

    export class PaginatePostDto {
    @IsNumber()
    @IsOptional()
    where__id_more_than?: number;
    @IsIn(["ASC", "DESC"])
    @IsOptional()
    order__createdAt?: "ASC" | "DESC" = "ASC"
    @IsNumber()
    @IsOptional()
    take?: number = 20;
    }

    • where__id_more_than: 마지막으로 가져온 데이터의 id를 받는다. 서버에서는 이 아이디를 기준으로 다음 데이터의 시작점을 잡는다.
    • take: 응답할 데이터의 개수를 의미한다. 기본값을 20으로 설정하여 마지막 id를 기준으로 20개의 데이터를 잘라낸다.
    • order__createdAt: 정렬 순서를 의미한다. 기본값을 오름차순으로 정해 id가 낮은 순서대로 데이터를 보내고 있다. 이 경우 보통 오래된 데이터가 먼저 보내지게 된다.
  • 이렇게 페이지네이션에 필요한 쿼리를 DTO로 작성했다. 이 DTO를 통과하지 못하면 컨트롤러에서 에러를 던질 테고, 통과한다면 서비스로 넘어가 로직에 사용될 것이다.

그런데 이상하다. 아무 값도 받지 않았을 때 기본값을 줬는데 class-validator가 에러를 던지는 게 아닌가? @IsOptional을 달았는데도 그렇다. 이건 왜냐면 해당 프로퍼티에 undefined을 내보내야 한다고 인식했는데, 기본값에 의해 number가 반환되고 있기 때문이다.
그럴 땐 다음의 옵션을 main.ts에 추가한다1.

main.ts

app.useGlobalPipes(
new ValidationPipe({
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);

  • transform: true를 추가하면 변형을 허용한다. 아무 쿼리도 못 받았을 때 원래 비어있는 값을 보내야 하지만 이 옵션을 추가하면 기본값을 내보낼 수 있다.
  • enableImplicitConversion: true를 추가하면 데코레이터에 기반하여 타입 변환을 자동으로 한다. 원래 모든 쿼리는 String 타입으로 들어오는데, 이 옵션을 추가하면 @IsNumber 데코레이터만으로 타입이 Number로 자동 변환된다. 이 옵션을 쓰지 않을 경우 @Type(()=>Number)처럼 추가 데코레이터를 일일이 붙여줘야 같은 효과를 누릴 수 있다.

  1. GET 컨트롤러

    받을 수 있는 쿼리를 DTO로 정의했으니 컨트롤러에서 사용한다. 이 쿼리는 이제 DTO의 내용을 따른다(아니라면 에러이기 때문에).

posts.controller.ts

@Get()
getPosts(
@Query() query: PaginatePostDto
){
return this.postsService.paginatePosts(query);
}


  1. 서비스 로직

    서비스에서는 받은 쿼리(DTO)를 기반으로 데이터를 찾는 함수를 작성한다. 컨트롤러에서는 주입된 서비스를 통해 이 함수를 불러낸다.

posts.service.ts

paginatePosts(dto: PaginatePostDto) {
return this.postRepository.find({
where: {
id: MoreThan(dto.where__id_more_than)
},
order: dto.order__createdAt,
take: dto.take,
})
}

💡 메타데이터 작성

위 조회의 결과, 응답으로 내보낼 데이터를 얻게 된다. 하지만 이 데이터는 단순히 데이터일 뿐, 어떤 페이지네이션의 결과인지, 다음 페이지가 있는지 등의 정보가 없다. 즉 페이지네이션에 관한 메타 데이터가 없다. 이게 없으면 클라이언트에서는 순간 난감해진다. 다음 페이지네이션을 처리하기 위한 지침이 없기 때문이다.
그럼 메타데이터를 예시로 작성하고 리턴이 어떻게 구성돼야 할지 확인해보자.


  • 커서 페이지네이션의 메타데이터 구성
    • after: 현재 커서가 가리키고 있는 id값이 온다. 다음 요청의 기준으로 사용되는 값이다.
    • count: 현재 조회하여 응답하는 데이터의 수다. 이 값이 take보다 작다면 남은 데이터가 없다는 뜻이다.
    • next: 다음 요청으로 쓰일 url 주소를 담아보낸다. 이로써 프론트엔드 쪽에서의 일을 쉽게 해줄 수 있다. 다음 데이터가 없다면 null로 처리해 추가 요청을 방지한다.
  • 로직 쪼개보기
  • 현재 내보내는 데이터의 개수를 구한다.
post.service.ts

paginatePosts(dto: PaginatePostDto){
const posts = this.postRepository.find({
where: {
id: MoreThan(dto.where__id_more_than)
}
order: dto.order__createdAt,
take: dto.take,
})
const count = posts.length;
}

  • after로 쓰일 마지막 데이터를 찾는다.
post.service.ts

paginatePosts(dto: PaginatePostDto){
const posts = this.postRepository.find({
where: {
id: MoreThan(dto.where__id_more_than)
}
order: dto.order__createdAt,
take: dto.take,
})
const count = posts.length;
const lastItem = posts.length > 0 ? posts.at(-1) : null;
}

  • 다음에 쓰일 요청 주소를 만든다.
post.service.ts

paginatePosts(dto: PaginatePostDto){
const posts = this.postRepository.find({
where: {
id: MoreThan(dto.where__id_more_than)
}
order: dto.order__createdAt,
take: dto.take,
})
const count = posts.length;
const lastItem = posts.length > 0 ? posts.at(-1) : null;
const PROTOCOL = 'http'; // 실제로는 env로 뺀다
const HOST = 'localhost:3000';
const nextUrl = lastItem && new URL(`${PROTOCOL}://${HOST}/posts`);
if(nextUrl){
for(const key of Object.keys(dto)){
if (key !== 'where__id_more_than') { // order와 take는 그대로 쓰인다
nextUrl.searchParams.append(key, dto[key]);
}
}
}
// 마지막 id값은 아까 찾은 lastItem을 기반으로 새로이 넣어준다
nextUrl.searchParams.append('where__id_more_than', lastItem?.id.toString());
}

  • 데이터와 메타 데이터를 포함하여 응답값을 내보낸다.
post.service.ts

paginatePosts(dto: PaginatePostDto){
const posts = this.postRepository.find({
where: {
id: MoreThan(dto.where__id_more_than)
}
order: dto.order__createdAt,
take: dto.take,
})
const count = posts.length;
const lastItem = posts.length > 0 ? posts.at(-1) : null;
const nextUrl = lastItem && new URL(`${PROTOCOL}://${HOST}/posts`);
...
nextUrl.searchParams.append('where__id_more_than', lastItem?.id.toString());
return {
data: posts,
meta: {
cursor: {
after: listItem?.id ?? null
},
count,
next: nextUrl?.toString() ?? null
}
}
}

  • 현재 내보내는 데이터의 개수를 구한다.
  • after로 쓰일 마지막 데이터를 찾는다.
  • 다음에 쓰일 요청 주소를 만든다.
  • 데이터와 메타 데이터를 포함하여 응답값을 내보낸다.
post.service.ts

paginatePosts(dto: PaginatePostDto){
const posts = this.postRepository.find({
where: {
id: MoreThan(dto.where__id_more_than)
}
order: dto.order__createdAt,
take: dto.take,
})
const count = posts.length;
}

이미지

이것이 페이지네이션의 기본적인 모양새다. 무한 스크롤에 필요한 필수 여건(마지막 id값, 반환되는 데이터의 수, 요청 url)이 갖춰졌다. 마지막 데이터의 id를 다음 요청의 키값으로 갖기에 새데이터가 추가되도 중복의 문제를 피한다.
여기서 기능을 더 추가하고, 일반화시킬 일이 앞으로 남았지만, 개념을 정리하는 데는 이 정도로도 손색이 없을 것 같다. 적어도 공부하는 내가 느끼기엔 그랬다.
다행히 다른 한축, 페이지 기반 페이지네이션 메타데이터는 이보다 훨씬 단촐하게 구성된다. 간단히 살펴보자.


  • 페이지 기반 페이지네이션의 메타데이터 구성

    pagePaginatePosts(dto: PaginatePostDto) {
    const [data, total] = await this.postsRepository.findAndCount({
    skip: dto.take * (dto.page - 1),
    take: dto.take,
    order: {
    createdAt: dto.order__createdAt,
    },
    });
    return {
    data,
    total,
    };
    }

    • page: DTO에서 현재 페이지가 몇 번째 페이지인지를 받는다(시작 1).
    • take: 몇 개의 데이터를 가져올 것인지를 받는다.
    • skip: 이 둘을 계산하여 여태까지 받은 데이터를 계산하고 그만큼을 건너뛴다.
    • total: 전체 데이터 수를 받는다. 이를 통해 몇 개의 페이지를 UI에 표시할지 결정할 수 있다.

이건 숫자를 대입해서 보면 단박에 이해된다. 첫시작에 page=1, 그럼 skip은 0…… 유저가 누르는 page 번호만 알아내면 되므로 복잡한 로직이 필요하지 않다.
다만 개수를 기준으로 페이지를 재단하기 때문에, 데이터가 추가되거나 삭제되는 경우 아래와 같은 중복 또는 누락 이슈가 있다.

이미지

참고링크

Footnotes

  1. class validator의 옵션들

Comment ?

▾ Comment

🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧