[간병인 프로젝트 리팩토링] 프로필 리스트 페이지 (NestJS)

윤학·2023년 8월 13일
0

간병인 프로젝트

목록 보기
6/8
post-thumbnail

기존 프로젝트에서 사용자에 관한 Api들을 리팩토링하고 이제 프로필에 관한 Api들을 리팩토링하고 있다.

오늘은 첫번째로 메인 페이지라고 할 수 있는 프로필 리스트를 받아오는 Api를 작성해볼 예정이다.

로직설명

로직은 간단하게 무한스크롤로 구현되어 있는데, 프론트엔드로부터 넘어온 마지막 프로필 id보다 오래된 프로필부터 일정 개수의 프로필을 계속 조회한다.

프론트엔드에서는 백엔드로부터 반환되는 프로필이 하나도 없다면 더이상 데이터가 없는것으로 판단하고 요청을 보내지 않는다.

기존 코드

user.controller.ts

    @Get('profile/:purpose')
    async getProfileList( @Request() req, @Query() query, @Param('purpose') purpose: string): 
        Promise<CareGiverProfileDto [] | CareGiverProfileDto> {
        //한 유저의 profile request
        if(!! query['profileId']) {
            //비회원도 프로필은 볼 수 있어야 하기 때문에 id가 없으면 임의의 전화번호가 될 수 없는 값으로 조회
            return await this.userService.getProfileOne(purpose, query['profileId'], query['userId']);
        }
        // 모든 프로필 request( start는 시작 순번 )
        if( !! query['start']) {
            // 필터 로직 생략
            return await this.userService.getProfileList(requestProfileListDto);
        }
    }

기존에는 프로필 리스트 조회와 프로필 하나를 상세조회하는 Api를 하나의 router에 작성하였다.

리스트를 조회할 때 정렬과 필터들도 있어 로직들이 더 있는데 아직 해당 기능들은 리팩토링하지 않아 제외시켰다.

user.service.ts

    async  getProfileList(requestProfileListDto: RequestProfileListDto): Promise<CareGiverProfileDto[]> {
        const { purpose, start, mainFilter, payFilter, startDateFilter, sexFilter,
            ageFilter, areaFilter, licenseFilter, warningFilter, strengthFilter, exceptLicenseFilter } = requestProfileListDto;
			
			// 생략
            return this.careGiverRepository
                .createQueryBuilder('cg')
                .innerJoin('cg.user', 'user')
                .offset(start)
                .limit(5)
                .getRawMany();
        }
    }

서비스 코드에서도 관련없는 코드들은 생략하여 select부분이 생략되었는데 전체적으로는 프로필 데이터를 MySQL로 저장하여 필요한 사용자 테이블과 JOIN해서 가져왔다.

그리고 프론트엔드에서 요청한 마지막 프로필 id를 offset으로 일정 개수의 프로필을 받아온다.

사용자 테이블과 JOIN을 수행한 이유는 사용자의 이름, 나이와 같은 데이터는 사용자 테이블에 있었기 때문이다.

리팩토링 코드

profile.controller.ts

	@Public()
    @Get('list')
    async getProfileList(@Query('lastProfileId') lastProfileId?: string): Promise<ProfileListDto []> {
        return await this.caregiverProfileService.getProfileList(lastProfileId);
    }

일단 Controller를 Profile모듈로 옮기고 리스트를 조회하는 Api와 하나의 프로필을 상세조회하는 Api를 분리하였다.

또한, 회원이 아닌 사용자들도 리스트를 조회할 수 있게 @Public() 데코레이터로 인증은 수행하지 않았다.

그리고 다음 프로필을 어디서부터 조회를 할지 Query 파라미터로 lastProfileId라는 이전 요청의 마지막 프로필 아이디를 받는데 처음 페이지를 로딩할 때는 해당 값이 넘어오지 않기에 Optional로 설정해주었다.

caregiver-profile.service.ts

    /* 프로필 리스트 조회 */
    async getProfileList(lastProfileId?: string): Promise<ProfileListDto []> {
        const profileCursor = this.caregiverProfileRepository.getProfileList(lastProfileId);

        return await firstValueFrom(
            from(profileCursor)
            .pipe(
                concatMap(async (profile: CaregiverProfile) => 
                    await this.fetchUserAndMerge(plainToInstance(CaregiverProfile, profile))),
                toArray()
            )
        )
    }
 
    /* 사용자 id를 받아오면서 profile 형식에 맞춰서 변경 */
    private async fetchUserAndMerge(profile: CaregiverProfile): Promise<ProfileListDto> { 
        const user = await this.userCommonService.findUserById(profile.getUserId());
        return this.caregiverProfileMapper.toListDto(user, profile);
    }

서비스코드에서 조금 변경된 점이 있다면 사용자에 관한 데이터는 MySQL에 저장해두고, 프로필에 관한 데이터는 MongoDB에 저장을 해두어서 어떻게 가져올지 생각해보았다.

가장 간단하게는 사용자의 아이디가 프로필 데이터에 저장되어 있기 때문에 프로필 리스트를 구하고, 다시 프로필 리스트를 돌면서 각 프로필에 맞는 사용자 데이터를 받아오면 된다고 생각했다.

하지만 두개의 데이터를 조금 더 효율적으로 받아올 수 없을까 생각해보다 rxjs의 mergeMap()이 이전 요청을 기다리지 않고 병렬로 실행되는 것을 알게 되어 프로필 데이터를 cursor로 반환시키고 접근할 때마다 사용자 데이터를 받아오면 조금 더 효율적이지 않을까 생각했다.

근데 mergeMap()은 병렬로 수행해주지만 요청의 도착순서를 보장해주지 않아 순서가 뒤죽박죽 된다면 다음 요청에서 중복 데이터가 발생할 수도 있겠다고 생각했다.

그래서 도착순서를 보장해주는 concatMap()을 사용했는데 결국 위의 코드는 for of가 더 깔끔할 것 같다.

본론으로 돌아가 cursor를 반환하는 부분을 봐보자.

caregiver-profile.repository.ts

    /* 데이터에 접근할 수 있는 Cursor 반환 */
    getProfileList(lastProfileId?: string): AggregationCursor {
        return this.mongodb
                .collection(this.collectionName)
                .aggregate(this.profileListPipelineStage(lastProfileId))
                .batchSize(20)
    }

    /* 프로필 리스트 조회하는 Stage */
    private profileListPipelineStage = (lastProfileId: string) => [
        { $match: 
            {
            ...this.ltProfileId(lastProfileId),
            isPrivate: false
            }
        },
        { $sort: { _id: -1 }},
        { $project: {
            userId: 1,
            career: 1,
            pay: 1,
            notice: 1,
            possibleDate: 1,
            possibleAreaList: 1,
            tagList: 1
        }}
    ]

    /* 마지막 프로필 아이디보다 오래된 프로필들 받아오기 위해 */
    private ltProfileId(profileId: string): null | { _id: { $lt: ObjectId }} {
        if( !profileId ) return null;

        return { _id: { $lt: new ObjectId(profileId) } };
    }

기존 로직에서는 Offset Limit 방식을 사용했는데 이 방식은 처음부터 Offset만큼의 데이터를 무조건 읽은 다음에 건너뛴다 하여 No Offset 방식을 사용해보았다.

(MongoDB가 인덱스 구조로 B-tree를 사용하고 ObjectId 값 인덱스도 생성된 순서대로 정렬된다고 알고 있어 적용해봤는데 실제로 더 효율적인지 검증이 필요하다.)

그리고 조회한 데이터를 프론트엔드 화면에 노출시키는 값에 맞게 가공해야하는데 이 부분은 Mapper에게 넘기고 ltProfileId()를 통해 이전 요청의 마지막 프로필보다 이전에 생성된 프로필들 중 비공개가 아닌 프로필만 가져오도록 하였다.

다시 아까 서비스 코드로 돌아가보면 이렇게 반환된 cursor에 접근할 때마다 fetchUserAndMerge()를 통해 사용자 데이터를 조회하고 Mapper를 통해 프론트엔드 데이터 형식에 맞게 가공하여 하나의 결과를 완성시켰다.

	/* 사용자 id를 받아오면서 profile 형식에 맞춰서 변경 */
    private async fetchUserAndMerge(profile: CaregiverProfile): Promise<ProfileListDto> { 
        const user = await this.userCommonService.findUserById(profile.getUserId());
        return this.caregiverProfileMapper.toListDto(user, profile);
    }

아직까지 DB에 저장된 값을 가지고 화면에 노출시킬 형식으로 변경할 때 어디까지 백엔드가 하는지 프론트엔드가 하는지 잘 모르겠다.

caregiver-profile.mapper.ts

    /* 사용자 데이터와 프로필 데이터로 클라이언트 노출용 데이터 변환 */
    toListDto(user: User, caregiverProfile: CaregiverProfile): ProfileListDto {
        return {
            user: {
                name: user.getName(),
                sex: user.getProfile().getSex(),
                age: this.toDtoAge(user.getProfile().getBirth())
            },
            profile: {
                id: caregiverProfile.getId(),
                userId: caregiverProfile.getUserId(),
                career: this.toDtoCareer(caregiverProfile.getCareer()),
                pay: caregiverProfile.getPay(),
                possibleDate: this.toDtoPossibleDate(caregiverProfile.getPossibleDate()),
                possibleAreaList: this.toDtoAreaList(caregiverProfile.getPossibleAreaList(), 'list'),
                tagList: caregiverProfile.getTagList(),
                notice: caregiverProfile.getNotice()
            }
        }
    };

    /* 클라이언트 데이터 노출에 맞게 나이 변환 */
    private toDtoAge(birth: number): number {
        const [currentYear, userYear] = [
            new Date().getFullYear(), 
            parseInt(birth.toString().substring(0, 4))
        ];

        return currentYear - userYear;
    };

    /* 클라이언트 데이터 노출에 맞게 경력 변환 */
    private toDtoCareer(career: number): string {
        if( career < 12 ) return `${career}개월`;

        const [year, month] = [Math.floor(career / 12), career % 12];
        return month ? `${year}${month}개월` : `${year}`;
    };

이렇게 하여 필터를 제외하고 프로필 리스트를 계속해서 받아오는 로직들을 리팩토링하였다.

추가로 개선할 점...

각 프로필에 해당하는 사용자 데이터가 업데이트도 거의 되지 않는 이름, 나이 값이고, 하나의 프로필만 가질 수 있기 때문에 MongoDB에 중복해서 저장하여 조회하는 것도 괜찮을 것 같다

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

0개의 댓글