[Spring Boot] 대댓글 기능 만들기

모지리 개발자·2022년 10월 20일
7

jpa

목록 보기
3/4

Intro

사이드 프로젝트는 간단한 커뮤니티였고 기획에는 대댓글 기능이 있었습니다. 이번글은 spring data jpa와 querydsl을 활용하여 대댓글기능을 어떻게 구현했는지 작성해보겠습니다. 전체코드는 github링크에서 확인하실 수 있습니다.

기획 정리하기

"우리 댓글 기능도 넣자, 대댓글 기능이 가능하게"
처음에는 단순하게 이정도로만 기획을 구성했습니다. 처음에는 댓글 기능이란게 매우 단순하다 생각해서 깊게 이야기할 필요 없다 생각했습니다. 하지만 개발을 시작하고 다양한 레퍼런스들을 찾아보면서 같은 대댓글 기능이여도 그안의 세부적인 기능은 다를 수 있다고 느꼈습니다.

예를 들어 어떤 커뮤니티에서는 부모 댓글이 삭제되면 자식댓글은 함께 모두 삭제하고 있었고 또 다른 커뮤니티에서는 자식 댓글을 살리고 부모 댓글의 컨텐츠 내용을 "삭제된 내용입니다"로 변경하고 보여주는 곳도 있었습니다.

이처럼 세부적인 내용도 함께 기획하여 나온 결과는 아래와 같습니다.

댓글 저장시 필요한 내용 : 제목, 타이틀, 부모댓글ID, 작성자ID, 게시글ID

댓글 삭제시 필요한 내용 : 댓글ID

  • 자식 댓글이 있다면 삭제 상태만 변경
  • 자식댓글이 없다면 삭제
  • 댓글 삭제될 시 부모 댓글도 삭제해야할지 확인하기(부모 댓글의 삭제 상태가 TRUE, 자식댓글이 1개이고 자식댓글이 삭제되었을 때)
  • 삭제 가능한 부모 댓글을 거슬러 올라가서 삭제 가능한 마지막 부모 댓글이 삭제되었을 경우 하위 댓글들은 모두 삭제

코드 작성하기

entity

Comment.java

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@DynamicInsert
@Table(name = "comment")
public class Comment extends BaseTimeEntity {

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

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

    @ColumnDefault("FALSE")
    @Column(nullable = false)
    private Boolean isDeleted;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    private Member writer;
    
    @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 = "board_id")
    private Board board;

    public Comment(String content) {
        this.content = content;
    }

    public void updateWriter(Member member) {
        this.writer = member;
    }

    public void updateBoard(Board board) {
        this.board = board;
    }

    public void updateParent(Comment comment) {
        this.parent = comment;
    }

    public void changeIsDeleted(Boolean isDeleted) {
        this.isDeleted = isDeleted;
    }
}

id : pk
content : 댓글 내용
isDeleted : 삭제유무(true시 삭제된 댓글, default는 false)
writer : 작성자
parent : 부모 댓글(null일 경우 최상위 댓글)
children : 자식댓글들
board : 게시글

댓글 작성하기

CommentRequestDTO

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CommentRequestDTO {
    private Long memberId;
    private Long parentId;
    private String content;

    public CommentRequestDTO(String content) {
        this.content = content;
    }
}

CommentService.java - insert method

@Transactional
    public Comment insert(Long boardId, CommentRequestDTO commentRequestDTO) {

        Member member = memberRepository.findById(commentRequestDTO.getMemberId())
                .orElseThrow(() -> new NotFoundException("Could not found member id : " + commentRequestDTO.getMemberId()));

        Board board = boardRepository.findById(boardId)
                .orElseThrow(() -> new NotFoundException("Could not found board id : " + boardId));

        Comment comment = commentRequestMapper.toEntity(commentRequestDTO);

        Comment parentComment;
        if (commentRequestDTO.getParentId() != null) {
            parentComment = commentRepository.findById(commentRequestDTO.getParentId())
                    .orElseThrow(() -> new NotFoundException("Could not found comment id : " + commentRequestDTO.getParentId()));
            comment.updateParent(parentComment);
        }

        comment.updateWriter(member);
        comment.updateBoard(board);

        return commentRepository.save(comment);

    }
  1. 글을 작성한 멤버를 조회합니다
  2. boardId로 게시글을 조회합니다.
  3. Comment 객체를 생성합니다.
  4. 위에서 조회한 멤버를 작성자로 연결합니다.
  5. 위에서 조회한 게시글을 연결합니다.
  6. 저장합니다.

댓글 조회하기

CommentRepositoryImpl.java

    @Override
    public List<CommentResponseDTO> findByBoardId(Long id) {

        List<Comment> comments = queryFactory.selectFrom(comment)
                .leftJoin(comment.parent).fetchJoin()
                .where(comment.board.id.eq(id))
                .orderBy(comment.parent.id.asc().nullsFirst(),
                        comment.createdAt.asc())
                .fetch();

        List<CommentResponseDTO> commentResponseDTOList = new ArrayList<>();
        Map<Long, CommentResponseDTO> commentDTOHashMap = new HashMap<>();

        comments.forEach(c -> {
            CommentResponseDTO commentResponseDTO = convertCommentToDto(c);
            commentDTOHashMap.put(commentResponseDTO.getId(), commentResponseDTO);
            if (c.getParent() != null) commentDTOHashMap.get(c.getParent().getId()).getChildren().add(commentResponseDTO);
            else commentResponseDTOList.add(commentResponseDTO);
        });
        return commentResponseDTOList;
    }

querydsl를 이용한 Comment 조회 코드 입니다.
부모 댓글 컬럼이 Null이라는 뜻은 최상위 댓글을 의미하므로 nullsFirst로 조회하였습니다.

예상 결과

1 NULL
8 NULL
2 1
3 1
4 2
5 2

댓글의 깊이 별로 구분되어 조회되었습니다.

그 후 아래에서는 반복문을 통해 DTO로 변환합니다.

CommentResponseDTO.java

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CommentResponseDTO {

    private Long id;
    private String content;
    private MemberDTO writer;
    private List<CommentResponseDTO> children = new ArrayList<>();

    public CommentResponseDTO(Long id, String content, MemberDTO writer) {
        this.id = id;
        this.content = content;
        this.writer = writer;
    }

    public static CommentResponseDTO convertCommentToDto(Comment comment) {
        return comment.getIsDeleted() ?
                new CommentResponseDTO(comment.getId(), "삭제된 댓글입니다.", null) :
                new CommentResponseDTO(comment.getId(), comment.getContent(), new MemberDTO(comment.getWriter()));
    }
}

convertCommentToDto 메서드는 IsDeleted가 True라면 "삭제된 댓글입니다"로 댓글 컨텐츠를 수정하여 객체를 생성합니다.

검증하기


다음과 같이 Board_ID가 1번인 게시글에 대한 댓글들이 있다고 가정했을 때 조회를 하게되면

"commentResponseDTOList": [
            {
                "id": 1,
                "content": "a",
                "writer": {
                    "id": 1,
                    "email": "email1",
                    "nickName": "nick1",
                    "level": "BRONZE",
                    "userRole": "ROLE_ADMIN"
                },
                "children": []
            },
            {
                "id": 2,
                "content": "b",
                "writer": {
                    "id": 1,
                    "email": "email1",
                    "nickName": "nick1",
                    "level": "BRONZE",
                    "userRole": "ROLE_ADMIN"
                },
                "children": [
                    {
                        "id": 3,
                        "content": "c",
                        "writer": {
                            "id": 1,
                            "email": "email1",
                            "nickName": "nick1",
                            "level": "BRONZE",
                            "userRole": "ROLE_ADMIN"
                        },
                        "children": []
                    },
                    {
                        "id": 4,
                        "content": "d",
                        "writer": {
                            "id": 1,
                            "email": "email1",
                            "nickName": "nick1",
                            "level": "BRONZE",
                            "userRole": "ROLE_ADMIN"
                        },
                        "children": [
                            {
                                "id": 5,
                                "content": "e",
                                "writer": {
                                    "id": 1,
                                    "email": "email1",
                                    "nickName": "nick1",
                                    "level": "BRONZE",
                                    "userRole": "ROLE_ADMIN"
                                },
                                "children": []
                            }
                        ]
                    }
                ]
            }
        ]

위와 같이 계층형 구조로 나오게됩니다.

댓글 삭제하기

위에서 언급했지만 댓글 삭제의 과정을 한번 더 작성해보겠습니다.
1. 댓글 삭제시 자식 댓글이 있다면 isDeleted 값만 True로 변경
2. 자식 댓글이 없다면 삭제
3. 2번 과정을 통해 자식댓글이 삭제되었다면 부모 댓글을 삭제해야하는지도 확인(부모 댓글의 삭제여부가 True이고 자식댓글이 1개일 경우에만 가능)
4. 부모 댓글을 삭제하게되면 하위댓글도 모두 삭제

4번은

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

orphanRemoval = true을 통해 가능하도록 하였습니다.
JPA CascadeType.REMOVE vs orphanRemoval = true 링크를 통해 해당 옵션의 특징을 확인하실 수 있습니다.

CommentService.java

    @Transactional
    public void delete(Long commentId) {
        Comment comment = commentRepository.findCommentByIdWithParent(commentId)
                .orElseThrow(() -> new NotFoundException("Could not found comment id : " + commentId));
        if(comment.getChildren().size() != 0) { // 자식이 있으면 상태만 변경
            comment.changeIsDeleted(true);
        } else { // 삭제 가능한 조상 댓글을 구해서 삭제
            commentRepository.delete(getDeletableAncestorComment(comment));
        }
    }

    private Comment getDeletableAncestorComment(Comment comment) {
        Comment parent = comment.getParent(); // 현재 댓글의 부모를 구함
        if(parent != null && parent.getChildren().size() == 1 && parent.getIsDeleted())
            // 부모가 있고, 부모의 자식이 1개(지금 삭제하는 댓글)이고, 부모의 삭제 상태가 TRUE인 댓글이라면 재귀
            return getDeletableAncestorComment(parent);
        return comment; // 삭제해야하는 댓글 반환
    }
}

CommentRepositoryImpl.java

    @Override
    public Optional<Comment> findCommentByIdWithParent(Long id) {

        Comment selectedComment = queryFactory.select(comment)
                .from(comment)
                .leftJoin(comment.parent).fetchJoin()
                .where(comment.id.eq(id))
                .fetchOne();

        return Optional.ofNullable(selectedComment);
    }
  1. 삭제해야하는 Comment를 Id를 통해 조회합니다.
  2. 자식 Comment List의 길이가 0이 아니라면 isDeleted값만 True로 변경합니다.
  3. 자식 Comment List의 길이가 0이라면 삭제합니다.
  4. 자식 Comment List의 길이가 0이라서 삭제될 경우 부모가 있고 부모의 자식이 한개이고 부모의 삭제 상태가 True라면 재귀를 하도록하였습니다.

찝찝한점

댓글 삭제 시 쿼리가 총 3개가 생성됩니다.
1. commment 조회 쿼리
2. comment.getChildren() 를 하면서 생성되는 쿼리
3. isDeleted값을 True로 변경해주는 쿼리

찝찝한 점이라고 작성한 이유는 굳이 문제점은 아니라고 생각했습니다. N+1문제가 생긴것도 아니고 쿼리하나가 추가로 생성된 것이 성능에 큰 영향을 줄것이라고 생각하진않았습니다. 하지만 쿼리 하나를 줄이기 위해 애초에 필드에 자식 댓글의 개수를 추가해놓으면 이 쿼리 하나마저도 줄일 수 있다는 생각이 들었습니다. 해당기능을 테스트하고 사용해보면서 문제가 있거나 추가할 내용이 있다면 추가해보도록 하겠습니다.

결론

querydsl과 spring data jpa를 이용하여 대댓글 기능을 구현해보았습니다. 대댓글 기능은 다양한 분야에서 많이 사용되는 기본적인 기능인만큼 잘 알고있다면 유용하게 사용할 수 있을 것이라고 생각됩니다.


참고글
쿠케캬캬님의 블로그

profile
항상 부족하다 생각하며 발전하겠습니다.

1개의 댓글

comment-user-thumbnail
2023년 8월 9일

참고해서 프로젝트 완성했었습니다! 감사합니다

답글 달기