한화시스템 BEYOND SW 11기/커뮤니티 플랫폼 프로젝트

[Spring]댓글/대댓글 기능

삼록이 2025. 6. 11. 10:01

댓글/대댓글 기능을 구현하는 것의 핵심은 자기 참조이다.

erd로 보면 Comment(댓글)엔티티가 자기자신을 참조해야한다.

 

이를 스프링으로 나타내면 아래와 같다.

Comment 클래스 속성을 선언하는 부분에 Comment 타입의  parent를 두고 관계를 맺는 것이다.

parent는 쉽게 말해 부모댓글이다. 예를 들어, id 10인 댓글은 id가 3인 댓글에 달려있는 대댓글이라하자.

그렇다면 이 id가 10인 댓글의 parent필드에는 3이 들어가게 되는 것이다,

즉 parent필드의 값이 null이 아니라면 그 댓글은 어떤 댓글의 자식댓글 즉 대댓글이다.

parent 필드의 값이 null 이라면, 그 댓글은 아무런 부모 댓글을 가지 않았다는 의미 즉, 일반적인 댓글이란 의미다.

 

@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class Comment extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @NotBlank(message = "최소 2자이상 최대 1000자 까지 입력이 가능합니다.")
    @Size(min = 2, max = 1000, message = "최소 2자이상 최대 1000자 까지 입력이 가능합니다")
    private String contents;
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;
    @ManyToOne(fetch = FetchType.LAZY)
    private Post post;
    @Builder.Default
    @Enumerated(EnumType.STRING)
    private DelYN delYN=DelYN.N;
//   대댓글기능을 구현하기 위해선 댓글에 parent필드가 필요하다. 이 필드가 null이면 대댓글이 달리지 않은 그냥 댓글이라는 뜻
//   여러 댓글들이 하나의 부모댓글을 가지니까
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Comment parent;
//    하나의 부모댓글에 여러 댓글이 달리니까 onetomany
    @OneToMany(mappedBy = "parent" , cascade = CascadeType.ALL)
    @Builder.Default
    private List<Comment> childs= new ArrayList<>();

 

그리고 하나의 댓글에는 여러 대댓글(자식댓글)이 달릴 수 있으니까 List 타입의 childs 필드도 선언한다.

그렇다면 이 childs 리스트에 아무 것도 없으면 대댓글이 안달린 댓글일 것이고 리스트의 길이가 1이상이면 최소한 1개의 대댓글이 달린 댓글이다.

 

그렇다. 프론트에서도 이 댓글을 가지고 올 때 이 댓글의 자식 댓글인 childs 리스트도 같이 가지고 와서 뿌려줘야 프론트에서 댓글과 대댓글 구조가 보이게 되는 것이다.


 

댓글 생성 로직

댓글을 다는 컨트롤러단 로직이다.

프론트에서 사용자가 댓글을 달려고 할 때 댓글알 다는 게시물 ID와 부모 댓글ID값을 받는다.

만약 parentId값이 null이라면 이 댓글은 대댓글이 아닌 일반적인 댓글인 것이다.

@AllArgsConstructor
@NoArgsConstructor
@Data
public class CommentCreateDto {
        @NotNull
        private String contents;
//      포스트 id값을 가지면 post에 대한 댓글 project id값을 가지면 project에 대한 댓글.
        private Long postId;
        private Long projectId;

//      댓글은 여기가 null값, 대댓글이라면 숫자값을 가질 것이다.
        private Long parentId;

}

 

//   1.댓글, 대댓글달기
@PostMapping("create")
public ResponseEntity<?> save(@RequestBody @Valid CommentCreateDto commentCreateDto) {
    commentService.save(commentCreateDto);
    return new ResponseEntity<>(new CommonDto(HttpStatus.CREATED.value(), "comment is created successfully", "sucess"), HttpStatus.CREATED);
}

 

그리고 서비스단 로직에서 프론트로부터 받아온 CommentCreateDto에서 parentId 값이 null인지 아닌지 분기처리를 하여 comment 엔티티로 아래와 같이 저장한다.

  1.댓글달기
        public void save(CommentCreateDto commentCreateDto){
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            User user = userRepository.findByLoginIdAndDelYN(authentication.getName(), DelYN.N).orElseThrow(()->new EntityNotFoundException("없는 아이디입니다"));
            Post post = postRepository.findById(commentCreateDto.getPostId()).orElseThrow(()->new EntityNotFoundException("없는 게시글입니다"));
            //부모댓글값이 없으면 원댓글로 저장
            if(commentCreateDto.getParentId()==null){
                Comment comment = Comment.builder()
                        .contents(commentCreateDto.getContents())
                        .user(user)
                        .post(post)
                        .build();
                commentRepository.save(comment);
            //부모댓글값이 있으면 대댓글로 저장
            } else{
                Comment parentComment = commentRepository.findById(commentCreateDto.getParentId()).orElseThrow(()->new EntityNotFoundException("원댓글이 없습니다"));


                Comment comment = Comment.builder()
                        .contents(commentCreateDto.getContents())
                        .user(user)
                        .post(post)
                        .parent(parentComment)
                        .build();
                commentRepository.save(comment);
            }

            User postAuthor = post.getUser();
            if(!user.equals(postAuthor)){ // 댓글 달린 게시글 작성자한테 10점 추가(단, 본인이 쓴 댓글 제외하고)
                user.rankingPointUpdate(+10); //댓글 생성시 10점 추가
                postAuthor.rankingPointUpdate(+10);
                userRepository.save(postAuthor); //다시 저장하여 점수 업데이트
            }
    }

 

 

 


게시글 조회 로직

댓글 생성은 parentId 값의 여부에 따라 댓글인지 대댓글인지 구분하여 저장할 수 있었다.

까다로운 부분은 이 댓글을 불러올 때다. 한 게시글 객체는 댓글리스트를 가지고 있고 게시글을 읽을 때 이 댓글리스트를 같이 가져오면 된다.

 

컨트롤러

//   4.게시글 상세보기
    @GetMapping("/detail/{id}")
    public ResponseEntity<?> findById(@PathVariable Long id){
       PostDetailDto postDetailDto = postService.findById(id);
       return new ResponseEntity<>(new CommonDto(HttpStatus.OK.value(), "the post is uploaded successfully",postDetailDto),HttpStatus.OK);

    }

 

서비스단

//    4.게시글 상세보기
    public PostDetailDto findById(Long id){
        Post post = postRepository.findByIdAndDelYN(id,DelYN.N).orElseThrow(()->new EntityNotFoundException("없는 게시글입니다"));
        String userId = SecurityContextHolder.getContext().getAuthentication().getName();
        redisServiceForViewCount.increaseViewCount(post.getId(),userId); //해당 포스트에 대해 조회수 1증가시킴
        return post.toDetailDto(redisTemplate, redisServiceForViewCount.getViewCount(id));

    }

사용자가 게시글을 보려하면 이 서비스단 로직이 호출되어 postDetailDto형태로 리턴하고 있다

@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class PostDetailDto {
    private Long postId;
    private Long postUserId;
    private String authorId;
    private String authorNickName;
    private String AuthorImage;
    private int rankingPointOfAuthor;
    private String title;
    private String contents;
    private LocalDateTime createdTime;
    private int likesCount;
    private int viewCount;
    private boolean liked;
    private String categoryName;
    private List<CommentDetailDto> commentList;
    private List<String> attachmentsUrl;
}

postDetailDto 를 보면 List타입으로 댓글리스트를 함께 리턴하는 것을 알 수 있고,

@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class CommentDetailDto {
    private Long commentId;
    private String contents;
    private String profileImageOfCommentAuthor;
    private String nickNameOfCommentAuthor;
    private String loginIdOfCommentAuthor;
    private LocalDateTime createdTime;
    private int rankingPointOfCommentAuthor;
    @Builder.Default
    private List<CommentDetailDto> childCommentList=new ArrayList<>();


    public CommentDetailDto pretendToDelete(){
        this.contents="[삭제된 댓글입니다]";
        return this;
    }
}

CommentDeatilDto는 한 댓글에 자신에게 달린 자식 댓글들(childCommentList)을 포함한다.

 

즉, 1.게시글을 불러온다 -> 2.게시글에 붙어있는 댓글리스트를 같이 불러온다 -> 3.댓글리스트 중 댓글 한 개 한 개는 또 자신에게 달릿 대댓글 리스트를 같이 불러온다.

그러면 여기서 유념해야할 부분은 2번 과정에서 대댓글이 아닌 댓글만 불러와야한다는 것이다.


 

그러면 다시  서비스단에서 Post 객체를 레포지토리에서 가지고 와 DetailDto로 컨트롤러단으로 넘기고 그게 프론트로 리턴되는 이 부분을 살펴보자

 

//    4.게시글 상세보기
    public PostDetailDto findById(Long id){
        Post post = postRepository.findByIdAndDelYN(id,DelYN.N).orElseThrow(()->new EntityNotFoundException("없는 게시글입니다"));
        String userId = SecurityContextHolder.getContext().getAuthentication().getName();
        redisServiceForViewCount.increaseViewCount(post.getId(),userId); //해당 포스트에 대해 조회수 1증가시킴
        return post.toDetailDto(redisTemplate, redisServiceForViewCount.getViewCount(id));

    }

아래가 Post의 toDetailDto 메서드이다. 

public PostDetailDto toDetailDto(RedisTemplate<String,String> redisTemplate,int viewCount){
        List<CommentDetailDto> topLevelComments = new ArrayList<>();
// 게시글 상세보기를 할 때 일단, 대댓글이 아닌 원댓글들만 먼저 조회되게 해준다, 여기서 모든 this.commentList를 조회되게 하면 x
// 사용자가 게시글 볼 때 댓글-대댓글 계층적으로 보여줘야하기때문에 여기서 parent값이 없는 원댓글들만
// comment.toDetailDto로 변환해주면 거기서 재귀적으로 원댓글의 대댓글들을 변환해준다.

        if(this.commentList != null){
            for(Comment c : this.commentList) {
                if (c.getParent() == null && c.getDelYN()==DelYN.N) { //원댓글이면서 삭제되지않은 원댓글들
                    topLevelComments.add(c.toDetailDto());
                } else if(c.getParent()==null && c.getDelYN()==DelYN.Y){
                    topLevelComments.add(c.toDetailDto().pretendToDelete()); //만약에 원댓글이 삭제되어도 자식댓글들은 계속 볼 수 있게 하기 위해 여기서 자식 댓글들을 추가한다.(그런데 eager타입이어야 즉시 반영됨)
                }
            }
        }
        List<String> attachmentUrls = new ArrayList<>();
        if(this.attachmentList != null){
            for(Attachment a : attachmentList){
                attachmentUrls.add(a.getUrlAdress());
            }
        }

        String likeCountKey = "post-" + this.id + "-likeCount";
        String likeUserKey = "post-" + this.id +"-likeUsers";
        String likeCountValue = redisTemplate.opsForValue().get(likeCountKey);
        int likesCount = likeCountValue==null||likeCountValue=="0" ? 0:Integer.parseInt(likeCountValue);
        String userId = SecurityContextHolder.getContext().getAuthentication().getName();
        boolean liked = redisTemplate.opsForSet().isMember(likeUserKey,userId);

        return PostDetailDto.builder()
                .postId(this.id)
                .postUserId(this.user.getId())
                .title(this.title)
                .contents(this.contents)
                .authorId(this.user.getLoginId())
                .authorNickName(this.user.getNickName())
                .AuthorImage(this.user.getProfileImagePath())
                .rankingPointOfAuthor(this.user.getRankingPoint())
                .likesCount(likesCount)
                .liked(liked)
                .viewCount(viewCount)
                .attachmentsUrl(attachmentUrls)
                .commentList(topLevelComments)
                .createdTime(this.getCreatedTime())
                .categoryName(this.category.getCategoryName())
                .build();
    }

}

보면 topLevelComments라는 리스트를 만들고 한 포스트에 달리 댓글중  parentId값이 없는 즉, 원댓글인 댓글들만 topLevelComments 리스트에 넣어 최종적으로 리턴한다.(원댓글이면서 삭제되지않은 원댓글들 주석달린 부분)

이 때, comment의 toDetailDto로 변환하는데,

아래가 comment의 toDeatilDto메서드이다.

public CommentDetailDto toDetailDto(){
    List<CommentDetailDto> childDetailList = new ArrayList<>();

    for(Comment childC : this.childs) {
        if(childC.getDelYN()==DelYN.N) {
            childDetailList.add(childC.toDetailDto()); //재귀호출!!
        } else if(childC.getDelYN()==DelYN.Y){
            childDetailList.add(childC.toDetailDto().pretendToDelete()); //삭제된것 처럼 처리하여 또 대댓글도 보일 수 있도록
        }

    }
            return CommentDetailDto.builder()
                    .commentId(this.id)
                    .contents(this.delYN == DelYN.Y ? "[삭제된 댓글입니다]" : this.contents) // 삭제처리한 댓글이라면 [삭제된 댓글]표시 아니면 원래 내용표시
                    .profileImageOfCommentAuthor(this.user.getProfileImagePath())
                    .loginIdOfCommentAuthor(this.user.getLoginId())
                    .nickNameOfCommentAuthor(this.user.getNickName())
                    .rankingPointOfCommentAuthor(this.user.getRankingPoint())
                    .childCommentList(childDetailList)
                    .createdTime(this.getCreatedTime())
                    .build();
        }

 

여기서도 topLevelComments라는 리스트를 만들었던 것처럼 childDetailList를 만들어 댓글들을 다시 add시키는데 

이 부분이 바로 재귀 호출하는 부분이다.

 

그러니까, 댓글-대댓글-대댓글의 대댓글 - .... 이런식으로 반복되는 구조에 있는 댓글들을 차례대로 가지고 와야하므로 이렇게 복잡하게 구성할 수 밖에 없었다...

(프론트상에서는 댓글-대댓글-대댓글의 대댓글 까지만 달 수 있도록 제한을 두었다.)

 


댓글, 대댓글 로직은 작업할 때 진행하면서도 많이 헷갈렸던 기억이 있다.

지금도 다시 복기하면서도 헷갈리면서.. 이렇게 짜는 방법이 최선인가싶다.

다음번에는 더 나은 코드와 구조를 찾아본 뒤 진행해봐야겠다.