이전 Pagination 일반 포스팅에서 pagePagination과 cursorPagination을 알아보고 구현해보았다.
그러나 해당 로직들을 그대로 사용하기엔 조금 무리가 있다.
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를 지우고 commonServices의 paginate로 적용한다.
posts 에 CommonService를 사용할 수 있도록 모듈 등록
posts.service.ts에서 commonService를 사용하고 있으려면 CommonModule이 등록되어야 한다.
@Module({
imports: [
TypeOrmModule.forFeature([PostsModel]),
AuthModule,
UsersModule,
CommonModule,
],
posts.module.ts 에서 CommonModule을 imports 한다.
@Module({
controllers: [CommonController],
providers: [CommonService],
exports: [CommonService],
})
common.module.ts 에서 CommonService를 exports 한다.
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 |