[스프링부트+JPA+queryDsl] 대댓글(계층형) 구현

손성우·2022년 8월 8일
8

JPA

목록 보기
2/3

요구사항
: 무한 대댓글 기능을 구현하고, 게시글 및 부모 댓글 삭제시 자식 댓글들 모두 삭제하기.

  1. Post(게시글) Entity
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class Post extends Timestamped {

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

  @Column(nullable = false)
  private String title;

  @Column(nullable = false)
  private String content;

  @OneToMany(fetch = FetchType.LAZY, mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
  private List<Comment> comments;

  @JoinColumn(name = "member_id", nullable = false)
  @ManyToOne(fetch = FetchType.LAZY)
  private Member member;

  public void update(PostRequestDto postRequestDto) {
    this.title = postRequestDto.getTitle();
    this.content = postRequestDto.getContent();
  }

  public boolean validateMember(Member member) {

    return !this.member.equals(member);
  }

}

@OneToMany(fetch = FetchType.LAZY, mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)를 이용해 최상위 객체인 게시글이 삭제 되면 그 게시글에 등록되어있던 댓글들 모두 삭제가 되게끔 한다. 여기서 중요한건 mappedBy = "post"를 하지 않으면, 연관관계의 주인이 설정되지 않아 게시글을 삭제할경우 참조키 제약조건 위반으로 예외가 생긴다.

  1. Comment(댓글) Entity
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class Comment extends Timestamped {

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

  @JoinColumn(name = "member_id", nullable = false)
  @ManyToOne(fetch = FetchType.LAZY)
  private Member member;

  @JoinColumn(name = "post_id", nullable = false)
  @ManyToOne(fetch = FetchType.LAZY)
  private Post post;

  @Column(nullable = false)
  private String content;

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

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

  public void update(CommentRequestDto commentRequestDto) {
    this.content = commentRequestDto.getContent();
  }

  // 부모 댓글 수정
  public void updateParent(Comment parent){
    this.parent = parent;
  }

  public boolean validateMember(Member member) {
    return !this.member.equals(member);
  }
}

Comment라는 엔터티 하나에 부모Comment와 자식Comment(List)를 넣어서 계층형 테이블로 구성한다.

1
  2
    3
7
  4
    5
      6  

이런 식의 구조가 될것이다.

3.댓글 저장

@Transactional
  public ResponseDto<?> createComment(CommentRequestDto requestDto, HttpServletRequest request) {

    
    Post post = checkMemberUtil.isPresentPost(requestDto.getPostId());
    if (null == post) {
      return ResponseDto.fail("NOT_FOUND", "존재하지 않는 게시글 id 입니다.");
    }

    Comment parent = null;
    // 자식댓글인 경우
    if(requestDto.getParentId() != null){
      parent = checkMemberUtil.isPresentComment(requestDto.getParentId());
      if (null == parent) {
        return ResponseDto.fail("NOT_FOUND", "존재하지 않는 댓글 id 입니다.");
      }
      // 부모댓글의 게시글 번호와 자식댓글의 게시글 번호 같은지 체크하기
      if(parent.getPost().getId() != requestDto.getPostId()){
        return ResponseDto.fail("NOT_FOUND", "부모댓글과 자식댓글의 게시글 번호가 일치하지 않습니다.");
      }
    }

    Comment comment = Comment.builder()
        .member(member)
        .post(post)
        .content(requestDto.getContent())
        .build();
    if(null != parent){
      comment.updateParent(parent);
    }
    commentRepository.save(comment);

    CommentResponseDto commentResponseDto = null;
    if(parent != null){
      commentResponseDto = CommentResponseDto.builder()
              .id(comment.getId())
              .author(comment.getMember().getNickname())
              .content(comment.getContent())
              .createdAt(comment.getCreatedAt())
              .modifiedAt(comment.getModifiedAt())
              .parentId(comment.getParent().getId())
              .build();
    } else {
      commentResponseDto = CommentResponseDto.builder()
              .id(comment.getId())
              .author(comment.getMember().getNickname())
              .content(comment.getContent())
              .createdAt(comment.getCreatedAt())
              .modifiedAt(comment.getModifiedAt())
              .build();
    }

    return ResponseDto.success(commentResponseDto);
  }

API요청을 하나로 처리하기 때문에, 자식 댓글인지 부모댓글인지를 구분하며 Comment를 생성해 저장합니다.

  1. 댓글 조회

CustomCommentRepository

@Repository
public class CommentCustomRepository {

    private JPAQueryFactory jpaQueryFactory;

    public CommentCustomRepository(JPAQueryFactory jpaQueryFactory) {
        this.jpaQueryFactory = jpaQueryFactory;
    }

    // 게시글의 댓글 전체 가져오기
    public List<Comment> findAllByPost(Post post){
        return jpaQueryFactory.selectFrom(comment)
                .leftJoin(comment.parent)
                .fetchJoin()
                .where(comment.post.id.eq(post.getId()))
                .orderBy(comment.parent.id.asc().nullsFirst(), comment.createdAt.asc())
                .fetch();
    }
}

출력결과 예시
1 Null
6 Null
2 1
3 1
4 2
5 2

1) 댓글 조회가 가장 중요한 부분이라 이 부분은 자세하게 풀어서 설명을 해보겠다.
먼저 querydsl로 조회를 한다. 부모 댓글이 NULL이면 최상위 댓글이므로 가장 먼저 조회되기 위해 nullsFirst()를 사용했고 그 다음은 작성시간순으로 조회했다. 중요한것은 fetchJoin()을 통해 N+1을 해결할 수 있는데 자세한 내용은 https://cobbybb.tistory.com/18 을 참고하기.
위 결과를 대댓글의 중첩구조로 바꿔야 한다.
1의 자식들은 (2,3), 그 다음 2의 자식들은 (4,5) 그 다음 6이런 식으로 말이다.

@Transactional(readOnly = true)
  public ResponseDto<?> getAllCommentsByPost(Long postId) {
    Post post = checkMemberUtil.isPresentPost(postId);
    if (null == post) {
      return ResponseDto.fail("NOT_FOUND", "존재하지 않는 게시글 id 입니다.");
    }

    List<Comment> commentList = commentCustomRepository.findAllByPost(post);

    List<CommentResponseDto> commentResponseDtoList = new ArrayList<>();
    Map<Long, CommentResponseDto> map = new HashMap<>();

    commentList.stream().forEach(c -> {
              CommentResponseDto cdto = new CommentResponseDto(c);
              if(c.getParent() != null){
                cdto.setParentId(c.getParent().getId());
              }
              map.put(cdto.getId(), cdto);
              if (c.getParent() != null) map.get(c.getParent().getId()).getChildren().add(cdto);
              else commentResponseDtoList.add(cdto);
            }
      );
    return ResponseDto.success(commentResponseDtoList);
  }

2) List< CommentResponseDto > commentResponseDtoList = new ArrayList<>() 이 밑으로가 중첩구조로 바꾸는 기능을 하는데 풀이를 하자면, Map에는 임시로 response할 객체를 담는다.
그리고 자식댓글이면(부모댓글이 null이 아니면) map에서 부모댓글을 찾아서 자식댓글 리스트(children)에 추가해준다.
최상위댓글(parentId == null)이면 바로 리스트에 추가해준다.

삭제랑 수정은 매우 간단하니 패스!

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

profile
백엔드 개발자를 꿈꾸며 공부한 내용을 기록하고 있습니다.

3개의 댓글

comment-user-thumbnail
2022년 11월 13일

좋은글 감사합니다^^

답글 달기
comment-user-thumbnail
2023년 3월 8일

대댓글 리턴하는 코드 이해하느라 고생했네요 ㅋㅋ
감사합니다 도움많이되었습니다.!!

답글 달기
comment-user-thumbnail
2023년 11월 13일

좋은글 잘보고있습니다.. 혹시 대댓글 관련 코드 전문을 볼수는 없을까요? checkMemberUtil이나 dto같은 부분을 살펴보고 싶습니다

답글 달기