ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [MySQL/Java] DB 쿼리 성능개선 (2) 랭킹 조회 속도 개선하기 (5s ➡️ 1s대로 줄이기!)
    DB/MySQL 2023. 6. 29. 21:59
    반응형

    기능 배경

    - 개선 대상 : 랭킹 조회 기능
    - 랭킹 조회 api 요청시 해당 게임의 Top3에 대한 정보를 DB에서 조회
       ➡️ ① Top3 정보 중 하나가 요청자와 일치할 경우 그대로 정보를 담아 return 
       ➡️ ② Top3 정보에 요청자가 없을 경우, 해당 사용자의 등수와 정보를 DB에서 재조회하여 담은 후 return

    기존에 해당 기능을 수행하기 위해 작성한 QueryRepository의 코드는 아래와 같다.
    QueryDSL을 사용해서 작성했는데.. 아무래도 QueryDSL을 잘 알지 못한 상태에서 코드부터 적어서 부족한 부분이 많아 보인다..!
    또, 프로젝트였기 때문에 데이터가 많을 경우를 고려하지 않고 아주 미니미한 목데이터 안에서만 테스트를 했기 때문에,
    일단 쿼리로 데이터가 "날라만 오면 되는" 식으로 작성을 했었던 것 같다.

    @Repository
    public class ParticipantQueryRepository {
    
        private final JPAQueryFactory query;
    
        public ParticipantQueryRepository(EntityManager em) {
            this.query = new JPAQueryFactory(em);
        }
    
        QParticipant participant = QParticipant.participant;
        QRoom room = QRoom.room;
    
        public List<Participant> getAllRank(Long roomId) {
            return query.selectFrom(participant)
                    .join(participant.room, room)
                    .fetchJoin()
                    .where(participant.room.id.eq(roomId),
                            participant.isFinished.isTrue())
                    .orderBy(
                            participant.solvedCnt.desc(),
                            participant.duration.asc())
                    .fetch();
        }
    
        public List<Participant> getTop3Rank(Long roomId) {
            return query.selectFrom(participant)
                    .join(participant.room, room)
                    .fetchJoin()
                    .where(participant.room.id.eq(roomId),
                            participant.isFinished.isTrue())
                    .orderBy(
                            participant.solvedCnt.desc(),
                            participant.duration.asc())
                    .limit(3)
                    .fetch();
        }
    
        public long getParticipantRank(Long roomId, Duration participantDuration, Integer participantSolvedCnt) {
    
            // Count the number of participants with a lower duration in the same room
            long rank = query.selectFrom(participant)
                    .join(participant.room, room)
                    .fetchJoin()
                    .where(
                            participant.room.id.eq(roomId),
                            participant.isFinished.isTrue(),
                            participant.solvedCnt.gt(participantSolvedCnt)
                                    .or(participant.solvedCnt.eq(participantSolvedCnt)
                                            .and(participant.duration.lt(participantDuration)))
                    )
                    .fetch().size();
    
            // Add 1 to the rank to get the participant's actual rank (since ranks start at 1)
            return rank + 1;
        }
    
    }

    포스트만으로 요청을 보내보면.. 응답받는데 무려 5초..! (참여자 20만명 기준)
    아무래도 내가 3등 안에 들지 않았을 경우 내 위로 몇 명이 있는지를 다 카운트 해줘야 하기 때문에.. 오래 걸리게 되는 것..!
    랭킹조회에 5초나 걸리는 서비스를 누가 이용할까?.. 바로 쿼리를 뜯어 보았다.

    개선해보자!

    문제점1. 무지성 Join과 fetchJoin()을 지양하자 (5s -> 4s 로 개선)

    우선 기존 코드에서 가장 큰 문제점은 불필요한 Join문을 사용하고 있다는 점과,
    굳이 필요 없는 fetchJoin()을 남발하고 있다는 점이다.
    기존코드로 발생하는 쿼리를 살펴보면, Participant와 Room에 대한 정보를 모두 한 번에 받아오고 있다. (굳이)

    어마무시한 쿼리길이;

    QueryDSL에서 테이블 간 조인 과정에서 각 테이블에 있는 정보가 모두 필요할 경우 N+1문제를 해결하기 위해 fetchJoin()을 사용한다.
    fetchJoin()을 사용하면 각 테이블의 컬럼들을 위와 같이 한 번에 select하기 때문에,
    쿼리를 불필요하게 한 번 더 날려서 조회를 하지 않아도 된다.

    그렇기 때문에 Join시에 fetchJoin()을 보통 사용하는데.. 문제는 내가 두 테이블에 있는 정보를 모두 사용하는가?이다.
    하지만 기존 코드를 보면.. 반환 값이 Participant로 Room의 정보는 필요하지 않으며,
    단순히 roomId를 확인하기 위해서만 Room 정보를 사용하고 있다.

    즉, Join도 필요하지 않고!!! fetchJoin()은 더더욱 필요가 없는 것.
    즉시 삭제해버렸다.

    4초로 줄긴 했지만.. 썩 만족스럽진 않은 수준.

    Join이나 fetchJoin()이 그렇게 많은 성능을 잡아 먹는 것은 아닌 모양.. 아무래도 테이블이 그렇게 크지는 않다보니?

    문제점2. 필요한 정보만 조회하자 (4s -> 1s로 개선)

    사실 내가 가장 근본적으로 개선해야겠다고 생각한 부분은 getParticipantRank 메소드 부분이다.
    기본 로직 자체가 요청자의 기록을 기준으로 위에 위치하고 있는 데이터들을 찾아야하기 때문에

    참여자가 많아지면 많아질수록 조회속도가 느려질 것이기 때문이다.

    날라가는 쿼리를 보니.. 정말 불필요한 정보가 너무 많았다. count만하면 되는데 굳이 Participant를 select할 이유가..?

    그래서 다음과 같이 코드를 수정했다.
    불필요하게 정보를 가져오지 않고 해당하는 데이터가 몇 개 인지를 카운트하는 것으로.
    또, fetch에 있어서도 우리는 count이기 때문에 1건만 나올 것이라 fetch()가 아닌 fetchOne()으로 변경했다.

        public long getParticipantRank(Long roomId, Duration participantDuration, Integer participantSolvedCnt) {
    
            // Count the number of participants with a lower duration in the same room
            long rank = query
                    .select(participant.count())
                    .from(participant)
                    .where(
                            participant.room.id.eq(roomId),
                            participant.isFinished.isTrue(),
                            participant.solvedCnt.gt(participantSolvedCnt)
                                    .or(participant.solvedCnt.eq(participantSolvedCnt)
                                            .and(participant.duration.lt(participantDuration)))
                    )
                    .fetchFirst();
    
            // Add 1 to the rank to get the participant's actual rank (since ranks start at 1)
            return rank + 1;
        }

    다음과 같이 쿼리도 간결해지고,

    시간도 897ms!!! 

     

    회사에 들어와서 일을 하며 느낀건데 query를 잘 짜는게 정말 중요하다는 생각..
    교육생 시절에는 데이터가 오는 쿼리를 작성하는 것에도 벅차서 닥치는대로 정보를 다 끌고 왔는데
    더 효율적인 쿼리를 계속 고민해봐야겠다는 생각!


    반응형

    'DB > MySQL' 카테고리의 다른 글

    [MySQL] Stored Procedure  (0) 2024.01.22
    [MySQL] Stored Function  (0) 2024.01.21
    [MySQL/Java] DB 쿼리 성능개선 (1) test용 mock/bulk data 삽입 방법  (0) 2023.06.26

    댓글

Designed by Tistory.