본문 바로가기

Proj/구구모

댓글 및 대댓글 기능 구현

  1. 개요

 간단한 MVP 기능을 구현하고 1차 기능으로 다음의 기능들이 선정되었다

  • 댓글 및 대댓글 기능
  • 댓글 알림 기능
  • 추천 게시글 기능
  • 회원가입 시 이메일 인증
  • 소셜 로그인 기능

 이 중 위의 3개 기능을 맡았고, 하나씩 해보려한다.

 

 2. ERD

이 기능의 관건은 대댓글의 구현이었다. 팀원분들과 상의한 결과 UI 등의 문제로 일단은 대댓글까지만 구현하기로 했다. 한 개의 댓글에서 여러 개의 대댓글이 나올 수 있으므로 다음과 같이 ERD를 설계하였다.

 

 사용자는 게시글과 댓글을 작성할 수 있으며, 하나의 게시글에는 여러 개의 댓글이 귀속된다. 또, 한 개의 댓글로부터 여러 개의 대댓글이 연결될 수 있다. 이를 바탕으로 구현을 하였다.

 

3. 구현

  3-1. Entity

package sideproject.gugumo.domain.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import sideproject.gugumo.domain.entity.post.Post;
import sideproject.gugumo.request.UpdateCommentReq;

import java.time.LocalDateTime;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Comment {

    @Id
    @GeneratedValue
    @Column(name = "comment_id")
    private Long id;

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

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

    @Column(length = 1000)
    private String content;


    private LocalDateTime createDate;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_comment_id")
    private Comment parentComment;

    //부모 댓글이 삭제되었을 경우->parentComment==null->얘가 부모 댓글이라 생각될 수 있음
    //위 이유에 의해 필요한 변수
    private boolean isNotRoot;

    //댓글의 순서를 보장하기 위해 사용되는 변수, 조상 댓글은 게시글의 전체 댓글 수, 손자 댓글은 조상의 변수를 물려받음
    //댓글 조회 시 얘를 기준으로 정렬할 예정
    private long orderNum;

    private boolean isDelete;


    public void tempDelete() {
        this.isDelete = true;
    }

    public void update(UpdateCommentReq req) {
        this.content = req.getContent();
    }


}

 

 ERD에서 작성한 필드값을 넣었다. 대부분 평범한 필드값이지만 대댓글을 구현하면서 필요한 몇 가지 필드를 설명하고자 한다.

 

  • isNotRoot: 대댓글 여부를 확인한다. True일 경우 대댓글, False일 경우 댓글이다 
  • orderNum: 댓글 조회 시 순서를 결정한다. 사용방법은 후술.
  • parentComment: 대댓글일 경우 부모 댓글을 연결한다. 댓글일 경우 null.

 

    @Builder
    public Comment(Post post, Comment parentComment, String content, Member member) {
        this.content = content;
        this.post = post;
        this.parentComment = parentComment;
        this.member = member;
        if (parentComment == null) {
            this.isNotRoot = false;
            this.orderNum = post.getCommentCnt();
        } else {
            this.isNotRoot = true;
            this.orderNum = parentComment.getOrderNum();

        }
        this.createDate = LocalDateTime.now();
        this.isDelete = false;
    }

 

 댓글을 생성하기 위한 빌더 패턴의 생성자이다. 상위 댓글이 없을 경우 isNotRoot=false(댓글), orderNum을 post에서 댓글의 개수를 가져와 저장한다. 상위 댓글이 있을 경우 isNotRoot=true(대댓글), orderNum은 부모댓글의 orderNum을 계승받는다. 이렇게 하면 댓글 조회 시 댓글과 대댓글을 묶어서 조회할 수 있다.

 

  3-2. Service

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class CommentService {

    private final CommentRepository commentRepository;
    private final MemberRepository memberRepository;
    private final PostRepository postRepository;

    @Transactional
    public void save(CreateCommentReq req, CustomUserDetails principal) {

        Member author = checkMemberValid(principal, "댓글 등록 실패: 비로그인 사용자입니다.",
                "댓글 등록 실패: 권한이 없습니다.");

        Post targetPost = postRepository.findByIdAndIsDeleteFalse(req.getPostId())
                .orElseThrow(() -> new PostNotFoundException("댓글 등록 실패: 존재하지 않는 게시글입니다."));

        //삭제된 댓글의 대댓글도 작성할 수 있어야 함->deleteFalse를 확인하지 않음
        Comment parentComment = req.getParentCommentId() != null ?
                commentRepository.findById(req.getParentCommentId())
                        .orElseThrow(()-> new CommentNotFoundException("대댓글의 상위 댓글이 존재하지 않습니다.")) : null;

        Comment comment = Comment.builder()
                .post(targetPost)
                .parentComment(parentComment)
                .member(author)
                .content(req.getContent())
                .build();


        commentRepository.save(comment);
        targetPost.increaseCommentCnt();

    }

    public List<CommentDto> findComment(Long postId, CustomUserDetails principal) {

        return commentRepository.findComment(postId, principal);


    }

    @Transactional
    public void updateComment(Long commentId, UpdateCommentReq req, CustomUserDetails principal) {

        Member member = checkMemberValid(principal, "댓글 갱신 실패: 비로그인 사용자입니다.",
                "댓글 갱신 실패: 권한이 없습니다.");

        Comment comment = commentRepository.findByIdAndIsDeleteFalse(commentId)
                .orElseThrow(() -> new CommentNotFoundException("댓글 갱신 실패: 해당 댓글이 존재하지 않습니다."));


        //댓글 작성자와 토큰 유저 정보가 다를 경우 처리
        if (!comment.getMember().equals(member)) {
            throw new NoAuthorizationException("댓글 갱신 실패: 권한이 없습니다.");
        }

        comment.update(req);


    }

    @Transactional
    public void deleteComment(Long commentId, CustomUserDetails principal) {
        //토큰에서
        Member member = checkMemberValid(principal, "댓글 삭제 실패: 비로그인 사용자입니다.",
                "댓글 삭제 실패: 권한이 없습니다.");

        Comment comment = commentRepository.findByIdAndIsDeleteFalse(commentId)
                .orElseThrow(() -> new CommentNotFoundException("댓글 삭제 실패: 존재하지 않는 댓글입니다."));

        if (!comment.getMember().equals(member)) {
            throw new NoAuthorizationException("댓글 삭제 실패: 권한이 없습니다.");
        }


        comment.tempDelete();
        comment.getPost().decreaseCommentCnt();

    }

    private Member checkMemberValid(CustomUserDetails principal, String noLoginMessage, String notValidUserMessage) {
        if (principal == null) {
            throw new NoAuthorizationException(noLoginMessage);
        }

        Member author = memberRepository.findByUsername(principal.getUsername())
                .orElseThrow(() -> new NoAuthorizationException(notValidUserMessage));

        if (author.getStatus() != MemberStatus.active) {
            throw new NoAuthorizationException(notValidUserMessage);
        }
        return author;
    }


}

 

 전체적으로 토큰으로 member를 조회하고, 필요 시(수정, 삭제) 작성자와 요청자를 비교하고, 요청을 수행하는 코드이다. 

 

  3-3. Repository

@Repository
public interface CommentRepository extends JpaRepository<Comment, Long>, CommentRepositoryCustom {

    public Optional<Comment> findByIdAndIsDeleteFalse(Long id);
}

 

 기본적으로 수정 및 삭제를 위해 spring data jpa를 이용하여 삭제되지 않은 댓글을 조회하는 코드를 작성하였다. 그런데

 

댓글 조회는 이보다 더 복잡한 쿼리를 짜야 하므로 querydsl을 사용하였다. 그 전에 dto를 보자.

package sideproject.gugumo.domain.dto;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.querydsl.core.annotations.QueryProjection;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CommentDto {

    private Long commentId;

    private String author;
    private boolean isYours;
    private boolean isAuthorExpired;

    private String content;
    private LocalDateTime createdDateTime;
    private boolean isNotRoot;
    private Long parentCommentId;       //부모가 존재하지 않으면 null 반환->값을 주지 않음
    private long orderNum;



    @QueryProjection
    public CommentDto(Long commentId, String author, boolean isYours, boolean isAuthorExpired, String content, LocalDateTime createdDateTime, boolean isNotRoot, Long parentCommentId, long orderNum) {
        this.commentId = commentId;
        this.author = author;
        this.isYours = isYours;
        this.isAuthorExpired = isAuthorExpired;
        this.content = content;
        this.createdDateTime = createdDateTime;
        this.isNotRoot = isNotRoot;
        this.parentCommentId = parentCommentId;
        this.orderNum = orderNum;
    }
}

 

 기본적으로 엔티티에 있는 필드값과 댓글 수정 및 삭제 여부를 확인할 수 있는 isYours, 탈퇴 게시자의 닉네임을 숨기기 위한 isAuthorExpired를 넣었다.

 

package sideproject.gugumo.repository;

import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;
import sideproject.gugumo.domain.dto.CommentDto;
import sideproject.gugumo.domain.dto.CustomUserDetails;
import sideproject.gugumo.domain.dto.QCommentDto;
import sideproject.gugumo.domain.entity.Member;
import sideproject.gugumo.domain.entity.MemberStatus;
import sideproject.gugumo.domain.entity.QMember;
import sideproject.gugumo.domain.entity.post.QPost;

import java.util.List;

import static sideproject.gugumo.domain.entity.QComment.comment;
import static sideproject.gugumo.domain.entity.QMember.member;
import static sideproject.gugumo.domain.entity.post.QPost.post;


public class CommentRepositoryImpl implements CommentRepositoryCustom{

    private final JPAQueryFactory queryFactory;
    private final MemberRepository memberRepository;


    public CommentRepositoryImpl(EntityManager em, MemberRepository memberRepository) {
        this.queryFactory = new JPAQueryFactory(em);
        this.memberRepository = memberRepository;
    }


    @Override
    public List<CommentDto> findComment(Long postId, CustomUserDetails principal) {

        Member user =
                principal == null ?
                        null : memberRepository.findByUsername(principal.getUsername()).get();

        if (user != null && user.getStatus() != MemberStatus.active) {
            user = null;
        }


        //isYours, isAuthorExpired 추가
        List<CommentDto> result = queryFactory.select(new QCommentDto(
                        comment.id,
                        comment.member.nickname,
                        user != null ? comment.member.eq(user) : Expressions.FALSE,
                        comment.member.isNull().or(comment.member.status.eq(MemberStatus.delete)),
                        comment.content,
                        comment.createDate,
                        comment.isNotRoot,
                        comment.parentComment.id,
                        comment.orderNum
                ))
                .from(comment)
                .join(comment.post, post)
                .leftJoin(comment.member, member)
                .where(
                        comment.post.id.eq(postId), comment.isDelete.isFalse()
                )
                .orderBy(comment.orderNum.asc(), comment.createDate.asc())
                .fetch();

        return result;
    }
}

 

 postId를 가져와서 각 필드를 찾는 코드이다. post에 맞는 comment와 삭제 여부를 확인하고, orderNum으로 정렬, 같을 경우(같은 댓글 및 아래의 대댓글) 생성 날짜를 기준으로 정렬하여 반환한다.

 

  3-4 Controller

package sideproject.gugumo.api.controller;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import sideproject.gugumo.domain.dto.CommentDto;
import sideproject.gugumo.domain.dto.CustomUserDetails;
import sideproject.gugumo.page.PageCustom;
import sideproject.gugumo.request.CreateCommentReq;
import sideproject.gugumo.request.UpdateCommentReq;
import sideproject.gugumo.response.ApiResponse;
import sideproject.gugumo.service.CommentService;

import java.util.List;

@RestController
@RequestMapping("/api/v1/comment")
@RequiredArgsConstructor
public class CommentController {

    private final CommentService commentService;

    @PostMapping("/new")
    @ResponseStatus(HttpStatus.CREATED)
    public ApiResponse<String> saveComment(@AuthenticationPrincipal CustomUserDetails principal,
                                           @Valid @RequestBody CreateCommentReq req) {
        commentService.save(req, principal);

        return ApiResponse.createSuccess("댓글 저장 완료");

    }

    @GetMapping("/{post_id}")
    public ApiResponse<List<CommentDto>> findComment(@AuthenticationPrincipal CustomUserDetails principal,
                                                     @PathVariable("post_id") Long postId) {

        return ApiResponse.createSuccess(commentService.findComment(postId, principal));
    }

    @PatchMapping("/{comment_id}")
    public ApiResponse<String> updateComment(@AuthenticationPrincipal CustomUserDetails principal,
                                             @PathVariable("comment_id") Long commentId,
                                             @RequestBody UpdateCommentReq req) {

        commentService.updateComment(commentId, req, principal);

        return ApiResponse.createSuccess("댓글 갱신 완료");

    }

    @DeleteMapping("/{comment_id}")
    public ApiResponse<String> deleteComment(@AuthenticationPrincipal CustomUserDetails principal,
                                             @PathVariable("comment_id") Long commentId) {
        commentService.deleteComment(commentId, principal);

        return ApiResponse.createSuccess("댓글 삭제 완료");

    }
}

 

 해당 요청을 처리하여 응답을 반환하는 코드이다. 

 

4. 후기

 점점 로직이 복잡해지는 기분이다. 이전 MVP에서는 블로그에 적지는 않았지만 기본적인 CRUD+@를 구현하는 느낌이었다면 이번에는 대댓글을 처리하는 방법을 고민해야 했다. 다음은 댓글 알림 기능으로 이것보다 더 어려운 로직을 구현할 예정이다. 갈 길이 멀다..

'Proj > 구구모' 카테고리의 다른 글

댓글 알림 구현(feat. sse)  (0) 2024.06.25
EC2 한국 시간으로 변경  (0) 2024.06.08
스프링 스케쥴러를 이용한 게시글 마감 처리  (0) 2024.06.07
추천 게시글 기능 구현  (0) 2024.06.04
AWS EC2를 이용한 배포  (0) 2024.05.23