JS/Nest.js

[Nest] 커서기반 page 구현

Mini_96 2024. 9. 21. 01:27

* 설계

이 요청에 대해
이런 json을 응답해주는게 목표임.

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

 

* dto 생성

  • dto
    • main에서 transform true 해줘야 기본값이 반영됨.
import { IsIn, IsNumber, IsOptional } from "class-validator";

export class PaginatePostDto {

  /**
   * 이전 마지막 데이터의 ID
   * 이것 이후로 데이터를 가져와야함
   */
  @IsNumber()
  @IsOptional()
  where__id_more_than?:number;

  @IsIn(['ASC', 'DESC']) //값제한
  @IsOptional()
  order__createdAt?: 'ASC' = 'ASC' ; //기본값은 ASC

  /**
   * 몇개의 데이터를 가져올건지
   */
  @IsNumber()
  @IsOptional()
  take: number=20;
}
app.useGlobalPipes(new ValidationPipe({
  transform: true, //dto를 수정 가능하게(dto 기본값 들어가도록)
}));
  • 컨트롤러
@Get()
getAllPosts(
  @Query() query : PaginatePostDto
){
  return this.postsService.getAllPosts();
}
  • 서비스 구현중
    • dto.id가 빌경우,?? 로 기본값 줘야함에 주의!
async paginatePosts(dto : PaginatePostDto){
  const posts = await this.postsRepository.find({
    where:{
      id: MoreThan(dto.where__id_more_than ?? 0), //없으면 기본값 0으로!
    },
    order:{
      createdAt: dto.order__createdAt,
    },
    take: dto.take,
  });

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

  return {
    data : posts,
    // cursor : {
    //   after : posts[posts.length-1].id,
    // },
    count : posts.length,
    next : "",
  }
}
  • 컨트롤러
    • pageDto는 query로 받는게 관례적임
@Get()
getAllPosts(
  @Query() query : PaginatePostDto
){
  return this.postsService.paginatePosts(query);
}

결과

 

* test용 데이터 생성

@Post('random')
@UseGuards(AccessTokenGuard)
async postPostRandom(@User() user: UsersModel){
  await this.postsService.generatePosts(user.id);

  return true;
}
async generatePosts(userId : number){
  for(let i=0;i<100;++i){
    await this.createPost(userId, {
      title : `임의로 생성된 제목 ${i}`,
      content : `임의로 생성된 내용 ${i}`,
    })
  }
}

 

 

* Type Annotation

  • 문제 : url은 기본적으로 string임 => 엔티티의 @IsNumber와 충돌

  • 해결1 : @Type => 검사전에, 강제형변환
export class PaginatePostDto {

  /**
   * 이전 마지막 데이터의 ID
   * 이것 이후로 데이터를 가져와야함
   */
  @Type(()=>Number)
  @IsNumber()
  @IsOptional()
  where__id_more_than?:number;

  • 해결2 : main에서 enable옵션 켜주기 (추천!)
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(new ValidationPipe({
    transform: true, //dto를 수정 가능하게(dto 기본값 들어가도록)
    transformOptions:{
      enableImplicitConversion: true, //Class-Validator Type에 맞게 자동형변환
    }
  }));

  await app.listen(3000);
}
bootstrap();
  • dto의 어노테이션에 맞춰서 형변환 해줌.
 */
@IsNumber()
@IsOptional()
where__id_more_than?:number;

 

* cnt, cursor, next 값 response 구현

async paginatePosts(dto : PaginatePostDto){
  const posts = await this.postsRepository.find({
    where:{
      id: MoreThan(dto.where__id_more_than ?? 0), //없으면 기본값 0으로!
    },
    order:{
      createdAt: dto.order__createdAt,
    },
    take: dto.take,
  });

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

  const lastItem = posts.length > 0 ? posts[posts.length-1] : null;

  //lastItem이 존재하는 경우에만
  const nextUrl = lastItem && new URL(`${PROTOCOL}://${HOST}/posts`);
  if(nextUrl){
    /**
     * dto 의 키값들 돌면서 (id, order, take)
     * param 채우기
     */
    for(const key of Object.keys(dto)){
      if(dto[key]){ //값이 있는지 체크
        if(key !== 'where__id__more_than'){ //나머지 속성들 넣어주고
          nextUrl.searchParams.append(key, dto[key]); //order=ASC&take=20
        }
      }
    }
    //마지막으로 id 넣어주기 (req(dto)에 id 입력안한경우도 작동해야함)
    //where__id=20
    nextUrl.searchParams.append('where__id__more_than', lastItem.id.toString());
  }

  return {
    data : posts,
    cursor : {
      after : lastItem.id,
    },
    count : posts.length, //null인경우 실행안됨.
    next : nextUrl.toString(), //toString으로 객체를 str로 바꿔야 표시됨!
  }
}

 

* 문제

  • 마지막 페이지인경우에도 next를 반환하고있음

  • 해결 : lastItem을 가져오는 조건추가
//가져온 게시물의길이 === 가져와야할 게시글 -> 정상작동
// 아닌경우 : 마지막페이지임 -> lastItem = null
const lastItem = posts.length > 0 && posts.length === dto.take ? posts[posts.length-1] : null;
  • return 시 ?로 예외처리 필수!
return {
  data : posts,
  cursor : {
    after : lastItem?.id, //null인경우 실행안됨 예외처리!.
  },
  count : posts.length, //null인경우 실행안됨.
  next : nextUrl?.toString(), //toString으로 객체를 str로 바꿔야 표시됨!
}

next가 없는 모습

  • ?? 를 이용해서 null로 명시해주기
return {
  data : posts,
  cursor : {
    after : lastItem?.id ?? null, //null인경우 실행안됨 예외처리!.
  },
  count : posts.length, //null인경우 실행안됨.
  next : nextUrl?.toString() ?? null, //toString으로 객체를 str로 바꿔야 표시됨!
}

 

* 내림차순 페이징 구현

  • dto 추가
/**
 * DESC와 짝궁
 */
@IsNumber()
@IsOptional()
where__id_less_than?:number;

서비스

 

  • where 객체 만들기
    • dto의 정렬 옵션에 따른 분기

async paginatePosts(dto : PaginatePostDto){
  //where 객체 만들기
  //typeorm 코드 긁어오기 => 자동완성, typeSafe 보장받음
  const where : FindOptionsWhere<PostsModel> = { }
  if(dto.where__id_less_than){
    where.id = LessThan(dto.where__id_less_than);
  }
  else{
    where.id = LessThan(dto.where__id_more_than);
  }

  const posts = await this.postsRepository.find({
    where,
    order:{
      createdAt: dto.order__createdAt,
    },
    take: dto.take,
  });