CommonService: 페이징 로직 상세 설명
CommonService
는 페이지 기반 페이징과 커서 기반 페이징을 처리하며, 주어진 DTO를 통해 TypeORM의 FindManyOptions
를 동적으로 생성해 데이터를 조회합니다.
import { BadRequestException, Injectable } from '@nestjs/common';
import { BasePaginationDto } from './dto/base-pagination.dto';
import { FindManyOptions, FindOptionsOrder, FindOptionsWhere, Repository } from 'typeorm';
import { BaseModel } from './entity/base.entity';
import { FILTER_MAPPER } from './const/filter-mapper.const';
import { HOST, PROTOCOL } from './const/env.const';
@Injectable()
export class CommonService {
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> = {},
) {
const findOptions = this.composeFindOptions<T>(dto);
const [data, count] = await repository.findAndCount({
...findOptions,
...overrideFindOptions,
});
return {
data,
total: count,
};
}
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}/${path}`);
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,
},
count: results.length,
next: nextUrl?.toString() ?? null,
};
}
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> {
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') {
options[field] = FILTER_MAPPER[operator](`%${value}%`);
} else {
options[field] = FILTER_MAPPER[operator](value);
}
}
return options;
}
}
1. 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);
}
}
dto.page
가 존재 →pagePaginate
호출 (페이지 기반 페이징).dto.page
가 없음 →cursorPaginate
호출 (커서 기반 페이징).
2. 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,
};
}
findAndCount
:- 데이터를 가져오는 동시에 총 개수를 반환합니다.
- 반환값:
[data: T[], count: number]
.
- 리턴 구조
{
"data": [...],
// 가져온 데이터 배열"total": 100
// 데이터 총 개수
}
3. 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 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 !== 'where__id__more_than' && key !== 'where__id__less_than') {
nextUrl.searchParams.append(key, dto[key]);
}
}
}
const key = dto.order__createdAt === 'ASC' ? 'where__id__more_than' : 'where__id__less_than';
nextUrl.searchParams.append(key, lastItem.id.toString());
}
return {
data: results,
cursor: {
after: lastItem?.id ?? null,
},
count: results.length,
next: nextUrl?.toString() ?? null,
};
}
- 데이터 조회:
repository.find
를 사용해 데이터를 가져옵니다. - 커서 설정:
lastItem
을 기준으로 커서 URL(nextUrl
)을 생성.- 다음 데이터 요청 시 필요한 조건(
where__id__more_than
또는where__id__less_than
)을 URL에 추가.
- 리턴 구조:
{
"data": [...],
// 가져온 데이터 배열"cursor": { "after": 5 },
// 마지막 데이터의 ID"count": 20,
// 데이터 개수"next": "http://..."// 다음 페이지 URL
}
4. composeFindOptions
메서드
DTO를 기반으로 FindManyOptions
객체를 생성합니다.
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,
};
}
- 필터 처리:
where
:where__
로 시작하는 필드를 필터 조건으로 변환.order
:order__
로 시작하는 필드를 정렬 조건으로 변환.
- 리턴 구조:
{
"where": { ... },
// 필터 조건"order": { ... },
// 정렬 조건"take": 20,
// 가져올 데이터 수"skip": 20
// 페이지 기반 페이징 시 오프셋
}
5. parseWhereFilter
메서드
DTO의 키값을 기반으로 필터 조건을 생성합니다.
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') {
options[field] = FILTER_MAPPER[operator](`%${value}%`);
} else {
options[field] = FILTER_MAPPER[operator](value);
}
}
return options;
}
- 키값 파싱:
- 길이가 2: 필드 이름과 값을 그대로 매핑.
- 길이가 3: 필드 이름과 연산자(
FILTER_MAPPER
)를 매핑.
- 예시:
// DTO
{
where__id__less_than: 100,
where__name__i_like: "John",
}
// 변환 결과
{
where: {
id: LessThan(100),
name: ILike("%John%"),
}
}
6. FILTER_MAPPER
TypeORM의 필터 유틸리티를 키값(operator
)과 매핑합니다.
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,
};
최종 구조
이 코드는 각 메서드가 DTO를 기반으로 동적으로 쿼리 옵션을 생성하며, 페이지 기반과 커서 기반 페이징을 모두 지원합니다.
구체적인 예시와 동작 방식이 명확하게 드러나며, 실무에서도 쉽게 확장 가능합니다.
'NestJS' 카테고리의 다른 글
Class Validator 정리 (1) | 2024.12.23 |
---|---|
NestJS Interceptor (0) | 2024.11.25 |
NestJS 페이징처리 (0) | 2024.11.19 |
NestJS Class-transfomer (0) | 2024.11.18 |
NestJS DTO,Validation (0) | 2024.11.18 |