🎉 berenickt 블로그에 온 걸 환영합니다. 🎉
Back
NestJs
22-Pagination-심화-일반화하기

1. BasePaginationDto 생성

pagination을 어떤 모듈에서 사용할 수 있게, common 서비스에 정의할 것이다.

  • 그러기 전에 페이지네이션을 할 수 있는 기본 베이스가 되는 DTO를 생성한다.
  • posts/dto/PaginatePostDto 내용만 잘라서
  • common.dto/base-pagination.dto.ts파일을 생성해 복붙한다.
base-pagination.dto.ts
1
import { IsIn, IsNumber, IsOptional } from 'class-validator'
2
3
export class BasePaginationDto {
4
@IsNumber()
5
@IsOptional()
6
page?: number
7
8
@IsNumber()
9
@IsOptional()
10
where__id_less_than?: number
11
12
/*** 이전 마지막 데이터의 ID
13
* 이 프로퍼티에 입력된 ID보다 높은 ID부터 값을 가져오기
14
*/
15
@IsNumber()
16
@IsOptional()
17
where__id_more_than?: number
18
19
/*** 정렬
20
* createAt : 생성된 시간의 내림차/오름차 순으로 정렬
21
*/
22
@IsIn(['ASC', 'DESC']) // 리스트에 있는 값들만 허용
23
@IsOptional()
24
order__createAt: 'ASC' | 'DESC' = 'ASC'
25
26
/*** 갖고올 데이터 개수
27
* 몇 개의 데이터를 응답으로 받을지
28
*/
29
@IsNumber()
30
@IsOptional()
31
take: number = 20
32
}

그리고 기존 PaginatePostDto은 BasePaginationDto을 상속받는다.

paginate-post.dto.ts
1
import { BasePaginationDto } from 'src/common/dto/base-pagination.dto'
2
3
export class PaginatePostDto extends BasePaginationDto {}
  • 이렇게만 상속으로 기본 DTO를 정의하고, 특정 모듈에서만 필요한 프로퍼티가 있으면,
  • 그 프로퍼티만 추가해주면 된다.

2. BasePaginationDto 리팩토링 & pagination() 선언

where__id__less_than와 같이 _를 2개로 바꾼다.

base-pagination.dto.ts
1
import { IsIn, IsNumber, IsOptional } from 'class-validator'
2
3
export class BasePaginationDto {
4
@IsNumber()
5
@IsOptional()
6
page?: number
7
8
@IsNumber()
9
@IsOptional()
10
where__id__less_than?: number
11
12
/*** 이전 마지막 데이터의 ID
13
* 이 프로퍼티에 입력된 ID보다 높은 ID부터 값을 가져오기
14
*/
15
@IsNumber()
16
@IsOptional()
17
where__id__more_than?: number
18
19
// 생략
20
}

posts 서비스에 _역시 2개로 바꿔준다.

  • where__id__less_than
  • where__id__more_than
  • cf. VSCode에 Ctrl + F로 한 번에 바꿔주면 편하다.

common 서비스에 일반화할 pagination()를 생성한다.

common.service.ts
1
import { Injectable } from '@nestjs/common'
2
import { FindManyOptions, Repository } from 'typeorm'
3
import { BasePaginationDto } from './dto/base-pagination.dto'
4
import { BaseModel } from './entities/base.entity'
5
6
@Injectable()
7
export class CommonService {
8
paginate<T extends BaseModel>(
9
dto: BasePaginationDto,
10
repository: Repository<T>,
11
overrideFindOptions: FindManyOptions<T> = {},
12
path: string,
13
) {}
14
}

3. 작업할 Pagination 로직 정리

common.service.ts
1
import { Injectable } from '@nestjs/common'
2
import { FindManyOptions, Repository } from 'typeorm'
3
import { BasePaginationDto } from './dto/base-pagination.dto'
4
import { BaseModel } from './entities/base.entity'
5
6
@Injectable()
7
export class CommonService {
8
paginate<T extends BaseModel>(
9
dto: BasePaginationDto,
10
repository: Repository<T>,
11
overrideFindOptions: FindManyOptions<T> = {},
12
path: string,
13
) {
14
if (dto.page) {
15
return this.pagePaginate(dto, repository, overrideFindOptions)
16
} else {
17
return this.cursorPaginate(dto, repository, overrideFindOptions, path)
18
}
19
}
20
21
private async pagePaginate<T extends BaseModel>(
22
dto: BasePaginationDto,
23
repository: Repository<T>,
24
overrideFindOptions: FindManyOptions<T> = {},
25
) {}
26
27
/***
28
* where__likeCount__more_than
29
* where__title__ilike
30
*/
31
private async cursorPaginate<T extends BaseModel>(
32
dto: BasePaginationDto,
33
repository: Repository<T>,
34
overrideFindOptions: FindManyOptions<T> = {},
35
path: string,
36
) {}
37
38
/** 반환하는 옵션
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
*/
64
private composeFindOptions<T extends BaseModel>(
65
dto: BasePaginationDto, //
66
): FindManyOptions<T> {}
67
}

4. DTO를 이용해 FindOptions 생성

common.service.ts
1
// 생략
2
3
@Injectable()
4
export class CommonService {
5
// 생략
6
private composeFindOptions<T extends BaseModel>(
7
dto: BasePaginationDto, //
8
): FindManyOptions<T> {
9
let where: FindOptionsWhere<T> = {}
10
let order: FindOptionsOrder<T> = {}
11
12
/***
13
* key -> where__id__less_than
14
* value -> 1
15
*/
16
for (const [key, value] of Object.entries(dto)) {
17
if (key.startsWith('where__')) {
18
where = { ...where, ...this.parseWhereFilter(key, value) }
19
} else if (key.startsWith('order__')) {
20
order = { ...order, ...this.parseOrderFilter(key, value) }
21
}
22
}
23
return {
24
where,
25
order,
26
take: dto.take,
27
skip: dto.page ? dto.take * (dto.page - 1) : null,
28
}
29
}
30
31
private parseWhereFilter<T extends BaseModel>(
32
key: string, //
33
value: any,
34
): FindOptionsWhere<T> {}
35
36
private parseOrderFilter<T extends BaseModel>(
37
key: string, //
38
value: any,
39
): FindOptionsOrder<T> {}
40
}

5. ParseWhereFilter 작업

common/const/filter-mapper.const.ts 파일을 만든다.

common/const/filter-mapper.const.ts
1
import {
2
Any,
3
ArrayContainedBy,
4
ArrayContains,
5
ArrayOverlap,
6
Between,
7
Equal,
8
ILike,
9
In,
10
IsNull,
11
LessThan,
12
LessThanOrEqual,
13
Like,
14
MoreThan,
15
MoreThanOrEqual,
16
Not,
17
} from 'typeorm'
18
19
/*** 예시
20
* where__id__not
21
*
22
* {
23
* where:{
24
* id: Not(value)
25
* }
26
* }
27
*/
28
export const FILTER_MAPPER = {
29
any: Any,
30
array_contained_by: ArrayContainedBy,
31
array_contains: ArrayContains,
32
array_overlap: ArrayOverlap,
33
between: Between,
34
equal: Equal,
35
ilike: ILike,
36
in: In,
37
is_null: IsNull,
38
less_than: LessThan,
39
less_than_or_equal: LessThanOrEqual,
40
like: Like,
41
more_than: MoreThan,
42
more_than_or_equal: MoreThanOrEqual,
43
not: 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
*/
6
private parseWhereFilter<T extends BaseModel>(
7
key: string, //
8
value: any,
9
): FindOptionsWhere<T> {
10
const options: FindOptionsWhere<T> = {}
11
const split = key.split('__')
12
13
if (split.length !== 2 && split.length !== 3) {
14
throw new BadRequestException(
15
`where 필터는 '__'로 split 햇을 떄, 길이가 2 또는 3이어야 합니다 - 문제되는 키값: ${key}`,
16
)
17
}
18
19
/*** 길이가 2일 경우 where__id = 3을
20
* FindOptionsWhere로 풀어보면 아래와 같다
21
* {
22
* where: {
23
* id : 3,
24
* }
25
* }
26
*/
27
if (split.length === 2) {
28
// ['where', 'id']
29
const [_, field] = split
30
// field -> 'id, value -> 3
31
options[field] = value
32
} 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']
43
const [_, field, operator] = split
44
45
/*** where__id__between = 3, 4
46
* 만약 split 대상 문자가 존재하지 않으면, 길이가 무조건 1이다.
47
*/
48
// const values = value.toString().split(',')
49
50
/***
51
* field -> id
52
* operator -> more_than
53
* FILTER_MAPPER[operator] -> MoreThan
54
*/
55
// if (operator === ' between') {
56
// options[field] = FILTER_MAPPER[operator](values[0], values[1])
57
// } else {
58
// options[field] = FILTER_MAPPER[operator](value)
59
// }
60
options[field] = FILTER_MAPPER[operator](value)
61
}
62
63
return options
64
}

6. composeFindOptions() 완성

parseOrderFilter()를 지우고, parseWhereFilter()에 타입을 추가해 1개함수로 합친다.

common.service.ts
1
// common.service 생략
2
private async cursorPaginate<T extends BaseModel>(
3
dto: BasePaginationDto,
4
repository: Repository<T>,
5
overrideFindOptions: FindManyOptions<T> = {},
6
path: string,
7
) {
8
const findOptions = this.composeFindOptions<T>(dto)
9
}
10
11
private composeFindOptions<T extends BaseModel>(
12
dto: BasePaginationDto, //
13
): FindManyOptions<T> {
14
// 생략
15
16
for (const [key, value] of Object.entries(dto)) {
17
if (key.startsWith('where__')) {
18
where = { ...where, ...this.parseWhereFilter(key, value) }
19
} else if (key.startsWith('order__')) {
20
order = { ...order, ...this.parseWhereFilter(key, value) }
21
}
22
}
23
// 생략
24
}
25
26
private parseWhereFilter<T extends BaseModel>(
27
key: string, //
28
value: any,
29
): FindOptionsWhere<T> | FindOptionsOrder<T> {
30
// 생략
31
}

7. Cursor Pagination 적용

coomon 서비스에서 cursorPaginate를 수정한다.

common.service.ts
1
// common.service.ts 생략
2
private async cursorPaginate<T extends BaseModel>(
3
dto: BasePaginationDto,
4
repository: Repository<T>,
5
overrideFindOptions: FindManyOptions<T> = {},
6
path: string,
7
) {
8
const findOptions = this.composeFindOptions<T>(dto)
9
10
const results = await repository.find({
11
...findOptions,
12
...overrideFindOptions,
13
})
14
15
/****
16
* 해당되는 포스트가 0개 이상이면, 마지막 포스트를 가져오고
17
* 아니면 null을 반환한다.
18
*/
19
const lastItem = results.length > 0 && results.length === dto.take ? results[results.length - 1] : null
20
const nextUrl = lastItem && new URL(`${PROTOCOL}://${HOST}/posts`)
21
22
/**** dto의 키값들을 루핑하면서
23
* 키값에 해당되는 벨류가 존재하면, parame에 그대로 붙여넣는다.
24
* 단, where__id__more_than 값만 lastItem의 마지막 값으로 넣어준다.
25
*/
26
if (nextUrl) {
27
for (const key of Object.keys(dto)) {
28
if (dto[key]) {
29
if (key !== 'where__id__more_than' && key !== 'where__id__less_than') {
30
nextUrl.searchParams.append(key, dto[key])
31
}
32
}
33
}
34
let key = null
35
if (dto.order__createdAt === 'ASC') {
36
key = 'where__id__more_than'
37
} else {
38
key = 'where__id__less_than'
39
}
40
nextUrl.searchParams.append(key, lastItem.id.toString())
41
}
42
43
return {
44
data: results,
45
cursor: {
46
after: lastItem?.id ?? null,
47
},
48
count: results.length,
49
next: nextUrl?.toString() ?? null,
50
}
51
}

이렇게 만든 common 모듈을 다른 모듈에서 쓸 수 있게 export한다.

common.module.ts
1
import { Module } from '@nestjs/common'
2
import { CommonService } from './common.service'
3
import { CommonController } from './common.controller'
4
5
@Module({
6
controllers: [CommonController],
7
providers: [CommonService],
8
exports: [CommonService],
9
})
10
export class CommonModule {}

그리고 common 모듈을 사용할 posts 모듈에서 import 한다.

posts.module.ts
1
@Module({
2
imports: [
3
TypeOrmModule.forFeature([
4
PostsModel, //
5
]),
6
AuthModule,
7
UsersModule,
8
CommonModule,
9
],
10
controllers: [PostsController],
11
providers: [PostsService],
12
})
13
export class PostsModule {}

posts 서비스에서 CommonService모듈을 불러온 뒤, paginatePosts()를 수정한다.

posts.service.ts
1
// 생략
2
3
@Injectable()
4
export class PostsService {
5
constructor(
6
@InjectRepository(PostsModel)
7
private readonly postsRepository: Repository<PostsModel>,
8
private readonly commonService: CommonService,
9
) {}
10
11
// 생략
12
13
/***
14
* 1) 오름차순으로 정렬하는 pagination만 구현한다
15
*/
16
async paginatePosts(dto: PaginatePostDto) {
17
return this.commonService.paginate(
18
dto, //
19
this.postsRepository,
20
{},
21
'posts',
22
)
23
// if (dto.page) {
24
// return this.pagePaginatePosts(dto)
25
// } else {
26
// return this.cursorPaginatePosts(dto)
27
// }
28
}
29
30
// 생략
31
}

다른 모듈에서 posts 모듈을 쓸 수 있도록 export 해준다.

posts.module.ts
1
@Module({
2
// 생략
3
providers: [PostsService, PostsImagesService],
4
exports: [PostsService],
5
})
6
export class PostsModule {}

8. Page Pagination 적용

common 서비스에 pagePaginate() 기능을 구현한다.

common.service.ts
1
// common.service.ts 생략
2
private async pagePaginate<T extends BaseModel>(
3
dto: BasePaginationDto,
4
repository: Repository<T>,
5
overrideFindOptions: FindManyOptions<T> = {},
6
) {
7
const findOptions = this.composeFindOptions<T>(dto)
8
const [data, total] = await repository.findAndCount({
9
...findOptions,
10
...overrideFindOptions,
11
})
12
13
return {
14
total,
15
data,
16
}
17
}

9. 추가 쿼리 프로퍼티 테스팅

paginate-post.dto에 추가할 쿼리 프로퍼티를 작성한다.

paginate-post.dto.ts
1
import { IsNumber, IsOptional, IsString } from 'class-validator'
2
import { BasePaginationDto } from 'src/common/dto/base-pagination.dto'
3
4
export class PaginatePostDto extends BasePaginationDto {
5
@IsNumber()
6
@IsOptional()
7
where__likeCount__more_than?: number
8
9
@IsString()
10
@IsOptional()
11
where__title__i_like?: string
12
}

parseWhereFilter()에 i_like 프로퍼티를 찾는 필터를 추가한다.

common.service.ts
1
private parseWhereFilter<T extends BaseModel>(
2
key: string, //
3
value: any,
4
): FindOptionsWhere<T> | FindOptionsOrder<T> {
5
// 생략
6
if (split.length === 2) {
7
// 생략
8
} else {
9
// 생략
10
if (operator === 'i_like') {
11
options[field] = FILTER_MAPPER[operator](`%${value}%`)
12
} else {
13
options[field] = FILTER_MAPPER[operator](value)
14
}
15
}
16
17
return options
18
}

10. DTO 프로퍼티 whitelisting 하기

paginate-post.dto.ts
1
import { IsNumber, IsOptional, IsString } from 'class-validator'
2
import { BasePaginationDto } from 'src/common/dto/base-pagination.dto'
3
4
export class PaginatePostDto extends BasePaginationDto {
5
@IsNumber()
6
@IsOptional()
7
where__likeCount__more_than?: number
8
9
// @IsString()
10
// @IsOptional()
11
// where__title__i_like?: string;
12
}

main.ts에 whitelist 옵션을 추가한다.

main.ts
1
async function bootstrap() {
2
const app = await NestFactory.create(AppModule)
3
4
app.useGlobalPipes(
5
new ValidationPipe({
6
transform: true,
7
transformOptions: {
8
// 임의로 변환을 허가
9
enableImplicitConversion: true,
10
},
11
whitelist: true,
12
forbidNonWhitelisted: true,
13
}),
14
)
15
16
await app.listen(3000)
17
}
18
bootstrap()
  • whitelist 옵션은 입력한 프로퍼티 값만 입력을 받게 하는 옵션이다.
  • forbidNonWhitelisted 옵션은 존재하지 않는 프로퍼티에 대해 에러를 던지는 옵션이다.

11. Override Options 사용

posts 서비스에서 Override 옵션을 추가한다.

posts.service.ts
1
async paginatePosts(dto: PaginatePostDto) {
2
return this.commonService.paginate(
3
dto, //
4
this.postsRepository,
5
{ relations: ['author'] }, // Override Options 추가
6
'posts',
7
)
8
}