1. cursorPageNation Dto 정하기
import { IsInt, IsObject, IsOptional, IsString } from 'class-validator';
export enum OrderField {
// string 맵핑 값에 무조건 소문자로 정의 postgreSQL에서 소문자만 인식함
REVIEW_COUNT = 'review_count', // 리뷰 수
AVERAGE_RATING = 'average_rating', // 평균 평점
EXPERIENCE = 'experience', // 경력
CONFIRMED_ESTIMATE_COUNT = 'confirmed_estimate_count', // 확정 견적 수
CREATED_AT = 'created_at', // 생성일 DESC 최신순
}
export enum OrderDirection {
ASC = 'ASC', // 오름차순
DESC = 'DESC', // 내림차순
}
export interface OrderItemMap {
field: OrderField; // 추가적인 순서가 있을 경우 여기에 정의
direction: OrderDirection;
}
export class CursorPaginationDto {
@IsString()
@IsOptional()
cursor?: string; // 처음 조회할 땐 안넣음
@IsObject()
order: OrderItemMap; // 무조건 order 순서 정해줘야 함
@IsInt()
@IsOptional()
take?: number = 5; // 기본은 5이지만 2또는 3으로 하고 싶으면 body값에 넣어주면 된다!
}
// 처음 조회할 때 body값 예시
{
// 최신순으로 정렬
order: {
field: "created_at",
direction: "DESC"
}
// 3개만 가져오고 싶을 경우
take: 3
}
// CursorPaginationDto extends 필수
export class GetMoverProfilesDto extends CursorPaginationDto {
// 아래 예시는 필터링에 대한 추가 속성임.
@IsObject()
@HasAtLeastOneTrue({
message: '서비스 유형은 최소 하나 이상 선택되어야 합니다.',
})
serviceType: Partial<ServiceTypeMap> = defaultServiceTypeMap;
@IsObject()
@HasAtLeastOneTrue({
message: '서비스 지역은 최소 하나 이상 선택되어야 합니다.',
})
serviceRegion: Partial<ServiceRegionMap> = defaultServiceRegionMap;
}
// 필터링이 없는 경우 그냥 extends에서만 끝내고 아래 코드와 같이 만듬
export class Dto이름 extends CursorPaginationDto {}
// 그래서 이 dto를 어디서 사용하는가?
2. 서비스에서 사용하는 방법
// 기사님 목록 조회
// 여기서 사용한다! controller에서 받은 dto를 여기에 넣는다.
async findAll(user: UserInfo, dto: GetMoverProfilesDto) {
// 필터링 관련 코드
// 집계 뷰테이블 조인하는 코드
// 커서기반 페이지 네이션을 사용할려면 쿼리빌더를 사용해야 한다!
// 이 코드는 예시
// 우리가 늘 findOne where select하는 부분을 쿼리빌더로 사용해서 조회한다
const qb = this.moverProfileRepository
.createQueryBuilder(MOVER_PROFILE_TABLE)
.select(MOVER_PROFILE_LIST_SELECT);
// 커서 기반 페이징 적용
const { nextCursor } =
await this.commonService.applyCursorPaginationParamsToQb(qb, dto);
// 이 예시에서는 집계 뷰테이블을 조인했기에 저 함수를 사용한 것이고
// 뷰테이블이 없는 경우, .getMany() .getRawMany() 등의 함수를 사용하여 응답값을 가져옴
const { entities, raw: aggregates } = await qb.getRawAndEntities();
// 엔티티와 뷰 데이터를 병합하는 코드
// 고객일 경우, 해당 기사에게 지정 견적 요청을 했는지 확인하는 코드
const count = await qb.getCount(); // 필요시 개수를 가져오고 싶으면..
//근데 .getManyAndCount() 이런 함수도 있음
// 반환시 nextCursor 필수!
return { movers: moversWithAggregates, count, nextCursor, targetMoverIds };
}
3. applyCursorPaginationParamsToQb 이 함수는 어떻게 구성되어 있는가?
async applyCursorPaginationParamsToQb<T>(
qb: SelectQueryBuilder<T>,
dto: CursorPaginationDto,
) {
const { cursor, take } = dto;
let { order } = dto; // dto에서 order 추출
// cursor가 있는 경우 즉 그 다음 조회인 경우
if (cursor) {
const decodedCursor = Buffer.from(cursor, 'base64').toString('utf-8');
const cursorObj = JSON.parse(decodedCursor) as {
values: Record<string, any>;
order: OrderItemMap;
};
const { values } = cursorObj;
order = cursorObj.order; // cursorObj에서 order 추출
const { field, direction } = order;
const orderAlias = this.getOrderFieldAlias(qb, field);
const cursorId = values.id;
const cursorValue = values[field];
const operator = direction === OrderDirection.DESC ? '<' : '>';
const equals = '=';
qb.andWhere(
`(${orderAlias} ${operator} :cursorValue OR (${orderAlias} ${equals} :cursorValue AND ${qb.alias}.id ${operator} :cursorId))`,
{
cursorValue,
cursorId,
},
);
}
const { field, direction } = order;
if (direction !== OrderDirection.ASC && direction !== OrderDirection.DESC) {
throw new BadRequestException('정렬 방향은 ASC 또는 DESC 여야 합니다.');
}
const orderAlias = this.getOrderFieldAlias(qb, field);
qb.addOrderBy(orderAlias, direction); // 정렬 기준 필드
qb.addOrderBy(`${qb.alias}.id`, direction); // 항상 id도 정렬에 포함
/**
* Q) qb.addOrderBy(`${qb.alias}.id`, direction); 이거 왜 해용 ?.?
* A) 정렬 기준 값이 동일할 때, 중복 제거 및 페이지네이션 정확도를 위한 보조 정렬로서,
* id를 추가로 정렬 기준에 포함시킴
* 예: experience 기준 DESC 정렬 시 경험치가 동일한 mover가 여러 명일 수 있으므로,
* mover.id를 추가 정렬 기준으로 설정해 고유한 정렬 순서를 보장함
*/
qb.take(take);
const results = await qb.getMany();
const nextCursor = this.generateNextCursor(results, order);
return { qb, nextCursor };
}
private generateNextCursor<T>(
results: T[],
order: OrderItemMap,
): string | null {
if (results.length === 0) return null;
/**
* cursorObj =
* {
* values: [{
* id: 27,
* field: value, // 정렬 필드값
* }, ...],
* order: {
* field: MoverOrderField,
* direction: OrderDirection,
* }
* }
*/
const lastItem = results.at(-1); // 마지막 요소 가져오기
const { field } = order; // 정렬 기준이 되는 필드 가져오기
const value = lastItem[field]; // 마지막 요소의 정렬 기준이 되는 필드 값 가져오기
const cursorObj = {
// 마지막 요소에 대한 값들 널기
values: {
id: lastItem['id'],
[field]: value,
},
order,
};
const nextCursor = Buffer.from(JSON.stringify(cursorObj)).toString(
'base64',
); // 커서 객체를 인코딩 함 그래서 문자열로 보냄 (보안성 때문에)
return nextCursor; // 만들어진 커서 문자열 반환
}
// order filed에 있던 필드를 진짜 db의 alias로 대체해주는 함수
private getOrderFieldAlias<T>(
qb: SelectQueryBuilder<T>,
field: OrderField,
): string {
// 정렬 필드에 따라 쿼리 빌더에 조인 및 선택 추가
// 추가적으로 필요한 경우, OrderField enum에 추가 정의 후 아래 switch 문에 추가
// 뷰가 조인되어 있는지 확인 (join 정보에서 mover_profile_view가 있는지)
const joinNames = qb.expressionMap.joinAttributes.map(
(join) => join.alias?.name,
);
const isViewJoined = joinNames.includes(MOVER_PROFILE_VIEW_TABLE);
switch (field) {
// MoverProfile 기준 정렬 필드
case OrderField.REVIEW_COUNT:
case OrderField.AVERAGE_RATING:
case OrderField.CONFIRMED_ESTIMATE_COUNT:
if (!isViewJoined) {
// 뷰가 join 되어있지 않으면 예외 처리
throw new BadRequestException(
`${field} 정렬 필드를 사용하려면 뷰(${MOVER_PROFILE_VIEW_TABLE})가 조인되어야 합니다.`,
);
}
return `${MOVER_PROFILE_VIEW_TABLE}.${field}`;
case OrderField.EXPERIENCE:
return `${MOVER_PROFILE_TABLE}.${field}`; // experience는 mover스키마에서만 필요
case OrderField.CREATED_AT:
return `${qb.alias}.${field}`; // 기본 테이블의 created_at 필드
default:
throw new BadRequestException('올바른 정렬 필드를 선택해주세요.');
}
}