1. BasePaginationDto 생성
pagination을 어떤 모듈에서 사용할 수 있게, common 서비스에 정의할 것이다.
- 그러기 전에 페이지네이션을 할 수 있는 기본 베이스가 되는 DTO를 생성한다.
posts/dto/PaginatePostDto내용만 잘라서common.dto/base-pagination.dto.ts파일을 생성해 복붙한다.
base-pagination.dto.ts
1import { IsIn, IsNumber, IsOptional } from 'class-validator'23export class BasePaginationDto {4@IsNumber()5@IsOptional()6page?: number78@IsNumber()9@IsOptional()10where__id_less_than?: number1112/*** 이전 마지막 데이터의 ID13* 이 프로퍼티에 입력된 ID보다 높은 ID부터 값을 가져오기14*/15@IsNumber()16@IsOptional()17where__id_more_than?: number1819/*** 정렬20* createAt : 생성된 시간의 내림차/오름차 순으로 정렬21*/22@IsIn(['ASC', 'DESC']) // 리스트에 있는 값들만 허용23@IsOptional()24order__createAt: 'ASC' | 'DESC' = 'ASC'2526/*** 갖고올 데이터 개수27* 몇 개의 데이터를 응답으로 받을지28*/29@IsNumber()30@IsOptional()31take: number = 2032}
그리고 기존 PaginatePostDto은 BasePaginationDto을 상속받는다.
paginate-post.dto.ts
1import { BasePaginationDto } from 'src/common/dto/base-pagination.dto'23export class PaginatePostDto extends BasePaginationDto {}
- 이렇게만 상속으로 기본 DTO를 정의하고, 특정 모듈에서만 필요한 프로퍼티가 있으면,
- 그 프로퍼티만 추가해주면 된다.
2. BasePaginationDto 리팩토링 & pagination() 선언
where__id__less_than와 같이 _를 2개로 바꾼다.
base-pagination.dto.ts
1import { IsIn, IsNumber, IsOptional } from 'class-validator'23export class BasePaginationDto {4@IsNumber()5@IsOptional()6page?: number78@IsNumber()9@IsOptional()10where__id__less_than?: number1112/*** 이전 마지막 데이터의 ID13* 이 프로퍼티에 입력된 ID보다 높은 ID부터 값을 가져오기14*/15@IsNumber()16@IsOptional()17where__id__more_than?: number1819// 생략20}
posts 서비스에 _역시 2개로 바꿔준다.
where__id__less_thanwhere__id__more_than- cf. VSCode에 Ctrl + F로 한 번에 바꿔주면 편하다.
common 서비스에 일반화할 pagination()를 생성한다.
common.service.ts
1import { Injectable } from '@nestjs/common'2import { FindManyOptions, Repository } from 'typeorm'3import { BasePaginationDto } from './dto/base-pagination.dto'4import { BaseModel } from './entities/base.entity'56@Injectable()7export class CommonService {8paginate<T extends BaseModel>(9dto: BasePaginationDto,10repository: Repository<T>,11overrideFindOptions: FindManyOptions<T> = {},12path: string,13) {}14}
3. 작업할 Pagination 로직 정리
common.service.ts
1import { Injectable } from '@nestjs/common'2import { FindManyOptions, Repository } from 'typeorm'3import { BasePaginationDto } from './dto/base-pagination.dto'4import { BaseModel } from './entities/base.entity'56@Injectable()7export class CommonService {8paginate<T extends BaseModel>(9dto: BasePaginationDto,10repository: Repository<T>,11overrideFindOptions: FindManyOptions<T> = {},12path: string,13) {14if (dto.page) {15return this.pagePaginate(dto, repository, overrideFindOptions)16} else {17return this.cursorPaginate(dto, repository, overrideFindOptions, path)18}19}2021private async pagePaginate<T extends BaseModel>(22dto: BasePaginationDto,23repository: Repository<T>,24overrideFindOptions: FindManyOptions<T> = {},25) {}2627/***28* where__likeCount__more_than29* where__title__ilike30*/31private async cursorPaginate<T extends BaseModel>(32dto: BasePaginationDto,33repository: Repository<T>,34overrideFindOptions: FindManyOptions<T> = {},35path: string,36) {}3738/** 반환하는 옵션39* where,40* order,41* take,42* skip -> page 기반일떄만43*44* DTO의 현재 싱긴 구조는 아래와 같다.45* {46* where__id__more_than:1,47* order__createAt: 'ASC'48* }49*50* 현재는 where__id__more_than 등에 해당하는 where 필터만 사용 중이지만,51* 나중에 where__likeCount__more_than 등 추가 필터를 넣고 싶어졌을 떄,52* 모든 where 필터링을 자동으로 파싱할 수 있을만한 기능을 제작해야 한다.53*54* 1) where로 시자한다면 필터 로직을 적용한다.55* 2) order로 시작한다면 정렬 로직을 적용한다.56* 3) 필터 로직을 적용한다 '__' 기준으로 split 했을떄 3개의 값으로 나뉘는지57* 2개의 값으로 나뉘는지 확인한다.58* 3-1) 3개의 값으로 나뉜다면 FILTER_MAPPER에서 해당되는 operator 함수를 찾아서 적용한다.59* ['where', 'id', 'more_than']60* 3-2) 2개의 값으로 나뉜다면 정확한 값을 필터하는 것이기 때문에 operator 없이 적용한다.61* where__id -> ['where', 'id']62* 4) order의 경우 3-2와 같이 적용한다.63*/64private composeFindOptions<T extends BaseModel>(65dto: BasePaginationDto, //66): FindManyOptions<T> {}67}
4. DTO를 이용해 FindOptions 생성
common.service.ts
1// 생략23@Injectable()4export class CommonService {5// 생략6private composeFindOptions<T extends BaseModel>(7dto: BasePaginationDto, //8): FindManyOptions<T> {9let where: FindOptionsWhere<T> = {}10let order: FindOptionsOrder<T> = {}1112/***13* key -> where__id__less_than14* value -> 115*/16for (const [key, value] of Object.entries(dto)) {17if (key.startsWith('where__')) {18where = { ...where, ...this.parseWhereFilter(key, value) }19} else if (key.startsWith('order__')) {20order = { ...order, ...this.parseOrderFilter(key, value) }21}22}23return {24where,25order,26take: dto.take,27skip: dto.page ? dto.take * (dto.page - 1) : null,28}29}3031private parseWhereFilter<T extends BaseModel>(32key: string, //33value: any,34): FindOptionsWhere<T> {}3536private parseOrderFilter<T extends BaseModel>(37key: string, //38value: any,39): FindOptionsOrder<T> {}40}
5. ParseWhereFilter 작업
common/const/filter-mapper.const.ts 파일을 만든다.
common/const/filter-mapper.const.ts
1import {2Any,3ArrayContainedBy,4ArrayContains,5ArrayOverlap,6Between,7Equal,8ILike,9In,10IsNull,11LessThan,12LessThanOrEqual,13Like,14MoreThan,15MoreThanOrEqual,16Not,17} from 'typeorm'1819/*** 예시20* where__id__not21*22* {23* where:{24* id: Not(value)25* }26* }27*/28export const FILTER_MAPPER = {29any: Any,30array_contained_by: ArrayContainedBy,31array_contains: ArrayContains,32array_overlap: ArrayOverlap,33between: Between,34equal: Equal,35ilike: ILike,36in: In,37is_null: IsNull,38less_than: LessThan,39less_than_or_equal: LessThanOrEqual,40like: Like,41more_than: MoreThan,42more_than_or_equal: MoreThanOrEqual,43not: Not,44}
다시 common 서비스에서 parseWhereFilter 내용을 추가한다.
common.service.ts
1// common.service.ts 생략2/*** 길이가 3일 경우3* e.g. where__id__more_than을 __를 기준으로 나누면,4* ['where', 'id', 'more_than']으로 나눌 수 있다.5*/6private parseWhereFilter<T extends BaseModel>(7key: string, //8value: any,9): FindOptionsWhere<T> {10const options: FindOptionsWhere<T> = {}11const split = key.split('__')1213if (split.length !== 2 && split.length !== 3) {14throw new BadRequestException(15`where 필터는 '__'로 split 햇을 떄, 길이가 2 또는 3이어야 합니다 - 문제되는 키값: ${key}`,16)17}1819/*** 길이가 2일 경우 where__id = 3을20* FindOptionsWhere로 풀어보면 아래와 같다21* {22* where: {23* id : 3,24* }25* }26*/27if (split.length === 2) {28// ['where', 'id']29const [_, field] = split30// field -> 'id, value -> 331options[field] = value32} else {33/*** 길이가 3일 경우 Typeorm 유틸리티 적용이 필요한 경우다34* where__id__more_than의 경우35* where는 버려도 되고, 두번쨰 값은 필터할 키값이 되고,36* 세번쨰 값은 typeorm 유틸리티가 된다.37*38* FILTER_MAPPER에 미리 정의해둔 값들로39* field 값에 FILTER_MAPPER에 해당되는 utility를 가져온 후40* 값에 적용해준다.41*/42// ['where', 'id', 'more_than']43const [_, field, operator] = split4445/*** where__id__between = 3, 446* 만약 split 대상 문자가 존재하지 않으면, 길이가 무조건 1이다.47*/48// const values = value.toString().split(',')4950/***51* field -> id52* operator -> more_than53* FILTER_MAPPER[operator] -> MoreThan54*/55// if (operator === ' between') {56// options[field] = FILTER_MAPPER[operator](values[0], values[1])57// } else {58// options[field] = FILTER_MAPPER[operator](value)59// }60options[field] = FILTER_MAPPER[operator](value)61}6263return options64}
6. composeFindOptions() 완성
parseOrderFilter()를 지우고, parseWhereFilter()에 타입을 추가해 1개함수로 합친다.
common.service.ts
1// common.service 생략2private async cursorPaginate<T extends BaseModel>(3dto: BasePaginationDto,4repository: Repository<T>,5overrideFindOptions: FindManyOptions<T> = {},6path: string,7) {8const findOptions = this.composeFindOptions<T>(dto)9}1011private composeFindOptions<T extends BaseModel>(12dto: BasePaginationDto, //13): FindManyOptions<T> {14// 생략1516for (const [key, value] of Object.entries(dto)) {17if (key.startsWith('where__')) {18where = { ...where, ...this.parseWhereFilter(key, value) }19} else if (key.startsWith('order__')) {20order = { ...order, ...this.parseWhereFilter(key, value) }21}22}23// 생략24}2526private parseWhereFilter<T extends BaseModel>(27key: string, //28value: any,29): FindOptionsWhere<T> | FindOptionsOrder<T> {30// 생략31}
7. Cursor Pagination 적용
coomon 서비스에서 cursorPaginate를 수정한다.
common.service.ts
1// common.service.ts 생략2private async cursorPaginate<T extends BaseModel>(3dto: BasePaginationDto,4repository: Repository<T>,5overrideFindOptions: FindManyOptions<T> = {},6path: string,7) {8const findOptions = this.composeFindOptions<T>(dto)910const results = await repository.find({11...findOptions,12...overrideFindOptions,13})1415/****16* 해당되는 포스트가 0개 이상이면, 마지막 포스트를 가져오고17* 아니면 null을 반환한다.18*/19const lastItem = results.length > 0 && results.length === dto.take ? results[results.length - 1] : null20const nextUrl = lastItem && new URL(`${PROTOCOL}://${HOST}/posts`)2122/**** dto의 키값들을 루핑하면서23* 키값에 해당되는 벨류가 존재하면, parame에 그대로 붙여넣는다.24* 단, where__id__more_than 값만 lastItem의 마지막 값으로 넣어준다.25*/26if (nextUrl) {27for (const key of Object.keys(dto)) {28if (dto[key]) {29if (key !== 'where__id__more_than' && key !== 'where__id__less_than') {30nextUrl.searchParams.append(key, dto[key])31}32}33}34let key = null35if (dto.order__createdAt === 'ASC') {36key = 'where__id__more_than'37} else {38key = 'where__id__less_than'39}40nextUrl.searchParams.append(key, lastItem.id.toString())41}4243return {44data: results,45cursor: {46after: lastItem?.id ?? null,47},48count: results.length,49next: nextUrl?.toString() ?? null,50}51}
이렇게 만든 common 모듈을 다른 모듈에서 쓸 수 있게 export한다.
common.module.ts
1import { Module } from '@nestjs/common'2import { CommonService } from './common.service'3import { CommonController } from './common.controller'45@Module({6controllers: [CommonController],7providers: [CommonService],8exports: [CommonService],9})10export class CommonModule {}
그리고 common 모듈을 사용할 posts 모듈에서 import 한다.
posts.module.ts
1@Module({2imports: [3TypeOrmModule.forFeature([4PostsModel, //5]),6AuthModule,7UsersModule,8CommonModule,9],10controllers: [PostsController],11providers: [PostsService],12})13export class PostsModule {}
posts 서비스에서 CommonService모듈을 불러온 뒤, paginatePosts()를 수정한다.
posts.service.ts
1// 생략23@Injectable()4export class PostsService {5constructor(6@InjectRepository(PostsModel)7private readonly postsRepository: Repository<PostsModel>,8private readonly commonService: CommonService,9) {}1011// 생략1213/***14* 1) 오름차순으로 정렬하는 pagination만 구현한다15*/16async paginatePosts(dto: PaginatePostDto) {17return this.commonService.paginate(18dto, //19this.postsRepository,20{},21'posts',22)23// if (dto.page) {24// return this.pagePaginatePosts(dto)25// } else {26// return this.cursorPaginatePosts(dto)27// }28}2930// 생략31}
다른 모듈에서 posts 모듈을 쓸 수 있도록 export 해준다.
posts.module.ts
1@Module({2// 생략3providers: [PostsService, PostsImagesService],4exports: [PostsService],5})6export class PostsModule {}
8. Page Pagination 적용
common 서비스에 pagePaginate() 기능을 구현한다.
common.service.ts
1// common.service.ts 생략2private async pagePaginate<T extends BaseModel>(3dto: BasePaginationDto,4repository: Repository<T>,5overrideFindOptions: FindManyOptions<T> = {},6) {7const findOptions = this.composeFindOptions<T>(dto)8const [data, total] = await repository.findAndCount({9...findOptions,10...overrideFindOptions,11})1213return {14total,15data,16}17}
9. 추가 쿼리 프로퍼티 테스팅
paginate-post.dto에 추가할 쿼리 프로퍼티를 작성한다.
paginate-post.dto.ts
1import { IsNumber, IsOptional, IsString } from 'class-validator'2import { BasePaginationDto } from 'src/common/dto/base-pagination.dto'34export class PaginatePostDto extends BasePaginationDto {5@IsNumber()6@IsOptional()7where__likeCount__more_than?: number89@IsString()10@IsOptional()11where__title__i_like?: string12}
parseWhereFilter()에 i_like 프로퍼티를 찾는 필터를 추가한다.
common.service.ts
1private parseWhereFilter<T extends BaseModel>(2key: string, //3value: any,4): FindOptionsWhere<T> | FindOptionsOrder<T> {5// 생략6if (split.length === 2) {7// 생략8} else {9// 생략10if (operator === 'i_like') {11options[field] = FILTER_MAPPER[operator](`%${value}%`)12} else {13options[field] = FILTER_MAPPER[operator](value)14}15}1617return options18}
10. DTO 프로퍼티 whitelisting 하기
paginate-post.dto.ts
1import { IsNumber, IsOptional, IsString } from 'class-validator'2import { BasePaginationDto } from 'src/common/dto/base-pagination.dto'34export class PaginatePostDto extends BasePaginationDto {5@IsNumber()6@IsOptional()7where__likeCount__more_than?: number89// @IsString()10// @IsOptional()11// where__title__i_like?: string;12}
main.ts에 whitelist 옵션을 추가한다.
main.ts
1async function bootstrap() {2const app = await NestFactory.create(AppModule)34app.useGlobalPipes(5new ValidationPipe({6transform: true,7transformOptions: {8// 임의로 변환을 허가9enableImplicitConversion: true,10},11whitelist: true,12forbidNonWhitelisted: true,13}),14)1516await app.listen(3000)17}18bootstrap()
- whitelist 옵션은 입력한 프로퍼티 값만 입력을 받게 하는 옵션이다.
- forbidNonWhitelisted 옵션은 존재하지 않는 프로퍼티에 대해 에러를 던지는 옵션이다.
11. Override Options 사용
posts 서비스에서 Override 옵션을 추가한다.
posts.service.ts
1async paginatePosts(dto: PaginatePostDto) {2return this.commonService.paginate(3dto, //4this.postsRepository,5{ relations: ['author'] }, // Override Options 추가6'posts',7)8}