[간병인 프로젝트 리팩토링] 프로필 정렬, 필터 (NestJS)

윤학·2023년 9월 4일
0

간병인 프로젝트

목록 보기
8/8
post-thumbnail

이전 글에서 MongoDB의 Pipeline 생성에 도움을 주는 클래스들을 만들어 봤으니 이번 글에선 이용하여 프로필 리스트들을 필터링 하는 과정을 리팩토링 해보자.

로직설명

로직은 간단하다. 들어오는 조건들에 대해 동적으로 쿼리를 생성하여 프로필들을 반환해준다.

달라진 점이 있다면 기존에는 MySQL을 사용했지만 현재는 MongoDB를 사용하고 있다는 점을 참고해서 봐주시면 좋을 것 같다.

기존코드

user.controller.ts

    @Get('profile/:purpose')
    async getProfileList( @Request() req, @Query() query, @Param('purpose') purpose: string): 
        Promise<CareGiverProfileDto [] | CareGiverProfileDto> {
        // 모든 프로필 request( start는 시작 순번 )
        if( !! query['start']) {
            const requestProfileListDto = new RequestProfileListDto();
            requestProfileListDto.purpose = purpose;
            requestProfileListDto.start = query['start'];
            requestProfileListDto.mainFilter = query['mainFilter'];
            requestProfileListDto.payFilter = query['payFilter'];
            requestProfileListDto.startDateFilter = query['startDateFilter'];
            requestProfileListDto.sexFilter = query['sexFilter'];
            requestProfileListDto.ageFilter = query['ageFilter'];
            requestProfileListDto.areaFilter = query['areaFilter'];
            requestProfileListDto.licenseFilter = query['licenseFilter'];
            requestProfileListDto.warningFilter = query['warningFilter'];
            requestProfileListDto.strengthFilter = query['strengthFilter'];

            return await this.userService.getProfileList(requestProfileListDto);
        }
    }

컨트롤러에서는 단순히 정렬 조건, 모든 필터 조건들이 Query String을 통해 요청이 들어오면 Dto로 변환하여 서비스 코드에 넘겨준다.

user.service.ts

    async getProfileList(requestProfileListDto: RequestProfileListDto): Promise<CareGiverProfileDto[]> {
        const { purpose, start, mainFilter, payFilter, startDateFilter, sexFilter,
            ageFilter, areaFilter, licenseFilter, warningFilter, strengthFilter, exceptLicenseFilter } = requestProfileListDto;

            return await query
                .select([
                    'cg.id as id',
                    'cg.career as career',
                    'cg.pay as pay',
                    'cg.startDate as startDate',
                    'cg.possibleArea as possibleArea',
                    'cg.license as license',
                    'cg.keywords as keywords',
                    'cg.notice as notice',
                    'user.name as name',
                    'user.birth as birth',
                    'user.sex as sex',
                    'user.purpose as purpose',
                    'user.isCertified as isCertified',
                    'user.warning as warning',
                ])
                .where('user.profile_off = :profile_off', { profile_off: false })
                .andWhere(
                    new Brackets((qb) => {
                        //일당 필터
                        if (!!payFilter) {
                            qb.andWhere('cg.pay <= :pay', {
                                pay: payFilter === 'under10' ? 10 :
                                    payFilter === 'under15' ? 15 : 20
                            })
                        }
                        //시작가능일 필터
                        if (!!startDateFilter) {
                            qb.andWhere('cg.startDate <= :startDate', {
                                startDate: startDateFilter === 'immediately' ? 1 : startDateFilter === '1week' ? 2
                                    : startDateFilter === '2week' ? 3 : startDateFilter === '3week' ? 4 : 5
                            })
                        }
                        //나이 필터
                        if (!!ageFilter) {
                            const { startAge, endAge } = getStartEndYear(ageFilter);
                            qb.andWhere('user.birth between :startAge and :endAge', {
                                startAge: endAge, endAge: startAge
                            })
                        }
                        //성별 필터
                        if (!!sexFilter) {
                            qb.andWhere('user.sex = :sex', { sex: sexFilter === '남' ? '남' : '여' });
                        }
                        //지역 필터
                        if (!!areaFilter) {
                            const areaList = convertStringToLikeQuery(areaFilter);

                            //첫번째 쿼리문 무조건 필터 한개는 들어오기
                            switch (areaList.length) {
                                case 1:
                                    qb.andWhere('cg.possibleArea like :area', { area: areaList[0] });
                                    break;
                                case 2:
                                    qb.andWhere(new Brackets((sub) => {
                                        sub.where('cg.possibleArea like :firstArea ', { firstArea: areaList[0] })
                                            .orWhere('cg.possibleArea like :secondArea', { secondArea: areaList[1] });
                                    }))
                                    break;
                                case 3:
                                    qb.andWhere(new Brackets((sub) => {
                                        sub.where('cg.possibleArea like :firstArea ', { firstArea: areaList[0] })
                                            .orWhere('cg.possibleArea like :secondArea', { secondArea: areaList[1] })
                                            .orWhere('cg.possibleArea like :thirdArea', { thirdArea: areaList[2] });
                                    }))
                                    break;
                            }
                        }
                        if (!!licenseFilter) {
                            const licenseList = convertStringToLikeQuery(licenseFilter);

                            switch (licenseList.length) {
                                case 1:
                                    qb.andWhere('cg.license like :license', { license: licenseList[0] });
                                    break;
                                case 2:
                                    qb.andWhere(new Brackets((sub) => {
                                        sub.where('cg.license like :firstLicense ', { firstLicense: licenseList[0] })
                                            .orWhere('cg.license like :secondLicense', { secondLicense: licenseList[1] });
                                    }))
                                    break;
                                case 3:
                                    qb.andWhere(new Brackets((sub) => {
                                        sub.where('cg.license like :firstLicense ', { firstLicense: licenseList[0] })
                                            .orWhere('cg.license like :secondLicense', { secondLicense: licenseList[1] })
                                            .orWhere('cg.license like :thirdLicense', { thirdLicense: licenseList[2] });
                                    }))
                                    break;
                            }
                        }
                        if (!!warningFilter) {
                            qb.andWhere('user.warning is null')
                        }
                        if (!!strengthFilter)
                            qb.andWhere('cg.strength != :strength', { strength: '{"first":"","second":""}' })
                        /* if( !!exceptLicenseFilter)
                            qb.andWhere('cg.license is not null') */
                    })
                )
                .orderBy(
                    mainFilter === 'pay' ? 'cg.pay' :
                        mainFilter === 'startDate' ? 'cg.startDate' :
                            mainFilter === 'heart' ? 'heart.count' : null,

                    mainFilter === 'pay' || mainFilter === 'startDate' ? 'ASC' : 'DESC'
                )
                .offset(start)
                .limit(5)
                .getRawMany();
}
    
export function convertStringToLikeQuery(filterString: string): string[] {
    const filterList = filterString.split(',');
    filterList.forEach((filter, index) => {
        const likeQuery = '\%' + filter + '\%';
        filterList[index] = likeQuery;
    });
    return filterList;
}

많이 길지만 크게 profile_off필드만이 비공개가 아닌 프로필을 가져와야 하기 때문에 고정된 조건이고, 이외 조건들은 전부 Brackets을 사용하여 조건이 있는 경우 계속해서 where 조건을 추가했다.

지역,자격증과 같이 배열 값들을 사용한 경우 MySQL의 JSON 타입으로 DB에 저장을 했는데 요청에서 Query String으로 넘어오면서 해당 조건들이 문자열로 변경되었다.

그래서 convertStringToLikeQuery()를 통해 배열로 변경하여 요소들의 갯수들을 파악하고 요소 하나씩 where 조건에 추가했다.

마지막으로 Limit Offset 방식을 사용하여 요청으로부터 들어온 Offset 다음부터 일정 갯수의 데이터를 가져온다.

뭐 하나 건들기 무서운 코드인데 짤 당시에 Brackets에 관한 자료를 찾기 위해 엄청 고생했던 기억이 난다.

일단 테이블 설계 능력을 키우자!

변경코드

profile.controller.ts

    @Public()
    @Get('list')
    async getProfileList(@Query() getProfilListDto: GetProfileListDto): Promise<ProfileListDto> {
        return await this.caregiverProfileService.getProfileList(getProfilListDto);
    }

회원이 아니어도 사용가능한 Api이기 때문에 @Public()을 달아준다.

profile.service.ts

    /* 프로필 리스트 조회 */
    async getProfileList(getProfileListDto: GetProfileListDto): Promise<ProfileListDto> {
        const listQueryOptions = this.caregiverProfileMapper.toListQueryOptions(getProfileListDto) // listQueryOption 객체로 변환
        const caregiverProfileListData = await this.caregiverProfileRepository.getProfileList(listQueryOptions); // DB에서 프로필 리스트 조회
        const mapToClientFormatList  = caregiverProfileListData.map( profileData => this.caregiverProfileMapper.toListDto(profileData) ) // 프로필들을 프론트엔드 포맷으로 변경
        const nextCursor = ProfileListCursor.createNextCursor(mapToClientFormatList, listQueryOptions); // 다음 요청시 필요한 커서 생성
        return { caregiverProfileListData, nextCursor: nextCursor.toClientNext() };
    }

이전 글에서 필터 조건들이 없었을 때는 rxjs를 사용햇었는데 다른 점이 없어서 더 직관적인 배열의 map을 사용했다.

map을 통해 DB에서 조회된 Document들을 하나씩 돌면서 프론트엔드 화면에 뿌릴 수 있게 가공했다.

그럼 먼저 map을 통해 순회 할 데이터들을 Repository에서 받아와보자.

참고로 요청에서 getProfileListDto형식으로 들어오면 로직에 이용할 수 있도록 listQueryOptions 객체로 변환해준다.

export class ProfileListQueryOptions {
    private nextCursor?: ProfileListCursor; // 다음번 조회의 기준 커서
    private sort?: ProfileSort; // 정렬 기준
    private filters?: ProfileFilter  // 필터들

    getNextCursor(): ProfileListCursor { return this.nextCursor; }; 
    getSortOptions(): ProfileSort | undefined { return this.sort; };
    hasSortOptions(): boolean { return this.sort.hasOption() }; // 기본 최신순을 제외한 정렬 옵션이 있는지
    getFilters(): ProfileFilter { return this.filters; };

    constructor(next?: ProfileListCursor, sort?: ProfileSort, filters?: ProfileFilter) {
        this.nextCursor = next;
        this.sort = sort;
        this.filters = filters;
    };
}

caregiver-profile.repository.ts

    /* 조회된 리스트 반환, 인스턴스로 변경하지 않고 Document 데이터 그대로 반환 */
    async getProfileList(listQueryOptions: ProfileListQueryOptions): Promise<CaregiverProfileListData []> {
        return await this.mongodb
                .collection(this.collectionName)
                .aggregate(this.profileQueryFactory.listPipeline(listQueryOptions))
                .limit(5)
                .toArray() as unknown as Array<CaregiverProfileListData>
    }

해당 코드블럭을 넘기시면 간략된 코드로 살펴보실 수 있습니다!

profile-query.factory.ts

Injectable()
export class ProfileQueryFactory extends MongoQueryFactory {

    /* 프로필 리스트를 조회하는 Query */
    listPipeline(listQueryOptions: ProfileListQueryOptions): any {

        /* builder 초기화 */
        const pipelineBuilder = AggregratePipelineBuilder.initialize();

        /* 정렬 조건이 추가로 있는 경우 해당 조건에 맞게 파이프라인 생성 */
        if (listQueryOptions.hasSortOptions()) {
            this.createListPipelineWithSortOptions(
                pipelineBuilder, listQueryOptions.getNextCursor(),
                listQueryOptions.getSortOptions(), listQueryOptions.getFilters()
            );
        }
        /* 정렬 조건이 추가로 없는 경우 최신순으로만 정렬하여 파이프라인 생성 */
        else {
            this.createListPipelineWithNonSortOptions(
                pipelineBuilder, listQueryOptions.getNextCursor(),
                listQueryOptions.getSortOptions(), listQueryOptions.getFilters()
            );
        }
        /* 정렬 조건에 따른 파이프라인 스테이지를 추가하고 마지막 Project 스테이지 추가 */
        return this.addListProjectStage(pipelineBuilder).build();
    };
    /* 정렬 조건이 추가로 있는 경우 */
    private createListPipelineWithSortOptions(
        pipelineBuilder: AggregratePipelineBuilder,
        nextCursor: ProfileListCursor,
        sort: ProfileSort,
        filters: ProfileFilter
    ) {
        const { pay, sex, startDate, age, area, license, strengthExcept } = filters;

        /* 정렬 조건이 일당 낮은 순 */
        pipelineBuilder.match(
            // 마지막 프로필 커서의 일당보다 크거나, 
            // 마지막 프로필 커서의 일당과 같으면서 프로필 아이디가 더 오래된 것 
            this.or([
                this.gtThan(sort.otherField(), nextCursor.combinedOtherSortNext()),
                this.combineOperators(
                    this.equals(sort.otherField(), nextCursor.combinedOtherSortNext()),
                    this.ltThan(sort.defaultField(), nextCursor.combinedDefaultSortNext())
                )
            ]),
            this.equals('isPrivate', false), // 비공개 프로필
            this.lteThan('pay', pay), // 필터 조건의 일당보다 낮은 프로필
            this.lteThan('possibleDate', startDate), // 필터 조건의 시작 가능일보다 빠른 프로필
            this.equals('sex', sex), // 필터 조건의 성별과 일치하는 프로필
            // 나이의 필터는 20대, 30대식으로 이루어지기에
            // 필터로 20대가 주어지면 조건은 20 ~ 29로 생성
            this.equals('age',
                this.combineOperators(
                    this.operator('gte', age),
                    this.operator('lt', age + 10)
                )
            ),
            this.notEmptyStrengthList(strengthExcept), // 강점 작성한 프로필
            this.matchAnyElementInArray('possibleAreaList', area), // 주어진 지역과 하나라도 일치하는 프로필
            this.matchAnyElementInArray('licenseList', license) // 주어진 자격증과 하나라도 일치하는 프로필
        )
        .sort( // 호출되는 시점에 정렬 기준으로 들어온 필드와 이후 최신순으로 정렬 
                this.orderBy(sort.otherField(), sort.otherFieldBy()),
                this.orderBy(sort.defaultField(), sort.defaultFieldBy())
        )
    };

    /* 정렬 조건이 추가로 없는 경우 */
    private createListPipelineWithNonSortOptions(
        pipelineBuilder: AggregratePipelineBuilder,
        nextCursor: ProfileListCursor,
        sort: ProfileSort,
        filters: ProfileFilter
    ) {
        const { pay, sex, startDate, age, area, license, strengthExcept } = filters;
        pipelineBuilder
            .match(
                /* 이전 요청에 아이디가 있다면 해당 아이디 다음부터 */
                this.ltThan('_id', nextCursor.defaultSortNext()),
                this.equals('isPrivate', false),
                this.lteThan('pay', pay),
                this.lteThan('possibleDate', startDate),
                this.equals('sex', sex),
                this.equals('age',
                    this.combineOperators(
                        this.operator('gte', age),
                        this.operator('lt', age + 10)
                    )
                ),
                this.notEmptyStrengthList(strengthExcept),
                this.matchAnyElementInArray('possibleAreaList', area),
                this.matchAnyElementInArray('licenseList', license)
            )
            .sort(
                this.orderBy(sort.defaultField(), sort.defaultFieldBy()) // 최신순 정렬
            )
    }
    /* 프로필 리스트 조회 파이프라인에 Project 스테이지 추가 */
    private addListProjectStage(pipelineBuilder: AggregratePipelineBuilder) {
        pipelineBuilder.project(
            this.rename('id', this.operator('toString', '$_id')), // _id -> id로 변경
            this.exclude('_id'), // _id 필드 제외
            this.select('userId'),
            this.select('name'),
            this.select('age'),
            this.select('sex'),
            this.select('career'),
            this.select('pay'),
            this.select('notice'),
            this.select('possibleDate'),
            this.select('possibleAreaList'),
            this.select('tagList')
        );
        return pipelineBuilder;
    }
    /* 강점을 작성하지 않은 프로필들은 제외 */
    private notEmptyStrengthList(strengthExcept: boolean): MongoQuery<[]> | null {
        if (!strengthExcept) return null;
        return this.notEmptyArray('strengthList');
    }
}

보기 힘드니 정렬이 있을 때만 조금 더 살펴보자.

정렬 옵션 존재 O

private createListPipelineWithSortOptions(
        pipelineBuilder: AggregratePipelineBuilder,
        nextCursor: ProfileListCursor,
        sort: ProfileSort,
        filters: ProfileFilter
    ) {
        const { pay, sex, startDate, age, area, license, strengthExcept } = filters;

        /* 정렬 조건이 일당 낮은 순 */
        pipelineBuilder.match(
            // 마지막 프로필 커서의 일당보다 크거나, 
            // 마지막 프로필 커서의 일당과 같으면서 프로필 아이디가 더 오래된 것 
            this.or([
                this.gtThan(sort.otherField(), nextCursor.combinedOtherSortNext()),
                this.combineOperators(
                    this.equals(sort.otherField(), nextCursor.combinedOtherSortNext()),
                    this.ltThan(sort.defaultField(), nextCursor.combinedDefaultSortNext())
                )
            ]),
            this.equals('isPrivate', false), // 비공개 프로필
            this.lteThan('pay', pay), // 필터 조건의 일당보다 낮은 프로필
            this.lteThan('possibleDate', startDate), // 필터 조건의 시작 가능일보다 빠른 프로필
            this.equals('sex', sex), // 필터 조건의 성별과 일치하는 프로필
            // 나이의 필터는 20대, 30대식으로 이루어지기에
            // 필터로 20대가 주어지면 조건은 20 ~ 29로 생성
            this.equals('age',
                this.combineOperators(
                    this.operator('gte', age),
                    this.operator('lt', age + 10)
                )
            ),
            this.notEmptyStrengthList(strengthExcept), // 강점 작성한 프로필
            this.matchAnyElementInArray('possibleAreaList', area), // 주어진 지역과 하나라도 일치하는 프로필
            this.matchAnyElementInArray('licenseList', license) // 주어진 자격증과 하나라도 일치하는 프로필
        )
        .sort( // 호출되는 시점에 정렬 기준으로 들어온 필드와 이후 최신순으로 정렬 
                this.orderBy(sort.otherField(), sort.otherFieldBy()),
                this.orderBy(sort.defaultField(), sort.defaultFieldBy())
        )
    };

옵션 있을 때와 없을 때 나눈 이유는 옵션이 없다면 마지막 프로필의 아이디만 조회해서 이후 프로필들을 가져오면 되지만 있을 때는 정렬 조건의 필드와 조합해서 이후 프로필들을 가져와야 하기 때문이다.

이해가 잘 안되니 예를 들어보면 정렬 조건으로 '일당이 낮은 순'으로 들어오고 nextCursor가 20_1ad82ke8s81381라고 해보자.

그럼 이후 조회는 일당이 20보다 작거나 또는 일당이 20이면서 프로필 아이디가1ad82ke8s81381인 프로필보다 오래전에 생성된 프로필을 조회하면 된다.
(일당이 같은 프로필이 여러개일 수 있는데 이 때는 기본적으로 최신순이므로 이전 요청의 프로필보다 전에 만들어진 프로필이 반환되어야함)

그래서 이전 코드에서처럼 if문으로 정렬 조건들을 비교하진 않고 sort.otherField()로부터 들어온 정렬 조건의 필드를 받을 수 있다.
또한 필터 조건들 중 null값들은 Stage에 추가될 때 자동적으로 반영이 되지 않는다.(이전 글 참고)

위의 코드에서 this.or() 부분은 아래와 같은 쿼리를 만들어낸다
다른 정렬 조건이 들어온다면 pay부분은 변경된다.

{ $or: [ 
  	{ pay: { $gt: 20 } },
    { pay: 20, _id: { $lt: new ObjectId(`1ad82ke8s81381`) } }
  ]
}

이제 조건들에 일치하는 프로필 리스트가 조회되었으니 nextCursor를 새로 만들어서 클라이언트에게 넘겨주자.

caregiver-profile.service.ts

const caregiverProfileListData = await this.caregiverProfileRepository.getProfileList(listQueryOptions); // DB에서 프로필 리스트 조회
const mapToClientFormatList  = caregiverProfileListData.map( profileData => this.caregiverProfileMapper.toListDto(profileData) ) // 프로필들을 프론트엔드 포맷으로 변경
const nextCursor = ProfileListCursor.createNextCursor(mapToClientFormatList, listQueryOptions); // 다음 요청시 필요한 커서 생성
return { caregiverProfileListData, nextCursor: nextCursor.toClientNext() };

조회된 프로필들 중 마지막 프로필의 데이터를 가지고 정렬 조건이 있으면 '_'와 함께 조합해서, 아니면 아이디만 넘겨준다.

profile-list-cursor.ts

    static createNextCursor(
        profileList: CaregiverProfileListData [],
        queryOptions: ProfileListQueryOptions
    ) {
        if( !profileList.length ) return new ProfileListCursor(null);
        
        const lastProfile = profileList.at(-1);
        
        let nextCursor = queryOptions.getSortOptions().hasOption() ? 
                this.createCombinedCursor(lastProfile, queryOptions.getSortOptions().otherField()) : lastProfile.id;
        
        return new ProfileListCursor(nextCursor)
    }

    private static createCombinedCursor(lastProfile: CaregiverProfileListData, otherSortField: string) {
        const otherSortFieldLastValue = otherSortField === 'pay' ? 
            lastProfile.pay : lastProfile.possibleDate;
        return `${otherSortFieldLastValue}_${lastProfile.id}`;
    } 

이렇게 해서 동적으로 들어오는 조건들을 처리할 수 있었다.

마치면서

사실 위의 Factory 메서드들은 기존 ORM에서 볼 수 있는 where, find와 같은 메서드로 만들지 않고 MongoDB에 치우쳐 있어 더 알아보기 힘들 수 있다.

다음 글에서는 위의 코드들에서 OCP원칙을 위반하는 객체들을 수정해보자!

profile
해결한 문제는 그때 기록하자

0개의 댓글