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

JPA + QueryDSL을 이용한 계층형 댓글, 대댓글

  • Post 엔티티 클래스
@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
@EntityListeners(AuditListener.class)
public class Post implements Auditable {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "post_id")
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false, length = 1000)
    private String content;

    @Embedded
    private TimeEntity timeEntity;

    @ColumnDefault("0")
    @Column(name = "view_count",nullable = false)
    private Integer viewCount;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = LAZY,cascade = CascadeType.PERSIST)
    @JoinColumn(name = "post_category_id")
    private PostCategory postCategory;

    @OneToMany(mappedBy = "post", orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

    @OneToMany(mappedBy = "post", orphanRemoval = true)
    private List<PostLike> postLikes = new ArrayList<>();

    @OneToMany(mappedBy = "post", orphanRemoval = true)
    private List<Scrap> scraps = new ArrayList<>();
  • Post 엔티티는 위와 같이 작성하였다. postcomment의 관계는 1:N 관계이다. 이 글의 핵심은 계층형 댓글과 대댓글 관계이므로 부연 설명은 패스하겠다.


  • Comment 엔티티 클래스
@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
@EntityListeners(AuditListener.class)
public class Comment implements Auditable {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "comment_id")
    private Long id;

    @Column(nullable = false, length = 1000)
    private String content;

    @Embedded
    private TimeEntity timeEntity;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "parent_id")
    private Comment parent;

    @OneToMany(mappedBy = "parent", orphanRemoval = true)
    private List<Comment> children = new ArrayList<>();

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "post_id")
    private Post post;
  • comment는 자기 자신을 selfjoin을 하고있으며, 부모 댓글이 삭제될 시 하위 댓글들도 같이 삭제가 된다.


게시글은 만들어져 있다고 가정하고 게시글, 댓글, 대 댓글을 한 번에 조회하는 쿼리를 작성해 보겠다.

  • DTO
@Getter
@Setter
@NoArgsConstructor
public class PostOneResponse {

    private Long postId;

    private String title;

    private String content;

    private int scrapCount;

    private int commentCount;

    private int likeCount;

    @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 Integer viewCount;

    private String username;

    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) {
        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;
    }

}
  • QueryDSL을 사용하기 때문에 위와같이 QueryProjection 어노테이션을 사용하는 DTO를 만들어 준다.
  • 여기서 처음 Post를 조회할 때 컬렉션 객체는 바로 가져올 수 없으므로 따로 처리를 해주어야 한다.

QueryDSL 코드

@RequiredArgsConstructor
public class PostRepositoryImpl implements PostCustomRepository {

    private final JPAQueryFactory queryFactory;

    @Override
    public Optional<PostOneResponse> findOnePostById(Long postId) {
        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))
                .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,
                        comment.timeEntity.createdDate,
                        comment.timeEntity.updatedDate))
                .from(comment)
                .innerJoin(comment.post, post)
                .innerJoin(post.user, user)
                .where(post.id.eq(postId).and(comment.parent.isNull()))
                .orderBy(comment.id.asc())
                .fetch();

        response.get().setComments(comments);

         return response;
    }
  • 먼저 viewCount를 조회 시 자동으로 1씩 증가하게 만들어주었다. (이부분은 신경쓰지 않아도 됩니다.)
  • 먼저 위에서 작성한 PostOneResponse 를 이용해서 post를 단건으로 조회한다.
    -> 조회 시 만약 게시글이 존재하지 않다면 Optional.empty()가 반환이 된다.
  • post를 조회한 후 parent id가 null이 들어가 있는 걸 조회한다. (최상위 댓글)
  • 위에서 설명한 것처럼 컬렉션 객체는 한 번에 가져올 수 없으므로 post에서 조회한 객체에 setComment를 사용해서 값을 넣어준다.


Service 비즈니스 로직

  • getOnePost()
    /**
     * 게시글 조회
     */
    @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);
                });
    }
  • 먼저 위에서 설명한 로직이 정상적으로 실행이 되었다면, postOneResponse 객체에 값이 들어가 있을 것이다.
  • 그 후 postOneReponsecomments 컬렉션을 foreach로 루프를 돌려서 commentIdpostId 로 조회를 한다.


findPostComments()

    @Override
    public List<CommentsChildrenResponse> findPostComments(Long postId, Long commentId) {

        return queryFactory.select(new QCommentsChildrenResponse(
                        comment.parent.id,
                        comment.id,
                        comment.content,
                        user.nickname,
                        comment.timeEntity.createdDate,
                        comment.timeEntity.updatedDate))
                .from(comment)
                .innerJoin(comment.parent)
                .innerJoin(comment.post, post)
                .innerJoin(post.user, user)
                .where(post.id.eq(postId).and(comment.parent.id.eq(commentId)))
                .orderBy(comment.id.asc())
                .fetch();
    }
  • 다음과 같이 DB에서 조회 후 반환된 값을 comment.setCildren() 메소드를 루프를 돌려서 List로 넣어준다.
{
    "postId": 1,
    "title": "hihi",
    "content": "dsadajklsdjskaldjlsajkdljakldjjdlasjdajdaaalkajdajdkasjdlas",
    "scrapCount": 0,
    "commentCount": 8,
    "likeCount": 0,
    "createDate": "2022-04-04 21:37:45",
    "updateDate": null,
    "viewCount": 101,
    "username": "dasdad",
    "comments": [
        {
            "parentId": null,
            "commentId": 1,
            "content": "무야호~",
            "username": "dasdad",
            "createDate": "2022-04-04 21:37:52",
            "updateDate": "2022-04-06 01:31:45",
            "children": [
                {
                    "parentId": 1,
                    "commentId": 4,
                    "content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
                    "username": "dasdad",
                    "createDate": "2022-04-04 21:38:19",
                    "updateDate": null
                },
                {
                    "parentId": 1,
                    "commentId": 9,
                    "content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
                    "username": "dasdad",
                    "createDate": "2022-04-07 00:38:18",
                    "updateDate": null
                }
            ]
        },
        {
            "parentId": null,
            "commentId": 2,
            "content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
            "username": "dasdad",
            "createDate": "2022-04-04 21:38:04",
            "updateDate": null,
            "children": [
                {
                    "parentId": 2,
                    "commentId": 5,
                    "content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
                    "username": "dasdad",
                    "createDate": "2022-04-04 21:38:21",
                    "updateDate": null
                }
            ]
        },
        {
            "parentId": null,
            "commentId": 3,
            "content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
            "username": "dasdad",
            "createDate": "2022-04-04 21:38:09",
            "updateDate": null,
            "children": [
                {
                    "parentId": 3,
                    "commentId": 7,
                    "content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
                    "username": "dasdad",
                    "createDate": "2022-04-06 23:35:28",
                    "updateDate": null
                },
                {
                    "parentId": 3,
                    "commentId": 8,
                    "content": "zxxzcxcxzcdsadadasddasdzsadadsdsdasdz",
                    "username": "dasdad",
                    "createDate": "2022-04-07 00:34:16",
                    "updateDate": null
                }
            ]
        }
    ]
}
  • postman으로 테스트를 한 결과 다음과 같은 결과 화면을 볼 수 있다.

하지만 아주 큰 문제가 있다.....😂 바로 N+1

  • 위의 코드에서 실행을 하면 정상적으로 잘 동작하지만 N+1 문제가 있다.
  • foreach반복문으로 commentRepository.findPostComment() 메소드를 상위 댓글의 수 만큼 반복을 하게 되는데 만약 부모 댓글의 수가 100개라면 100번 이상의 Select 쿼리가 나가게 된다.

일단 완성은 하였지만 다시 리팩토링을 할 예정이다.

  • 그래서 제가 생각한 방법으로는 post를 조회 시 comment의 모든 데이터를 정렬을 해서 가져오고, MAP으로 값을 넣어주는 방식을 생각하고있다. 시간이 된다면 꼭 리팩토링을 할 예정이다.

좋은 웹페이지 즐겨찾기