밍글에서 게시물 상세페이지의 대댓글 API를 작성할 때 N+1 문제를 만났었습니다.
이번 글에서는 JPA의 fetch join과 default batch size를 비교해보고, 이를 이용해 성능을 높였던 사례에 대해서 적어보고자 합니다.
페치 조인과 일반 조인의 차이
배치 사이즈를 추가하면 엔티티 조회 시 지연 로딩으로 인해 나가는 비슷한 여러 쿼리를 하나의 IN
쿼리로 만들어줍니다.
이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회할 수 있습니다.
설정 방법: 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size 또는 @BatchSize 를 적용
hibernate.default_batch_fetch_size: 글로벌 설정 (application.yml)
@BatchSize: 개별 최적화
보통 JPA 성능 문제는 아래와 같은 방법으로 해결합니다.
이를 염두에 두고, 문제가 되었던 PostService의 메서드를 살펴보겠습니다.
@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번
2,3번째 쿼리
//1. postId로 찾은 게시물의 댓글, 대댓글 리스트 각각 가져오기
List<UnivComment> univComments = postRepository.getUnivComments(postId, memberIdByJwt);//2
List<UnivComment> univCoComments = postRepository.getUnivCoComments(postId, memberIdByJwt); //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;
}
주석 4. CoCommentDTO - 대댓글 DTO 만들기
.map(cc -> new CoCommentDTO(postRepository.findUnivComment(cc.getMentionId()), cc, memberIdByJwt, univPost.getMember().getId()))
주석 5. 현재 접근하는 유저가 해당 대댓글을 좋아요했는지 여부 확인
이를 확인하기 위해 repository를 들르거나 엔티티 그래프 탐색으로 접근할 수 있습니다.
주석 6. 댓글 DTO 생성 후 최종 DTOList 에 넣어주기
CommentResponse univCommentResponse = new CommentResponse(c, coCommentDTO, memberIdByJwt, univPost.getMember().getId());
이제 댓글 엔티티를 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;
}
}
// ...중략 ...
}
4번째 쿼리
5번째 쿼리
이렇게 총 5개의 쿼리가 나가는걸 확인하였습니다.
UnivComment -> Member
N : 1
또 다른 고민은 대댓글 DTO의 넣어줄 member 엔티티의 닉네임을 조회하는 것이었습니다.
이는 fetch join과 batch size를 활용해 해결할 수 있습니다.
JPA 강의를 들으며 배운 내용은 다대일 관계에서는 fetch join을 이용해 연관된 엔티티까지 한번에 조회해오는것이었습니다.
이를 우리 프로젝트에 적용한다면, UnivComment와 다대일 관계인 Member 엔티티를 fetch join해와 List를 찾는 쿼리 안에서 함께 한번에 조회할 수 있습니다.
그렇다면, 댓글 DTO를 만들 때 comment.getMember() .getNickname()를 실행하더라도 이미 페치 조인으로 UnivComment -> Member는 이미 조회 된 상태 이므로 지연로딩을 하지 않습니다. 이에, nickname에 접근할 때 마다 쿼리가 나가지 않기에 N+1 문제를 해결할 수 있습니다.
장점:
만일 위와 같은 방법으로 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 문제를 해결 해보았습니다.
감사합니다.