[스프링부트+JPA+queryDsl] 대댓글(계층형) 구현
해당글의 엔티티부분을 참고했으나 나머지 부분들은 제 입맛에 맞게 수정한 코드입니다. 특정한 부분이 포함이 되어있지 않을 수 있으나 저같이 초보 개발자들이 따라하기 나쁘지 않은 로직을 정리해 기록하기 위해 작성한 글입니다.

타임리프로 갖고놀기

단순히 복잡한 로직을 구현할 필요 없이 타임리프로 어떠한 댓글은 숨기고 어떠한 댓글은 출력하는 방식으로 대댓글 입력/출력을 구현해보자.

해당 방식은 대댓글에도 대댓글을 등록할 수 있는게 아닌 댓글에 대댓글만 등록할 수 있는 로직을 구현한다.

엔티티 구성하기

기존의 댓글 하나만 입력할 수 있던 로직에서 연관관계를 설정할 부모 댓글 컬럼을 생성해주고, 댓글들을 불러올때 필요한 자식 댓글을 담아줄 HashSet, 그리고 부모댓글인지 확인해줄 isParent 컬럼을 생성하자.

댓글을 등록할때는 대댓글이 아니라면 isParent 를 Y 으로 설정해 값을 넣어주고
대댓글일때는 N를 입력해 값을 넣어줘 타임리프에서 댓글 사이에 대댓글이 들어가는 형태로 뷰를 작성할 계획이다.

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

    @Setter
    @OneToMany(mappedBy = "parent")
    @ToString.Exclude
    private Set<ArticleComment> children = new LinkedHashSet<>();

    @Setter
    private String isParent;

기존에 생성해놨던 댓글 엔티티에 이렇게 대댓글 로직을 위한 멤버변수들을 선언해준다.
부모댓글의 경우에는 여러개의 답글이 하나의 댓글로 연관관계가 설정될것이니 @ManyToOne 어노테이션을 사용해준다.

자식댓글의 경우에는 무한참조가 발생될 수 있기 때문에 @ToString.Exclude 어노테이션을 사용해주고, 자식댓글또한 여러개의 답글이 하나의 댓글에 등록될것이니 원투매니를 사용해준다.

기존에 만들어둔 dto 수정하기

기존에 만들어둔 dto는 대댓글을 출력할때 필요한 isParent 와 children set 을 전달해주지 않으니 차례대로 수정을 해주자.

public record ArticleCommentDto(
        Long id,
        Long articleId,
        UserAccountDto userAccountDto,
        String content,
        LocalDateTime createdAt,
        String createdBy,
        LocalDateTime modifiedAt,
        String modifiedBy,
        String deleted,
        Set<ArticleCommentDto> children,
        String isParent
) {
    public static ArticleCommentDto of(Long articleId, UserAccountDto userAccountDto, String content,  Set<ArticleCommentDto> children,String isParent) {
        return new ArticleCommentDto(null, articleId, userAccountDto, content, null, null, null, null, null,  children, isParent);
    }
    public static ArticleCommentDto of(Long id, Long articleId, UserAccountDto userAccountDto, String content, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy,String deleted,  Set<ArticleCommentDto> children,String isParent) {
        return new ArticleCommentDto(id, articleId, userAccountDto, content, createdAt, createdBy, modifiedAt, modifiedBy, deleted,  children, isParent);
    }

    public static ArticleCommentDto from(ArticleComment entity) {
        return new ArticleCommentDto(
                entity.getId(),
                entity.getArticle().getId(),
                UserAccountDto.from(entity.getUserAccount()),
                entity.getContent(),
                entity.getCreatedAt(),
                entity.getCreatedBy(),
                entity.getModifiedAt(),
                entity.getModifiedBy(),
                entity.getDeleted(),
                entity.getChildren().stream()
                        .map(ArticleCommentDto::from)
                        .collect(Collectors.toCollection(LinkedHashSet::new)),
                entity.getIsParent()
        );
    }

기존에 deleted 까지만 있던 부분에서 children 과 isParent 가 추가되었다.
자식댓글은 기존에 엔티티이므로 dto로 다시 변환해 linkedHashSet으로 전달해준다.

마찬가지로 reponse dto 에 isParent 와 children 을 추가해주고, request 에는 부모댓글 등록에 필요한 ParentId 를 추가해주면 된다.

public record ArticleCommentRequest(
        Long articleId,
        String content,
        Long parentId) implements Serializable {

    public static ArticleCommentRequest of(Long articleId, String content) {
        return new ArticleCommentRequest(articleId, content, null);
    }
    public static ArticleCommentRequest of(Long articleId, String content, Long parentId) {
        return new ArticleCommentRequest(articleId, content, parentId);
    }
    public ArticleCommentDto toDto(UserAccountDto userAccountDto) {
        return ArticleCommentDto.of(
                articleId,
                userAccountDto,
                content,
                null,
                null
        );
    }
}

request 같은 경우에는 댓글을 등록할때 children, isParent 를 설정할것이기 때문에 null로 전달해도 상관없다. (사실 이 부분은 구조적으로 결함이 있는것 같다. dto 에서 해당 부분을 입력받지 않도록 수정해도 된다.)

비즈니스 로직 수정

이제 기존에 만들어둔 서비스클래스를 수정할 차례이다. 대댓글이 아니라면 isParent 에 Y를 입력해 등록하고, 대댓글이라면 isParent에 N을 입력하고 따로 부모댓글을 등록하고 부모댓글의 children 에 대댓글을 넣어줘야하기 때문에 메소드를 따로 선언해주었다.

저는 TDD방식으로 차근차근 코드를 작성해나갔으나 이 글은 구현 순서를 기록해두는 글이므로 테스트코드 작성은 패스하겠습니다.

    public void saveArticleComment(ArticleCommentDto dto) {
        try {
            Article article = articleRepository.getReferenceById(dto.articleId());
                ArticleComment comment = articleCommentRepository.save( dto.toEntity(article,dto.userAccountDto().toEntity()));
                comment.setIsParent("Y");
        } catch (EntityNotFoundException e) {
            log.warn("댓글 저장 실패. 댓글 작성에 필요한 정보를 찾을 수 없습니다 - {}", e.getLocalizedMessage());
        }
    }
    
        public void saveChildrenComment(Long parentId, ArticleCommentDto children) {
        try {
            ArticleComment parent = articleCommentRepository.getReferenceById(parentId);
            ArticleComment articleComment = children.toEntity(parent.getArticle(),children.userAccountDto().toEntity());
            articleCommentRepository.save(articleComment);
            articleComment.setIsParent("N");
            articleComment.setParent(parent);
            Set<ArticleComment> set = parent.getChildren();
            set.add(articleComment);
            parent.setChildren(set);
        } catch (EntityNotFoundException e) {
            log.warn("댓글 저장 실패. 댓글 작성에 필요한 정보를 찾을 수 없습니다 - {}", e.getLocalizedMessage());
        }
    }

이렇게 부모댓글 저장용 메소드와 자식댓글 저장용 메소드를 따로 선언한다.

*jpa는 따로 수정해 다시 세이브를 하지 않고 set 만 해줘도 자동으로 업데이트가 된다.

컨트롤러 수정

이제 따로 대댓글 입력을 위한 컨트롤러 메소드를 선언해주면 된다.

    @PreAuthorize("isAuthenticated()")
    @PostMapping("/{articleId}/reply")
    public String writeChildrenComment(@PathVariable Long articleId,ArticleCommentRequest dto, @AuthenticationPrincipal BoardPrincipal principal) {
        if (principal.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_USER"))) {
            articleCommentService.saveChildrenComment(dto.parentId(),dto.toDto(principal.toDto()));
            return "redirect:/articles/" + articleId;
        }
        return "redirect:/articles/" + articleId;
    }

댓글,대댓글을 입력하기 위해서는 인증이 이미 되어있어야하기 때문에 인터셉터를 위해 @PreAuthorize 어노테이션을 사용해준다.

이러면 끝일줄 알았지?~~~ 타임리프로 구현하는게 더 어려웠다.

다음글에서 이어집니다.

profile
자스코드훔쳐보는변태

0개의 댓글