엔티티: 셀프참조로 부모 댓글과 자식 댓글 리스트 참조
댓글저장: parentId 값을 통해 부모를 지정
댓글조회: 부모가 없는 댓글 먼저, 그 후에 생성날짜 내림차순으로 정렬
댓글조회+: 정렬 후 ResponseDto 객체에 JSON 형식으로 댓글 구조 생성 후 응답
댓글삭제: 하위 댓글이 삭제되었으면 삭제, 남아있다면 상태만 삭제로 변경
댓글이 저장될 때 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 |
---|---|---|
1 | Comment 1 | null |
4 | Comment 2 | null |
2 | Comment 1-1 | 1 |
3 | Comment 1-1-1 | 2 |
5 | Comment 2-1 | 4 |
6 | Comment 2-1-1 | 5 |
7 | Comment 2-2 | 4 |
의 순서대로 부모가 없는 댓글인 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가지 종류가 있다.
웹사이트를 보다 보면 대댓글이 남아있는데 부모 댓글을 삭제한 경우에 부모 댓글을 "삭제한 댓글입니다"라고 표현해주고 그 아래의 대댓글은 그대로 보여주는 웹사이트가 있다.
(지금 찾으려니 못찾겠음...)
이처럼 자식 댓글이 아직 남아있는 경우에는 "댓글상태" 만 삭제로 변경하고 자식 댓글이 없는 경우에 실제로 댓글을 삭제하게 로직을 짤 수 있다.
<댓글 삭제 로직>
@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를 사용하여 원하는 만큼만 댓글의 개수를 지정하여 반환할 수 있는 방법도 생각해봐야 할 것 같다.
안녕하세요. 잘 봤습니다. 혹시 responseDto도 올려주실 수있나요??
예제대로 따라했는데. service 조회부분에서 map.get(c.getParent().getId()).getChildren().add(cdto);
add메소드 선언이 안되네용.