본문 바로가기
NestJS

NestJS - Pagination 심화 (일반화하기)

by Programmer.Junny 2025. 3. 5.

이전 Pagination 일반 포스팅에서 pagePaginationcursorPagination을 알아보고 구현해보았다.

그러나 해당 로직들을 그대로 사용하기엔 조금 무리가 있다.

Like, ILike, Between 등 다양한 필터들을 사용할 수 없기 때문이다. 이러한 필터들과 옵션들을 사용할 수 있도록 하기 위한 일반화 과정이 필요하다.

BasePaginationDto 생성하기

우선 PaginatePostDto 에 작성되어있는 프로퍼티들을 공통으로 사용할 수 있도록 BasePaginationDto를 구현하도록 한다.

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

export class BasePaginationDto {
    @IsNumber()
    @IsOptional()
    page?: number;

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

    @IsIn(['ASC', 'DESC'])
    @IsOptional()
    // eslint-disable-next-line @typescript-eslint/prefer-as-const
    order__createdAt: 'ASC' | 'DESC' = 'ASC';

    // 몇 개의 데이터를 받을지
    @IsNumber()
    @IsOptional()
    take: number = 20;
}

해당 코드는 PaginatePostDto에 있던 프로퍼티들을 그대로 옮긴 것이다.

common.service.ts 에 paginate 메서드 선언하기

일반화를 하기 위해 기존 posts.service.ts에 작성되어있는 로직들을 common.service.ts로 일반화하여 옮기도록 하자.

paginate<T extends BaseModel>(
        dto: BasePaginationDto,
        repository: Repository<T>,
        overrideFindOptions: FindManyOptions<T> = {},
        path: string,
    ) {
        if(dto.page) {
            return this.pagePaginate(
                dto,
                repository,
                overrideFindOptions,
            );
        } else {
            return this.cursorPaginate(
                dto,
                repository,
                overrideFindOptions,
                path,
            );
        }
    }

paginate 메서드는 제네릭으로 구현했다. Repository와 FindManyOptions 에서 제네릭을 넣기 위해 사용된다.

pagePaginate 메서드 구현하기

private async pagePaginate<T extends BaseModel>(
        dto: BasePaginationDto,
        repository: Repository<T>,
        overrideFindOptions: FindManyOptions<T> = {},
    ) {
        const findOptions = this.composeFindOptions<T>(dto);

        const [data, count] = await repository.findAndCount({
            ...findOptions,
            ...overrideFindOptions,
        });

        return {
            data,
            total: count,
        }
    }

pagePaginate 메서드는 내부적으로 쓰기 때문에 접근제한자를 private으로 설정했다. 또한 composeFindOptions<T> 를 통해 where, order, take, skip과 같은 옵션들을 가져와 설정한다.

cursorPaginate 메서드 구현하기

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 repository.find({
            ...findOptions,
            ...overrideFindOptions,
        });

        const whereMoreThanName = 'where__id__more_than';
        const whereLessThanName = 'where__id__less_than';

        const lastItem = results.length > 0 && results.length === dto.take ? results[results.length - 1] : null;
    
        const nextURL = lastItem && new URL(`${PROTOCOL}://${HOST}/${path}`);
    
        if(nextURL) {
            for(const key of Object.keys(dto)) {
                if(dto[key]) {
                    if(key !== whereMoreThanName && key !== whereLessThanName) {
                        nextURL.searchParams.append(key, dto[key]);
                    } 
                }
            }
    
            let key: string | null = null;
    
            if(dto.order__createdAt === 'ASC') {
                key = whereMoreThanName;
            } else {
                key = whereLessThanName;
            }
    
            nextURL.searchParams.append(key, lastItem.id.toString());
        }

        return {
            data: results,
            cursor: {
                after: lastItem?.id ?? null,
            },
            count: results.length,
            next: nextURL?.toString() ?? null,
        }
    }

마찬가지로 composeFindOptions<T>로 옵션들을 가져온 뒤 repository.find를 통해 걸러진 데이터들을 가져온 뒤, 이후엔 기존과 같이 nextURL 등을 만들도록 진행한다.

composeFindOptions 메서드 구현하기

private composeFindOptions<T extends BaseModel>(
        dto: BasePaginationDto,
    ) : FindManyOptions<T> {
        /**
         * where
         * order,
         * take,
         * skip -> page 기반일때만,
         */

        let where: FindOptionsWhere<T> = {};
        let order: FindOptionsOrder<T> = {};
        
        // dto를 plain object로 변환
        const plainDto = JSON.parse(JSON.stringify(dto));
        for(const [key, value] of Object.entries(plainDto)) {
            if(key.startsWith('where__')) {
                where = {
                    ...where,
                    ...this.parseWhereFilter(key, value),
                }
            } else if(key.startsWith('order__')) {
                order = {
                    ...order,
                    ...this.parseWhereFilter(key, value),
                }
            }
        }

        return {
            where,
            order,
            take: dto.take,
            skip: dto.page ? dto.take * (dto.page - 1) : undefined,
        }
    }

composeFindOptions<T>는 dto의 내부 프로퍼티들을 가져와 where 혹은 order에 매핑한 후 반환한다.

parseWhereFilter 메서드 구현하기

private parseWhereFilter<T extends BaseModel>(key: string, value: any) : FindOptionsWhere<T> | FindOptionsOrder<T> {
        const options: FindOptionsWhere<T> = {};

        const split = key.split('__');

        if(split.length !== 2 && split.length !== 3) {
            throw new BadRequestException(
                `where 필터는 '__'로 split했을 때 길이가 2 또는 3이어야 합니다 - 문제되는 키값 : ${key}`
            );
        }

        if(split.length === 2) {
            const [_, field] = split;

            options[field] = value;
        } else {
            const [_, field, operator] = split;

            // const values = value.toString().split(',');

            // if(operator === 'between') {
            //     options[field] = FILTER_MAPPER[operator](values[0], values[1]);
            // } else {
            //     options[field] = FILTER_MAPPER[operator](value);
            // }
            if(operator === 'i_like') {
                options[field] = FILTER_MAPPER[operator](`%${value}%`);
            } else {
                options[field] = FILTER_MAPPER[operator](value);
            }
        }

        return options;
    }

parseWhereFilter 메서드는 dto의 'where__id__more_than' 과 같은 프로퍼티들을 '__' 기준으로 나누고, operator를  'FILTER_MAPPER'에서 가져와 options에 값을 대입한다.

where:{
   id: 3,
}

options는 위와 같은 형식이 된다.

FILTER_MAPPER 객체 구현하기

import { Any, ArrayContainedBy, ArrayContains, ArrayOverlap, Between, Equal, ILike, In, IsNull, LessThan, LessThanOrEqual, Like, MoreThan, MoreThanOrEqual, Not } from "typeorm";

/**
 * where__id_not
 * 
 * {
 *  where: {
 *      id: Not(value),
 *  }
 * }
 */
export const FILTER_MAPPER = {
    not: Not,
    less_than: LessThan,
    less_than_or_equal: LessThanOrEqual,
    more_than: MoreThan,
    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,
}

FILTER_MAPPER는 'where__id__more_than' 에서 more_than과 같은 것을 실제 typeORM 함수들로 변환해주는 객체이다.

posts.service.ts 의 paginatePosts 메서드에 적용하기

  async paginatePosts(dto: PaginatePostDto) {
    return this.commonServices.paginate(
      dto,
      this.postsRepository,
      {},
      'posts',
    );
    // if(dto.page) {
    //   return this.pagePaginatePosts(dto);
    // } else {
    //   return this.cursorPaginatePosts(dto);
    // }   
  }

기존에 사용하던 pagePaginatePosts와 cursorPaginatePosts를 지우고 commonServicespaginate로 적용한다.

posts 에 CommonService를 사용할 수 있도록 모듈 등록

posts.service.ts에서 commonService를 사용하고 있으려면 CommonModule이 등록되어야 한다.

@Module({
  imports: [
    TypeOrmModule.forFeature([PostsModel]),
    AuthModule,
    UsersModule,
    CommonModule,
  ],

posts.module.ts 에서 CommonModuleimports 한다.

@Module({
  controllers: [CommonController],
  providers: [CommonService],
  exports: [CommonService],
})

common.module.ts 에서 CommonServiceexports 한다.

PaginatePostDto 에서 추가 프로퍼티 작성하기

import { IsNumber, IsOptional, IsString } from "class-validator";
import { BasePaginationDto } from "src/common/dto/base-pagination.dto";

export class PaginatePostDto extends BasePaginationDto{
    @IsNumber()
    @IsOptional()
    where__likeCount__more_than: number;

    @IsString()
    @IsOptional()
    where__title__i_like: string;
}

기존 PaginatePostDto는 BasePaginationDto를 상속받게 하면 BasePaginationDto의 프로퍼티들을 유지하며 추가적인 프로퍼티를 작성할 수 있게 된다.

DTO 프로퍼티 whitelisting 하기

import { IsNumber, IsOptional, IsString } from "class-validator";
import { BasePaginationDto } from "src/common/dto/base-pagination.dto";

export class PaginatePostDto extends BasePaginationDto{
    @IsNumber()
    @IsOptional()
    where__likeCount__more_than: number;

    // @IsString()
    // @IsOptional()
    // where__title__i_like: string;
}

만약 where__title__i_like를 서버측에서 비활성화했으나, 클라이언트에선 쿼리로 보내고 있으면 어떻게 될까?

where__likeCount__more_than 만 쿼리로 보낼 때, 포스트 개수가 6개인 것을 알 수 있다.

서버에서 where__title__i_like 프로퍼티를 비활성화했는데도 불구하고, 클라이언트측에서 where__title__i_like 를 담아보내면 서버에서 실행되는 것을 볼 수 있다. (포스트 개수가 1개)

디버깅을 해봐도 역시 where__title__i_like 값을 받아오고 있다.

whitelist 활성화 하기

whitelist란 DTO에 정의되지 않은 불필요하거나 원치 않는 프로퍼티들을 자동으로 제거하여, 클라이언트로부터 오는 데이터에서 오직 정의된 속성만 남도록 하는 기능이다.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

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

  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
whitelist: true,

main.ts의 useGlobalPipes에 whitelist를 true로 활성화한다.

그리고 다시 포스트맨을 실행하면 이전과는 다르게 where__title__i_like를 쿼리에 담아 서버로 보내도 서버에서 실행되지 않는 것을 알 수 있다.

서버 디버깅에서도 where__title__i_like 를 받아오지 않고 있다.

forbidNonWhitelisted 활성화 하기

그러나 에러를 던지지 않고 실행되는 것은 클라이언트와 서버의 프로토콜이 제대로 이루어지지 않았다고 볼 수 있다.

프로토콜이 다르니 아예 에러를 던져 클라이언트가 잘못된 쿼리를 보내고 있다고 하는 옵션이 forbidNonWhitelisted이다.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

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(process.env.PORT ?? 3000);
}
bootstrap();
whitelist: true,
forbidNonWhitelisted: true,

forbidNonWhitelisted를 true로 만들어주면 된다.

클라이언트에서 쿼리를 보냈으나, 서버에서는 where__title__i_like가 없기 때문에 에러를 반환하여 클라이언트의 실수를 알아차릴 수 있게된다.

Override Options 사용해보기

  async paginatePosts(dto: PaginatePostDto) {
    return this.commonServices.paginate(
      dto,
      this.postsRepository,
      {
      	relations: ['author'],
      },
      'posts',
    );
    // if(dto.page) {
    //   return this.pagePaginatePosts(dto);
    // } else {
    //   return this.cursorPaginatePosts(dto);
    // }   
  }

commonServices.paginate의 두 번째 파라미터는 overrideFindOptions: FindManyOptions<T> = {}, 로 다양한 옵션을 설정할 수 있도록 한다. 위와 같이 relations: ['author']를 하게 되면 포스트에 author 의 정보가 보이게 된다.

@ManyToOne(() => UsersModel, (user) => user.posts, {
    eager: true,
    nullable: false,
  })
  @JoinTable()
  author: UsersModel;

혹은 해당 옵션이 없어도, PostsModel에 author에 eager 관계옵션을 설정하여 author를 보이도록 할 수 있다.

'NestJS' 카테고리의 다른 글

[NestJS] 파일 업로드 (클래식 방식)  (0) 2025.03.07
[NestJS] Config 모듈 사용하기  (0) 2025.03.05
NetJS - Pagination 기본  (0) 2025.03.02
NestJS - Class Transformer  (0) 2025.02.28
NestJS - Class Validation과 DTO  (0) 2025.02.28

최근댓글

최근글

skin by © 2024 ttuttak