NestJS / / 2024. 11. 20. 07:04

NestJS Pagination Common

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
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유