흰 스타렉스에서 내가 내리지

쿼리를 5,000번 날리는 API가 있다?😬 쿼리 줄이기 8시간 삽질 후기 본문

Spring

쿼리를 5,000번 날리는 API가 있다?😬 쿼리 줄이기 8시간 삽질 후기

주씨. 2024. 2. 15. 12:06
728x90

들어가며

API 하나에 쿼리가 너무 많이 발생하여, 이를 어떻게 하면 한 두번으로 줄일 수 있을까? 하고 8시간 동안 삽질한 후기다.

 

 

ERD

 

Folder 에는 Term 이 여러개 들어있다.

Convert 해서 Folder 의 컬럼에 리스트 문자열로 저장했기 때문에, 직접적으로 연관관계는 없다.

Term 은 Comment 들을 가지고 있고, Comment 와 Comment_like 가 1대다 관계이다.

 

 

구현하고자 하는 API :

1. FolderId 를 통해 Folder 에 들어있는 TermId 들을 불러온다.

2. 각각의 Term 에 대해서, Term 세부 정보와 Comment 들, 그리고 각각의 Comment 는 Comment_Like 와 Join 하여 좋아요 여부를 불러와야 한다.

 

 

처음에 대충 짜본 코드

public Object getFolderTermDetailEach(Long folderId, Long memberId) {
    Member memberPS = memberRepository.getReferenceById(memberId);

    Folder folderPS = folderRepository.findById(folderId)
            .orElseThrow(() -> new CustomApiException("폴더가 존재하지 않습니다."));
	
    ...
	
    // 1. Term DB 접근
    List<Term> termListInFolder = termRepository.findTermsByIdList(folderPS.getTermIds());

    List<TermDetailInfoDto> responseDtoList = new ArrayList<>();
    for (Term term: termListInFolder){
        TermDetailInfoDto termDetailInfoDto = new TermDetailInfoDto(term);  

        List<TermDetailInfoDto.CommentDetailInfoDto> commentList = term.getComments().stream().map(comment -> {
        	// 2. CommentLike DB 접근
            CommentLikeStatus commentLikeStatus = commentLikeRepository.getStatusByCommentAndMember(comment, memberPS)
                    .orElse(CommentLikeStatus.NO);
            return TermDetailInfoDto.CommentDetailInfoDto.of(comment, commentLikeStatus);  
        }).toList();

        termDetailInfoDto.setComments(commentList);

        responseDtoList.add(termDetailInfoDto);
    }

    return responseDtoList;
}

 

FolderIdList ([1, 2, 3, 4, ...]) 를 통하여, SQL 쿼리를 날려 Term 정보들을 불러온다. 

이 떄, fetch join 을 이용하여 comment 정보들도 함께 불러온다.

 

여기서 문제는, comment 와 연관된 comment_like 를 join 하여 좋아요 정보를 가져와야 한다는 것이다. 

fetch join 의 대상이 되는 객체로부터 또 다른 테이블을 join 해올 수 있다면, SQL 쿼리 한번으로 이 API 는 완성되는 것이다. 

그래서 그 방법을 찾아보려고 6시간 가량을 삽질했지만, 끝내 답을 찾지 못했다. 

여유로울 때 더 연구해보도록 하자. 

 

그래서 (2번 주석을 보면) 각각의 Comment 객체마다 Comment_Like 테이블에 접근하여 좋아요 정보를 불러오고 있다

즉,  Comment 의 개수만큼 SELECT ~ JOIN ~ 쿼리문이 발생하게 된다. 

Folder 내 단어들의 Comment 총합 개수가 1000개라면, SELECT 쿼리가 1000번 날아가게 되는 것이다. 

만약 이 서비스가 아주 성공하여 댓글이 만개, 십만개 달린다면? DB에 부하를 아주 크게 주는 것이다. 이는 분명히 잘못됐다.

 

 

또, Comment Dto 를 구성하는 과정에서, Comment 작성자 정보를 담기 위해, Comment 와 연관된 Member 의 정보에도 접근한다.

fetch join 을 통해 Term 엔티티와 Comment 엔티티는 전부 Persistence Context 에 저장이 되어 있지만, Comment 와 연관된 Member 와 Comment_Like 는 Persistence Context 에 있지 않다. 

즉, ResponseDto 를 구성하는 과정에서, 예를 들어 comment.getMember().getName(); 을 호출할 경우, 이 Member 가 Persistence Context 에 존재하지 않는다면, 정보를 DB 에서 불러오는 쿼리가 발생한다

 

Comment 를 작성한 Member 가 2~3 명이라면, SELECT 쿼리도 그 Member 의 개수에 해당하는 2~3 번 만큼만 발생할 테지만, 만약 Member 가 1000 명이라면, 1000번의 쿼리가 발생하는 것이다. (한 번 불러온 Member 는 Persistence Context 에 저장되어 똑같은 것을 두 번 불러오지는 않는다.)

 

정리하자면, Folder 내에 저장된 Term 의 개수가 50개이고, Term 마다 Comment 는 10개씩 달려있고, 각각의 작성자가 모두 다르다고 가정한다면, API 한 번 호출에 총 50 * 10 * 10 = 5,000 번의 DB SELECT 쿼리가 발생하는 것이다. 이런 미친 로직은 절대로 짜지 않도록 하자. 아무리 JPA 가 편하고, 코드가 직관적이라 하더라도...

 

 

그러면 어떡해?

위에서 언급한대로, SQL 쿼리 한번에 전체 ResponseDto 가 구성된다면 정말 좋겠지만, 나중에 연구해보기로 하고, 

내가 선택한 방법은, DB에 날리는 쿼리를 줄이는 대신 서버에서의 데이터를 가공하는데 연산을 늘리는 것이었다. 

 

작성한 코드는 아래와 같다. 

public Object getFolderTermDetailEach(Long folderId, Long memberId) {
    Member memberPS = memberRepository.getReferenceById(memberId);

    Folder folderPS = folderRepository.findById(folderId)
            .orElseThrow(() -> new CustomApiException("폴더가 존재하지 않습니다."));

    ...

	// 1. Term 접근. Comment 는 Fetch Join 해오지 않는다. 
    List<TermDetailInfoDto> responseDtoList = termRepository.findTermsByIdList(folderPS.getTermIds());
    
    // 2. Comment 정보들을 Comment_Like 와 JOIN 하여 불러온다.
    List<TermDetailInfoDto.CommentDetailInfoDto> commentDetailByTermIdList = commentRepository.getCommentDetailByTermIdList(folderPS.getTermIds(), memberId);

    for(TermDetailInfoDto responseDto: responseDtoList){
        Long termId = responseDto.getId();

        responseDto.setComments(
                commentDetailByTermIdList.stream()
                        .filter(dto -> dto.getTermId().longValue() == termId.longValue())
                        .toList()
        );
    }

    return responseDtoList;
}

 

내가 주목한 것은, 나는 Folder 에 담긴 TermIds 를 리스트 형태로 갖고 있다는 것이었다. ([1, 2, 3, 4, 5, ...])

 

먼저, Term의 정보들을 우선적으로 불러와 Dto를 채웠다. (쿼리 1번 발생)

Comment 를 Fetch Join 하지 않았기 때문에, Persistence Context 에는 Term 이외에 존재하지 않는다. 

 

두번째로, 여기가 포인트다.

Comment 는 Term 과 연관되어 있으므로, TermId 리스트에 존재하는 TermId 들을 FK 로 가지는 Comment 들을 전부 불러온다

이 때, Comment_Like 도 LEFT JOIN 하여 좋아요 정보를 같이 불러온다. (쿼리 1번 발생)

 

문장으로 쓰면 이해가 잘 안가니 내가 작성한 JPQL 문을 첨부한다.

@Query("SELECT new XxxxDto(c, c.member, cl.status, c.term.id) " +
        "FROM Comment c " +
        "INNER JOIN Member m ON m.id = c.member.id " +
        "LEFT JOIN CommentLike cl " +
        "ON c.id = cl.comment.id AND cl.member.id = :loginMemberId " +
        "WHERE c.term.id IN :termIdList")

 

 

이렇게 하면, 총 2번의 쿼리 만으로도 내가 원하는 데이터들을 전부 불러올 수 있다. 

termId 도 함께 불러와서, Dto 에 일단 저장을 해 놓는다. 이는 나중에 Term 과 필터링을 통해 매핑할 때 쓰인다. 

termId 를 API 응답에 담을 필요는 없으므로 @JsonIgnore 처리를 해 준다. 

 

Hibernate: 
    select
		********************************
    from
        term t1_0 
    where
        t1_0.term_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 
 ============================ ============================ ============================
 Hibernate: 
    select
		************************************
    from
        comment c1_0 
    join
        member m1_0 
            on m1_0.member_id=c1_0.member_id 
    left join
        comment_like cl1_0 
            on c1_0.comment_id=cl1_0.comment_id 
            and cl1_0.member_id=? 
    left join
        member m4_0 
            on m4_0.member_id=c1_0.member_id 
    where
        c1_0.term_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

 

검증하기 위해 Test 코드를 수행한 결과, 위와 같이 정말로 쿼리 2번에 모든 데이터를 불러온 것을 확인 할 수 있다.

 

 

데이터 가공

현재 상태는, ResponseDto 에 Term 정보만 담겨 있고, Comment 정보는 따로 분리되어 있다. 

CommentDto 에 아까 TermId 를 불러 왔으니, Java 의 stream.filter 문법을 사용하여 매칭해주면 된다. 

 

 

 

개선할 점

CommentDto 에 Member 정보는 3~4 개밖에 필요하지 않다. 

하지만 위 코드는 Member 의 모든 정보를 불러오고 있다. 

즉, 불필요한 Member 의 정보까지 불러오고 있는 것이다. 

만약 Member 테이블이 계속 커질 경우, 이 또한 DB 에 어느정도 부하를 줄 것이다. 

 

이는 JPQL 쿼리 문에서, Dto 생성자에 member 객체를 넣는 것이 아닌, 필드 하나하나를 담아주면 해결이 된다. 

이 포스팅에서 코드를 간단하게 하기 위해, 그리고 빨리 결과를 검증하기 위해 member 객체를 바로 넘겨줬지만, 필요한 정보만 불러올 수 있도록 지금 바로 수정하러 가자!