Jpa QueryDsl로 대댓글 구현하기

Jaeyoung·2023년 3월 29일
0
post-thumbnail
post-custom-banner

이번에 하는 프로젝트가 커뮤니티 특성이 있어서 댓글을 구현해야하는 상황이 생겼는데 댓글을 구현하기 위한 인사이트와 생각해온 과정을 공유하기 위해 글을 작성하게 되었습니다. 구현하기에 앞서 먼저 생각해온 아이디어와 그 과정들을 보겠습니다.

아이디어

처음에 생각한 아이디어는 댓글 테이블과 대댓글 테이블을 분리해서 설계를 하자 였습니다. 테이블은 아래와 같습니다.

처음에는 엄청 단순하게 접근했던 것 같습니다. 댓글 테이블과 대댓글 테이블을 분리해서 단순한 구조로 가자 라는 생각을 가졌는데 이러한 구조로 가게되면 대댓글의 대댓글을 생성 해야하는 경우 테이블이 무한정으로 늘어나야한다는 큰 단점이 있어서 이러한 구조는 포기하게 되었습니다. 그 다음으로 생각하게 된 구조가 자기자신 테이블을 참조하는 방식입니다. 구조는 아래와 같습니다.

이렇게 되면 무한정으로 대댓글이 늘어나도 처리가 가능하기 때문에 아까와 같은 무한정으로 테이블을 생성하는 문제에서는 벗어날 수 있습니다. 요즘 많은 서비스들이 비즈니스적으로 One Depth에 그 이상 Depth는 태그로 처리하는 방식을 채택 하고 있는데 이런 경우에는 어떻게 설계해야할까요? 해당 방식으로 처리하는 서비스가 많아지기도 했고 UI적으로 봤을 때 훨씬 가독성이 좋아 해당방법을 채택하게 되었는데 제가 설계한 방법은 아래와 같습니다.

맨 처음과 같이 댓글 테이블과 대댓글 테이블을 나눠 주었는데요 ChildComment(대댓글) 테이블에서는 Comment Table(댓글)의 comment_id를 Foreign Key로 두어 부모 자식관계를 형성 시키고 tag_user_id를 nullable로 설정해서 대댓글의 대댓글이라면 부모 대댓글의 user_id를 tag_user_id로 설정해서 처리하도록 해주었습니다. 이제 이걸 토대로 한번 구현해 보도록 하겠습니다.

구현하기

위에 간략하게 대댓글에 대한 아이디어를 알아보았습니다 이제는 실제 프로젝트에서 사용하는 데이터베이스 구조를 통해 Entity를 생성해 보도록 하겠습니다. 위에는 대략적으로 아이디어만 보기 위해 간단하게 구성하였는데 실제 프로젝트에서는 아래와 같이 구성되어있습니다.

복잡성 때문에 불필요한 부분은 제거하고 간단하게 재구성해서 Entity를 생성해 보도록 하겠습니다.

@Getter
@NoArgsConstructor
@Entity
public class Member extends BaseEntity {
    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @Column(unique = true)
    private String nickname;
}
@Getter
@NoArgsConstructor
@ToString
@Entity
public class Post {
    @Id
    @GeneratedValue
    @Column(name = "post_id")
    private Long id;
    private String title;
    private String content

    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToMany(cascade = CascadeType.REMOVE)
    @JoinColumn(name = "post_id")
    private Set<MindSharePostComment> comments = new HashSet<>();

    public MindSharePost(Member member, String title, String content){
        this.member = member;
        this.title = title;
        this.content = content;
    }
}

Post Entity입니다. 댓글 Entity와는 일대다 관계이기 때문에 OneToMany로 연관관계를 설정해주었습니다. 이때 글이 삭제되는 경우에는 해당 글과 관련된 댓글은 삭제 해줘야하기 때문에 CascadeType을 Remove로 설정해 주었습니다. post_id를 통해 join 할 수 있도록 JoinColumn을 지정해주었습니다. 이때 List가 아닌 Set으로 설정해주었는데 Set으로 설정하게 된 이유는 **Jpa MultipleBagFetchException** 해당 글에서 확인할 수 있습니다.

@Getter
@NoArgsConstructor
@ToString
@Entity
public class Comment {
    @Id
    @GeneratedValue
    @Column(name = "comment_id")
    private Long id;
    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    @Column(name = "post_id")
    private Long postId;
    private String content;
    private Boolean isDeleted;

    @OneToMany(cascade = CascadeType.REMOVE)
    @JoinColumn(name = "parent_comment_id")
    private Set<MindSharePostChildComment> childComments = new HashSet<>();

    public MindSharePostComment(Member member,Long postId, String content){
        this.member = member;
        this.postId = postId;
        this.content = content;
        this.isDeleted = false;
    }
}

댓글 Entity 입니다. isDelete 필드같은경우는 댓글이 삭제되었을 때 대댓글까지 제거 되지 않도록 댓글이 삭제된 경우 isDeleted를 true로 update를 해줘서 대댓글을 유지할 수 있도록 구현하기 위해 추가하였습니다. childComments 같은경우는 대댓글인데 댓글과 대댓글은 일대다 관계이기 때문에 OneToMany를 설정해 주었습니다. 아까 댓글이 삭제되었을 때 대댓글까지 제거되지 않도록 한다했는데 cascade type을 Remove로 설정한게 이상해 보일 수 있는데 댓글이 삭제되었을 때 실제 데이터베이스에서는 삭제하지않고 isDeleted를 true로 update하기 때문에 Cascade type을 remove로 설정해도 상관없습니다. 그러면 왜 설정했을까요? 그 이유는 글이 삭제될 때 댓글도 같이 삭제가 되는데 해당 옵션을 설정 해주지 않으면 서로 연관이 되어있어 제거가 되지않기 때문입니다.

@Getter
@NoArgsConstructor
@Entity
public class MindSharePostChildComment extends BaseEntity {
    @Id
    @GeneratedValue
    @Column(name = "comment_id")
    private Long id;

    @ManyToOne
    @JoinColumn(referencedColumnName = "member_id", nullable = false)
    private Member member;

    @ManyToOne
    @JoinColumn(name = "tag_member_id")
    private Member tagMember;

    @Column(name = "parent_comment_id")
    private Long parentCommentId;

    private String content;

    public MindSharePostChildComment(Member member, Member tagMember,
								Long parentCommentId, String content){
        this.member = member;
        this.tagMember = tagMember;
        this.parentCommentId = parentCommentId;
        this.content = content;
    }
}

ChildComment Entity 입니다. tagMember는 대댓글의 대댓글을 위해 존재하는 필드입니다. 그래서 tagMember에는 부모 대댓글의 Member 정보를 담아줍니다. tagMember는 일반 댓글에 대한 대댓글인 경우에는 null로 설정하기 위해 nullable로 설정해주었습니다. 다음은 Repository 부분입니다.

@RequiredArgsConstructor
@Repository
public class PostQueryRepository {
    private final JPAQueryFactory queryFactory;

    public Optional<Post> searchPost(Long postId) {
        Post post = queryFactory.selectFrom(QPost)
                .leftJoin(QPost.comments, QComment).fetchJoin()
                .leftJoin(QComment.childComments, QChildComment).fetchJoin()
                .where(QPost.id.eq(postId))
                .fetchOne();
        return Optional.ofNullable(post);
    }

		public void deletePostComment(Long commentId) {
        queryFactory.update(QComment)
                .set(QComment.isDeleted,Expressions.asBoolean(true))
                .execute();
    }
}

일단 QueryDsl을 사용하기 위해 JpaQueryFactory를 가져오고 searchPost 메서드를 보면 글을 조회하는 메서드인데 QueryFactory를 통해 Post를 조회하고 있습니다. 댓글과 대댓글은 fetchJoin으로 가져올것이기 때문에 leftJoin으로 둘다 설정해주도록 합니다. FetchJoin은 자체적으로 Left Outer Join을 쓰기 때문입니다. 다음 삭제로직인 deletePostComment에 대해 한번 보겠습니다. commentId를 받아 해당 commentId에 해당하는 comment의 isDeleted를 true로 변경해주는 단순한 로직입니다.

마무리

이렇게 대댓글을 QueryDsl로 구현해봤습니다. 간단한 로직이지만 처음하다보니 익숙하지 않아 좀 헤맸던것 같습니다. 여러 아이디어를 구상하면서 구현하다보니 설계능력이 이전보다 많이 늘었던 것 같습니다. 그래서 앞으로도 기능구현할 때 여러 아이디어로 접근해서 구현해봐야겠다는 생각을 가지게 되었습니다.

profile
Programmer
post-custom-banner

0개의 댓글