[간병인 프로젝트 리팩토링] 프로필 상세조회 (NestJS)

윤학·2023년 9월 3일
0

간병인 프로젝트

목록 보기
7/8
post-thumbnail

이번 글은 프로필 리스트에서 상세조회를 하면 나타나는 상세 페이지에 대한 리팩토링을 진행할 예정이다.

작성하지 못한 글들도 많은데 더 주기를 짧게 할 수 있도록 노력해야겠다.

화면은 아래와 같다.

로직설명

기존에 프로필을 조회했을 때 조회한 사용자가 보호자로 앱을 이용중이라면 조회수를 증가시켜 조회수가 많은 인기 프로필로 제공하기 위해 추가 로직이 있었다.

보호자들에게 유용한 데이터라고 생각하여 보호자들의 데이터만 취합했기에 조회한 사용자가 간병인이라면 추가 로직은 없었다.

그럼 한번 코드로 살펴보자.

기존코드

먼저 들어오는 요청을 받아보자.

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']);
        }
    }

프로필의 리스트를 조회하는 라우터와 프로필의 상세 정보를 조회하는 라우터를 하나로 썼기 때문에 쿼리 파라미터로 프로필의 아이디가 들어왔는지 검사한다.

Path 파라미터로 purpose를 받는 이유는 기존에는 간병인과 더불어 한가지의 역할이 더 있었기에 구분하고자 사용했었지만 지금은 삭제했다.

user.service.ts

    /**
     * 특정 사용자의 프로필 return
     * @param purpose 가입목적
     * @param profileId 해당 목적 테이블의 id
     * @returns 특정 사용자 profile
     */
    async getProfileOne(purpose: string, profileId: string, userId: string): Promise<CareGiverProfileDto> {
        if (purpose === 'careGiver') {
            let heart = await this.heartRepository
                .createQueryBuilder('heart')
                .select('COUNT(heart.heart_id) AS heartCount')
                .groupBy('heart.heart_id')
                .having('heart.heart_id =:profileId', { profileId: profileId })
                .addSelect(subQuery => {
                    return subQuery
                        .select('heart.heart_id AS isHearted')
                        .from(Heart, 'heart')
                        .where('heart.user_id = :userId AND heart.heart_id = :profileId', {
                            userId: userId, profileId: profileId
                        })
                }, 'isHearted')
                .getRawOne();
            //조회되는 값이 없으면 만들어서 data return
            heart === undefined ? heart = { heartCount: 0, isHearted: null } : heart

            const profile = await this.careGiverRepository
                .createQueryBuilder('cg')
                .innerJoin('cg.user', 'user')
                .where('cg.id = :profileId', { profileId: profileId })
                .andWhere('user.profile_off = :profile_off', { profile_off: false })
                .addSelect([
                    'user.id',
                    'user.name',
                    'user.birth',
                    'user.sex',
                    'user.purpose',
                    'user.isCertified',
                    'user.warning',
                ])
                .getOne();

            //찜 관련 db 반환위해
            const result = { ...profile, heart }

            if (profile === null)
                throw new HttpException(
                    '해당 간병인의 프로필은 현재 비공개 및 탈퇴의 이유로 찾을 수 없습니다.',
                    HttpStatus.NOT_FOUND
                )

            return result;
        }
    }

해당 프로필의 상세 정보들과 찜에 대한 정보를 각각 받아오고 조합하여 결과를 반환해주고 있다.

반환하기 전에 해당 프로필의 간병인이 프로필을 비공개로 돌렸는데, 이미 이전에 리스트가 로드되어 있던 사용자에게는 프로필이 로드되어 있어 조회할 수도 있는 상황이 있을 수 있다.

때문에 상세조회를 하게 될 경우 프로필의 비공개 여부를 검사하며 위의 코드에서는 profile === null로 수행하고 있다.

프로필 조회수 증가하는 로직은?

profile.middleware.ts

@Injectable()
export class ProfileMiddleWare implements NestMiddleware {
    constructor (
        private jwtService: JwtService,
        private userService: UserService
    ){}
    async use(req: any, res: any, next: (error?: any) => void) {
        //보호자의 경우만 프로필 조호시
        //해당 프로필의 조회수 1 증가
        //순위에 노출된 프로필 조회시 증가 안시킴
        if(
            req.headers['authorization'] !== undefined &&
            !! req.query.profileId 
        ) {
            const _authorization = req.headers['authorization'];
            const _accessToken: string = _authorization.split(' ')[1];
            const _userid = this.jwtService.decode(_accessToken)['userid'];
            const { id, purpose } = await this.userService.findId(_userid);
            const { profileId } = req.query;
            if ( purpose === '보호자' )
                this.userService.countViewProfile( id, profileId );
        }
        next();
    }
} 

음... 이때는 미들웨어를 통해서 조회수를 증가시켰었나보다.

그래서 글 작성하면서 왜 인증 검사하고 있는데 JwtGuard 만들어 놓고 안썼을까 했는데 미들웨어를 사용해서였었다.

마지막으로 저장소에 조회수를 증가시켜 저장하자.

user.service.ts

    //보호자가 프로필 조회할 때마다 count 집계
    async countViewProfile( id: string, profileId: string ) {
        const _viewKey = 'profile:' + profileId + ":user:" + id;
        const _isViewed = await this.redis.SISMEMBER('user.viewed', _viewKey);
        if( !_isViewed ) {
            this.redis.ZINCRBY('count.viewed.profiles', 1, profileId);
        }
        this.redis.SADD('user.viewed', _viewKey);
    }

증가시키기 전에 해당 데이터 집계 시간에 중복으로 조회된 경우가 있다면 추가하지 않는다.

Redis의 자료구조 중에선 SET을 이용해서 조회한 사용자와 프로필 아이디를 기록하고, 순위 집계를 위해 SORTED SET을 이용한 것으로 보인다.

그럼 코드를 얼른 변경해보자!

리팩토링 코드

찜 관련 데이터 조회 부분은 추후에 찜 관련 Api에 대한 글을 작성한 이후 다른 글로 업로드 할 예정입니다!

일단 요청 받으면서 시작해보자!

profile.controller.ts

    @Get('detail/:id')
    async getProfileDetail(
        @Param('id') profileId: string,
        @AuthenticatedUser() authenticatedUser: User
    ){
        return await this.caregiverProfileService.getProfile(profileId, authenticatedUser.getId())
    }

리팩토링하면서 항상 느끼는 것은 모듈, HTTP Method, URI는 꼭 바꾸는 것 같다...ㅎㅎ

살펴보면, 전역으로 설정된 인증 Guard로 통과된 사용자를 파라미터 데코레이터를 통해 추출한다. 이 사용자는 곧 해당 프로필을 조회한 사용자다.

위의 파라미터 데코레이터를 모두 사용하는 일이 조금씩 생격나는데 2개를 결합해서 하나의 파라미터 데코레이터를 만드는게 나을까 생각하고 있다.

현재는 위의 방식이 더 직관적으로 알 수 있기에 남겨두었다.

profile.service.ts

    /* 프로필 상세보기 */
    async getProfile(profileId: string, viewUser: User): Promise<ProfileDetailDto> {
        const profile = await this.caregiverProfileRepository.findById(profileId);
        
		profile.checkPrivacy();

        if( viewUser.getRole() === ROLE.PROTECTOR )
            await this.profileViewRankService.increment(profileId, viewUser);

        return this.caregiverProfileMapper.toDetailDto(profile);
    }

바뀐점이 있다면 프로필 비공개 검사를 객체에 위임했다.

caregiver-profile.ts

	/* 위에는 필드, getter/setter등이 많아서 생략 */

    /* 프로필이 비공개인지 확인 */
    checkPrivacy(): void {
        if( this.isPrivate )
            throw new NotFoundException(ErrorMessage.NotFoundProfile);
    }

조회한 프로필의 isPrivate 필드를 검사하여 404 에러를 던지도록 하였다.

그리고 조회한 사용자가 보호자라면 조회수를 증가시키는 로직을 탄다.

profile-view-rank.service.ts

@Injectable()
export class ProfileViewRankService implements IRankService {

    constructor(
        private readonly profileViewRankRepository: ProfileViewRankRepository,
        private readonly profileViewRankManager: ProfileViewRankManager
    ) {};

    async increment(profileId: string, viewUser: User): Promise<void> {
        /* 만약 해당 프로필을 처음 조회한 사용자라면 명단에 추가이후 조회 집계 증가 */
        if( !await this.profileViewRankManager.isActionPerformedByUser(profileId, viewUser.getId()) ) {
            await Promise.all([
                this.profileViewRankManager.recordUserAction(profileId, viewUser.getId()),
                this.profileViewRankRepository.increment(profileId)
            ])
        }
    };
}

사용자의 조회 내역을 관리하는 클래스가 필요했는데 이를 ProfileViewRankManager로 지었다.

profile-view-rank.manager.ts

@Injectable()
export class ProfileViewRankManager implements IRankManager {
    constructor(
        @InjectRepository(ProfileViewRecord)
        private readonly profileViewRecordRepository: IActionRecordRepository<ProfileViewRecord>
    ) {}

    /* 프로필을 조회한 사용자 기록 */
    async recordUserAction(profileId: string, viewUserId: number, ): Promise<void> {
        await this.profileViewRecordRepository.save(new ProfileViewRecord(profileId, viewUserId));
    };

    /* 해당 사용자가 해당 프로필을 조회했는지 검사 */
    async isActionPerformedByUser(profileId: string, userId: number): Promise<boolean> {
        return await this.profileViewRecordRepository
                            .findRecordByActionAndUser(profileId, userId) ? true : false;
    }
}

해당 클래스는 recordUserAction() 로 조회한 사용자를 기록하고, isActionPerformedByUser() 를 통해 내역이 있는지 검사해주는 매니저 역할을 한다.(클래스 이름이 적절하지는 않아 보인다..)

그리고 기존에는 조회 내역도 모두 Redis를 사용했는데 이번에는 조회 내역을 MySQL을 사용하고 인덱스를 걸었다.

async findRecordByActionAndUser(this: Repository<ProfileViewRecord>, profileId: string, userId: number)
    :Promise<ProfileViewRecord> {
        return await this.createQueryBuilder('pvr')
                .where('pvr.profile_id = :profileId', { profileId: UUIDUtil.toBinaray(profileId) })
                .andWhere('pvr.user_id = :userId', { userId })
                .getOne();
    }

이제 RankManager가 검사를 해주어서 해당 집계 시간에 처음 조회한 사용자라면 내역을 추가하고 랭크를 갱신한다.

profile-view-rank.service.ts

await Promise.all([
	this.profileViewRankManager.recordUserAction(profileId, viewUser.getId()),
    this.profileViewRankRepository.increment(profileId)
])

profile-view-rank.repository.ts

@Injectable()
export class ProfileViewRankRepository implements IRankRepository {
    private rankKey: string;

    constructor(
        @Inject('REDIS_CLIENT')
        private readonly redis: RedisClientType,
        private readonly configService: ConfigService
    ) {
        this.rankKey = configService.get('profile_view_rank_key');
    };

    async increment(profileId: string): Promise<void> {
        await this.redis.ZINCRBY(this.rankKey, 1, profileId);
    }
}

이렇게 해서 무사히 이번에도 잘 마무리 할 수 있었다.

가면 갈수록 느끼지만 웬만하면 MySQL을 쓸 걸 후회된다.

데이터베이스가 분산되어 있으니 따로따로 조회하는 일들이 있어 쉬운 일이 아닌 것 같다.

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

0개의 댓글