NestJS-Cursor Pagination

jaegeunsong97·2023년 12월 10일
0

NestJS

목록 보기
20/37

🖊️Pagination 이론

Pagination은 많은 데이터를 부분적으로 나눠서 불러오는 기술입니다. 데이터를 한번에 전부 주면 성능이 느려지고 메모리가 터지는 문제를 해결하기 위해 Pagination 사용합니다.

  • 부분적으로 쪼개서 불러오는 경우입니다.
    • 한번에 20개씩
    • 쿠팡의 데이터를 1번에 전부 보내는 것이 아니라, 조금씩 나눠서 보냅니다.
  • 현재 클라우드 시스템, 데이터 전송시 돈이 든다.
  • 수억개의 데이터를 1번에 보내면 메모리가 폭발합니다.
    • 메모리가 폭발하지 않아도 데이터 전송 시, 오랜시간이 걸립니다.

페이지네이션은 1 ~ 20번까지 데이터가 있으면 FE에서 저장을 합니다. 그리고 21 ~40번까지의 데이터를 다시 서버에게 요청하고 데이터를 가지고 와서 1 ~ 20번과 21 ~ 40번까의 데이터를 합쳐서 저장을 하는 방식입니다. 즉, FE는 데이터를 계속해서 가지고 있는 상태가 됩니다.

페이지네이션에는 Page based pagination, Cursor pagination 2가지 방식이 있습니다.

📍Page based pagination

Page based pagination은 1, 2, 3, 4..처럼 가장 기본적인 형태입니다. 따라서 요청을 할 때, 데이터의 개수(take), 몇 번째 페이지(page), 몇개를 건너뛸 것(skip)인지 명시가 필요합니다.

1번째 페이지 요청 시

2번째 페이지 요청 시

3번째 페이지 요청 시

하지만 page based pagination에는 데이터를 삭제하거나 삽입 시, 문제점이 있습니다. 먼저 데이터를 삽입할 때의 문제점을 보겠습니다.

1 ~ 4를 불러옵니다.

중간에 3.5 데이터가 생깁니다.

그러면 원하는 5 ~ 8번의 데이터가 아닌 4번부터 7번까지 데이터를 가져오게 됩니다. 예를 들면, 1, 2, 3을 눌러서 가져오는 페이지에서 1번을 누르고 오랜 시간이 지나 2번을 누르면 1번에서 봤던 데이터들이 2번에 다시 로딩되는 경우가 이에 해당합니다.

이번에는 데이터가 삭제되는 경우를 보겠습니다.

4번을 삭제하겠습니다.

원하는 데이터를 가지고 오지 않고 6 ~ 9번까지만 가져오게 됩니다.

📍Cursor pagination

가장 최근데 가져온 데이터를 기준으로 다음 데이터를 가져오는 방법입니다. 요청을 보낼 때 마지막 데이터의 기준값과 몇개의 데이터를 가져올지 명시합니다. 무한 스크롤과 같은 곳에서 사용이 됩니다.

단, 최근 데이터의 기준값을 기반으로 쿼리가 작성되기 때문에 Page based pagination의 데이터 추가, 삭제시 발생하는 중복 누락 확률이 적습니다.

1번째 요청(id > 0 4개)

2번째 요청

3번째 요청

데이터 추가 시, 어떻게 동작하는지 보겠습니다.

1 ~ 4까지 불러오고 3.5가 추가되면,

2번째 요청에서는 id > 4인 조건이 있기 때문에 3.5와 4를 건너뛰고 5 ~ 8까지를 가져오게 됩니다. 따라서 마지막 데이터와 첫 번째 데이터가 중복되지가 않습니다.

이번에는 데이터 삭제를 보겠습니다. 1번째 요청으로 1 ~ 4번까지 불러왔습니다.

4를 삭제

그럼에도 id > 4라는 기준이 있기 때문에 5 ~ 8번까지의 데이터를 불러옵니다.

📍request response 형태

http://localhost:posts?
order_createdAt=ASC&where__id_more_than=3&take=2

{property}_{filter} 형식
order_createdAt: 오름차/내림차
where__id_more_than: 어떤 ID 이후로 데이터를 요청할 것인지
take: 몇개의 데이터를 요청할건지


🖊️PaginationDto, MoreThan, Order

  • posts/dto/paginate-post.dto.ts

커서페이지네이션 기반으로 만듭니다. paginate DTO를 반들고, 컨트롤러에 적용을 해봅시다.

import { IsIn, IsNumber, IsOptional } from "class-validator";

export class PaginatePostDto {

     // 이전 마지막 데이터의 ID
     // 이 프로퍼티에 입력된 ID보다 높은 ID 부터 값을 가져오기
     @IsNumber()
     @IsOptional()
     where__id_more_than?: number;

     // 정렬
     // createAt -> 생성된 시간의 내림차/오름차 순으로 정렬
     @IsIn(['ASC'])
     @IsOptional()
     order__createdAt?: 'ASC' = 'ASC';

     // 몇개의 데이터를 응답으로 받을지
     @IsNumber()
     @IsOptional()
     take: number = 20;
}
  • posts.controller.ts
@Get()
getPosts(
  	@Query() query: PaginatePostDto, // 변경
) {
  	return this.postsService.getAllPosts();
}

이제 서비스 코드를 바꿔봅시다.

  • posts.service.ts
// 1) 오름차 순으로 정렬하는 페이지네이션만 구현
async paginatePosts(dto: PaginatePostDto) {
    // 1, 2, 3, 4, 5
    const posts = await this.postsRepository.find({
        where: {
            // 더 크다 / 더 많다
            id: MoreThan(dto.where__id_more_than ?? 0), // 값이 X or NULL -> 0으로
        },
        // order__createdAt
        order: {
          	createdAt: dto.order__createdAt,
        },
        take: dto.take,
    });

    /**
    * Response
    * 
    * data: Data[],
    * cursor: {
    *   after: 마지막 Data의 Id
    * },
    * count: 응답한 데이터의 갯수
    * next: 다음 요청을 할때 사용할 URL
    */
    return {
      	data: posts,
    }
}

이제 컨트롤러에 적용을 하겠습니다.

  • posts.controller.ts
@Get()
getPosts(
  	@Query() query: PaginatePostDto,
) {
  	return this.postsService.paginatePosts(query); // 변경
}

포스트맨으로 테스트를 하고 결과를 보면 id가 2가 나온 부분 부터 정렬이 제대로 안된 것을 알 수 있다.

{
    "data": [
        {
            "id": 4,
            "updatedAt": "2024-01-27T18:08:07.556Z",
            "createdAt": "2024-01-27T18:08:07.556Z",
            "title": "첫번째 title",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 5,
            "updatedAt": "2024-01-27T18:10:58.378Z",
            "createdAt": "2024-01-27T18:10:58.378Z",
            "title": "첫번째 title",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 6,
            "updatedAt": "2024-01-27T18:11:07.035Z",
            "createdAt": "2024-01-27T18:11:07.035Z",
            "title": "첫번째 title",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 7,
            "updatedAt": "2024-01-27T18:37:00.861Z",
            "createdAt": "2024-01-27T18:37:00.861Z",
            "title": "첫번째 title",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 8,
            "updatedAt": "2024-01-27T20:03:54.611Z",
            "createdAt": "2024-01-27T20:03:54.611Z",
            "title": "첫번째 title",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 2,
            "updatedAt": "2024-01-27T20:40:11.525Z",
            "createdAt": "2024-01-27T17:40:51.930Z",
            "title": "NestJS Lecture",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 3,
            "updatedAt": "2024-01-27T20:40:26.982Z",
            "createdAt": "2024-01-27T18:00:05.730Z",
            "title": "NestJS Lecture",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        }
    ]
}

PaginatePostDto에 기본값으로 정렬이 되게끔 만들었습니다. 하지만 DTO에서 기본값이 ASC가 되도록 만들려면 추가적인 작업 main.tstransform: true를 추가해야합니다. 왜냐하면 transform 변화는 해도 된다는 의미입니다.

만약에 Query에 값을 넣지 않았다면, 원래라면 넣지 않았다면 그대로 서비스로직에서 받아서 사용했습니다. 이게 class-validator와 class-transformer의 작동 방식입니다. 하지만 우리는 기본 값을 넣지 않아도 DTO에서 transformer(변형)을 통해서 DTO를 형성해주기를 원합니다.

즉, 변화라는 작업을 해도 좋습니다처럼, Dto에서 디폴트 값들을 넣어서 인스턴스를 생성해도 괞찬다라는 허가를 해주는 것입니다.

  • main.ts
async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.useGlobalPipes(new ValidationPipe({
      	transform: true, // 변화는 해도 된다.
    }));
    await app.listen(3000);
}
bootstrap();

포스트맨으로 테스트 하겠습니다. 순서가 바뀐 것을 알 수 있습니다.

{
    "data": [
        {
            "id": 2,
            "updatedAt": "2024-01-27T20:40:11.525Z",
            "createdAt": "2024-01-27T17:40:51.930Z",
            "title": "NestJS Lecture",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 3,
            "updatedAt": "2024-01-27T20:40:26.982Z",
            "createdAt": "2024-01-27T18:00:05.730Z",
            "title": "NestJS Lecture",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 4,
            "updatedAt": "2024-01-27T18:08:07.556Z",
            "createdAt": "2024-01-27T18:08:07.556Z",
            "title": "첫번째 title",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 5,
            "updatedAt": "2024-01-27T18:10:58.378Z",
            "createdAt": "2024-01-27T18:10:58.378Z",
            "title": "첫번째 title",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 6,
            "updatedAt": "2024-01-27T18:11:07.035Z",
            "createdAt": "2024-01-27T18:11:07.035Z",
            "title": "첫번째 title",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 7,
            "updatedAt": "2024-01-27T18:37:00.861Z",
            "createdAt": "2024-01-27T18:37:00.861Z",
            "title": "첫번째 title",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 8,
            "updatedAt": "2024-01-27T20:03:54.611Z",
            "createdAt": "2024-01-27T20:03:54.611Z",
            "title": "첫번째 title",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        }
    ]
}

🖊️Create Random data logic

테스트용 랜덤 컨트롤러와 서비스 로직을 작성하겠습니다.

  • posts.controller.ts
@Post('random')
@UseGuards(AccessTokenGuard)
async postPostsRandom(
  	@User() user: UsersModel
) {
    await this.postsService.generatePosts(user.id);
    return true;
}
  • posts.service.ts
async generatePosts(userId: number) {
    for(let i = 0; i < 100; i++) {
        await this.createPost(userId, {
            title: `임의로 생성된 ${i}`,
            content: `임의로 생성된 포수트 내용 ${i}`,
        });
    }
}

포스트맨으로 테스트하고 pgADMIN으로 체크하겠습니다.


🖊️Type Annotation & Implicit Conversion 적용

{
    "data": [
        {
            "id": 2,
            "updatedAt": "2024-01-27T20:40:11.525Z",
            "createdAt": "2024-01-27T17:40:51.930Z",
            "title": "NestJS Lecture",
            "content": "첫번쨰 content",
            "likeCount": 0,
            "commentCount": 0
        },
		.
        .
        {
            "id": 21,
            "updatedAt": "2024-01-28T02:12:53.903Z",
            "createdAt": "2024-01-28T02:12:53.903Z",
            "title": "임의로 생성된 12",
            "content": "임의로 생성된 포수트 내용 12",
            "likeCount": 0,
            "commentCount": 0
        }
    ]
}

포스트맨으로 실행을 하면 데이터가 20개씩 주는 것을 알 수 있습니다.

이번에는 다음 페이지를 보도록 하겠습니다.

{
    "message": [
        "where__id_more_than must be a number conforming to the specified constraints"
    ],
    "error": "Bad Request",
    "statusCode": 400
}

에러에서는 where__id_more_than이 number로 들어와야 한다고 합니다. Query의 경우 항상 모든 값을 string 형태로 간주합니다. 이 부분 때문에 에러가 발생한 것입니다.

  • paginate-post.dto.ts

number가 아닌 Number는 넣는 이유는 number는 type이기 때문에 안되고 함수 안에서는 값을 반환해야하기 때문에 Number를 받아야합니다.

import { Type } from "class-transformer";
.
.
@Type(() => Number) // 추가
@IsNumber()
@IsOptional()
where__id_more_than?: number;

포스트맨으로 테스트를 해보겠습니다.

{
    "data": [
        {
            "id": 25,
            "updatedAt": "2024-01-28T02:12:53.949Z",
            "createdAt": "2024-01-28T02:12:53.949Z",
            "title": "임의로 생성된 16",
            "content": "임의로 생성된 포수트 내용 16",
            "likeCount": 0,
            "commentCount": 0
        },
	    .
        .
        {
            "id": 44,
            "updatedAt": "2024-01-28T02:12:54.085Z",
            "createdAt": "2024-01-28T02:12:54.085Z",
            "title": "임의로 생성된 35",
            "content": "임의로 생성된 포수트 내용 35",
            "likeCount": 0,
            "commentCount": 0
        }
    ]
}

하지만 @Type()은 잘 사용하지 않습니다. 왜냐하면 귀찮기 때문입니다. 매번 붙이면 귀찮습니다. 따라서 다른 해결방법이 존재합니다.

// @Type(() => Number) 제거
@IsNumber()
@IsOptional()
where__id_more_than?: number;
  • main.ts
async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.useGlobalPipes(new ValidationPipe({
        transform: true,
        transformOptions: { // 추기
          	enableImplicitConversion: true // 임의로 변환하는 것을 허가한다.
        }
    }));
    await app.listen(3000);
}
bootstrap();

main.ts 코드는 임의로 변환하는 것을 허가한다라는 의미로, 만약 @Type()이 없고 @IsNumber()로 되어있으면 알아서 string을 number로 변환 후, @IsNumber()를 통과시킵니다.

따라서 enableImplicitConversion: true를 해줘, @Type()을 붙이는 귀찮은 작업이 필요가 없는 것입니다.

포스트맨으로 다시 테스트를 해도 동일한 결과가 나옵니다.

{
    "data": [
        {
            "id": 25,
            "updatedAt": "2024-01-28T02:12:53.949Z",
            "createdAt": "2024-01-28T02:12:53.949Z",
            "title": "임의로 생성된 16",
            "content": "임의로 생성된 포수트 내용 16",
            "likeCount": 0,
            "commentCount": 0
        },
	    .
        .
        {
            "id": 44,
            "updatedAt": "2024-01-28T02:12:54.085Z",
            "createdAt": "2024-01-28T02:12:54.085Z",
            "title": "임의로 생성된 35",
            "content": "임의로 생성된 포수트 내용 35",
            "likeCount": 0,
            "commentCount": 0
        }
    ]
}

🖊️Cursor Pagination 메타데이터 생성

  • posts.service.ts
async paginatePosts(dto: PaginatePostDto) {
    const posts = await this.postsRepository.find({
        where: {
            id: MoreThan(dto.where__id_more_than ?? 0),
        },
        order: {
        	createdAt: dto.order__createdAt,
        },
        take: dto.take,
    });

    // 해당되는 post가 0개 이상이면
    // 마지막 post를 가져오고
    // 아니면 null을 반환한다.
    const lastItem = posts.length > 0 ? posts[posts.length - 1] : null;
    const nextUrl = lastItem && new URL('http://localhost:3000/posts'); // lastItem이 존재하는 경우에만 URL 가져오기
    if (nextUrl) {
      /**
      * dto의 key값들을 반복하면서 key값에 해당되는 value가 존재하면
      * param에 그대로 붙여넣는다.
      * 
      * 단, where__id_more_than 값만 lastItem의 마지막 값으로 넣어준다.
      */
      for (const key of Object.keys(dto)) { // dto의 key 값들을 반복하는 것
          if (dto[key]) {
              if (key === 'where__id_more_than') {
                	// QueryParameter를 URL객체에서는 searchParam이라고 함
                	nextUrl.searchParams.append(key, lastItem.id.toString());
              } else {
                	nextUrl.searchParams.append(key, dto[key]);
              }
          }
      }
    }
    return {
        data: posts,
        cursor: {
          	after: lastItem?.id, // ?는 null 일 수도 있어서
        },
        count: posts.length,
        next: nextUrl?.toString() // ?는 null 일 수도 있어서
    }
}

posts는 말 드대로 데이터를 페이징해서 가져오는 것입니다. 그리고 lastItem은 페이징해서 가져온 데이터의 마지막 데이터를 가져옵니다. 마지막 데이터의 id를 알면 다음 페이징 데이터 id를 알 수 있기 때문입니다.

따라서 nextUrl의 경우 lastItem이 없는 경우에는 false가 되고 nextUrl은 null이 되게 됩니다. nextUrl이 false가 되었기 때문에 바로 return이 되는 것입니다.

반대로 nextUrl이 있는 경우, 받은 dto의 키값을 반복하면서 where__id_more_than value를 가져옵니다. 그러면 이 부분은 다음 페이징을 하는 url을 만들게 됩니다.

근데 nextUrl에서 배포시 http 부분이 달라질 수 있으니까, 조정을 해봅시다.

  • common/const/env.const.ts
export const PROTOCOL = 'http';
export const HOST = 'localhost:3000';
  • posts.service.ts
const nextUrl = lastItem && new URL(`${PROTOCOL}://${HOST}/posts`);

이제 포스트맨으로 테스트를 해봅시다.

{
    "data": [
        {
            "id": 25,
            "updatedAt": "2024-01-28T02:12:53.949Z",
            "createdAt": "2024-01-28T02:12:53.949Z",
            "title": "임의로 생성된 16",
            "content": "임의로 생성된 포수트 내용 16",
            "likeCount": 0,
            "commentCount": 0
        },
	    .
        .
        {
            "id": 44,
            "updatedAt": "2024-01-28T02:12:54.085Z",
            "createdAt": "2024-01-28T02:12:54.085Z",
            "title": "임의로 생성된 35",
            "content": "임의로 생성된 포수트 내용 35",
            "likeCount": 0,
            "commentCount": 0
        }
    ],
    "cursor": {
        "after": 44
    },
    "count": 20,
    "next": "http://localhost:3000/posts?order__createdAt=ASC&take=20&where__id_more_than=44" // ??????
} 

근데 보면 next 부분에 where__id_more_than이 들어가 있지 않습니다.

const posts = await this.postsRepository.find({
    where: {
      	id: MoreThan(dto.where__id_more_than ?? 0), // 이 부분 때문에
    },      
    order: {
    	createdAt: dto.order__createdAt,
    },
    take: dto.take,
});

왜냐하면 저희는 id: MoreThan(dto.where__id_more_than ?? 0)라고 디폴트를 0으로 해놓았습니다. 따라서 where__id_more_than에 키값이 존재하지 않기 때문에 나오지 않게 된 것입니다. 로직을 바꿔봅시다.

  • posts.service.ts
const lastItem = posts.length > 0 ? posts[posts.length - 1] : null;
const nextUrl = lastItem && new URL(`${PROTOCOL}://${HOST}/posts`);
if (nextUrl) {
    for (const key of Object.keys(dto)) {
        if (dto[key]) {
          	if (key !== 'where__id_more_than') nextUrl.searchParams.append(key, dto[key]); // 변경
        }
    }
    nextUrl.searchParams.append('where__id_more_than', lastItem.id.toString()); // 변경
}

포스트맨으로 테스트 하겠습니다. 올바르게 나온 것을 알 수 있습니다. 링크를 클릭해봅시다.

{
    "data": [
        {
            "id": 25,
            "updatedAt": "2024-01-28T02:12:53.949Z",
            "createdAt": "2024-01-28T02:12:53.949Z",
            "title": "임의로 생성된 16",
            "content": "임의로 생성된 포수트 내용 16",
            "likeCount": 0,
            "commentCount": 0
        },
        .
        .
        {
            "id": 44,
            "updatedAt": "2024-01-28T02:12:54.085Z",
            "createdAt": "2024-01-28T02:12:54.085Z",
            "title": "임의로 생성된 35",
            "content": "임의로 생성된 포수트 내용 35",
            "likeCount": 0,
            "commentCount": 0
        }
    ],
    "cursor": {
        "after": 44
    },
    "count": 20,
    "next": "http://localhost:3000/posts?order__createdAt=ASC&take=20&where__id_more_than=44"
}

{
    "data": [
        {
            "id": 45,
            "updatedAt": "2024-01-28T02:12:54.090Z",
            "createdAt": "2024-01-28T02:12:54.090Z",
            "title": "임의로 생성된 36",
            "content": "임의로 생성된 포수트 내용 36",
            "likeCount": 0,
            "commentCount": 0
        },
        .
        .
        {
            "id": 64,
            "updatedAt": "2024-01-28T02:12:54.232Z",
            "createdAt": "2024-01-28T02:12:54.232Z",
            "title": "임의로 생성된 55",
            "content": "임의로 생성된 포수트 내용 55",
            "likeCount": 0,
            "commentCount": 0
        }
    ],
    "cursor": {
        "after": 64
    },
    "count": 20,
    "next": "http://localhost:3000/posts?order__createdAt=ASC&take=20&where__id_more_than=64"
}

🖊️마지막 페이지 로직 조건 추가

{
    "data": [
        {
            "id": 107,
            "updatedAt": "2024-01-28T02:12:54.570Z",
            "createdAt": "2024-01-28T02:12:54.570Z",
            "title": "임의로 생성된 98",
            "content": "임의로 생성된 포수트 내용 98",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 108,
            "updatedAt": "2024-01-28T02:12:54.578Z",
            "createdAt": "2024-01-28T02:12:54.578Z",
            "title": "임의로 생성된 99",
            "content": "임의로 생성된 포수트 내용 99",
            "likeCount": 0,
            "commentCount": 0
        }
    ],
    "cursor": {
        "after": 108
    },
    "count": 2,
    "next": "http://localhost:3000/posts?order__createdAt=ASC&take=20&where__id_more_than=108"
}

원래 페이징은 20개씩 되는데 108로 조회할 경우 밑에 next와 같은 것들이 불편하게 느껴집니다. 이 부분을 제거해봅시다.

  • posts.service.ts
const lastItem = posts.length > 0 && posts.length === dto.take ? posts[posts.length - 1] : null; // 변경
const nextUrl = lastItem && new URL(`${PROTOCOL}://${HOST}/posts`);
if (nextUrl) {
    for (const key of Object.keys(dto)) {
        if (dto[key]) {
          	if (key !== 'where__id_more_than') nextUrl.searchParams.append(key, dto[key]);
        }
    }
    nextUrl.searchParams.append('where__id_more_than', lastItem.id.toString());
}

위의 로직은 posts의 길이가 0보다 커야하고, 20개를 요청했는데 19개가 오는 경우 마지막이기 때문에 null로 되도록 만드는 것입니다.

포스트맨으로 테스트를 해보겠습니다.


{
    "data": [
        {
            "id": 107,
            "updatedAt": "2024-01-28T02:12:54.570Z",
            "createdAt": "2024-01-28T02:12:54.570Z",
            "title": "임의로 생성된 98",
            "content": "임의로 생성된 포수트 내용 98",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 108,
            "updatedAt": "2024-01-28T02:12:54.578Z",
            "createdAt": "2024-01-28T02:12:54.578Z",
            "title": "임의로 생성된 99",
            "content": "임의로 생성된 포수트 내용 99",
            "likeCount": 0,
            "commentCount": 0
        }
    ],
    "cursor": {},
    "count": 2
}

🖊️다음커서가 존재X, undefined 대신 null 반환

위에 cursor에는 데이터가 공백입니다. 이 부분이 불편하기 때문에 다음 데이터가 없는 경우 null을 반환하도록 만들겠습니다. paginatePosts메소드 return 부분을 바꿔줍니다.

  • posts.service.ts
return {
    data: posts,
    cursor: {
      	after: lastItem?.id ?? null, // 변경
    },
    count: posts.length,
    next: nextUrl?.toString() ?? null // 변경
}
{
    "data": [
        {
            "id": 107,
            "updatedAt": "2024-01-28T02:12:54.570Z",
            "createdAt": "2024-01-28T02:12:54.570Z",
            "title": "임의로 생성된 98",
            "content": "임의로 생성된 포수트 내용 98",
            "likeCount": 0,
            "commentCount": 0
        },
        {
            "id": 108,
            "updatedAt": "2024-01-28T02:12:54.578Z",
            "createdAt": "2024-01-28T02:12:54.578Z",
            "title": "임의로 생성된 99",
            "content": "임의로 생성된 포수트 내용 99",
            "likeCount": 0,
            "commentCount": 0
        }
    ],
    "cursor": {
        "after": null
    },
    "count": 2,
    "next": null
}

🖊️내림차순 next 토큰 로직 작성

정렬은 현재 오름차순으로만 되어있습니다. 오름차순과 내림차순이 모두 가능하도록 만들어 봅시다.

  • paginate-post.dto.ts
export class PaginatePostDto {

     @IsNumber()
     @IsOptional()
     where__id_less_than?: number; // 추가

     @IsNumber()
     @IsOptional()
     where__id_more_than?: number;

     @IsIn(['ASC', 'DESC']) // DESC추가
     @IsOptional()
     order__createdAt?: 'ASC' | 'DESC' = 'ASC'; // DESC추가
  
     @IsNumber()
     @IsOptional()
     take: number = 20;
}
  • posts.service.ts
async paginatePosts(dto: PaginatePostDto) {

    const posts = await this.postsRepository.find({
        where: {
          	id: MoreThan(dto.where__id_more_than ?? 0),
        },
        order: {
            createdAt: dto.order__createdAt,
        },
        take: dto.take,
    });
  
    const lastItem = posts.length > 0 && posts.length === dto.take ? posts[posts.length - 1] : null;
    const nextUrl = lastItem && new URL(`${PROTOCOL}://${HOST}/posts`);
    if (nextUrl) {
        for (const key of Object.keys(dto)) {
            if (dto[key]) {
                if (key !== 'where__id_more_than' && key !== 'where__id_less_than') { // 추가
                      nextUrl.searchParams.append(key, dto[key]);
                }
            }
        }
    }
  	// 추가
    let key = null;
    if (dto.order__createdAt === 'ASC') key = 'where__id_more_than';
    else key = 'where__id_less_than';

    nextUrl.searchParams.append(key, lastItem.id.toString());
    return {
        data: posts,
        cursor: {
         	 after: lastItem?.id ?? null,
        },
        count: posts.length,
        next: nextUrl?.toString() ?? null
    }
}

포스트맨으로 테스트를 해보겠습니다.

{
    "data": [
        {
            "id": 108,
            "updatedAt": "2024-01-28T02:12:54.578Z",
            "createdAt": "2024-01-28T02:12:54.578Z",
            "title": "임의로 생성된 99",
            "content": "임의로 생성된 포수트 내용 99",
            "likeCount": 0,
            "commentCount": 0
        },
        .
        .
        {
            "id": 89,
            "updatedAt": "2024-01-28T02:12:54.431Z",
            "createdAt": "2024-01-28T02:12:54.431Z",
            "title": "임의로 생성된 80",
            "content": "임의로 생성된 포수트 내용 80",
            "likeCount": 0,
            "commentCount": 0
        }
    ],
    "cursor": {
        "after": 89
    },
    "count": 20,
    "next": "http://localhost:3000/posts?order__createdAt=DESC&take=20&where__id_less_than=89"
}

🖊️내림차순 where 쿼리 작성

이제 service코드에 where__id_less_than을 받았을 때 어떻게 할 것인지 로직을 작성해봅시다.

  • posts.service.ts
async paginatePosts(dto: PaginatePostDto) {

    // GoToDefinition: find -> FindManyOptions -> FindOneOptions -> where? 찾기
    const where: FindOptionsWhere<PostsModel> = {}; // 추가

    /**
    * {
    *   id: id: LessThan(dto.where__id_less_than)
    * }
    */
    if (dto.where__id_less_than) { // 추가
      	where.id = LessThan(dto.where__id_less_than);
    } else if(dto.where__id_more_than) {
      	where.id = MoreThan(dto.where__id_more_than);
    }
  
    const lastItem = posts.length > 0 && posts.length === dto.take ? posts[posts.length - 1] : null;
    const nextUrl = lastItem && new URL(`${PROTOCOL}://${HOST}/posts`);
    if (nextUrl) {
        for (const key of Object.keys(dto)) {
            if (dto[key]) {
                if (key !== 'where__id_more_than' && key !== 'where__id_less_than') {
                      nextUrl.searchParams.append(key, dto[key]);
                }
            }
        }
    }

    let key = null;
    if (dto.order__createdAt === 'ASC') key = 'where__id_more_than';
    else key = 'where__id_less_than';

    nextUrl.searchParams.append(key, lastItem.id.toString());
    return {
        data: posts,
        cursor: {
         	 after: lastItem?.id ?? null,
        },
        count: posts.length,
        next: nextUrl?.toString() ?? null
    }
}

포스트맨으로 테스트를 해봅시다.

{
    "data": [
        {
            "id": 108,
            "updatedAt": "2024-01-28T02:12:54.578Z",
            "createdAt": "2024-01-28T02:12:54.578Z",
            "title": "임의로 생성된 99",
            "content": "임의로 생성된 포수트 내용 99",
            "likeCount": 0,
            "commentCount": 0
        },
        .
        .
        {
            "id": 89,
            "updatedAt": "2024-01-28T02:12:54.431Z",
            "createdAt": "2024-01-28T02:12:54.431Z",
            "title": "임의로 생성된 80",
            "content": "임의로 생성된 포수트 내용 80",
            "likeCount": 0,
            "commentCount": 0
        }
    ],
    "cursor": {
        "after": 89
    },
    "count": 20,
    "next": "http://localhost:3000/posts?order__createdAt=DESC&take=20&where__id_less_than=89"
}

next를 클릭하면 잘나오는 것을 알 수 있습니다.

{
    "data": [
        {
            "id": 88,
            "updatedAt": "2024-01-28T02:12:54.422Z",
            "createdAt": "2024-01-28T02:12:54.422Z",
            "title": "임의로 생성된 79",
            "content": "임의로 생성된 포수트 내용 79",
            "likeCount": 0,
            "commentCount": 0
        },
        .
        .
        {
            "id": 69,
            "updatedAt": "2024-01-28T02:12:54.284Z",
            "createdAt": "2024-01-28T02:12:54.284Z",
            "title": "임의로 생성된 60",
            "content": "임의로 생성된 포수트 내용 60",
            "likeCount": 0,
            "commentCount": 0
        }
    ],
    "cursor": {
        "after": 69
    },
    "count": 20,
    "next": "http://localhost:3000/posts?order__createdAt=DESC&take=20&where__id_less_than=69"
}
profile
블로그 이전 : https://medium.com/@jaegeunsong97

0개의 댓글