🚀 프로젝트/🎸 고스락 티켓

[Gosrock/Nestjs] PageDto를 이용한 페이지네이션 구현하기 (Paging)

gengminy 2022. 8. 17. 14:16

고스락 티켓 예매 페이지 22th 프로젝트의 일부인

페이지네이션 / 페이징 구현에 대한 글입니다

 

어드민 페이지에서 내가 구현한 티켓 서비스의 티켓을

특정 조건에 맞게 N개 가져올 필요가 있었다

 

그래서 페이지네이션을 제네릭을 이용하여 구현하게 되었다

 

언제 들었는지 기억은 안나지만 이런 말이 문득 생각난다

개발자가 힘들수록 사용자는 편리해진다

 

페이지네이션도 그렇다

서버 개발자는 페이징 구현이 귀찮고 짜증나지만

그것을 사용하는 프론트 개발자는 편할지어니,,,,,,

 

 

🔨 PageOptionsDto 구현

📝 enum.ts

enum PageOrder {
  ASC = 'ASC',
  DESC = 'DESC'
}

오름차순 / 내림차순 옵션을 위한 Enum 이다

 

📝 page-options.dto.ts

export class PageOptionsDto {
  @ApiPropertyOptional({ enum: PageOrder, default: PageOrder.ASC })
  @IsEnum(PageOrder)
  @IsOptional()
  @Expose()
  readonly order: PageOrder = PageOrder.ASC;

  @ApiPropertyOptional({
    minimum: 1,
    default: 1
  })
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Expose()
  readonly page: number = 1;

  @ApiPropertyOptional({
    minimum: 1,
    maximum: 50,
    default: 10
  })
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(50)
  @Expose()
  readonly take: number = 10;

  get skip(): number {
    return (this.page - 1) * this.take;
  }
}

page-options.dto 는 Request, page.dto 는 Response 용이다

우선 Request 를 먼저 보내는게 순서니까 옵션부터 알아보자

 

페이지 옵션에는 order, page, take, skip 이 네 가지 옵션이 들어간다

  • order : 오름차순 / 내림차순 정렬
  • page : 현재 가져올 페이지 번호
  • take : 한 페이지 당 몇 개의 원소를 가져올지
  • skip : 탐색을 시작하는 원소의 위치 ({현재 페이지 -1} * 가져오는 원소 개수)

그리고 각 멤버에 대해 validation 을 해주면 끝이다

여기서는 integer 조건과 최소, 최대, 기본값을 지정해주었다

 

skip은 해당 멤버를 불러올 때마다 값이 달라지기 때문에

get 으로 따로 처리했다

 

 

🔨 TicketFindDto 구현

📝 enum.ts

enum PerformanceDate {
  YB = 'YB',
  OB = 'OB'
}

enum TicketStatus {
  DONE = '입장완료',
  ENTERWAIT = '입금확인',
  ORDERWAIT = '확인대기',
  EXPIRE = '기한만료'
}

 

 

티켓 상태에 대한 enum 이다

 

📝 ticket-find.dto.ts

export class TicketFindDto {
  @ApiProperty({
    description: '티켓 상태',
    enum: TicketStatus,
    required: false
  })
  @IsEnum(TicketStatus)
  @IsOptional()
  @Expose()
  readonly status: TicketStatus;

  @ApiProperty({
    description: '공연 날짜',
    enum: PerformanceDate,
    required: false
  })
  @IsEnum(PerformanceDate)
  @IsOptional()
  @Expose()
  readonly date: PerformanceDate;
}

스웨거를 위해서 DTO 를 추가로 구현했다

여기서는 티켓의 상태와 공연 날짜를 추가로 조건으로 받았다

이것도 어드민 페이지에서 편하게 쓰기 위해 검색 조건에 추가한 것이다

 

또한 이 조건들이 붙지 않았을 때는

해당 조건에서 값에 상관없이 모든 결과를 불러올 수 있도록 하는게 의도인데

스웨거에서도 사용하기 쉽도록 required: false 옵션을 붙여주었다

 

 

🔨 Ticket 모듈에 적용

📝 tickets.controller.ts

  @ApiOperation({
    summary: '[어드민]해당 조건의 티켓을 모두 불러온다'
  })
  @Get('/find')
  @Roles(Role.Admin)
  getTicketsWith(
    @Query() ticketFindDto: TicketFindDto,
    @Query() pageOptionsDto: PageOptionsDto
  ) {
    return this.ticketService.findAllWith(ticketFindDto, pageOptionsDto);
  }

일단 다음 페이지로 가기, 또는 뒤로가기나 앞으로 가기를 했을 때에도

이전 검색 결과를 유지하기 위해서 GET 메소드를 사용했고

검색 조건은 쿼리 파라미터로 넘겨주었다

 

아까 만든 TicketFindDto와 PageOptionsDto 를 레포지토리까지 넘겨주면 된다

 

 

📝 tickets.repository.ts

  /**
   * 해당 ticketStatus를 참조하여 해당하는 Ticket 엔티티를 가지고 온다 (관리자용)
   * @param ticketStatus TicketStatus Enum
   * @param pageOptionsDto 페이지네이션 메타 정보
   */
  async findAllWith(
    ticketFindDto: TicketFindDto,
    pageOptionsDto: PageOptionsDto
  ): Promise<PageDto<Ticket>> {
    const { status, date } = ticketFindDto;
    const queryBuilder = this.ticketRepository.createQueryBuilder('ticket');

    //조건부 검색
    if (status) {
      queryBuilder.andWhere({ status });
    }
    if (date) {
      queryBuilder.andWhere({ date });
    }

    queryBuilder
      .orderBy('ticket.id', pageOptionsDto.order)
      .leftJoinAndSelect('ticket.user', 'user')
      .leftJoinAndSelect('ticket.admin', 'admin')
      .skip(pageOptionsDto.skip)
      .take(pageOptionsDto.take);

    const itemCount = await queryBuilder.getCount();
    const { entities } = await queryBuilder.getRawAndEntities();

    const pageMetaDto = new PageMetaDto({ pageOptionsDto, itemCount });

    //console.log(1);
    return new PageDto(entities, pageMetaDto);
  }

TicketFindDto 의 멤버를 찾아서

값이 있을 때만 queryBuilder 의 where 절에 추가시켜준다

 

쿼리를 날릴 때도 order, skip, take 조건이 있기 때문에

이를 pageOptionsDto 에서 찾아서 넣어준다

 

이후 결과에서 전체 검색 결과의 개수와 엔티티를 가져오는데

보통은 쿼리 빌더에서 getMany 나 getOne으로 가져오는 것과 달리

getRawAndEntities 를 통해 원시 데이터(raw data)를 추출해낸다

 

이건 내가 pageDto 구현부에서 데이터를 제네릭으로 구현했기 때문이다

 

그냥 getMany 로 들고 오면 값이 안들어 가는걸 확인할 수 있다

제네릭으로 구현했기 때문에 Ticket 엔티티에 제대로 매칭이 안된다

 

그래서 raw data를 뽑아내서 직접 넣어주면 된다

 

이제 내가 사용한 페이징 옵션과 결과 개수를 메타 정보로 만든 후에

결과인 PageDto에 넣어주면 된다

 

 

🔨 PageDto, PageMetaDto 구현

📝 page-meta-dto.interface.ts

import { PageOptionsDto } from './page-options.dto';

export interface PageMetaDtoParameters {
  pageOptionsDto: PageOptionsDto;
  itemCount: number;
}

PageOptionsDto 와 아이템 결과의 개수를 가지고 있는

타입스크립트 타입 적용을 위한 인터페이스

 

📝 page-meta.dto.ts

export class PageMetaDto {
  @ApiProperty({ description: '페이지 정보입니다.' })
  @Expose()
  readonly page: number;

  @ApiProperty({ description: '몇개를 받아가는지 한페이지 당 원소갯수' })
  @Expose()
  readonly take: number;

  @ApiProperty({ description: '총 아이템 숫자 ( 검색 조건에 맞는 )' })
  @Expose()
  readonly itemCount: number;

  @ApiProperty({ description: '총 페이지 숫자 ( 검색 조건에 맞는 )' })
  @Expose()
  readonly pageCount: number;

  @ApiProperty({ description: '이전페이지가 있는지에 대한정보' })
  @Expose()
  readonly hasPreviousPage: boolean;

  @ApiProperty({ description: '다음페이지가 있는지에 대한 정보' })
  @Expose()
  readonly hasNextPage: boolean;

  constructor({ pageOptionsDto, itemCount }: PageMetaDtoParameters) {
    this.page = pageOptionsDto.page;
    this.take = pageOptionsDto.take;
    this.itemCount = itemCount;
    this.pageCount = Math.ceil(this.itemCount / this.take);
    this.hasPreviousPage = this.page > 1;
    this.hasNextPage = this.page < this.pageCount;
  }
}

호출할 때 사용한 PageOptionsDto 를 그대로 넣어주고

생성자에서 값을 변환해 넣어준다

 

각 멤버에 대한 설명은 주석을 참고

 

여기서 전체페이지, 이전 페이지가 있는지, 다음 페이지가 있는지 등등을 검사해서 저장한다

어드민 페이지에서 참고하기 정말 좋은 정보들이다

 

 

📝 page.dto.ts

export class PageDto<T> {
  @IsArray()
  @ApiProperty({ type: 'generic', isArray: true })
  @Expose()
  readonly data: T[];

  @ApiProperty({ type: () => PageMetaDto })
  @Type(() => PageMetaDto)
  @Expose()
  readonly meta: PageMetaDto;

  constructor(data: T[], meta: PageMetaDto) {
    this.data = data;
    this.meta = meta;
  }
}

제네릭으로 구현해서 어떠한 타입이든 처리할 수 있도록 했다

아까 raw data 로 결과 엔티티를 넣어버린 이유도 제네릭 때문

이렇게 만들어두어서 다른 모듈에서도 호출할 수 있다

 

Order, User 에서도 이 dto 정보를 사용중이다 하 하

 

스웨거 정보

 

{
  "statusCode": "상태코드",
  "success": "성공여부",
  "data": {
    "data": [
      {
        "id": "티켓 고유 식별번호입니다.",
        "uuid": "티켓의 고유 아이디(uuid) 입니다.",
        "date": "공연일자 입니다. (YB/OB)",
        "status": "티켓의 상태입니다. (입장대기/입장완료)",
        "admin": {
          "id": "유저의 고유 아이디입니다.",
          "name": "유저의 입금자명입니다.",
          "phoneNumber": "유저의 휴대전화번호 입니다.",
          "role": "유저의 권한입니다."
        },
        "user": {
          "id": "유저의 고유 아이디입니다.",
          "name": "유저의 입금자명입니다.",
          "phoneNumber": "유저의 휴대전화번호 입니다.",
          "role": "유저의 권한입니다."
        },
        "createdAt": "티켓 생성 일자"
      }
    ],
    "meta": {
      "page": "페이지 정보입니다.",
      "take": "몇개를 받아가는지 한페이지 당 원소갯수",
      "itemCount": "총 아이템 숫자 ( 검색 조건에 맞는 )",
      "pageCount": "총 페이지 숫자 ( 검색 조건에 맞는 )",
      "hasPreviousPage": "이전페이지가 있는지에 대한정보",
      "hasNextPage": false
    }
  }
}

이렇게 데이터 필드에 결괏값 배열이 채워지고

메타 정보와 같이 나오게 된다

 

또 중요한게 결과가 없을 때에도 null 이 아닌 빈 배열을 주어야 한다

그래야 프론트에서도 처리하기가 더 수월하다

 

관리자 페이지에서 구현된 모습

 

생각보다 ORM 동작 원리에 대해 빠삭하게 알아야하고

쿼리 빌더로 쿼리 날리는 것까지 열심히 공부해야했던 구현이었다

 

그래도 막상 구현해보니 재미있었다

실력이 쑥쑥 늘어나는 느낌😎