JPA + QueryDSL 계층형 댓글, 대댓글 구현(2)

이번엔 전편에 이어서 계층형 댓글, 대댓글을 다시 리팩토링해볼 예정이다.

  • 이전 게시글에서는 계층형 댓글, 대댓글을 구현은 되었지만 N+1 문제가 있었다. 이번에는 그 N+1 문제를 해결해 볼 것이다.
    @Transactional
    public PostOneResponse getOnePost(Long postId) {
        PostOneResponse postOneResponse = postRepository.findOnePostById(postId)
                .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_POST));
        commentsExtractor(postId, postOneResponse);
        return postOneResponse;
    }

    private void commentsExtractor(Long postId, PostOneResponse postOneResponse) {
        postOneResponse.getComments()
                .forEach(comment -> {
                            List<CommentsChildrenResponse> comments = commentRepository.findPostComments(postId, comment.getCommentId());
                            comment.setChildren(comments);
                });
    }
  • 위의 로직은 이 전편에서 만들었던 N+1 문제가 발생하던 로직이다.

  • 만약 부모 댓글의 개수가 100개라면 100번 이상의 쿼리가 나가게 되는 아주 좋지 못한 코드이다..ㅠ 🥲

  • QueryDSL 코드
@RequiredArgsConstructor
public class PostRepositoryImpl implements PostCustomRepository {

    private final JPAQueryFactory queryFactory;

    @Override
    public Optional<PostOneResponse> findOnePostById(Long postId, Long userId) {
        queryFactory.update(post)
                .set(post.viewCount, post.viewCount.add(1))
                .where(post.id.eq(postId))
                .execute();

        Optional<PostOneResponse> response = Optional.ofNullable(queryFactory
                .select(new QPostOneResponse(
                        post.id,
                        post.title,
                        post.content,
                        post.scraps.size(),
                        post.comments.size(),
                        post.postLikes.size(),
                        post.timeEntity.createdDate,
                        post.timeEntity.updatedDate,
                        post.viewCount,
                        user.nickname,
                        JPAExpressions
                                .selectFrom(post)
                                .where(user.id.eq(userId))
                                .exists(),
                        JPAExpressions
                                .selectFrom(postLike)
                                .where(postLike.post.eq(post).and(user.id.eq(userId)))
                                .exists(),
                        JPAExpressions
                                .selectFrom(scrap)
                                .where(scrap.post.eq(post).and(user.id.eq(userId)))
                                .exists()))
                .from(post)
                .innerJoin(post.user, user)
                .where(post.id.eq(postId))
                .fetchOne());

        if (response.isEmpty()) {
            return Optional.empty();
        }

        List<PostOneCommentResponse> comments = queryFactory
                .select(new QPostOneCommentResponse(
                        comment.parent.id,
                        comment.id,
                        comment.content,
                        user.nickname,
                        JPAExpressions
                                .selectFrom(comment)
                                .where(user.id.eq(userId))
                                .exists(),
                        comment.timeEntity.createdDate,
                        comment.timeEntity.updatedDate))
                .from(comment)
                .innerJoin(comment.post, post)
                .innerJoin(comment.user, user)
                .where(post.id.eq(postId).and(comment.parent.id.isNull()))
                .orderBy(comment.id.asc())
                .fetch();

        List<CommentsChildrenResponse> childComments = queryFactory
                .select(new QCommentsChildrenResponse(
                        comment.parent.id,
                        comment.id,
                        comment.content,
                        user.nickname,
                        JPAExpressions
                                .selectFrom(comment)
                                .where(user.id.eq(userId))
                                .exists(),
                        comment.timeEntity.createdDate,
                        comment.timeEntity.updatedDate
                ))
                .from(comment)
                .innerJoin(comment.post, post)
                .innerJoin(comment.user, user)
                .where(post.id.eq(postId).and(comment.parent.id.isNotNull()))
                .fetch();


        comments.stream()
                .forEach(parent -> {
                    parent.setChildren(childComments.stream()
                            .filter(child -> child.getParentId().equals(parent.getCommentId()))
                            .collect(Collectors.toList()));
                });

        response.get().setComments(comments);

         return response;
    }
}
  • 위의 코드는 게시글을 조회할 때 댓글 + 대댓글 을 한번에 다 가져와서 JSON 을 계층형으로 만들어주는 쿼리이다.

  • 이 전편에 비해서 코드가 많이 바뀌었다. 😁

  • 간략히 위의 코드를 설명하자면 게시글 조회 로직이 실행되는 순간 viewCount가 1이 증가하는 쿼리가 나가고, post를 가져오는 조회 쿼리가 나가게 된다.

  • JPAExpressions 은 서브쿼리로 사용을 하였는데 이것은 boolean 타입으로 반환이 되며 사용자의 게시글, 좋아요 여부, 스크랩 여부를 확인하는 QueryDSL에서 지원하는 기능이다.

  • post가 만약 없다면 Optional이 return이 되고, 존재한다면 아래 로직을 실행한다. 댓글을 가져오는 방법이 여러가지가 있는데 저는 부모 댓글을 먼저 가져왔습니다. (parentId = null)

  • 부모 댓글을 가져온 후 자식 댓글을 가져온다. (parentId = notNull)

  • 이렇게 가져온 후 부모 댓글이 List 로 반환이 되고 stream을 사용해서 commentId 와 자식 댓글의 parentId를 비교해서 같은 값이 있다면 List 타입으로 넣어주게 된다.

  • 이렇게 총 4번의 쿼리가 실행이 된다. (update 1번, select 3번)



  • PostOneResponse
@ApiModel(description = "결과 응답 데이터 모델")
@Getter
@Setter
@NoArgsConstructor
public class PostOneResponse {

    @ApiModelProperty(value = "게시글 Id")
    private Long postId;

    @ApiModelProperty(value = "게시글 제목")
    private String title;

    @ApiModelProperty(value = "게시글 내용")
    private String content;

    @ApiModelProperty(value = "해당 게시글의 전체 스크랩 수")
    private int scrapCount;

    @ApiModelProperty(value = "해당 게시글의 전체 댓글의 수")
    private int commentCount;

    @ApiModelProperty(value = "해당 게시글의 전체 좋아요 수")
    private int likeCount;

    @ApiModelProperty(value = "해당 게시글의 생성 시간")
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime createDate;

    @ApiModelProperty(value = "해당 게시글의 수정 시간")
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime updateDate;


    @ApiModelProperty(value = "해당 게시글의 조회수")
    private Integer viewCount;

    @ApiModelProperty(value = "해당 게시글의 생성 회원 이름")
    private String username;

    @ApiModelProperty(value = "해당 게시글의 유저 본인 확인")
    private boolean myPost;

    @ApiModelProperty(value = "해당 게시글의 좋아요 본인 확인")
    private boolean myLike;

    @ApiModelProperty(value = "해당 게시글의 스크랩 본인 확인")
    private boolean myScrap;

    @ApiModelProperty(value = "해당 게시글의 댓글")
    private List<PostOneCommentResponse> comments = new ArrayList<>();

    @QueryProjection
    public PostOneResponse(Long postId, String title, String content, int scrapCount, int commentCount, int likeCount, LocalDateTime createDate, LocalDateTime updateDate, Integer viewCount, String username, boolean myPost, boolean myLike, boolean myScrap) {
        this.postId = postId;
        this.title = title;
        this.content = content;
        this.scrapCount = scrapCount;
        this.likeCount = likeCount;
        this.commentCount = commentCount;
        this.createDate = createDate;
        this.updateDate = updateDate;
        this.viewCount = viewCount;
        this.username = username;
        this.myPost = myPost;
        this.myLike = myLike;
        this.myScrap = myScrap;
    }

}


  • postOneCommentResponse
@Getter
@Setter
@NoArgsConstructor
public class PostOneCommentResponse {

    private Long parentId;

    private Long commentId;

    private String content;

    private String username;

    private boolean myComment;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime createDate;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime updateDate;

    private List<CommentsChildrenResponse> children = new ArrayList<>();

    @QueryProjection
    public PostOneCommentResponse(Long parentId, Long commentId, String content, String username, boolean myComment, LocalDateTime createDate, LocalDateTime updateDate) {
        this.parentId = parentId;
        this.commentId = commentId;
        this.content = content;
        this.username = username;
        this.myComment = myComment;
        this.createDate = createDate;
        this.updateDate = updateDate;
    }


}


  • CommentsChildrenResponse
@Getter
@Setter
@NoArgsConstructor
public class CommentsChildrenResponse {

    private Long parentId;

    private Long commentId;

    private String content;

    private String username;

    private boolean myComment;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime createDate;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private LocalDateTime updateDate;

    @QueryProjection
    public CommentsChildrenResponse(Long parentId, Long commentId, String content, String username, boolean myComment, LocalDateTime createDate, LocalDateTime updateDate) {
        this.parentId = parentId;
        this.commentId = commentId;
        this.content = content;
        this.username = username;
        this.myComment = myComment;
        this.createDate = createDate;
        this.updateDate = updateDate;
    }

}


  • 결과화면 (Postman)



N+1 문제는 완전히 사라졌고 나름 전에 비해 깔끔하게 리팩토링이 된것같다!!.. 🏡

  • 내가 만든 방식 외에도 계층형 쿼리를 만드는 방법은 여러가지가 있는걸로 아는데 처음 만들어보는 계층형 쿼리이다보니 좀 어렵게 느껴졌던 것 같다.

  • 다음 편은 게시글 + 파일 업로드편(AWS S3) 만들어볼 예정이다.

좋은 웹페이지 즐겨찾기