지금부터는 pagination 코드를 변경해보겠습니다. 왜냐하면 Cursor 페이지네이션 코드만 봐도 대략 30줄이 넘는 코드를 매번 작성해야 합니다. 만약 다른 review같은 부분에서도 페이지네이션이 필요한 경우, 마찬가지로 30줄을 적을 수 없기 때문입니다. 따라서 일반화를 해보도록 하겠습니다.
일단 페이지네이션을 모든 모듈에서 사용할 수 있도록 common에서 작업을 하도록 하겠습니다.
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에서는 상속을 받아줍시다.
import { BasePaginationDto } from "src/common/dto/base-pagination.dto";
export class PaginatePostDto extends BasePaginationDto {}
@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에 페이지네이션 기반의 기능을 만들겠습니다.
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에서 받겠습니다.
@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[]>;
const posts = await this.postsRepository.find({ // FindMantOptions 에서 Entity는 PostsModel의미
where,
order: {
createdAt: dto.order__createdAt,
},
take: dto.take,
});
그리고 path를 받도록 하겠습니다.
@Injectable()
export class CommonService {
paginate<T extends BaseModel>(
dto: BasePaginationDto,
repository: Repository<T>,
overrideFindOptions: FindManyOptions<T> = {},
path: string, // 추가
) {}
}
path: string
을 추가하는 이유는 nextUrl 때문입니다. 가장 마지막 부분인 /posts
부분이 계속해서 바뀌기 때문입니다. 그러면 이제 common에서 해당 url을 생성할 수 있게 됩니다.
const nextUrl = lastItem && new URL(`${PROTOCOL}://${HOST}/${path}`); // 마지막 부분 posts가 바뀌게 됨
이제부터 cursor 기반의 페이지네이션과 page 기반의 페이지네이션으로 나눠야합니다. 따라서 일단 split을 합니다.
@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__than
과 where__id__less_than
만 사용하고 있었습니다. 하지만 where__likeCount_more_than
, where__title_ilike
위에 2개의 경우를 선택해서 보여주고 싶은 경우가 생길 수 있습니다.
예시이지만, 1번째는 where 쿼리를 사용하고, likeCount라는 프로퍼티를, more_than을 사용해서 필터링 하겠다는 의미입니다. 즉 우리는 어떠한 값이와도 일반화를 하겠다는 것입니다.
일단 composeFindOptions함수를 새로 1개 만들고, composeFindOptions의 반환타입을 정의하겠습니다. FindManyOptions는 이미 학습을 했기 때문에 넘어가겠습니다.
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 필터들을 자동으로 파싱 할 수 있을만한 기능
을 제작해야 합니다.
where로 시작
한다면 필터 로직
을 적용한다.order로 시작
한다면 정렬 로직
을 적용한다.__
기준으로 split 했을때 3개의 값으로 나뉘는지, 2개의 값으로 나뉘는지 확인한다.FILTER_MAPPER(우리가 작성한 more_than과 같은 유틸리티를 typeORM 유틸리티로 바꾸는 것)
에서 해당되는 operator 함수를 찾아서 적용한다.['where', 'id', 'more_than']
where__id
-> ['where', 'id']
일단 코드를 작성합니다.
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를 먼저 해보겠습니다.
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에 관한 코드를 먼저 작성하겠습니다.
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개의 파라미터를 받는 것 까지 고려해서 작성을 하겠습니다.
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를 작성해 보겠습니다. 이미 완성한 ParseWhereFilter와 매우 비슷하기 때문에 빠르게 완성하겠습니다. 어차피 나중에 ParseWhereFilter와 합치겠지만 연습을 위해 1번더 작성하겠습니다.
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을 완성해보도록 하겠습니다. 결과값을 불러오도록 만들겠습니다.
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 하겠습니다.
@Module({
controllers: [CommonController],
providers: [CommonService],
exports: [CommonService] // 추가
})
export class CommonModule {}
@Module({
imports: [
TypeOrmModule.forFeature([
PostsModel,
]),
AuthModule,
UsersModule,
CommonModule, // 추가
],
controllers: [PostsController],
providers: [PostsService],
exports: [PostsService]
})
export class PostsModule {}
그리고 posts.service.ts에서 paginatePosts()를 전부 주석처리를 하고, commonService를 주입받도록 하겠습니다. 또한 paginatePosts()에 commonService 코드를 작성하겠습니다.
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을 작업하겠습니다. 간단합니다.
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에 추가를 해보겠습니다.
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
를 해보겠습니다. 필터의 옵션마다 분기처리가 필요합니다.
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에 쿼리를 추가하겠습니다.
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
}
추가 쿼리 프로퍼티를 하는 과정에서 치명적인 단점이 있습니다. 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: true
면 validator
가 validation
데코레이터가 적용되지 않은 모든 프로퍼티를 전부 삭제
한다 라는 의미입니다.
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_like
가 strip
된 것을 알 수 있습니다. 이렇게 하면 해커들이 탐구하지 못하도록 막아야 합니다. 하지만 이것도 아직 부족합니다. 왜냐하면 FE입장에서 오타로 보냈으면, 적용이 된 쿼리인지 아닌지 알 수 없습니다. 따라서 main.ts에 forbidNonWhitelisted: true
를 추가하겠습니다.
forbidNonWhitelisted: true
면, strip
대신에 에러
를 던진다는 것입니다. 그리고 포스트맨으로 테스트를 하겠습니다.
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
로 보낼 수 있도록 만들었습니다.
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
를 넣어보겠습니다. 그 후에 포스트맨으로 테스트를 하겠습니다.
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를 덮어씌워버린다.
});