[Spring JPA] 계층형 댓글 (2)

SangYu Lee·2023년 2월 28일
1

JPA 와 QueryDsl 사용한 계층형 댓글 구현 -2-

선 요약 :

  1. 엔티티: 셀프참조로 부모 댓글과 자식 댓글 리스트 참조

  2. 댓글저장: parentId 값을 통해 부모를 지정

  3. 댓글조회: 부모가 없는 댓글 먼저, 그 후에 생성날짜 내림차순으로 정렬

  4. 댓글조회+: 정렬 후 ResponseDto 객체에 JSON 형식으로 댓글 구조 생성 후 응답

  5. 댓글삭제: 하위 댓글이 삭제되었으면 삭제, 남아있다면 상태만 삭제로 변경


댓글 조회(QueryDsl 사용)

댓글이 저장될 때 Id가 부여되는데, Id는 저장된 순서이지 계층형을 나타내고 있지 않다.

위의 그림에서 "Comment 1-1"이라는 댓글의 Id는 내가 댓글 단 순서에 따라 다르다. 그렇지만 response로 돌려줄 때에는 계층 구조에 맞게 데이터를 보내야 한다.

아래의 그림에 나타난 순서로 댓글을 달았고 그에 따라 Id가 부여되었다고 가정하자

참고로 "Comment 1-1" 이라는 건 ID값과 무관한 댓글 내용이며 계층을 나타내기 위해 이렇게 작성하였다

1) 부모 댓글이 없는 댓글을 우선으로 Id 내림차순 조회하고, 2) 부모가 있는 댓글끼리는 생성 순서로 정렬해서 조회해야 한다.

@Repository
public class CommentCustomRepositoryImpl implements CommentCustomRepository{
    @Override
    public List<Comment> findCommentByPost(Post post) {
        return jpaQueryFactory.selectFrom(comment)
                .leftJoin(comment.parent)
                .fetchJoin()
                .where(comment.post.postId.eq(post.getPostId()))
                .orderBy(comment.parent.commentId.asc().nullsFirst(), comment.createdAt.asc())
                .fetch();
    }
}

인자(argument)로 post가 들어가는 이유는 전체 댓글 조회가 아닌, 특정 post에 달린 댓글을 조회하기 때문이다.

위의 조건으로 조회하였을 때 댓글은

ID댓글내용부모ID
1Comment 1null
4Comment 2null
2Comment 1-11
3Comment 1-1-12
5Comment 2-14
6Comment 2-1-15
7Comment 2-24

의 순서대로 부모가 없는 댓글인 Comment 1과 Comment 2가 먼저 조회되었고, 나머지는 "댓글 생성순서 = ID값 부여순서" 이기 때문에 내림차순으로 조회되었다.

계층형 구조 만들기

이렇게 조회된 댓글 List를 바탕으로 계층형 구조를 만들어야 한다. 아래에 설명이 있지만 먼저 어떤 로직인지 파악해본다면 더 이해가 빠를 것이다.

<핸들러 메서드 전체>

    @GetMapping("/listof/{post-id}")
    public ResponseEntity<DataResponseDto<?>> getComment(@PathVariable("post-id") @Positive long postId,
                                                          @Positive @RequestParam(required = false, defaultValue = "1") int page,
                                                          @Positive @RequestParam(required = false, defaultValue = "10") int size) {
        // 댓글을 조회하고 싶은 Post를 찾는다
        Post findPost = postService.findPostNoneSetView(postId);
        
        // QueryDsl을 사용하여 만들었던 댓글 조회
        List<Comment> commentList = commentService.findComments(findPost);
        
        // 계층형 구조가 다 만들어진 결과인 ResponseDto 리스트 객체 생성
        List<CommentDetailResponseDto> result = new ArrayList<>();
        
        // 계층형 구조를 만들어주기 위해 Map을 도구로 사용
        Map<Long, CommentDetailResponseDto> map = new HashMap<>();

		// 계층형 구조로 만들기
        commentList.stream().forEach(c-> {
            CommentDetailResponseDto rDto = CommentDetailResponseDto.convertCommentToDto(c);
            // map <댓글Id, responseDto>
            map.put(c.getCommentId(), rDto);
            // 댓글이 부모가 있다면
            if(c.getParent() != null) {
                // 부모 댓글의 id의 responseDto를 조회한다음
                map.get(c.getParent().getCommentId())
                        // 부모 댓글 responseDto의 자식으로
                        .getChildren()
                        // rDto를 추가한다.
                        .add(rDto);
            }
            // 댓글이 최상위 댓글이라면
            else{
                // 그냥 result에 추가한다.
                result.add(rDto);
            }
        });

        return ResponseEntity.ok(new DataResponseDto<>(result));
    }

데이터베이스에서 정렬해서 조회한 댓글 리스트를 바탕으로

"[ stream 으로 리스트 내의 댓글을 순회하며 ]"

다음 로직을 수행한다.

1. ResponseDto를 만들고 계층형 구조를 위해 Map에 추가된다.

Map은 <댓글Id, ResponseDto>의 구조이며, 부모 댓글 하위에 자식 댓글을 넣어주기 위해 쓰는 도구이다.

2. 부모가 없는 댓글은 바로 result에 추가된다.

<Result>

{
    "data": [
        {
            "commentId": 1,
            "parentId": null,
            "memberId": 1,
            "commentContent": "Comment 1",
            "status": "Alive",
            "createdAt": "2023-02-21T00:06:43.676635",
            "modifiedAt": "2023-02-21T00:06:43.676635",
            "children": []
        },
        {
            "commentId": 4,
            "parentId": null,
            "memberId": 1,
            "commentContent": "Comment 2",
            "status": "Alive",
            "createdAt": "2023-02-21T00:07:33.50709",
            "modifiedAt": "2023-02-21T00:07:33.50709",
            "children": []
        },
    ]
}

3. 부모가 있는 댓글은 부모의 responseDto에 있는 "children" 리스트에 추가된다.

위에 있는 핸들러 메서드에서 이 부분을 보자

map.get(key) 는 key에 맞는 value를 반환하는 메서드이다

map.get(c.getParent().getCommentId())   .getChildren().add(rDto);

여기서 key는 부모 댓글의 Id이고, value는 부모 댓글의 responseDto 이다.

부모 댓글의 responseDto에 있는 children List에 자기 자신의 ResponseDto를 넣어주는 로직으로 간단하게 " 부모 아래에 자식을 넣어주는 것 " 으로 생각할 수 있다.

예를 들어 위에서 부모가 없는 가장 상위의 댓글인 Comment 1과 Comment 2가 추가되고 난 후, 그 다음 차례는 Id값으로 "2"을 가지고 있는 Comment 1-1이다. Comment 1-1의 부모는 Comment 1이고, Comment 1의 ResponseDto아래에 있는 Children에 자기 자신의 ResponseDto를 추가했을 테니 다음과 같이 들어가 있을 것이다.

{
    "data": [
        {
            "commentId": 1,
            "parentId": null,
            "memberId": 1,
            "commentContent": "Comment 1",
            "status": "Alive",
            "createdAt": "2023-02-21T00:06:43.676635",
            "modifiedAt": "2023-02-21T00:06:43.676635",
            "children": [
                {
                    "commentId": 2,
                    "parentId": 1,
                    "memberId": 1,
                    "commentContent": "Comment 1-1",
                    "status": "Alive",
                    "createdAt": "2023-02-21T00:07:50.508899",
                    "modifiedAt": "2023-02-21T00:13:51.959996",
                    "children": []
                }
            ]
        },
        {
            "commentId": 4,
            "parentId": null,
            "memberId": 1,
            "commentContent": "Comment 2",
            "status": "Alive",
            "createdAt": "2023-02-21T00:07:33.50709",
            "modifiedAt": "2023-02-21T00:07:33.50709",
            "children": []
        },
    ]
}

위의 구조를 분석하면

이렇게 볼 수 있다.
나머지 댓글들도 똑같은 방식을 통해 result에 계층형 댓글 구조를 만든다.


댓글 삭제

댓글을 삭제는 2가지 종류가 있다.

  1. 실제로 댓글을 삭제하는 경우
  2. 댓글 상태만 삭제로 변경하는 경우

웹사이트를 보다 보면 대댓글이 남아있는데 부모 댓글을 삭제한 경우에 부모 댓글을 "삭제한 댓글입니다"라고 표현해주고 그 아래의 대댓글은 그대로 보여주는 웹사이트가 있다.
(지금 찾으려니 못찾겠음...)

이처럼 자식 댓글이 아직 남아있는 경우에는 "댓글상태" 만 삭제로 변경하고 자식 댓글이 없는 경우에 실제로 댓글을 삭제하게 로직을 짤 수 있다.

<댓글 삭제 로직>

@Service
@Transactional
public class CommentService {

    public void deleteComment(Comment comment) {
        // 자식이 있는 댓글이라면
        if(comment.getChildren().size() != 0) {
            // 삭제 상태로 변경
            comment.changeStatus(Comment.CommentStatus.Dead);
        }
        // 자식이 없는 댓글이라면
        else {
            // 자기 자신과 함께 삭제 가능한 조상 댓글을 전부 삭제
            commentRepository.delete(getDeletableAncestorComment(comment));
        }
    }

    public Comment getDeletableAncestorComment(Comment comment) {
        Comment parent = comment.getParent();
        // 1. 부모 댓글이 존재하고 2. 부모의 자식이 1개이며 3. 부모가 상태가 dead인 경우
        if(parent != null && parent.getChildren().size() == 1 && parent.getStatus() == Comment.CommentStatus.Dead){
            // 재귀로 삭제할 조상을 모두 리턴한다
            return getDeletableAncestorComment(parent);
        }
        return comment;
    }
 }

자기 자신과 함께 삭제 가능한 조상 댓글을 전부 삭제하는 이유는, 자기 자신이 마지막 남은 댓글이고 자신이 삭제됨으로써 부모 댓글 또한 삭제 상태에서 실제로 삭제가 될 수 있는 조건이 만족되었기 때문이다.

아래와 같은 구조에서 Comment 1-1을 삭제한다면 아직 자식 댓글 Comment 1-1-1이 남아있기 때문에 상태만 삭제로 변경될 것이다. (나는 Alive/Dead 라고 설정하였다)

{
    "data": [
        {
            "commentId": 1,
            "parentId": null,
            "memberId": 1,
            "commentContent": "Comment 1",
            "status": "Alive",
            "createdAt": "2023-02-21T00:06:43.676635",
            "modifiedAt": "2023-02-21T00:06:43.676635",
            "children": [
                {
                    "commentId": 2,
                    "parentId": 1,
                    "memberId": 1,
                    "commentContent": "Comment 1-1",
                    "status": "Dead",  /* 상태가 변경됨 */
                    "createdAt": "2023-02-21T00:07:50.508899",
                    "modifiedAt": "2023-02-21T00:13:51.959996",
                    "children": [
                     	{
                            "commentId": 3,
                            "parentId": 2,
                            "memberId": 1,
                            "commentContent": "Comment 1-1-1",
                            "status": "Alive",
                            "createdAt": "2023-02-21T00:07:50.508899",
                            "modifiedAt": "2023-02-21T00:13:51.959996",
                            "children": []
                		}
                    ]
                }
            ]
        },
        {
            "commentId": 4,
            "parentId": null,
            "memberId": 1,
            "commentContent": "Comment 2",
            "status": "Alive",
            "createdAt": "2023-02-21T00:07:33.50709",
            "modifiedAt": "2023-02-21T00:07:33.50709",
            "children": []
        },
    ]
}

이 상태에서 Comment 1-1-1을 삭제한다면, Comment 1-1-1은 자식 댓글이 없기 때문에 실제로 삭제되고, Comment 1-1 또한 자식이 없는 상태 && 자신이 상태가 "Dead"이기 때문에 실제로 DB에서 삭제된다.

{
    "data": [
        {
            "commentId": 1,
            "parentId": null,
            "memberId": 1,
            "commentContent": "Comment 1",
            "status": "Alive",
            "createdAt": "2023-02-21T00:06:43.676635",
            "modifiedAt": "2023-02-21T00:06:43.676635",
            "children": []
        },
        {
            "commentId": 4,
            "parentId": null,
            "memberId": 1,
            "commentContent": "Comment 2",
            "status": "Alive",
            "createdAt": "2023-02-21T00:07:33.50709",
            "modifiedAt": "2023-02-21T00:07:33.50709",
            "children": []
        },
    ]
}

🌼 이렇게 구현하면 기초적인 계층형 댓글 구조를 만들 수 있다.

더 알아봐야할 점

  • 댓글에 depth, degree 등의 필드를 만들고 관리하는 방법

  • 현재는 post와 연관된 모든 댓글을 반환하지만, page를 사용하여 원하는 만큼만 댓글의 개수를 지정하여 반환할 수 있는 방법도 생각해봐야 할 것 같다.

참고 : https://kukekyakya.tistory.com/9?category=1022639

profile
아이스커피

1개의 댓글

comment-user-thumbnail
2023년 3월 1일

안녕하세요. 잘 봤습니다. 혹시 responseDto도 올려주실 수있나요??
예제대로 따라했는데. service 조회부분에서 map.get(c.getParent().getId()).getChildren().add(cdto);
add메소드 선언이 안되네용.

답글 달기