NestJS-Pagination 고도화

jaegeunsong97·2024년 1월 30일
0

NestJS

목록 보기
22/37

🖊️BasePaginationDto 생성

지금부터는 pagination 코드를 변경해보겠습니다. 왜냐하면 Cursor 페이지네이션 코드만 봐도 대략 30줄이 넘는 코드를 매번 작성해야 합니다. 만약 다른 review같은 부분에서도 페이지네이션이 필요한 경우, 마찬가지로 30줄을 적을 수 없기 때문입니다. 따라서 일반화를 해보도록 하겠습니다.

일단 페이지네이션을 모든 모듈에서 사용할 수 있도록 common에서 작업을 하도록 하겠습니다.

  • common/dto/base-paginate.dto.ts
import { IsIn, IsNumber, IsOptional } from "class-validator";

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

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

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

    // 정렬
    // createdAt: 생성된 시간의 내림차/오름차 순으로 정렬
    @IsIn(['ASC', 'DESC']) // 여기 안에 있는 값들만 가능
    @IsOptional()
    order__createdAt?: 'ASC' | 'DESC' = 'ASC'; // 기본값

    // 몇개의 데이터를 가져올지
    @IsNumber()
    @IsOptional()
    take: number = 20; // 기본값
}

BasePaginationDto에 이렇게 작성을 하는 이유는 page, where__id_more_than, where__id_less_than, order__createdAt, take는 모든 페이지네이션 종류에 따라서 반드시 필요로 하는 값들이기 때문입니다.

내림차순인지 오름차순인지 그리고 어떤 기준에서부터 가져올지가 적혀있기 때문입니다.

이제 post dto에서는 상속을 받아줍시다.

  • posts/dto/paginate-post.dto.ts
import { BasePaginationDto } from "src/common/dto/base-pagination.dto";

export class PaginatePostDto extends BasePaginationDto {}

🖊️BasePaginationDto 리펙토링, paginate()

  • common/dto/base-paginate.dto.ts
@IsNumber()
@IsOptional()
where__id__more_than?: number; // 추가

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

이렇게 __로 바꾼 이유는 typeORM을 사용할 때, where안의 id안에 more_than 유틸리티 값을 넣을 것이기 때문입니다. 따라서 유틸리티는 _ 로 사용을 하는 것이고 key값은 __ 로 사용할 것입니다. posts.service.ts로 가서 where__id_more_than, where__id_less_than로 되어있는 모든 것들을 where__id__more_than, where__id__less_than로 바꿔줍니다.

이제 common.service.ts에 페이지네이션 기반의 기능을 만들겠습니다.

  • posts.service.ts
async pagePaginatePosts(dto: PaginatePostDto) {
    const [posts, count] = await this.postsRepository.findAndCount({
        skip: dto.take * (dto.page - 1),
        take: dto.take,
        order: {
          	createdAt: dto.order__createdAt,
        }
    });

    return {
        data: posts,
        total: count
    }
}

posts.service.ts를 보면 dto: BasePaginationDto를 파라미터로 받고, 내부에서 this.postsRepository를 사용하고 있습니다. 따라서 이 2가지를 CommonService의 paginate에서 받겠습니다.

  • common.service.ts
@Injectable()
export class CommonService {

    paginate(
    	dto: BasePaginationDto,
		repository: Repository, // 일반화가 필요하다!
    ) {}
}

만약 Repository를 Repository<PostsModel>과 같이 받으면 안됩니다. 이렇게 되면 항상 페이지네이션을 할 때 PostsModel의 저장소만 사용하기 때문입니다. 따라서 Generic을 사용해서 받겠습니다.

@Injectable()
export class CommonService {
  
	// 페이지네이션을 구현하는 Entity는 대부분 BaseModel을 상속받습니다.
    paginate<T extends BaseModel>( // 좀 더 자세한 타입
    	dto: BasePaginationDto,
		repository: Repository<T>,
    ) {}
}
@Injectable()
export class CommonService {
  
    paginate<T extends BaseModel>(
        dto: BasePaginationDto,
        repository: Repository<T>,
        // FindManyOptions: 만약에 상위에서 사용하고 싶은 option이 있으면 덮어씌우게 만드는 것
        overrideFindOptions: FindManyOptions<T> = {},
    ) {}
}

FindManyOptions에 관해서 알아보겠습니다. Find -> 정의로 이동

FindManyOptions는 말 그래도 find 내부에서 where, order과 같은 것들을 의미합니다. 또한 FindManyOptions는 <Entity>타입으로 되어있고, 이는 posts.service.ts에서는 PostsModel을 의미합니다.

/**
* Finds entities that match given find options.
*/
find(options?: FindManyOptions<Entity>): Promise<Entity[]>;
  • posts.service.ts
const posts = await this.postsRepository.find({ // FindMantOptions 에서 Entity는 PostsModel의미
    where,
    order: {
      	createdAt: dto.order__createdAt,
    },
    take: dto.take,
});

그리고 path를 받도록 하겠습니다.

  • common.service.ts
@Injectable()
export class CommonService {
  
    paginate<T extends BaseModel>(
    	dto: BasePaginationDto,
       	repository: Repository<T>,
       	overrideFindOptions: FindManyOptions<T> = {},
       	path: string, // 추가
    ) {}
}

path: string을 추가하는 이유는 nextUrl 때문입니다. 가장 마지막 부분인 /posts 부분이 계속해서 바뀌기 때문입니다. 그러면 이제 common에서 해당 url을 생성할 수 있게 됩니다.

  • posts.service.ts
const nextUrl = lastItem && new URL(`${PROTOCOL}://${HOST}/${path}`); // 마지막 부분 posts가 바뀌게 됨

🖊️Pagination 로직 정리

이제부터 cursor 기반의 페이지네이션과 page 기반의 페이지네이션으로 나눠야합니다. 따라서 일단 split을 합니다.

  • common.service.ts
@Injectable()
export class CommonService {

    paginate<T extends BaseModel>(
    	dto: BasePaginationDto,
        repository: Repository<T>,
        overrideFindOptions: FindManyOptions<T> = {},
        path: string,
    ) {}
	
  	// private 하는 이유는 우리는 pagePaginate와 cursorPaginate를 사용할 필요가 없어서 가리기 용
    private async pagePaginate<T extends BaseModel>(
    	dto: BasePaginationDto,
       	repository: Repository<T>,
       	overrideFindOptions: FindManyOptions<T> = {},
  		// page 기반 페이지네이션은 page는 필요 없습니다.
    ) {

    }

    private async cursorPaginate<T extends BaseModel>(
        dto: BasePaginationDto,
        repository: Repository<T>,
        overrideFindOptions: FindManyOptions<T> = {},
        path: string, // cursor니까 path필요!
    ) {

    }
}

이렇게 2가지 함수를 만들었다는 것은 paginate()를 사용하면 분기처리를 하겠다는 것입니다.

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);
}

private async pagePaginate<T extends BaseModel>(
    dto: BasePaginationDto,
    repository: Repository<T>,
    overrideFindOptions: FindManyOptions<T> = {},
) {

}

private async cursorPaginate<T extends BaseModel>(
    dto: BasePaginationDto,
    repository: Repository<T>,
    overrideFindOptions: FindManyOptions<T> = {},
    path: string,
) {

}

먼저 어려운 cursor Pagination먼저 해보도록 하겠습니다.

여태까지는 where__id__more__thanwhere__id__less_than만 사용하고 있었습니다. 하지만 where__likeCount_more_than, where__title_ilike
위에 2개의 경우를 선택해서 보여주고 싶은 경우가 생길 수 있습니다.

예시이지만, 1번째는 where 쿼리를 사용하고, likeCount라는 프로퍼티를, more_than을 사용해서 필터링 하겠다는 의미입니다. 즉 우리는 어떠한 값이와도 일반화를 하겠다는 것입니다.

일단 composeFindOptions함수를 새로 1개 만들고, composeFindOptions의 반환타입을 정의하겠습니다. FindManyOptions는 이미 학습을 했기 때문에 넘어가겠습니다.

  • common.service.ts
private composeFindOptions<T extends BaseModel>(
	dto: BasePaginationDto,
) : FindManyOptions<T> {
          /**
           * 반환하고 싶은 형태
           * where,
           * order,
           * take,
           * skip -> page 기반일 때만 반환
           */
}

이 composeFindOptions()를 pagePaginate()와 cursorPaginate()에서 사용할 것입니다.

이제부터 작업 순서를 정리하겠습니다. DTO의 현재 생긴 구조는 아래와 같습니다.

예시)
{
  where__id_more_than: 1,
  order__createdAt: 'ASC'
}

하지만 order도 여러개가 추가될 수 있고, where도 여러개가 추가될 수 있습니다.

현재는 where__id__more_than / where__id__less_than에 해당되는 where 필터만 사용중이지만, 나중에 where__likeCount__more_than 이나 where__title__ilike 등 추가 필터를 넣고 싶어졌을 때, 모든 where 필터들을 자동으로 파싱 할 수 있을만한 기능을 제작해야 합니다.

  • 1) where로 시작한다면 필터 로직을 적용한다.
  • 2) order로 시작한다면 정렬 로직을 적용한다.
  • 3) 필터 로직을 적용한다면 __ 기준으로 split 했을때 3개의 값으로 나뉘는지, 2개의 값으로 나뉘는지 확인한다.
    • 3-1) 3개의 값으로 나뉜다면 FILTER_MAPPER(우리가 작성한 more_than과 같은 유틸리티를 typeORM 유틸리티로 바꾸는 것)에서 해당되는 operator 함수를 찾아서 적용한다.
      ['where', 'id', 'more_than']
    • 3-2) 2개의 값으로 나뉜다면 정확한 값을 필터하는 것이기 때문에 operator 없이 적용한다.
      where__id -> ['where', 'id']
  • 4) order의 경우 3-2와 같이 적용한다.

🖊️DTO를 이용해서 FindOptions 생성

일단 코드를 작성합니다.

  • common.service.ts
private composeFindOptions<T extends BaseModel>(
	dto: BasePaginationDto,
) : FindManyOptions<T> {
	let where: FindOptionsWhere<T> = {}; // typeORM에 정의됨
    let order: FindOptionsOrder<T> = {}; // typeORM에 정의됨

    for (const [key, value] of (Object.entries(dto))) { 
      	// entries를 사용하면 key, value를 가져온다
      	// where__id_more_than(key): 1(value)
        if (key.startsWith('where__')) {
            where = { // where 새로 만들기
              	...where, // 기존에 있던 where

            }
        } else if (key.startsWith('order__')) {
            order = { // order 새로 만들기
              	...order, // 기존에 있던 order

            }
        }
    }

    return {
      	// 반환형태
        where,
        order,
        take: dto.take,
        skip: dto.page ? dto.take * (dto.page - 1) : null, // 없는 경우 cursor 기반 페이지네이션 따라서 skip 필요 없음
    };
}

어느정도 구조가 나왔습니다. 이제 선언을 해보겠습니다.

private composeFindOptions<T extends BaseModel>(
	dto: BasePaginationDto,
) : FindManyOptions<T> {
	let where: FindManyOptions<T> = {};
    let order: FindManyOptions<T> = {};

    for (const [key, value] of (Object.entries(dto))) {
        if (key.startsWith('where__')) {
            where = {
              	...where,

            }
        } else if (key.startsWith('order__')) {
            order = {
              	...order,

            }
        }
    }

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

// value가 any인 이유: validator를 통과하면 실제 값이 어떤 것인지 알 수 없기 때문에
private parseWhereFilter<T extends BaseModel>(key: string, value: any): FindOptionsWhere<T>{ 

}

private parseOrderFilter<T extends BaseModel>(key: string, value: any): FindOptionsWhere<T>{ 

}

parseWhereFilter가 FindOptionsWhere<T>를 반환할 수 있고 parseOrderFilter가 FindOptionsOrder<T>를 반환하게 되면, composeFindOptions에 스프레드 형식으로 추가를 할 수 있습니다.

private composeFindOptions<T extends BaseModel>(
	dto: BasePaginationDto,
) : FindManyOptions<T> {
	let where: FindManyOptions<T> = {};
    let order: FindManyOptions<T> = {};

    for (const [key, value] of (Object.entries(dto))) {
        if (key.startsWith('where__')) {
            where = {
              	...where,
				...this.parseWhereFilter(key, value), // key, value를 이용해서 계속해서 추가
            }
        } else if (key.startsWith('order__')) {
            order = {
              	...order,
				...this.parseOrderFilter(key, value), // key, value를 이용해서 계속해서 추가
            }
        }
    }

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

private parseWhereFilter<T extends BaseModel>(key: string, value: any): FindOptionsWhere<T>{ 

}

private parseOrderFilter<T extends BaseModel>(key: string, value: any): FindOptionsWhere<T>{ 

}

여기서 key, value를 이용해서 추가를 한다는 것은 let where: FindManyOptions<T> = {};let order: FindManyOptions<T> = {};에 추가를 한다는 의미입니다.

즉, key value 값 별로 where로 시작하는 키워드들은 전부 where에 추가를 하며, order로 시작하는 키워드들은 전부 order에 추가를 하게 되는 것입니다.


🖊️ParseWhereFilter 작업

어려운 ParseWhereFilter를 먼저 해보겠습니다.

  • common.service.ts
private parseWhereFilter<T extends BaseModel>(key: string, value: any): FindOptionsWhere<T>{ 
    const options: FindManyOptions<T> = {};

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

    return options;
}

먼저 split을 하면 리스트가 만들어지고, 길이로 에러를 체크합니다. 그리고 split의 길이가 2인 경우의 코드를 작성합니다.

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

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

	/**
     * 길이가 2인 경우는 where__id = 3
     * FindOptionsWhere로 풀어보면 아래와 같다
     * 
     * {
     *   where: {
     *        id: 3
     *   }
     * }
     */
    if (split.length === 2) {
        const [_, field] = split; // ['where', 'id']
        /**
         * field -> 'id' / value -> 3
         * 형태
         * {
         *   id: 3,
         * }
         */
        options[field] = value;
    }

    return options;
}

이번에는 split.length가 3인경우의 코드를 작성하겠습니다. 이 경우에는 FILTER MAPPER를 작성해야 합니다. 따라서 FILTER MAPPER에 관한 코드를 먼저 작성하겠습니다.

  • common/const/filter-mapper.const.ts
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를 작성했으니 서비스 코드를 작성하겠습니다. 지금의 경우에는 between처럼 2개의 파라미터를 받는 것 까지 고려해서 작성을 하겠습니다.

  • common.service.ts
private parseWhereFilter<T extends BaseModel>(key: string, value: any): FindOptionsWhere<T>{ 
    const options: FindManyOptions<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 {
    	/**
        * 길이가 3일 경우에는 TypeORM 유틸리티 적용이 필요한 경우다.
        * 
        * where__id__more_than의 경우
        * where는 버려도 되고 두번째 값은 필터할 키값이 되고
        * 세번쨰 값은 typeORM 유틸리티가 된다.
        * 
        * FILTER_MAPPER에 미리 정의해둔 값들로
        * field 값에 FILTER_MAPPER에서 해당되는 utility를 가져온 후 값에 적용 해준다.
        */
        const [_, field, operator] = split; // ['where', 'id', 'more_than']

        // where__id__between = 3,4
        // 만약에 split 대상 문자가 존재하지 않으면 길이가 무조건 1이다.
        const values = value.toString().split(',');

        // field -> id
        // operator -> more_than
        // FILTER_MAPPER[operator] -> MoreThan
        if (operator === 'between') { // between같은 경우 2개의 값을 받는다.
          	options[field] = FILTER_MAPPER[operator](values[0], values[1]);
        } else {
          	options[field] = FILTER_MAPPER[operator](value);
        }
    }
    return options;
}
private parseWhereFilter<T extends BaseModel>(key: string, value: any): FindOptionsWhere<T>{ 
    const options: FindManyOptions<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;
        options[field] = FILTER_MAPPER[operator](value); // 지금은 단일 방법만 생각, between 코드 생략
    }
    return options;
}

ParseWhereFilter를 완성했습니다. 이제 ParseWhereFilter를 적용해 보겠습니다.


🖊️composeFindOptions() 완성

이번에는 composeFindOptions를 작성해 보겠습니다. 이미 완성한 ParseWhereFilter와 매우 비슷하기 때문에 빠르게 완성하겠습니다. 어차피 나중에 ParseWhereFilter와 합치겠지만 연습을 위해 1번더 작성하겠습니다.

  • common.service.ts
private parseOrderFilter<T extends BaseModel>(key: string, value: any): 
	FindOptionsOrder<T>{ 
    const order: FindOptionsOrder<T> = {};
  	/**
     * order는 무조건 2개로 split
     */
    const split = key.split('__');
    if (split.length !== 2) {
        throw new BadRequestException(
          `order 필터는 '__'로 split 했을 때 길이가 2여야 합니다. - 문제되는 키값 : ${key}`,
        );
    }
    const [_, field] = split;
    order[field] = value;
    return order;
}

근데 생각해보면 생긴 구조가 parseWhereFilter와 똑같이 생겼습니다.

parseWhereFilter() {
	.
    .
    // 여기와 동일하다
    if (split.length === 2) {
      const [_, field] = split;
      options[field] = value;
	} else {
  	.
    .
}

따라서 합치도록 하겠습니다.

private composeFindOptions<T extends BaseModel>(
	dto: BasePaginationDto,
) : FindManyOptions<T> {
	let where: FindOptionsWhere<T> = {};
    let order: FindOptionsOrder<T> = {};

    for (const [key, value] of (Object.entries(dto))) {
        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) : null,
    };
}


private parseWhereFilter<T extends BaseModel>(key: string, value: any): FindOptionsWhere<T> | FindOptionsOrder<T>{ // FindOptionsWhere<T> 또는 FindOptionsOrder<T> 가 나온다
    const options: FindManyOptions<T> = {};

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

    if (split.length === 2) { // parseOrderFilter와 코드가 동일
        const [_, field] = split;
        options[field] = value;
    } else {
        const [_, field, operator] = split;
        options[field] = FILTER_MAPPER[operator](value);
    }
    return options;
}

이제 완성한 composeFindOptions()를 cursorPaginate()에 사용하도록 하겠습니다.

private async cursorPaginate<T extends BaseModel>(
    dto: BasePaginationDto,
    repository: Repository<T>,
    overrideFindOptions: FindManyOptions<T> = {},
    path: string,
) {
  	// dto를 받기만 하면 내부에서 알아서 생성
	const findOptions = this.composeFindOptions<T>(dto); // 추가
}

이제는 cursor pagination 로직을 작성하겠습니다.


🖊️Cursor Pagination 적용

이제 cursor pagination을 완성해보도록 하겠습니다. 결과값을 불러오도록 만들겠습니다.

  • common.service.ts
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, // 함수자체에서 override할 값들
    });
}

그리고 우리는 정렬을 하고 id를 기반으로 가져올 것입니다. cursor를 만드는 코드를 posts.service.ts에서 복사해 가져옵니다.

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 lastItem = results.length > 0 && results.length === dto.take ? results[results.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: results,
        cursor: {
          	after: lastItem?.id ?? null, // lastItem이 존재하면 id로 아니면 null
        },
        count: results.length,
        next: nextUrl?.toString() ?? null, // nextUrl 존재하면 toString으로 아니면 null
     }
}

여기서 잘 봐야할 부분은 composeFindOptions입니다. 불특정 다수의 option이 들어왔을 때 어떻게 일반화 처리를 하는지 입니다.

cursor pagination이 잘 작동하는지 확인하도록 하겠습니다. common.service.ts를 외부에서도 사용할 수 있도록 exports 해주도록 하겠습니다. 그리고 posts.module.ts에서는 imports 하겠습니다.

  • common.module.ts
@Module({
    controllers: [CommonController],
    providers: [CommonService],
    exports: [CommonService] // 추가
})
export class CommonModule {}
  • posts.module.ts
@Module({
    imports: [
        TypeOrmModule.forFeature([
          PostsModel,
        ]),
        AuthModule,
        UsersModule,
        CommonModule, // 추가
    ],
    controllers: [PostsController],
    providers: [PostsService],
    exports: [PostsService]
})
export class PostsModule {}

그리고 posts.service.ts에서 paginatePosts()를 전부 주석처리를 하고, commonService를 주입받도록 하겠습니다. 또한 paginatePosts()에 commonService 코드를 작성하겠습니다.

  • posts.service.ts
constructor(
    @InjectRepository(PostsModel)
    private readonly postsRepository: Repository<PostsModel>,
    private readonly commonService: CommonService, // 추가
) {}
.
.
async paginatePosts(dto: PaginatePostDto) {
    return this.commonService.paginate( // 추가
        dto,
        this.postsRepository,
        {}, // overrideOption은 안넣기
        'posts'
    );
    // if (dto.page) return this.pagePaginatePosts(dto);
    // else return this.cursorPaginatePosts(dto);
}

여기까지만 하고 포스트맨을 잠시 사용하겠습니다.

{
    "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
        }
    ],
    "cursor": {
        "after": 21
    },
    "count": 20,
    "next": "http://localhost:3000/posts?order__createdAt=ASC&take=20&where__id__more_than=21"
}

{
    "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"
}

🖊️Page Pagination 작업

이제 page 기반의 pagination을 작업하겠습니다. 간단합니다.

  • common.service.ts
private async pagePaginate<T extends BaseModel>( // page 기반 페이지네이션은 page는 필요 없습니다.
    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,
      }
}

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

{
    "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
        }
    ],
    "total": 107
}

{
    "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
        }
    ],
    "total": 107
}

page기반의 pagination은 어떤 형태든 적용이 가능합니다. 단, cursor 기반의 pagination은 지금 무조건 생성된 시간을 기반으로 만든 로직입니다. 따라서 다른 기준으로 작성을 하고 싶으면 배운 것을 기반으로 만들면 됩니다.


🖊️추가 쿼리 프로퍼티 테스트

우리가 composeFindOptions을 만든이유는 단순히 where__id__more_than과 같은 값만 사용하기 위해서 만든 것은 아닙니다. 만약 PaginationPostDto와 같은 곳에 원하는 필터값을 추가하면 추가를 하는데로 필터링이 되도록 만든 것 입니다.

테스트를 해보겠습니다. PaginationPostDto에 추가를 해보겠습니다.

  • posts/dto/paginate-post.dto.ts
export class PaginatePostDto extends BasePaginationDto {

     @IsNumber()
     @IsOptional()
     where__likeCount__more_than: number;
}

그리고 pgAdmin으로 이동해 likeCount 값을 변경해보겠습니다.

49이상인 값들만 가져오는 것을 알 수 있습니다.

{
    "data": [
        {
            "id": 60,
            "updatedAt": "2024-01-28T02:12:54.202Z",
            "createdAt": "2024-01-28T02:12:54.202Z",
            "title": "임의로 생성된 51",
            "content": "임의로 생성된 포수트 내용 51",
            "likeCount": 150,
            "commentCount": 0
        },
        {
            "id": 58,
            "updatedAt": "2024-01-28T02:12:54.186Z",
            "createdAt": "2024-01-28T02:12:54.186Z",
            "title": "임의로 생성된 49",
            "content": "임의로 생성된 포수트 내용 49",
            "likeCount": 100,
            "commentCount": 0
        },
        {
            "id": 55,
            "updatedAt": "2024-01-28T02:12:54.163Z",
            "createdAt": "2024-01-28T02:12:54.163Z",
            "title": "임의로 생성된 46",
            "content": "임의로 생성된 포수트 내용 46",
            "likeCount": 100,
            "commentCount": 0
        },
        {
            "id": 52,
            "updatedAt": "2024-01-28T02:12:54.139Z",
            "createdAt": "2024-01-28T02:12:54.139Z",
            "title": "임의로 생성된 43",
            "content": "임의로 생성된 포수트 내용 43",
            "likeCount": 200,
            "commentCount": 0
        },
        {
            "id": 30,
            "updatedAt": "2024-01-28T02:12:53.985Z",
            "createdAt": "2024-01-28T02:12:53.985Z",
            "title": "임의로 생성된 21",
            "content": "임의로 생성된 포수트 내용 21",
            "likeCount": 100,
            "commentCount": 0
        },
        {
            "id": 20,
            "updatedAt": "2024-01-28T02:12:53.889Z",
            "createdAt": "2024-01-28T02:12:53.889Z",
            "title": "임의로 생성된 11",
            "content": "임의로 생성된 포수트 내용 11",
            "likeCount": 100,
            "commentCount": 0
        },
        {
            "id": 15,
            "updatedAt": "2024-01-28T02:12:53.839Z",
            "createdAt": "2024-01-28T02:12:53.839Z",
            "title": "임의로 생성된 6",
            "content": "임의로 생성된 포수트 내용 6",
            "likeCount": 100,
            "commentCount": 0
        },
        {
            "id": 3,
            "updatedAt": "2024-01-27T20:40:26.982Z",
            "createdAt": "2024-01-27T18:00:05.730Z",
            "title": "NestJS Lecture",
            "content": "첫번쨰 content",
            "likeCount": 100,
            "commentCount": 0
        }
    ],
    "total": 8
}

이제 우리는 프로퍼티에 값들만 추가하면 마음대로 데이터를 필터링해서 가져올 수 있습니다. 이번에는 where__title__ilike를 해보겠습니다. 필터의 옵션마다 분기처리가 필요합니다.

  • common.service.ts
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;
		
      	// 추가
        if (operator === 'i_like') {
          	operator[field] = FILTER_MAPPER[operator](`%${value}%`); // SQL에 글자대로 매칭해줘
        } else {
          	operator[field] = FILTER_MAPPER[operator](value);
        }
      	// 여기까지

      	options[field] = FILTER_MAPPER[operator](value);
    }
    return options;
}

PaginatePostDto에 쿼리를 추가하겠습니다.

  • posts/dto/paginate-dto.ts
export class PaginatePostDto extends BasePaginationDto {

     @IsNumber()
     @IsOptional()
     where__likeCount__more_than: number;

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

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

where__title__i_like가 2라는 의미는 title에 2가 들어간 모든 것을 가져오라는 것입니다.

{
    "data": [
        {
            "id": 101,
            "updatedAt": "2024-01-28T02:12:54.520Z",
            "createdAt": "2024-01-28T02:12:54.520Z",
            "title": "임의로 생성된 92", // 2 존재
            "content": "임의로 생성된 포수트 내용 92",
            "likeCount": 0,
            "commentCount": 0
        },
        .
        .
        {
            "id": 11,
            "updatedAt": "2024-01-28T02:12:53.797Z",
            "createdAt": "2024-01-28T02:12:53.797Z",
            "title": "임의로 생성된 2", // 2 존재
            "content": "임의로 생성된 포수트 내용 2",
            "likeCount": 0,
            "commentCount": 0
        }
    ],
    "total": 19
}

요번에는 임의를 입력하고 ASC로 해보겠습니다.

{
    "data": [
        {
            "id": 9,
            "updatedAt": "2024-01-28T02:12:53.718Z",
            "createdAt": "2024-01-28T02:12:53.718Z",
            "title": "임의로 생성된 0",
            "content": "임의로 생성된 포수트 내용 0",
            "likeCount": 0,
            "commentCount": 0
        },
        .
        .
        {
            "id": 28,
            "updatedAt": "2024-01-28T02:12:53.970Z",
            "createdAt": "2024-01-28T02:12:53.970Z",
            "title": "임의로 생성된 19",
            "content": "임의로 생성된 포수트 내용 19",
            "likeCount": 0,
            "commentCount": 0
        }
    ],
    "total": 100
}

이번에는 중복으로 where__likeCount__more_than, where__title__i_like이 들어가는지 확인해보겠습니다.

{
    "data": [
        {
            "id": 30,
            "updatedAt": "2024-01-28T02:12:53.985Z",
            "createdAt": "2024-01-28T02:12:53.985Z",
            "title": "임의로 생성된 21",
            "content": "임의로 생성된 포수트 내용 21",
            "likeCount": 100,
            "commentCount": 0
        }
    ],
    "total": 1
}

🖊️DTO 프로퍼티 whitelisting

추가 쿼리 프로퍼티를 하는 과정에서 치명적인 단점이 있습니다. PaginateDto에서 where__title__i_like를 지우고 포스탬으로 요청을 보내겠습니다.

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

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

{
    "data": [
        {
            "id": 30,
            "updatedAt": "2024-01-28T02:12:53.985Z",
            "createdAt": "2024-01-28T02:12:53.985Z",
            "title": "임의로 생성된 21",
            "content": "임의로 생성된 포수트 내용 21",
            "likeCount": 100,
            "commentCount": 0
        }
    ],
    "total": 1
}

여전히 1개가 나오는 것을 알 수 있습니다. 우리는 PaginateDto에서 지웠기 때문에, 적용이 되지않고 where__likeCount__more_than만 적용되서 여러개가 나와야 합니다. 디버깅을 해보겠습니다.

where__title__i_like에는 문자로 2가 들어온 것을 알 수 있습니다. 하지만 우리는 PaginateDto에 분명히 주석으로 삭제를 해놨습니다. 이렇게 되면 정말 큰 문제가 발생합니다.

해커가 FILTER_MAPPER를 이용해서 모든 DB의 내용들을 전부 볼 수 있다는 것입니다.

// 이 부분을 이용해서 해킹 가능
if (operator === 'i_like'){
  	options[field] = FILTER_MAPPER[operator](`%${value}%`)
}else{
  	options[field] = FILTER_MAPPER[operator](value);
}

따라서 PaginateDto에 적힌 값들 이외의 것들은 전부 막을 것 입니다. main.ts에 whitelist를 추가해줍니다. whitelist: truevalidatorvalidation 데코레이터가 적용되지 않은 모든 프로퍼티를 전부 삭제한다 라는 의미입니다.

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

즉, 아래의 코드처럼 데코레이터가 적용이 안되어 있는 필터 조건이 URL로 들어오면, 적용이 안된 필터만 삭제한다는 것입니다. 포스트맨으로 테스트를 해보겠습니다.

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

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

{
    "data": [
        {
            "id": 3,
            "updatedAt": "2024-01-27T20:40:26.982Z",
            "createdAt": "2024-01-27T18:00:05.730Z",
            "title": "NestJS Lecture",
            "content": "첫번쨰 content",
            "likeCount": 100,
            "commentCount": 0
        },
        .
        .
        {
            "id": 60,
            "updatedAt": "2024-01-28T02:12:54.202Z",
            "createdAt": "2024-01-28T02:12:54.202Z",
            "title": "임의로 생성된 51",
            "content": "임의로 생성된 포수트 내용 51",
            "likeCount": 150,
            "commentCount": 0
        }
    ],
    "total": 8
}

where__title__i_likestrip된 것을 알 수 있습니다. 이렇게 하면 해커들이 탐구하지 못하도록 막아야 합니다. 하지만 이것도 아직 부족합니다. 왜냐하면 FE입장에서 오타로 보냈으면, 적용이 된 쿼리인지 아닌지 알 수 없습니다. 따라서 main.ts에 forbidNonWhitelisted: true를 추가하겠습니다.

forbidNonWhitelisted: true면, strip대신에 에러를 던진다는 것입니다. 그리고 포스트맨으로 테스트를 하겠습니다.

  • 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();

{
    "message": [
        "property where__title__i_like should not exist"
    ],
    "error": "Bad Request",
    "statusCode": 400
}

이제 PaginateDto에 주석을 해제하고 다시 2개가 적용될 때와 1개만 적용할 때를 확인해보겠습ㄴ니다.

{
    "data": [
        {
            "id": 3,
            "updatedAt": "2024-01-27T20:40:26.982Z",
            "createdAt": "2024-01-27T18:00:05.730Z",
            "title": "NestJS Lecture",
            "content": "첫번쨰 content",
            "likeCount": 100,
            "commentCount": 0
        },
        .
        .
        {
            "id": 60,
            "updatedAt": "2024-01-28T02:12:54.202Z",
            "createdAt": "2024-01-28T02:12:54.202Z",
            "title": "임의로 생성된 51",
            "content": "임의로 생성된 포수트 내용 51",
            "likeCount": 150,
            "commentCount": 0
        }
    ],
    "total": 8
}

{
    "data": [
        {
            "id": 11,
            "updatedAt": "2024-01-28T02:12:53.797Z",
            "createdAt": "2024-01-28T02:12:53.797Z",
            "title": "임의로 생성된 2",
            "content": "임의로 생성된 포수트 내용 2",
            "likeCount": 0,
            "commentCount": 0
        },
        .
        .
        {
            "id": 101,
            "updatedAt": "2024-01-28T02:12:54.520Z",
            "createdAt": "2024-01-28T02:12:54.520Z",
            "title": "임의로 생성된 92",
            "content": "임의로 생성된 포수트 내용 92",
            "likeCount": 0,
            "commentCount": 0
        }
    ],
    "total": 19
}

{
    "data": [
        {
            "id": 30,
            "updatedAt": "2024-01-28T02:12:53.985Z",
            "createdAt": "2024-01-28T02:12:53.985Z",
            "title": "임의로 생성된 21",
            "content": "임의로 생성된 포수트 내용 21",
            "likeCount": 100,
            "commentCount": 0
        }
    ],
    "total": 1
}

whitelist를 통해서 모든 Dto에 정의한 프로퍼티들이 query 또는 body로 보낼 수 있도록 만들었습니다.


🖊️Override Options 사용

paginate<T extends BaseModel>(
    dto: BasePaginationDto,
    repository: Repository<T>,
    overrideFindOptions: FindManyOptions<T> = {}, // 이거
    path: string,
) {

overrideFindOptions에 관해서 알아보겠습니다. 원래 getAllPosts()를 보면 author 정보를 같이 가지고 왔습니다. 하지만 이전에도 봤듯이, post에대한 정보만 가져오고 author정보는 가져오지 않습니다.

async getAllPosts() {
    return await this.postsRepository.find({
        relations: [
          	'author',
        ],
    });
}

당연히 author를 가져오지 않는 이유는 override할 옵션을 넣지 않았기 때문입니다. override할 옵션 author를 넣어보겠습니다. 그 후에 포스트맨으로 테스트를 하겠습니다.

  • posts.service.ts
async paginatePosts(dto: PaginatePostDto) {
    return this.commonService.paginate(
        dto,
        this.postsRepository,
        {}, // 여기에 넣어줘야 함!
        'posts'
    );
}
.
.
변경
.
.
async paginatePosts(dto: PaginatePostDto) {
    return this.commonService.paginate(
        dto,
        this.postsRepository,
        {
        	relations: ['author']
        },
        'posts'
    );
}

{
    "data": [
        {
            "id": 30,
            "updatedAt": "2024-01-28T02:12:53.985Z",
            "createdAt": "2024-01-28T02:12:53.985Z",
            "title": "임의로 생성된 21",
            "content": "임의로 생성된 포수트 내용 21",
            "likeCount": 100,
            "commentCount": 0,
            "author": {
                "id": 1,
                "updatedAt": "2024-01-26T05:58:10.800Z",
                "createdAt": "2024-01-26T05:58:10.800Z",
                "nickname": "codefactory",
                "email": "codefactory@codefactory.ai",
                "role": "USER"
            }
        }
    ],
    "total": 1
}

이러한 이유 때문에 paginate 함수에 overrideFindOptions를 사용하는 것입니다. 즉, 중요한 정보 또는 필터링을 해야되는 정보를 override해서 변형시켜 바꿀 수 있습니다.

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);
}

또한 기억해야 하는 부분은 덮어쓴다는 것입니다. 덮어씌우기 때문에 나중에 넣은 것입니다.

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, // findOptions를 덮어씌워버린다.
    });
profile
블로그 이전 : https://medium.com/@jaegeunsong97

0개의 댓글