Home
프로필

페이지네이션 일반화

image

💡 일반화의 필요성

💡 일반화 구현

  • 일반화 단계에서는 특정 모듈이 아니라 common(공통) 모듈에서 작업한다.

  • 페이지네이션에서 그랬던 것처럼 우선 DTO를 만든다.

  • 이 DTO는 모든 유형의 페이지네이션을 커버하는 부모 DTO이다.

    base-pagination.dto.ts

    export class BasePaginationDto {
    @IsNumber()
    @IsOptional()
    page: number;
    @IsNumber()
    @IsOptional()
    where__id__more_than?: number;
    @IsNumber()
    @IsOptional()
    where__id__less_than?: number;
    // 정렬 기준
    @IsIn(['ASC', 'DESC'])
    @IsOptional()
    order__createdAt: 'ASC' | 'DESC' = 'ASC';
    // 요청하는 아이템의 수
    @IsNumber()
    @IsOptional()
    take: number = 20;
    }

  • 이전과 달라진 점

    • 페이지 기반의 페이지네이션도 지원하기 위해 page 프로퍼티도 추가했다.
    • 글을 최신순으로 보여주기 위해 내림차순 정렬 where__id__less_than도 추가했다.
  • 만들어진 부모 DTO를 상속한다

    post-pagination.dto.ts

    export class PostPaginationDto extends BasePaginationDto {
    @IsNumber()
    @IsOptional()
    where__likeCount__more_than?: number;
    }

💡 서비스 로직 작성 1

  • DTO를 common 모듈에서 만들었듯, 페이지네이트 함수도 마찬가지다.
  • 로직에서 챙겨할 부분을 순서대로 정리하면
    • 먼저 DTO를 기반으로 파인드 옵션을 구성하여 응답할 데이터를 찾는다.
    • 파인드 옵션에는 조건(where)이 들어가고 그에 해당하는 데이터를 얻는다.
    • 얻은 데이터를 바탕으로 메타데이터(데이터의 수, 마지막 데이터의 id, 다음 요청에 쓰일 url)를 만든다.
  • 그럼 천천히 따라가보자.
    • 모든 데이터가 들어올 수 있도록 제네릭을 설정한다. 제네릭 T에는 각각의 모델 엔티티가 들어오고 이는 BaseModel을 상속받는다.
    common.service.ts

    @Injectable()
    export class CommonService {
    private async cursorPaginate<T extends BaseModel>(
    dto: BasePaginationDto,
    repository: Repository<T>,
    overrideFindOptions: FindManyOptions<T> = {},
    path: string,
    ){
    ...
    }
    }

    • 로직을 처리하기 위해 요구되는 정보를 생각해본다. 이를 깡그리 파라미터로 받도록 하자. 일단, DTO는 빼박이다. DTO에 담긴 쿼리를 분석하여 데이터를 찾는다.
    • 그리고 데이터를 찾는 데는 리포지토리가 이용되기 때문에 그를 두번째 파라미터로 받는다. 세번째는 추가 옵션을 넣어야 할 때를 대비해 overrideFindOptions을, 마지막은 nextURL을 만들기 위해 path를 받는다.
    common.service.ts

    @Injectable()
    export class CommonService {
    private async cursorPaginate<T extends BaseModel>(
    dto: BasePaginationDto,
    repository: Repository<T>,
    overrideFindOptions: FindManyOptions<T> = {},
    path: string,
    ){
    ...
    }
    }

    • 그럼 데이터를 찾아보자. 리포지토리에 find를 쓰면 된다는 것을 이미 알고 있다. 가령 이런 식인데, 문제는 하드코딩을 해서는 모든 상황에 유연하게 대처할 수 없다는 거다. DTO에 의해 형식이 제한되긴 하지만, DTO가 받는 값들 내에서도 분기처리 할 지점들이 생기기 마련이다. 가령 오름차순이냐 내림차순이냐 같은 문제가 있다.
    common.service.ts

    @Injectable()
    export class CommonService {
    private async cursorPaginate<T extends BaseModel>(
    dto: BasePaginationDto,
    repository: Repository<T>,
    overrideFindOptions: FindManyOptions<T> = {},
    path: string,
    ){
    const results = await this.repository.find({
    where: {
    id: MoreThan(dto.where__id__more_than),
    },
    order: {
    createdAt: dto.order__createdAt,
    },
    ...overrideFindOptions,
    });
    ...
    }
    }

    • 모든 데이터가 들어올 수 있도록 제네릭을 설정한다. 제네릭 T에는 각각의 모델 엔티티가 들어오고 이는 BaseModel을 상속받는다.
    • 로직을 처리하기 위해 요구되는 정보를 생각해본다. 이를 깡그리 파라미터로 받도록 하자. 일단, DTO는 빼박이다. DTO에 담긴 쿼리를 분석하여 데이터를 찾는다.
    • 그리고 데이터를 찾는 데는 리포지토리가 이용되기 때문에 그를 두번째 파라미터로 받는다. 세번째는 추가 옵션을 넣어야 할 때를 대비해 overrideFindOptions을, 마지막은 nextURL을 만들기 위해 path를 받는다.
    • 그럼 데이터를 찾아보자. 리포지토리에 find를 쓰면 된다는 것을 이미 알고 있다. 가령 이런 식인데, 문제는 하드코딩을 해서는 모든 상황에 유연하게 대처할 수 없다는 거다. DTO에 의해 형식이 제한되긴 하지만, DTO가 받는 값들 내에서도 분기처리 할 지점들이 생기기 마련이다. 가령 오름차순이냐 내림차순이냐 같은 문제가 있다.
    common.service.ts

    @Injectable()
    export class CommonService {
    private async cursorPaginate<T extends BaseModel>(
    dto: BasePaginationDto,
    repository: Repository<T>,
    overrideFindOptions: FindManyOptions<T> = {},
    path: string,
    ){
    ...
    }
    }


  • 이런 경우의 수를 분기처리하는 메써드를 따로 만든다. 케이스는 두 가지다. whereorder. 내용이 길어지니 새로운 챕터에서 다시 정리하고 짚어 본다.

💡 서비스 로직 작성 2

  • 기존에는 id값을 기준으로 이상인 값만 필터링했다(위 코드의 예시).

  • 그런데 필터링 기준이 이하인 값일 수도, 아니 아예 id가 아닌 경우가 올 수 있다.

  • 가령 내가 좋아요 누른 포스트만 모아 본다든가, 특정 가격대만 본다든가 등등

  • 이런 필터링 기준은 무궁무진하다. 그리고 거기서 일반화의 필요도 생겨난다.

  • 무엇을 해야 할지 알았으니 필터링을 유연하게 가져갈 수 있는 로직을 작성한다.

    • typeormFindManyOptions을 다룬다.

    • where로 시작한다면 필터 로직을 적용한다.

    • order로 시작한다면 정렬 로직을 적용한다.

    • 필터 로직을 적용했다면 언더바 두개 __를 기준으로 split 했을 때 3개의 값을 얻어내게 된다(프로퍼티, 키, 유틸리티).

    • 정렬 로직은 이 경우 항상 2개의 값으로 나뉜다.

    • 그래서 3개의 값으로 나뉘면, 즉 where 필터면, 2번째 값에 해당하는 오퍼레이터 함수를 적용하여 필터링에 활용한다.

      common.service.ts

      @Injectable()
      export class CommonService {
      private parseFilter<T extends BaseModel>(key: string, value: string): FindManyOptions<T>{
      const options = {} satisfies FindOptionsWhere<T> | FindOptionsOrder<T>
      const split = key.split('__');
      if(split.length !== 2 && split.length !== 3)
      throw new BadRequestException();
      if(split.length === 2){
      const [_, field] = split;
      options[field] = value;
      } else if(split.length === 3){
      const [_, field, operator] = split as [string ,string, TFilterMapper];
      options[field] = FilterMapper[opertator](value);
      }
      return options;
      }
      }

    • 어떤 오퍼레이터를 적용해야 할지는 필터맵퍼를 이용한다. 맵핑을 위해 작성한 내용은 다음과 같다.

      filter-mapper.const.ts

      import {
      ArrayContainedBy,
      ArrayContains,
      ...
      } from 'typeorm';
      export const FILTER_MAPPER = {
      not: Not,
      less_than: LessThan,
      more_than: MoreThan,
      less_than_or_equal: LessThanOrEqual,
      more_than_or_equal: MoreThanOrEqual,
      equal: Equal,
      like: Like,
      i_like: ILike,
      between: Between,
      in: In,
      any: Any,
      is_null: IsNull,
      array_contains: ArrayContains,
      array_contained_by: ArrayContainedBy,
      array_overlap: ArrayOverlap,
      };
      export type TFilterMapper = keyof typeof FILTER_MAPPER;

    • 이제 composeFindOptions 메써드를 만든다. DTO를 받아 각각을 담는 변수 whereorder를 선언한다. 이후 반복문을 돌리며 parseFilter에 의해 맵핑된 내용을 저장하고 반환한다.

      common.service.ts

      @Injectable()
      export class CommonService {
      private composeFindOptions<T extends BaseModel>(
      dto: BasePaginationDto,
      ): FindManyOptions<T>{
      let where = {} satisfies FindOptionsWhere<T>
      let order = {} satisfies FindOptionsOrder<T>
      for(const [key, value] of Object.entries(dto)){
      if(key.startsWith('where__')){
      where = {
      ...where,
      ...this.parseFilter(key, value),
      }
      } else if(key.startsWith('order__')){
      order = {
      ...order,
      ...this.parseFilter(key, value)
      }
      }
      }
      return {
      where,
      order,
      take: dto.take,
      skip: dto.page ? dto.take * (dto.page - 1) : null
      }
      }
      }

  • 여기까지 하면 데이터를 find하는 로직이 완성됐다. 남은 건 이 데이터를 기반으로 메타데이터를 구성하는 일이다. 이전에 작성했던 것과 거의 같으므로 설명은 생략한다.

    common.service.ts

    @Injectable()
    export class CommonService {
    private async cursorPaginate<T extends BaseModel>(
    dto: BasePaginationDto,
    repository: Repository<T>,
    overrideFindOptions: FindManyOptions<T> = {},
    path: string,
    ){
    const findOptions = this.composeFindOptions<T>(dto);
    const results = await this.repository.find({
    ...findOptions,
    ...overrideFindOptions,
    });
    // 메타데이터 구성
    ...
    }
    ...
    }

💡 Whitelist

  • 클라이언트에서 실수로 쿼리를 잘못 작성했을 경우가 있다. 혹은 해커들이 악용하거나
  • 그럴 때를 대비해서 DTO에 정의한 값 외의 쿼리가 들어올 때 이를 차단하는 법이 있다.
  • main.tsValidation에서 whitelist 옵션을 주면 된다.
  • 그러면 밸리데이터에서 검증하지 않는 모든 프로퍼티를 삭제(거부)한다.
  • forbidNonWhiteListed 옵션은 whitelist 옵션과 함께 사용되며, 잘못된 쿼리가 들어왔을 때 에러를 내 문제를 알려준다.
    main.ts

    async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.useGlobalPipes(
    new ValidationPipe({
    transform: true,
    transformOptions: {
    enableImplicitConversion: true,
    },
    whitelist: true,
    forbidNonWhitelisted: true,
    }),
    );
    await app.listen(3000);
    }
    bootstrap();


Comment ?

▾ Comment

🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧 🚧