반응형

이번 시간에는 계층형 댓글을 구현해보겠습니다.

각 댓글마다 하위 댓글을 계층적으로 작성해나갈 수 있습니다.

 

전반적인 과정은 계층형 카테고리를 구현하던 아래 링크와 유사합니다.

2021.12.08 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (15) - 게시판 - 계층형 카테고리 - 1

 

하지만 이번에는 요구사항을 조금 달리하여, 각 댓글이 즉시 삭제되지 않도록 하겠습니다.

특정한 댓글이 삭제되더라도, 하위 댓글을 다 삭제하지 않는 이상, 그대로 남아있는 것입니다.

 

그림으로 예시를 들어보면 다음과 같습니다.

대댓글 이미지

각 댓글에는 하위 댓글을 달 수 있으며, 댓글을 삭제한다고 하더라도 즉시 삭제되는 것이 아닙니다.

삭제 댓글1은 하위 댓글6이 그대로 남아있고,

삭제 댓글2는 하위 댓글4, 5가 그대로 남아있기 때문에, 삭제를 했더라도 그대로 남아있습니다.

하위 댓글 6을 삭제하면 삭제 댓글1이 정말로 제거될 것이고,

하위 댓글 4,5를 삭제하면 삭제 댓글2가 정말로 제거될 것입니다.

하위 댓글이 없는 경우에만 해당 댓글이 즉시 제거되는 것입니다.

 

 

이제 코드를 작성해보겠습니다.

entity.comment.Comment 엔티티를 작성해줍니다.

package kukekyakya.kukemarket.entity.comment;

import ...

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Slf4j
public class Comment extends EntityDate {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

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

    @Column(nullable = false)
    private boolean deleted; // 1

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Member member; // 2

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Post post; // 3

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Comment parent; // 4

    @OneToMany(mappedBy = "parent")
    private List<Comment> children = new ArrayList<>(); // 5

    public Comment(String content, Member member, Post post, Comment parent) {
        this.content = content;
        this.member = member;
        this.post = post;
        this.parent = parent;
        this.deleted = false;
    }

    public Optional<Comment> findDeletableComment() { // 6
        return hasChildren() ? Optional.empty() : Optional.of(findDeletableCommentByParent());
    }

    public void delete() { // 7
        this.deleted = true;
    }

    private Comment findDeletableCommentByParent() { // 8
        return isDeletableParent() ? getParent().findDeletableCommentByParent() : this;
    }

    private boolean hasChildren() { // 9
        return getChildren().size() != 0;
    }

    private boolean isDeletableParent() { // 10
        return getParent() != null && getParent().isDeleted() && getParent().getChildren().size() == 1;
    }
}

1. 삭제 여부를 표시해줍니다.

삭제는 했더라도 아직 하위 댓글이 있어서 실제로는 남겨둬야한다면, deleted는 true가 될 것입니다.

 

2~4. @OnDelete(action=onDeleteAction.CASCADE)를 설정해놨기 때문에, Member나 Category, 상위 Comment가 제거된다면, 연쇄적으로 현재의 댓글도 제거될 것입니다.

* @OnDelete와 CascadeType.REMOVE의 차이가 궁금하다면, 다음 링크를 참고하시길 바랍니다.

2021.12.09 - [Spring] - JPA cascade = CascadeType.REMOVE와 @OnDelete(action = OnDeleteAction.CASCADE)의 차이

 

5. 각 댓글의 하위 댓글들을 참조할 수 있도록, @OneToMany 관계를 맺어줍니다.

 

6. 현재 댓글을 기준으로, 실제로 삭제할 수 있는 댓글을 찾아줍니다.

이 메소드의 결과로 찾아낸 댓글은, 실제로 데이터베이스에서 제거해도 무방할 것입니다.

만약 이 메소드의 결과로 찾아낸 댓글이 없다면, 하위 댓글이 아직 있다는 것을 의미하므로, 현재 댓글은 실제 데이터베이스에서 즉시 제거하면 안됩니다.

실제 데이터를 제거하는 것이 아니라, 현재 댓글은 7번(delete) 메소드로 삭제 표시만 해주어야할 것입니다.

현재 댓글에 하위 댓글이 있으면, 즉시 제거하면 안되는 댓글이므로 비어있는 Optional이 반환될 것이고,

현재 댓글에 하위 댓글이 없으면, 실제로 제거해도 되는 댓글을 찾기 위해 상위 댓글로 거슬러올라가면서 검사할 것입니다.

 

7. 실제로 삭제하는 것이 아니라 삭제 표시만 해야되는 상황이라면, deleted를 true로 설정해줍니다.

하위 댓글이 남아있어서 실제로 제거할 수 없는 댓글인 경우, 이 메소드를 호출하게 될 것입니다.

 

8. 상위 댓글로 거슬러올라가면서, 실제로 제거해도 되는 댓글을 찾아낼 것입니다.

상위 댓글이 실제로 제거해도 되는 댓글이라면, 다시 상위 댓글로 거슬러올라가면서, 삭제 가능한 지점을 찾아낼 것입니다.

 

9. 하위 댓글이 있는지 판별합니다.

 

10. 현재 댓글의 상위 댓글이 제거해도 되는 것인지 판별합니다.

부모가 있고, 이미 삭제 처리를 받았었고, 자식의 개수가 1이라면, 제거해도 되는 것입니다.

자식의 개수가 1이라는 것은, 지금 삭제 요청을 받은 현재의 댓글 외에, 다른 하위 댓글들은 없는 상황을 의미할 것입니다.

 

여기서 주의할 점은, parent나 children을 참조할 때, getter 메소드를 이용했다는 것입니다.

jpa에서는 엔티티를 조회할 때, 작성한 엔티티를 상속받은 프록시를 만들어서 사용하게 됩니다.

만약 this.parent나 this.children으로 참조한다면, 해당 값들은 실제로 데이터베이스에 저장되어있음에도 불구하고,

fetch 전략을 LAZY로 설정해둔 까닭에 실제 데이터들을 불러올 수 없습니다.

LAZY 전략으로 설정되어서 아직 조회되지 않은 데이터들을 적절한 시기에 불러올 수 있도록, getChildren()과 getParent()와 같은 형태로 작성한 것입니다.

 

 

위에서 구현하고자하는 코드에 대해 예시를 들어보겠습니다.

(댓글1) <- (삭제된 댓글2) <- (삭제된 댓글3) <- 댓글4

(삭제된 댓글2) <- 댓글5

예시에서 "1<-2"는, 2가 1의 하위 댓글임을 의미합니다.

 

여기에서 댓글4를 삭제 요청했다고 가정해보겠습니다.

댓글4는 Comment.findDeletableComment에서 자식이 없는 것을 확인하고,

Comment.findDeletableCommentByParent로 실제로 제거해도되는 댓글을 찾게 됩니다.

먼저 자신의 부모인 (삭제된 댓글3)을 검사할 것입니다.

(삭제된 댓글3)는 지금 삭제가 가능한 댓글4 외에 다른 자식이 없으므로, (삭제된 댓글3)은 실제로 제거해도 됩니다.

다음으로 (삭제된 댓글3)의 부모인 (삭제된 댓글2)를 검사할 것입니다.

(삭제된 댓글2)는 지금 삭제가 가능한 댓글3 외에도 다른 자식 댓글5가 있으므로, (삭제된 댓글2)는 실제로 제거하면 안됩니다.

결국 (삭제된 댓글3)의 this가 findDeletableCommentByParent의 결과로 반환됩니다.

이렇게 찾아진 (삭제된 댓글3)가 데이터베이스에서 제거된다면, 결국 이 댓글의 모든 하위 댓글(댓글3, 4)은 delete cascade 설정에 의해 모두 지워지게 될 것입니다.

 

이번에는 댓글4를 삭제 요청했던 것이 아니라, 댓글1을 삭제 요청했었다고 가정해보겠습니다.

댓글1은 이미 자식 댓글을 가지고 있습니다. 따라서 현재 댓글을 제거하면 안됩니다.

Comment.findDeletableComment는 비어있는 Optional을 반환할 것이고, 결국 댓글1은 실제로 제거하는게 아니라 deleted만 true로 바꿔줘야할 것입니다.

 

 

Comment에 작성된 로직을 테스트해보겠습니다.

test 디렉토리에서 동일한 패키지 경로에 CommentTest를 작성해줍니다.

package kukekyakya.kukemarket.entity.comment;

import ...

class CommentTest {

    @Test
    void deleteTest() {
        // given
        Comment comment = createComment(null);
        boolean beforeDeleted = comment.isDeleted();

        // when
        comment.delete();

        // then
        boolean afterDeleted = comment.isDeleted();
        assertThat(beforeDeleted).isFalse();
        assertThat(afterDeleted).isTrue();
    }

    @Test
    void findDeletableCommentWhenExistsTest() {
        // given

        // root 1
        // 1 -> 2
        // 2(del) -> 3(del)
        // 2(del) -> 4
        // 3(del) -> 5
        Comment comment1 = createComment(null);
        Comment comment2 = createComment(comment1);
        Comment comment3 = createComment(comment2);
        Comment comment4 = createComment(comment2);
        Comment comment5 = createComment(comment3);
        comment2.delete();
        comment3.delete();
        ReflectionTestUtils.setField(comment1, "children", List.of(comment2));
        ReflectionTestUtils.setField(comment2, "children", List.of(comment3, comment4));
        ReflectionTestUtils.setField(comment3, "children", List.of(comment5));
        ReflectionTestUtils.setField(comment4, "children", List.of());
        ReflectionTestUtils.setField(comment5, "children", List.of());

        // when
        Optional<Comment> deletableComment = comment5.findDeletableComment();

        // then
        assertThat(deletableComment).containsSame(comment3);
    }

    @Test
    void findDeletableCommentWhenNotExistsTest() {
        // given

        // root 1
        // 1 -> 2
        // 2 -> 3
        Comment comment1 = createComment(null);
        Comment comment2 = createComment(comment1);
        Comment comment3 = createComment(comment2);
        ReflectionTestUtils.setField(comment1, "children", List.of(comment2));
        ReflectionTestUtils.setField(comment2, "children", List.of(comment3));
        ReflectionTestUtils.setField(comment3, "children", List.of());

        // when
        Optional<Comment> deletableComment = comment2.findDeletableComment();

        // then
        assertThat(deletableComment).isEmpty();
    }
}

위에서 말했던 로직을 테스트해주었습니다.

코드의 이해를 위해, 테스트를 자세히 살펴보시길 바랍니다.

정상적인 테스트 수행을 위해 ReflectionTestUtils로 알맞은 children을 주입시켜주었습니다.

 

 

테스트에 사용된 CommentFactory는 다음과 같습니다.

package kukekyakya.kukemarket.factory.entity;

import kukekyakya.kukemarket.entity.comment.Comment;
import kukekyakya.kukemarket.entity.member.Member;
import kukekyakya.kukemarket.entity.post.Post;

import ...

public class CommentFactory {

    public static Comment createComment(Comment parent) {
        return new Comment("content", createMember(), createPost(), parent);
    }

    public static Comment createDeletedComment(Comment parent) {
        Comment comment = new Comment("content", createMember(), createPost(), parent);
        comment.delete();
        return comment;
    }

    public static Comment createComment(Member member, Post post, Comment parent) {
        return new Comment("content", member, post, parent);
    }
}

각 팩토리 메소드는 parent를 파라미터로 전달받을 수 있습니다.

 

 

이제 repository.comment.CommentRepository를 작성해주겠습니다.

package kukekyakya.kukemarket.repository.comment;

import ...

public interface CommentRepository extends JpaRepository<Comment, Long> {
    @Query("select c from Comment c left join fetch c.parent where c.id = :id")
    Optional<Comment> findWithParentById(Long id);
    
    @Query("select c from Comment c join fetch c.member left join fetch c.parent where c.post.id = :postId order by c.parent.id asc nulls first, c.id asc")
    List<Comment> findAllWithMemberAndParentByPostIdOrderByParentIdAscNullsFirstCommentIdAsc(Long postId);
}

2개의 쿼리가 작성되어있습니다.

첫번째 쿼리는, 댓글의 id로 조회하면서 자신의 부모와 fetch join된 결과를 반환합니다.

두번째 쿼리는, 부모의 아이디로 오름차순 정렬하되 NULL을 우선적으로 하고, 그 다음으로 자신의 아이디로 오름차순 정렬하여 조회합니다. 이 쿼리는 모든 댓글 목록을 조회할 때 사용될 것입니다.

c.parent와 조인을 할 때는, left join fetch를 했다는 사실을 염두에 두시길 바랍니다.

parent는 null일 수 있고, join fetch의 기본 설정은 inner join이기 때문에, 부모가 없는 댓글도 정상적으로 조회할 수 있도록 left join fetch를 한 것입니다.

 

두 번째 쿼리에 대해 자세히 설명하기 전에, 우리가 어떻게 계층형 댓글을 구현해나갈지 먼저 살펴보도록 하겠습니다.

 

계층형 댓글을 설명하기 위해, 다음과 같은 예제를 사용하겠습니다.

id는 댓글의 id 컬럼, p_id는 댓글의 부모 id 컬럼입니다.

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

데이터베이스에 위와 같은 형태로 댓글이 저장되어 있다고 가정해보겠습니다.

 

만약 어떤 댓글을 생성한 뒤에, 그에 대한 하위 댓글을 생성하려면, 그 상위 댓글은 이미 데이터베이스에 저장되어 있을 것입니다.

하위 댓글은 상위 댓글보다 먼저 생성될 수 없습니다.

우리는, 상위 댓글을 지정하여 하위 댓글을 생성할 수 있습니다.

 

 

위와 같은 예시 데이터를, CommentRepository에 작성된 findAllWithMemberAndParentByPostIdOrderByParentIdAscNullsFirstCommentIdAsc로 조회해보겠습니다.

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

조회된 결과는 위와 같습니다.

 

우리는 조회된 댓글을 계층형으로 만들어서 응답해줘야합니다.

위와 같은 결과를 어떻게 계층형으로 변환할 수 있을까요?

 

위 데이터는 p_id로 오름차순 정렬되어있습니다.

또한, 반드시 상위 댓글이 만들어진 뒤에 하위 댓글이 생성될 수 있기 때문에,

p_id에 나타나는 댓글의 실제 데이터는, 하위 댓글보다 반드시 더 먼저 조회되어있습니다.

예를 들어,

id=2 댓글의 부모 댓글 id=1은 첫번째로 조회되었고,

id=3 댓글의 부모 댓글 id=1은 첫번째로 조회되었고,

id=4 댓글의 부모 댓글 id=2는 세번째로 조회되었고,

...

id=6 댓글의 부모 댓글 id=4는 다섯번째로 조회되었습니다.

댓글의 부모 댓글은, 하위 댓글보다 반드시 먼저 조회되어 있는 것입니다.

부모 댓글의 id를 오름차순으로 정렬했기 때문에 당연한 결과입니다.

 

이를 이용하면,

조회된 데이터를 순차적으로 탐색하면서도, 각 상위 댓글의 하위 댓글이 무엇인지 판별해낼 실마리가 보이는 것입니다.

부모 댓글이 없다면 루트 댓글임을 의미하므로, p_id가 NULL인 댓글은 우선적으로 나타나야합니다.

 

또한, 우리가 지정한 id 생성 전략에 의해서, 댓글의 id는 순차적으로 증가합니다.

즉 id는 데이터가 생성된 순서를 의미하기 때문에, 두번째 정렬 조건으로 id 오름차순을 지정해준다면,

같은 부모 댓글을 가지는 하위 댓글들은, 생성된 순서로 정렬될 수 있습니다.

 

* 우리는 부모 댓글의 id 값을 첫번째 정렬 조건으로 사용하고 있습니다. 부모 댓글 id 값은 외래 키로 지정되어있고, 외래 키에 대한 인덱스 자동 생성 여부는 데이터베이스 종류에 따라 다르지만, 댓글의 개수는 그리 많지 않을 것이기 때문에 인덱스가 없더라도 큰 지장이 없을 것입니다. 만약 인덱스 생성이 되어있지 않다면, 상황에 따라 외래 키에도 인덱스를 직접 생성해주면 될 것입니다.

 

***

- 계층형 구조를 만드는 또 다른 방법? (안보셔도 됩니다)

사실 계층형 구조는 다음과 같은 방법으로도 만들 수 있습니다.

하위 댓글을 @OneToMany 관계로 만들어서, 루트 댓글(p_id=NULL)만 조회해둔 뒤에, 하위 댓글을 재귀적으로 조회해나가면 됩니다.

하지만 이런 방식을 취할 경우, 하위 댓글을 호출할 때마다 SELECT 쿼리를 반복적으로 수행해야하며, 중첩된 계층이 깊어질수록 성능 상의 문제가 생길 수 있습니다.

 

그렇다고 복잡함을 감수하고 단일한 쿼리로 조회하자니, 쿼리 작성에 어려움이 있습니다. 우리는 쿼리와 자바 코드 모두를 이용하겠습니다. 단일 쿼리를 통해서 모든 댓글을 조회한 뒤, 자바 코드로 직접 계층형 구조로 변환해주도록 하겠습니다. 확장이 어려운 데이터베이스가 복잡한 쿼리를 수행하는 것에 비해, 확장이 용이한 서버 애플리케이션에서 직접 변환해주는 것이 더욱 효율적일 것입니다.

 

@OneToMany 관계를 이용할 때의 반복적인 children SELECT 쿼리에 대해서 하나 더 언급하자면, 초기에는 루트 댓글만 조회해둔 뒤에, children을 조회하는 유사한 SELECT 쿼리를, default_batch_fetch_size 설정을 통해 IN 쿼리로 한 번에 처리할 수 있지 않을까 싶었지만, 의도한대로 동작하지 않았습니다.

각 루트의 children을 조회했을 때, 특정 child의 children을 다시 조회하려면, 미리 쿼리를 날려보지않고서는 확인할 수 없으므로 당연한 것이었습니다.

 

그럼에도 불구하고 IN 쿼리를 하려면, 일단 있는 모든 댓글들을 미리 컨텍스트에 조회해둔 뒤에, 그 후 하위 댓글들을 다시 조회해나가는 방법도 있겠습니다.

어차피 모든 하위 댓글들은 이미 조회된 상태이므로, 특정 child는 이미 알고 있는 상태입니다.

이렇게 하면 IN 쿼리는 분명 수행되지만, 모든 댓글을 조회하는 쿼리도 포함되어야하므로, 두 번의 쿼리가 생성됩니다.

또한, 두 번의 쿼리만으로 수행된다하더라도, 동일한 댓글들을 중복해서 받아야하는 문제점이 있습니다.

구현이나 코드 측면에서는 더욱 간결해지겠지만, 동일한 데이터를 두 번이나 요청하면서 대역폭을 낭비할 이유는 없습니다.

***

 

* 여기에 적힌 내용은 계층형 카테고리를 설명할 때 작성된 내용과 동일하지만, 설명의 편의를 위해 한번 더 작성하였습니다.

 

 

이제 CommentRepository.findAllWithMemberAndParentByPostIdOrderByParentIdAscNullsFirstCommentIdAsc를 통해 계층형 구조로 변환시킬 실마리를 얻었습니다.

이에 대한 자세한 내용은 뒤에서 살펴보도록 하고, CommentRepository에 대한 테스트를 수행해보겠습니다.

 

 

test 디렉토리에서 동일한 패키지 경로 내에 CommentRepositoryTest를 작성해줍니다.

전체 소스코드는 다음과 같습니다.

package kukekyakya.kukemarket.repository.comment;

import ...

@DataJpaTest
@Import(QuerydslConfig.class)
class CommentRepositoryTest {

    @Autowired MemberRepository memberRepository;
    @Autowired CategoryRepository categoryRepository;
    @Autowired PostRepository postRepository;
    @Autowired CommentRepository commentRepository;
    @PersistenceContext EntityManager em;

    Member member;
    Category category;
    Post post;

    @BeforeEach
    void beforeEach() {
        member = memberRepository.save(createMember());
        category = categoryRepository.save(createCategory());
        post = postRepository.save(createPost(member, category));
    }

    @Test
    void createAndReadTest() {
        // given
        Comment comment = commentRepository.save(createComment(member, post, null));
        clear();

        // when
        Comment foundComment = commentRepository.findById(comment.getId()).orElseThrow(CommentNotFoundException::new);

        // then
        assertThat(foundComment.getId()).isEqualTo(comment.getId());
    }

    @Test
    void deleteTest() {
        // given
        Comment comment = commentRepository.save(createComment(member, post, null));
        clear();

        // when
        commentRepository.deleteById(comment.getId());

        // then
        assertThat(commentRepository.findById(comment.getId())).isEmpty();
    }

    @Test
    void deleteCascadeByMemberTest() {
        // given
        Comment comment = commentRepository.save(createComment(member, post, null));
        clear();

        // when
        memberRepository.deleteById(member.getId());
        clear();

        // then
        assertThat(commentRepository.findById(comment.getId())).isEmpty();
    }

    @Test
    void deleteCascadeByPostTest() {
        // given
        Comment comment = commentRepository.save(createComment(member, post, null));
        clear();

        // when
        postRepository.deleteById(post.getId());
        clear();

        // then
        assertThat(commentRepository.findById(comment.getId())).isEmpty();
    }

    @Test
    void deleteCascadeByParentTest() {
        // given
        Comment parent = commentRepository.save(createComment(member, post, null));
        Comment child = commentRepository.save(createComment(member, post, parent));
        clear();

        // when
        commentRepository.deleteById(parent.getId());
        clear();

        // then
        assertThat(commentRepository.findById(child.getId())).isEmpty();
    }

    @Test
    void getChildrenTest() {
        // given
        Comment parent = commentRepository.save(createComment(member, post, null));
        commentRepository.save(createComment(member, post, parent));
        commentRepository.save(createComment(member, post, parent));
        clear();

        // when
        Comment comment = commentRepository.findById(parent.getId()).orElseThrow(CommentNotFoundException::new);

        // then
        assertThat(comment.getChildren().size()).isEqualTo(2);
    }

    @Test
    void findWithParentByIdTest() {
        // given
        Comment parent = commentRepository.save(createComment(member, post, null));
        Comment child = commentRepository.save(createComment(member, post, parent));
        clear();

        // when
        Comment comment = commentRepository.findWithParentById(child.getId()).orElseThrow(CommentNotFoundException::new);

        // then
        assertThat(comment.getParent()).isNotNull();
    }

    @Test
    void deleteCommentTest() {
        // given

        // root 1
        // 1 -> 2
        // 2(del) -> 3(del)
        // 2(del) -> 4
        // 3(del) -> 5
        Comment comment1 = commentRepository.save(createComment(member, post, null));
        Comment comment2 = commentRepository.save(createComment(member, post, comment1));
        Comment comment3 = commentRepository.save(createComment(member, post, comment2));
        Comment comment4 = commentRepository.save(createComment(member, post, comment2));
        Comment comment5 = commentRepository.save(createComment(member, post, comment3));

        comment2.delete();
        comment3.delete();
        clear();

        // when
        Comment comment = commentRepository.findWithParentById(comment5.getId()).orElseThrow(CommentNotFoundException::new);
        comment.findDeletableComment().ifPresentOrElse(c -> commentRepository.delete(c), () -> comment5.delete());
        clear();

        // then
        List<Comment> comments = commentRepository.findAll();
        List<Long> commentIds = comments.stream().map(c -> c.getId()).collect(toList());
        assertThat(commentIds.size()).isEqualTo(3);
        assertThat(commentIds).contains(comment1.getId(), comment2.getId(), comment4.getId());
    }

    @Test
    void deleteCommentQueryLogTest() {
        // given

        // 1(del) -> 2(del) -> 3(del) -> 4(del) -> 5
        Comment comment1 = commentRepository.save(createComment(member, post, null));
        Comment comment2 = commentRepository.save(createComment(member, post, comment1));
        Comment comment3 = commentRepository.save(createComment(member, post, comment2));
        Comment comment4 = commentRepository.save(createComment(member, post, comment3));
        Comment comment5 = commentRepository.save(createComment(member, post, comment4));
        comment1.delete();
        comment2.delete();
        comment3.delete();
        comment4.delete();
        clear();

        // when
        Comment comment = commentRepository.findWithParentById(comment5.getId()).orElseThrow(CommentNotFoundException::new);
        comment.findDeletableComment().ifPresentOrElse(c -> commentRepository.delete(c), () -> comment5.delete());
        clear();

        // then
        List<Comment> comments = commentRepository.findAll();
        List<Long> commentIds = comments.stream().map(c -> c.getId()).collect(toList());
        assertThat(commentIds.size()).isEqualTo(0);
    }

    @Test
    void findAllWithMemberAndParentByPostIdOrderByParentIdAscNullsFirstCommentIdAscTest() {
        // given
        // 1		NULL
        // 2		1
        // 3		1
        // 4		2
        // 5		2
        // 6		4
        // 7		3
        // 8		NULL
        Comment c1 = commentRepository.save(createComment(member, post, null));
        Comment c2 = commentRepository.save(createComment(member, post, c1));
        Comment c3 = commentRepository.save(createComment(member, post, c1));
        Comment c4 = commentRepository.save(createComment(member, post, c2));
        Comment c5 = commentRepository.save(createComment(member, post, c2));
        Comment c6 = commentRepository.save(createComment(member, post, c4));
        Comment c7 = commentRepository.save(createComment(member, post, c3));
        Comment c8 = commentRepository.save(createComment(member, post, null));
        clear();

        // when
        List<Comment> result = commentRepository.findAllWithMemberAndParentByPostIdOrderByParentIdAscNullsFirstCommentIdAsc(post.getId());

        // then
        // 1	NULL
        // 8	NULL
        // 2	1
        // 3	1
        // 4	2
        // 5	2
        // 7	3
        // 6	4
        assertThat(result.size()).isEqualTo(8);
        assertThat(result.get(0).getId()).isEqualTo(c1.getId());
        assertThat(result.get(1).getId()).isEqualTo(c8.getId());
        assertThat(result.get(2).getId()).isEqualTo(c2.getId());
        assertThat(result.get(3).getId()).isEqualTo(c3.getId());
        assertThat(result.get(4).getId()).isEqualTo(c4.getId());
        assertThat(result.get(5).getId()).isEqualTo(c5.getId());
        assertThat(result.get(6).getId()).isEqualTo(c7.getId());
        assertThat(result.get(7).getId()).isEqualTo(c6.getId());
    }

    void clear() {
        em.flush();
        em.clear();
    }

}

Entity에 설정된 JPA 옵션들과 함께 리포지토리를 테스트해주었습니다.

한 가지 테스트만 세부적으로 살펴보고, 다른 테스트의 자세한 설명은 생략하겠습니다.

 

 

deleteCommentQueryLogTest를 다시 살펴보면서 개선점을 고민해봅시다.

* 이에 대한 부분은 선택사항이기 때문에, 단순히 넘어가셔도 좋습니다.

@Test
void deleteCommentQueryLogTest() {
    // given

    // 1(del) -> 2(del) -> 3(del) -> 4(del) -> 5
    Comment comment1 = commentRepository.save(createComment(member, post, null));
    Comment comment2 = commentRepository.save(createComment(member, post, comment1));
    Comment comment3 = commentRepository.save(createComment(member, post, comment2));
    Comment comment4 = commentRepository.save(createComment(member, post, comment3));
    Comment comment5 = commentRepository.save(createComment(member, post, comment4));
    comment1.delete();
    comment2.delete();
    comment3.delete();
    comment4.delete();
    clear();

    // when
    Comment comment = commentRepository.findWithParentById(comment5.getId()).orElseThrow(CommentNotFoundException::new);
    comment.findDeletableComment().ifPresentOrElse(c -> commentRepository.delete(c), () -> comment5.delete());
    clear();

    // then
    List<Comment> comments = commentRepository.findAll();
    List<Long> commentIds = comments.stream().map(c -> c.getId()).collect(toList());
    assertThat(commentIds.size()).isEqualTo(0);
}

지금 작성된 위 테스트를 수행해봅시다.

5번 댓글의 상위 댓글은 모두 deleted=true이기 때문에, findDeletableComment의 결과는 comment1이 나와야할 것입니다.

따라서 모든 댓글이 실제로 데이터베이스에서도 제거되는 것입니다.

 

그렇다면, 다음 코드는 어떤 쿼리들을 생성할까요?

Comment comment = commentRepository.findWithParentById(comment5.getId()).orElseThrow(CommentNotFoundException::new);
comment.findDeletableComment().ifPresentOrElse(c -> commentRepository.delete(c), () -> comment5.delete());

로그를 한번 확인해봅시다.

 

다음과 같이 9개의 select 쿼리가 생성되었습니다.

Hibernate: select comment0_.id as id1_1_0_, comment1_.id as id1_1_1_, comment0_.created_at as created_2_1_0_, comment0_.modified_at as modified3_1_0_, comment0_.content as content4_1_0_, comment0_.deleted as deleted5_1_0_, comment0_.member_id as member_i6_1_0_, comment0_.parent_id as parent_i7_1_0_, comment0_.post_id as post_id8_1_0_, comment1_.created_at as created_2_1_1_, comment1_.modified_at as modified3_1_1_, comment1_.content as content4_1_1_, comment1_.deleted as deleted5_1_1_, comment1_.member_id as member_i6_1_1_, comment1_.parent_id as parent_i7_1_1_, comment1_.post_id as post_id8_1_1_ from comment comment0_ left outer join comment comment1_ on comment0_.parent_id=comment1_.id where comment0_.id=?

Hibernate: select children0_.parent_id as parent_i7_1_0_, children0_.id as id1_1_0_, children0_.id as id1_1_1_, children0_.created_at as created_2_1_1_, children0_.modified_at as modified3_1_1_, children0_.content as content4_1_1_, children0_.deleted as deleted5_1_1_, children0_.member_id as member_i6_1_1_, children0_.parent_id as parent_i7_1_1_, children0_.post_id as post_id8_1_1_ from comment children0_ where children0_.parent_id=?
Hibernate: select children0_.parent_id as parent_i7_1_0_, children0_.id as id1_1_0_, children0_.id as id1_1_1_, children0_.created_at as created_2_1_1_, children0_.modified_at as modified3_1_1_, children0_.content as content4_1_1_, children0_.deleted as deleted5_1_1_, children0_.member_id as member_i6_1_1_, children0_.parent_id as parent_i7_1_1_, children0_.post_id as post_id8_1_1_ from comment children0_ where children0_.parent_id=?
Hibernate: select comment0_.id as id1_1_0_, comment0_.created_at as created_2_1_0_, comment0_.modified_at as modified3_1_0_, comment0_.content as content4_1_0_, comment0_.deleted as deleted5_1_0_, comment0_.member_id as member_i6_1_0_, comment0_.parent_id as parent_i7_1_0_, comment0_.post_id as post_id8_1_0_ from comment comment0_ where comment0_.id=?
Hibernate: select children0_.parent_id as parent_i7_1_0_, children0_.id as id1_1_0_, children0_.id as id1_1_1_, children0_.created_at as created_2_1_1_, children0_.modified_at as modified3_1_1_, children0_.content as content4_1_1_, children0_.deleted as deleted5_1_1_, children0_.member_id as member_i6_1_1_, children0_.parent_id as parent_i7_1_1_, children0_.post_id as post_id8_1_1_ from comment children0_ where children0_.parent_id=?
Hibernate: select comment0_.id as id1_1_0_, comment0_.created_at as created_2_1_0_, comment0_.modified_at as modified3_1_0_, comment0_.content as content4_1_0_, comment0_.deleted as deleted5_1_0_, comment0_.member_id as member_i6_1_0_, comment0_.parent_id as parent_i7_1_0_, comment0_.post_id as post_id8_1_0_ from comment comment0_ where comment0_.id=?
Hibernate: select children0_.parent_id as parent_i7_1_0_, children0_.id as id1_1_0_, children0_.id as id1_1_1_, children0_.created_at as created_2_1_1_, children0_.modified_at as modified3_1_1_, children0_.content as content4_1_1_, children0_.deleted as deleted5_1_1_, children0_.member_id as member_i6_1_1_, children0_.parent_id as parent_i7_1_1_, children0_.post_id as post_id8_1_1_ from comment children0_ where children0_.parent_id=?
Hibernate: select comment0_.id as id1_1_0_, comment0_.created_at as created_2_1_0_, comment0_.modified_at as modified3_1_0_, comment0_.content as content4_1_0_, comment0_.deleted as deleted5_1_0_, comment0_.member_id as member_i6_1_0_, comment0_.parent_id as parent_i7_1_0_, comment0_.post_id as post_id8_1_0_ from comment comment0_ where comment0_.id=?
Hibernate: select children0_.parent_id as parent_i7_1_0_, children0_.id as id1_1_0_, children0_.id as id1_1_1_, children0_.created_at as created_2_1_1_, children0_.modified_at as modified3_1_1_, children0_.content as content4_1_1_, children0_.deleted as deleted5_1_1_, children0_.member_id as member_i6_1_1_, children0_.parent_id as parent_i7_1_1_, children0_.post_id as post_id8_1_1_ from comment children0_ where children0_.parent_id=?
Hibernate: delete from comment where id=?

첫번째 쿼리는, comment5의 id로 findWithParentId를 수행하면서, parent와 left outer join하며 조회한 것입니다.

그리고 나머지 쿼리는, 실제로 삭제 가능한 댓글을 찾기 위해 Comment.findDeletableComment을 수행하면서 생성한 것입니다.

comment5는 자신의 자식이 있는지 판별하기 위해 children을 한번 조회한 뒤, 

Comment.findDeletableCommentByParent를 수행하면서 해당 댓글의 부모를 거슬러가면서 부모의 자식들을 검사하게 됩니다.

즉, 각각의 쿼리는,

두번째 쿼리, comment5의 children 조회,

세번째 쿼리, comment5가 fetch join했던 parent인 comment4의 children 조회,

네번째 쿼리, comment4의 parent인 comment3 조회,

다섯번째 쿼리, comment3의 children 조회, 

여섯번째 쿼리, comment3의 parent인 comment2 조회,

일곱번째 쿼리, comment2의 children 조회, 

여덟번째 쿼리, comment2의 parent인 comment2 조회,

아홉번째 쿼리, comment1의 children 조회. 

 

이러한 방식에는 문제가 생길 여지가 있습니다.

어떠한 댓글이 부모 댓글의 깊이를 N만큼 검사하게된다면, 약 2 * N번의 select 쿼리가 추가적으로 생성될 것이기 때문입니다.

 

이러한 문제를 해결하기 위해 여러가지 선택지가 있었습니다.

모든 댓글들을 한 번에 다 불러와서 삭제해야되는 지점을 찾아준다든지, cte같은 것을 이용하는 native query를 작성하여 재귀적인 결과를 한 번에 받아온다든지, 쿼리에 IN 절을 사용하여 쿼리의 개수를 최소화하는 방법 등을 고민했었습니다.

첫번째 방식은 쿼리의 개수는 줄일 수 있더라도, 간단한 작업에 불러오는 데이터가 많아질 수 있었습니다.

두번째 방식은 확실하지만, 실제로 댓글의 깊이는 그렇게 크지 않을 것이기 때문에, 더욱 간단한 해결책을 원했습니다. 

결국 세번째 방식을 택하게 되었습니다.

단일한 쿼리로 모든 것을 수행할 수도 없고, 완벽한 해결책은 아니지만, 조금이나마 효율을 높이는데 도움이 될 것이라고 여겨졌습니다.

 

* 사실 이러한 방법 외에도 EntityGraph를 만들거나, fetch 전략을 EAGER로 설정하는 시행착오도 겪어봤지만,

EntityGraph는 2단계의 깊이까지밖에 허용되지 않았고, jpa 자체만으로 재귀적인 쿼리를 수행하는 특별한 방법은 찾지 못했으며, 

parent EAGER 설정 + children in 절을 이용하는 것은 확실히 제일 간편하지만, 어떠한 잠재적인 위험이 있을지 모르기 때문에 기피하게 되었습니다.

 

 

세번째 방식을 이용한다고하더라도 구현 방식은 다양할 수 있었지만, 제가 선택한 방식에 대해서만 서술하고 넘어가겠습니다.

저는 JPA 설정과 코드 동작을 달리하는 방식을 선택하였습니다.

application.yml에 다음 설정을 추가해주겠습니다.

spring:
  ...
  jpa:
    ...
    properties:
      hibernate:
        default_batch_fetch_size: 100

default_batch_fetch_size를 설정해준 것입니다.

이를 이용하면 한 번에 조회될 만한 쿼리들은 즉시 생성되어 전송되는 것이 아니라, IN 절을 사용하여 한 번의 쿼리로 조회할 수 있을 것입니다. 

 

 

Comment 엔티티의 메소드는 다음과 같이 수정해줍니다.

// Comment.java
public Optional<Comment> findDeletableComment() {
    return hasChildren() ? Optional.empty() : Optional.of(findDeletableCommentByParent());
}

public void delete() {
    this.deleted = true;
}

private Comment findDeletableCommentByParent() { // 1
    if (isDeletedParent()) {
        Comment deletableParent = getParent().findDeletableCommentByParent();
        if(getParent().getChildren().size() == 1) return deletableParent;
    }
    return this;
}

private boolean hasChildren() {
    return getChildren().size() != 0;
}

private boolean isDeletedParent() { // 2
    return getParent() != null && getParent().isDeleted()
}

1번과 2번 메소드에 대해서 구현 방식이 바뀌었습니다.

2번에서는 parent의 자식의 개수가 1개인지 확인하면서, 삭제 가능한 부모인지 즉시 판별했었지만,

수정된 코드에서는 deleted=true 설정이 되어있는지만 판별해주었습니다.

결국, 1번에서는 삭제되어있는 최상위 부모까지 모두 올라간 뒤에, 해당 지점부터 점차 내려오면서,

자신의 부모가 삭제 가능한지(자식의 개수가 1개인지) 뒤늦게 확인하고 있는 것입니다.

삭제 할 수 있다면, 반환 받았던 상위 댓글을 그대로 응답해주고,

삭제 할 수 없다면, 반환 받았던 상위 댓글은 어차피 삭제할 수 없으므로, 자신을 반환하는 것입니다.

 

즉, 모든 parent를 다 조회해둔 뒤에, 해당 parent의 children들을 뒤늦게조회하므로,

이러한 children 조회 작업은, IN 절을 이용하여 하나의 쿼리로 수행될 수 있을 것입니다.

 

자식의 개수를 뒤늦게 판별하기 때문에, 상위 댓글로 거슬러올라가야하는 깊이는 늘어날 수도 있겠지만,

1개의 깊이마다 2개의 쿼리가 생성되는 상황을, 1개의 쿼리만 생성되도록 개선하고자 한 것입니다.

 

 

deleteCommentQueryLogTest 테스트를 다시 수행하고, 아까와 동일한 코드의 로그를 확인해보겠습니다.

Hibernate: select comment0_.id as id1_1_0_, comment1_.id as id1_1_1_, comment0_.created_at as created_2_1_0_, comment0_.modified_at as modified3_1_0_, comment0_.content as content4_1_0_, comment0_.deleted as deleted5_1_0_, comment0_.member_id as member_i6_1_0_, comment0_.parent_id as parent_i7_1_0_, comment0_.post_id as post_id8_1_0_, comment1_.created_at as created_2_1_1_, comment1_.modified_at as modified3_1_1_, comment1_.content as content4_1_1_, comment1_.deleted as deleted5_1_1_, comment1_.member_id as member_i6_1_1_, comment1_.parent_id as parent_i7_1_1_, comment1_.post_id as post_id8_1_1_ from comment comment0_ left outer join comment comment1_ on comment0_.parent_id=comment1_.id where comment0_.id=?

Hibernate: select children0_.parent_id as parent_i7_1_1_, children0_.id as id1_1_1_, children0_.id as id1_1_0_, children0_.created_at as created_2_1_0_, children0_.modified_at as modified3_1_0_, children0_.content as content4_1_0_, children0_.deleted as deleted5_1_0_, children0_.member_id as member_i6_1_0_, children0_.parent_id as parent_i7_1_0_, children0_.post_id as post_id8_1_0_ from comment children0_ where children0_.parent_id in (?, ?)
Hibernate: select comment0_.id as id1_1_0_, comment0_.created_at as created_2_1_0_, comment0_.modified_at as modified3_1_0_, comment0_.content as content4_1_0_, comment0_.deleted as deleted5_1_0_, comment0_.member_id as member_i6_1_0_, comment0_.parent_id as parent_i7_1_0_, comment0_.post_id as post_id8_1_0_ from comment comment0_ where comment0_.id=?
Hibernate: select comment0_.id as id1_1_0_, comment0_.created_at as created_2_1_0_, comment0_.modified_at as modified3_1_0_, comment0_.content as content4_1_0_, comment0_.deleted as deleted5_1_0_, comment0_.member_id as member_i6_1_0_, comment0_.parent_id as parent_i7_1_0_, comment0_.post_id as post_id8_1_0_ from comment comment0_ where comment0_.id=?
Hibernate: select comment0_.id as id1_1_0_, comment0_.created_at as created_2_1_0_, comment0_.modified_at as modified3_1_0_, comment0_.content as content4_1_0_, comment0_.deleted as deleted5_1_0_, comment0_.member_id as member_i6_1_0_, comment0_.parent_id as parent_i7_1_0_, comment0_.post_id as post_id8_1_0_ from comment comment0_ where comment0_.id=?
Hibernate: select children0_.parent_id as parent_i7_1_1_, children0_.id as id1_1_1_, children0_.id as id1_1_0_, children0_.created_at as created_2_1_0_, children0_.modified_at as modified3_1_0_, children0_.content as content4_1_0_, children0_.deleted as deleted5_1_0_, children0_.member_id as member_i6_1_0_, children0_.parent_id as parent_i7_1_0_, children0_.post_id as post_id8_1_0_ from comment children0_ where children0_.parent_id in (?, ?, ?)
Hibernate: delete from comment where id=?

이번에는 6개의 select 쿼리만 생성되었습니다.

쿼리를 자세히 살펴보면, in 절을 통해서 하나의 쿼리로 여러 개의 children들을 조회하고 있습니다.

첫번째 쿼리는, findByWithParentId에 의해서 생성되었을 것입니다.

두번째 쿼리는, comment5의 Comment.findDeletableParent에서 자식을 검사하고, fetch join했던 comment4의 자식을 검사하는 과정 2개가 하나의 IN 절로 수행,

세번째~다섯번째 쿼리는, 삭제된 부모를 거슬러올라가면서 comment3, comment2, comment1 조회 쿼리 3번,

여섯번째 쿼리는, 삭제된 부모들의 자식들을 검사하는 과정 3개가 하나의 IN 절로 수행.

 

이제 하위 댓글이 상위 댓글을 거슬러가면서 먼저 조회해두기 때문에,

children을 조회하는 쿼리는 IN 절을 통해서 하나의 쿼리로 묶어낼 수 있었고,

이로 인해 여러 번의 SELECT 쿼리가 생성되는 상황을 방지할 수 있는 것입니다. 

 

물론, 더욱 높은 성능을 추구하고자 한다면, 다른 방법으로 충분히 개선할 수도 있겠습니다.

하지만 댓글의 깊이는 그렇게 깊지 않을 것이고, pk로 조회하는 쿼리는 여전히 반복되지만,

논클러스터링 인덱스를 이용하는 쿼리는 한번에 처리되므로, 그나마 개선의 여지를 가질 수 있게 된 것입니다.

댓글의 깊이가 N일 때, 약 N * 2개의 쿼리가 생성되던 문제를, 약 N개의 쿼리가 생성되도록 축소시킬 수 있었습니다.

댓글의 깊이를 적정 선으로 유지한다면, N은 그렇게 크지 않을 것입니다.

 

삭제 방식에 관한 부분은 기존의 방식을 그대로 사용하거나, 지금의 개선된 방식을 사용하거나, 본인이 원하는 방식을 찾아도 될 것입니다.

 

내용이 길어져서 다음 게시글에서 이어서 작성하겠습니다.

이번 시간에는, 계층형 댓글의 조회와 삭제를 어떻게 수행할 수 있을 것인지 논의하면서 계층형 댓글을 위한 엔티티와 리포지토리를 작성하였습니다.

또한, 계층형 댓글 삭제 방식의 문제점을 살펴보고, 개선점을 고민해보았습니다.

다음 시간에는, 이렇게 작성된 엔티티와 리포지토리를 이용하여, 서비스 로직을 작성해보겠습니다.

 

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

반응형

+ Recent posts