[밍글] 게시물 대댓글 로직에서 N+1 문제 해결하기

KIM TAEHYUN·2023년 6월 10일
1
post-thumbnail

밍글에서 게시물 상세페이지의 대댓글 API를 작성할 때 N+1 문제를 만났었습니다.
이번 글에서는 JPA의 fetch join과 default batch size를 비교해보고, 이를 이용해 성능을 높였던 사례에 대해서 적어보고자 합니다.

fetch join이란

  • 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능입니다.
  • Repository 메소드 안의 JPQL에서 join fetch 명령어로 사용할 수 있습니다.

페치 조인과 일반 조인의 차이

  • 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회(즉시 로딩)
  • 페치 조인은 객체 그래프를 SQL 한번에 조회하는 개념이다.
  • 연관된 엔티티들을 SQL 한 번으로 조회 - 성능 최적화

default batch size란

배치 사이즈를 추가하면 엔티티 조회 시 지연 로딩으로 인해 나가는 비슷한 여러 쿼리를 하나의 IN 쿼리로 만들어줍니다.
이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회할 수 있습니다.

설정 방법: 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size 또는 @BatchSize 를 적용
hibernate.default_batch_fetch_size: 글로벌 설정 (application.yml)
@BatchSize: 개별 최적화


보통 JPA 성능 문제는 아래와 같은 방법으로 해결합니다.

  • 다대일, 일대일 관계는 fetch join으로 해결
  • 일대다 관계는 batch size로 해결

이를 염두에 두고, 문제가 되었던 PostService의 메서드를 살펴보겠습니다.

  • PostService.java
@Transactional(readOnly = true)  
public List<CommentResponse> getUnivComments(Long postId) throws BaseException {  
    UnivPost univPost = postRepository.checkUnivPostDisabled(postId); // 1번  
    if (univPost == null) throw new BaseException(POST_NOT_EXIST);  
    if (univPost.getStatus().equals(REPORTED) || univPost.getStatus().equals(DELETED)) return new ArrayList<>();  
    Long memberIdByJwt = jwtService.getUserIdx();  
    try {  
        //1. postId로 찾은 게시물의 댓글, 대댓글 리스트 각각 가져오기  
        List<UnivComment> univComments = postRepository.getUnivComments(postId, memberIdByJwt); //2번. 댓글
        List<UnivComment> univCoComments = postRepository.getUnivCoComments(postId, memberIdByJwt); //3번. 대댓글  
        //2. 댓글에 대댓글 DTO를 넣어 만들 최종 대댓글 DTO 생성  
        List<CommentResponse> univCommentResponseList = new ArrayList<>();  
        //3. 댓글 리스트를 순회하며 댓글 하나당 대댓글 리스트 넣어서 합쳐주기  
        for (UnivComment c : univComments) { //parentComment 하나당 해당하는 UnivComment 타입의 대댓글 찾아서 리스트 만들기  
            List<UnivComment> CoCommentList = univCoComments.stream()  
                    .filter(cc -> c.getId().equals(cc.getParentCommentId()))  
                    .collect(Collectors.toList()); //댓글의 id와 대댓글의 parentCommentId와 일치하면 해당 댓글의 대댓글이다
            if ((c.getStatus() == PostStatus.INACTIVE) && CoCommentList.size() == 0) continue; //만약 삭제된 댓글이며 대댓글이 없을 시 대댓글 DTO 만들필요x  
  
            //댓글 하나당 만들어진 대댓글 리스트를 대댓글 DTO 형태로 변환  
            List<CoCommentDTO> coCommentDTO = CoCommentList.stream() // CoCommentDTO를 만들때  대댓글의 id로 댓글 좋아요 리스트를 배치로 조회해 쿼리 1번 나감. (일대다)
                    .filter(cc -> cc.getStatus().equals(PostStatus.ACTIVE) || cc.getStatus().equals(REPORTED) || cc.getStatus().equals(DELETED)) //신고되거나 운영자에 의해 삭제된 댓글까지 표시하기 위해 DTO에 담기. INACTIVE는 담지x
                    .map(cc -> new CoCommentDTO(postRepository.findUnivComment(cc.getMentionId()), cc, memberIdByJwt, univPost.getMember().getId())) //4. 이미 모든 댓글을 찾아와 영속성에 있기에 repo를 접근해도 쿼리가 나가지 않는다.  
                    .collect(Collectors.toList());  
            /** 5. 쿼리문 나감. 결론: for 문 안에서 쿼리문 대신 DTO 안에서 해결 */  
            //boolean isLiked = postRepository.checkCommentIsLiked(c.getId(), memberIdByJwt);  
            //6. 댓글 DTO 생성 후 최종 Response에 넣어주기  
            CommentResponse univCommentResponse = new CommentResponse(c, coCommentDTO, memberIdByJwt, univPost.getMember().getId()); 
            univCommentResponseList.add(univCommentResponse);  
        }  
        return univCommentResponseList;  
    } catch (Exception e) {  
        throw new BaseException(DATABASE_ERROR);  
    }  
}

1번째 쿼리
UnivPost univPost = postRepository.checkUnivPostDisabled(postId); // 1번

  • 받아온 postId로 존재하는 post인지 확인하기 위해 1번째 쿼리가 나갑니다.

2,3번째 쿼리

    //1. postId로 찾은 게시물의 댓글, 대댓글 리스트 각각 가져오기  
    List<UnivComment> univComments = postRepository.getUnivComments(postId, memberIdByJwt);//2
    List<UnivComment> univCoComments = postRepository.getUnivCoComments(postId, memberIdByJwt); //3번
  • 받아온 postId로 찾은 게시물의 댓글, 대댓글 리스트를 각각 가져옵니다. 각 한번씩으로 2,3번째 쿼리가 나갑니다.

Repository 클래스를 보겠습니다.

//댓글만 가져오기
public List<UnivComment> getUnivComments(Long postId, Long memberIdByJwt) {  
    List<UnivComment> univCommentList = em.createQuery("select uc from UnivComment uc join uc.univPost as p " +  
                    " where p.id = :postId and uc.parentCommentId is null and uc.member.id not in (select bm.blockedMember.id from BlockMember bm where bm.blockerMember.id = :memberIdByJwt)" +  
                    " order by uc.createdAt asc ", UnivComment.class)  
            .setParameter("postId", postId)  
            .setParameter("memberIdByJwt", memberIdByJwt)  
            .getResultList();  
    return univCommentList;  
}  
  
//대댓글만 가져오기 
public List<UnivComment> getUnivCoComments(Long postId, Long memberIdByJwt) { // join fetch uc.member as m  
    List<UnivComment> univCoCommentList = em.createQuery("select uc from UnivComment uc join uc.univPost as p" +  
                    " where p.id = :postId and uc.parentCommentId is not null and uc.member.id not in (select bm.blockedMember.id from BlockMember bm where bm.blockerMember.id = :memberIdByJwt) " +  
                    " order by uc.createdAt asc", UnivComment.class)  
            .setParameter("postId", postId)  
            .setParameter("memberIdByJwt", memberIdByJwt)  
            .getResultList();  
    return univCoCommentList;  
}
  • 댓글, 대댓글이 한 테이블에 있기에 한 댓글당 대댓글을 DTO로 만들어 넣어주기 위해,
  • 대댓글 여부를 따질 수 있는 parentCommentId 의 null 체크로 댓글과 대댓글을 따로 가져옵니다.

주석 4. CoCommentDTO - 대댓글 DTO 만들기

.map(cc -> new CoCommentDTO(postRepository.findUnivComment(cc.getMentionId()), cc, memberIdByJwt, univPost.getMember().getId()))
  • 지연 로딩은 영속성 컨텍스트에 있으면 영속성 컨텍스트에 있는 엔티티를 사용하고 없으면 SQL을 실행합니다. 따라서 같은 영속성 컨텍스트에서 이미 로딩한 UnivComment 엔티티를 추가로 조회하면 SQL을 실행하지 않습니다.
  • 우리는 이미 한 게시글에 해당하는 모든 댓글 엔티티들을 찾아와 영속성에 있기에 CoCommentDTO를 만들 때 repo를 접근해도 쿼리가 나가지 않습니다.

주석 5. 현재 접근하는 유저가 해당 대댓글을 좋아요했는지 여부 확인
이를 확인하기 위해 repository를 들르거나 엔티티 그래프 탐색으로 접근할 수 있습니다.

  • 다른 방법으로는, for loop 안에서 repository를 통해 확인할 수 있습니다. 하지만 이는 Repository 접근 횟수만큼 쿼리가 N번 나갈 것이기에, 엔티티 그래프로 탐색을 했습니다.
  • 하지만, 엔티티 그래프로 탐색을 하더라도 일대다 매핑이 되어있는 UnivComment가 UnivCommentLike를 접근한다면 동일하게 N번 쿼리가 나갈 것입니다.
  • 이에, commentId로 CommentLike를 한번에 batch size로 조회한다면 1번의 쿼리로 일대다 매핑된 UnivCommentLike를 한번에 가져올 수 있습니다.

주석 6. 댓글 DTO 생성 후 최종 DTOList 에 넣어주기

CommentResponse univCommentResponse = new CommentResponse(c, coCommentDTO, memberIdByJwt, univPost.getMember().getId());
  • 각 댓글별로 만든 대댓글리스트 DTO(coCommentDTO)를 넣어주어 현재 접근중인 댓글 (c)과 함께 인자로 전달해 CommentResponse를 만들어줍니다.

이제 댓글 엔티티를 DTO로 만드는 CoCommentDTO를 살펴보겠습니다.

public CoCommentDTO(UnivComment mention, UnivComment coComment, Long memberId, Long authorId) {  
    Long coCommentWriter = coComment.getMember().getId();
    this.commentId = coComment.getId();  
    this.parentCommentId = coComment.getParentCommentId();  
    if (!coComment.isAnonymous() && Objects.equals(coCommentWriter, authorId)) { //대댓글 작성자가 익명이 아니면서 글 작성자일 경우: 닉네임(글쓴이) 표시  
        this.nickname = coComment.getMember().getNickname()+ "(글쓴이)";  //4번째 쿼리
    } else if (coComment.isAnonymous() && coComment.getAnonymousId() != 0L) { //익명이면서 부여된 익명 넘버가 0이 아닐 경우 (글 작성자가 아닐때): 익명1처럼 익명+익명 넘버 표시  
        this.nickname = "익명 " + coComment.getAnonymousId();  
    } else if (!coComment.isAnonymous()) { //대댓글 작성자가 익명이 아닐 경우: 닉네임 표시  
        this.nickname = coComment.getMember().getNickname();  
    } else if (coComment.isAnonymous() && Objects.equals(coCommentWriter, authorId)) { //대댓글 작성자가 익명이면서 글 작성자일 경우: 익명(글쓴이) 표시. 익명 넘버 필요x  
        this.nickname = "익명(글쓴이)";  
    }  
  
	//... 중략 ...
    this.likeCount = coComment.getUnivCommentLikes().size(); //5번째 쿼리
    this.isLiked = false;  
    for (UnivCommentLike ucl : coComment.getUnivCommentLikes()) { 
        if (Objects.equals(ucl.getMember().getId(), memberId)) {
            this.isLiked = true;  
            break;        
        }  
    }  
    // ...중략 ...
}
  • Comment 엔티티를 조회할 때 memberId 필드는 이미 조회해오기에, comment.getMember().getId()나 mention.getMember().getId() 를 할때는 쿼리가 나가지 않습니다.

4번째 쿼리

  • coComment.getMember().getNickname();
  • UnivComment에 지연 로딩으로 연관된 Member 프록시에서 getNickname()을 호출할 때 쿼리가 나간다.
  • batch size를 적용했기 때문에 in절을 이용해 UnivComment List에 연관된 모든 member를 한번에 가져온다.

5번째 쿼리

  • Hibernate가 Comment 엔티티 리스트를 조회해올때 가져왔던 commentId를 이용해 배치사이즈로 univCommentLikes를 한번에 조회해온다.
  • 1번 같은 엔티티를 같은 쿼리문으로 가져오는게 여러개잇으면 in절(리스트)을 써서 한번에 가져오는것.
  • ? 자리에 멤버 전체중에 in절 리스트 안에있는 userId를 가진 유저만 가져오겟다 ? 자리에 CommentLike 전체중에 in절 리스트 안에 있는 commentId를 가진 univCommentLikes만 가져온다.

이렇게 총 5개의 쿼리가 나가는걸 확인하였습니다.


다대일 관계 fetch join vs batch size

UnivComment -> Member
N : 1

또 다른 고민은 대댓글 DTO의 넣어줄 member 엔티티의 닉네임을 조회하는 것이었습니다.
이는 fetch join과 batch size를 활용해 해결할 수 있습니다.

방법 1: fetch join

JPA 강의를 들으며 배운 내용은 다대일 관계에서는 fetch join을 이용해 연관된 엔티티까지 한번에 조회해오는것이었습니다.
이를 우리 프로젝트에 적용한다면, UnivComment와 다대일 관계인 Member 엔티티를 fetch join해와 List를 찾는 쿼리 안에서 함께 한번에 조회할 수 있습니다.
그렇다면, 댓글 DTO를 만들 때 comment.getMember() .getNickname()를 실행하더라도 이미 페치 조인으로 UnivComment -> Member는 이미 조회 된 상태 이므로 지연로딩을 하지 않습니다. 이에, nickname에 접근할 때 마다 쿼리가 나가지 않기에 N+1 문제를 해결할 수 있습니다.

장점:

  • comment를 조회할 때 member 엔티티와 이에 연관된 모든 컬렉션을 함께 한 쿼리에 가져옵니다.
  • Member 엔티티의 또 다른 필드가 필요하다면 N+1 문제를 걱정하지 않고 편하게 접근할 수 있습니다.

방법 2: batch size

만일 위와 같은 방법으로 Member를 함께 조회해온다면, 확실히 N+1 문제를 해결할 수 있을것입니다.

하지만, 이 member 엔티티의 의 Lazy loading으로 인해 쿼리문이 나가는것을 방지하기 위해 fetch join을 해 가져온다면, Member에 연관되어있는 수많은 엔티티들과 컬렉션들이 함께 조회될 것입니다.
디버그를 찍어보았을떄, 아래 사진과 같이 Member 엔티티와 연관된 수많은 엔티티 및 컬렉션들이 함께 조회되는걸 볼 수 있습니다.

하지만, 우리는 댓글 DTO를 만들때 Member의 닉네임밖에 필요하지 않습니다.
또한, 우리는 default batch size를 통해 member의 nickname을 한번에 가져올 수 있습니다.

게다가, 현재 서비스를 분석해보았을때 한 게시글의 달리는 댓글은 실제로 많아야 30개를 넘지 않습니다.
이는 batch size를 비교적 작은 100으로 지정하여도 한 번에 가져올 수 있는 만큼입니다. (보통 100~1000의 범위를 쓴다고 한다.)
즉, fetch join이 아닌 batch size를 이용하게 되더라도 fetch join에 비해 member를 batch로 가져오는 단 한 번의 쿼리만 추가되는것입니다.

이에, member를 batch size로 조회해오는 방향이 적절하다고 판단했습니다.


이렇게 대댓글 API를 만들며 마주한 N+1 문제를 해결 해보았습니다.
감사합니다.

0개의 댓글