반응형

***

계층형 댓글 기능은,

2021.12.19 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (26) - 계층형 댓글 - 1

위 시리즈에서 조금 더 개선되었습니다.

***

 

스프링부트 + JPA로 무한 대댓글 기능을 구현해보겠습니다.

@Entity
public class Comment extends CreatedDateEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "comment_id")
    private Long id;

    @Column(nullable = false)
    @Lob
    private String content;

    @Enumerated(value = EnumType.STRING)
    private DeleteStatus isDeleted;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User writer;

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

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

기본적인 댓글의 Entity는 위와 같습니다.

id, 내용, 삭제된 상태, 작성자, 부모 댓글의 id값을 가지고 있고, OneToMany관계로 자식 댓글 리스트를 가지고 있습니다.

1
  2
    4
      6
    5
  3
    7
8

위와 같은 형태와 순서로 작성된 댓글이 있다고 가정하겠습니다.

각 댓글 컬럼은 부모 댓글의 id값을 가지고 있습니다.

따라서 DB에 저장된 ID와 부모댓글ID 값은 다음과 같을 것입니다.

1 NULL
2 1
3 1
4 2
5 2
6 4
7 3
8 NULL

1열은 각 댓글의 id값, 2열은 부모 댓글의 id값입니다.

계층 구조로 편하게 바꾸기 위하여 아래 코드를 이용하여, 부모 댓글 내림차순, 작성일자 내림차순으로 정렬하여 조회하겠습니다.

@Override
public List<Comment> findCommentByTicketId(Long ticketId) {
    return queryFactory.selectFrom(comment)
            .leftJoin(comment.parent)
            .fetchJoin()
            .where(comment.ticket.id.eq(ticketId))
            .orderBy(
                    comment.parent.id.asc().nullsFirst(),
                    comment.createdAt.asc()
            ).fetch();
}

querydsl을 이용한 조회 코드입니다.

부모 댓글 컬럼이 NULL이라면, 최상위 댓글이므로 nullsFirst로 조회하였습니다.

그러면 기존 예시의 결과는 아래와 같을 것입니다.

1 NULL
8 NULL
2 1
3 1
4 2
5 2
7 3
6 4

댓글의 깊이 별로 구분되어 조회된 것을 확인할 수 있었습니다.

List로 반환된 위 결과를 이용하여 대댓글의 중첩구조로 바꿔낼 것입니다.

즉, 1의 자식 댓글은 (2, 3), 2의 자식 댓글은(4, 5), 4의 자식 댓글은 (6)...

이런 식으로 각 댓글DTO는 자신의 자식 댓글들을 가지게 됩니다.

중첩구조로 변환하는 코드는 아래와 같습니다.

private List<CommentDto> convertNestedStructure(List<Comment> comments) {
    List<CommentDto> result = new ArrayList<>();
    Map<Long, CommentDto> map = new HashMap<>();
    comments.stream().forEach(c -> {
        CommentDto dto = convertCommentToDto(c);
        map.put(dto.getId(), dto);
        if(c.getParent() != null) map.get(c.getParent().getId()).getChildren().add(dto);
        else result.add(dto);
    });
    return result;
}

조회 결과인 comments List는 깊이와 작성순으로 정렬된 결과를 가지고 있습니다.

정렬이 되어있기 때문에, 자식 댓글을 확인할 때는 부모 댓글이 이미 map에 들어가있는 상황입니다.

최상위 댓글이라면 result에 넣어주고, 부모가 있는 자식 댓글이라면 부모DTO의 자식 댓글 리스트로 add 해줍니다.

따라서 기존 예시로 보자면, result에는 1번과 8번 댓글이 담겨있고,

1번의 자식 댓글 리스트에는 2번과 3번 댓글이 담겨있고,

2번의 자식 댓글 리스트에는 4번과 5번 댓글이 담겨있는 형태의 중첩구조로 만들어질 것입니다.

 

 

이번에는 삭제 기능을 만들어보겠습니다.

1. 자식 댓글이 있으면 삭제 상태(isDeleted)만 변경할 것입니다.

2. 자식 댓글이 없으면 바로 삭제할 것입니다.

3. 2번에서 댓글을 삭제했으면 부모 댓글도 삭제할지 판단해야합니다.

4. 부모 댓글의 삭제 여부는 삭제 상태가 TRUE이고, 자식 댓글의 개수가 지금 삭제하는 댓글 1개여야만 합니다.

5. 부모 댓글을 삭제할 수 있으면, 조부모 댓글도 삭제할 수 있는지 판단해야합니다.

6. 삭제할 수 있는 부모 댓글로 거슬러 올라가서 최상위의 조상 댓글을 삭제하면, 하위 댓글도 모두 다 삭제될 것입니다.

6번이 가능한 이유는 아래와 같습니다.

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

위 Entity코드에서 봤듯이 OneToMany에서 orphanRemoval = true입니다.

이렇게 하면 고아 객체는 삭제되기 때문에, 조상 댓글을 삭제하면 고아가 된 하위 댓글들은 연쇄적으로 삭제됩니다.

구현 코드는 아래와 같습니다.

public void deleteComment(Long commentId) {
    Comment comment = commentRepository.findCommentByIdWithParent(commentId).orElseThrow(CommentNotFoundException::new);
    if(comment.getChildren().size() != 0) { // 자식이 있으면 상태만 변경
        comment.changeDeletedStatus(DeleteStatus.Y);
    } else { // 삭제 가능한 조상 댓글을 구해서 삭제
        commentRepository.delete(getDeletableAncestorComment(comment));
    }
}

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

위와 같은 형태로 대댓글의 삭제 코드를 구현할 수 있었습니다.

오류가 있으면 지적 부탁드립니다.

반응형

+ Recent posts