- [feature] 댓글 기능 추가2025년 02월 19일 22시 49분 06초에 업로드 된 글입니다.작성자: do_hyuk
현재 mvp 기능들만 구현하고 배포한 웹 서비스에 댓글 기능을 추가하자는 의견이 나와서 추가해보도록 하자.
요구사항
- 대댓글 방식은 닉네임 언급 방식으로 진행
- 상위 댓글과 하위 댓글에 각각 페이지네이션 적용
- 상위 댓글이 삭제될 시 하위 댓글 모두 삭제
- 하위 댓글이 삭제되면 해당 객체 하나만 삭제
설계
댓글의 연관관계를 보면 다음과 같다.
코드 게시글 (1) - (N) 댓글
TIL 게시글 (1) - (N) 댓글
회원과 연관관계를 맺지 않은 이유는 우리 서비스 상에서 회원이 자신이 작성한 댓글에 접근할 일이 없다고 판단하였기에 불필요하다 생각되어 관계를 맺지 않고 회원 아이디만 따로 컬럼으로 저장하도록 했다.
또한 댓글 테이블의 컬럼 중 parent_id는 대댓글 구현을 위해 상위 댓글의 아이디를 저장하는 컬럼이다.
게시글 마다 댓글 테이블을 따로 만들기로 결정했기 때문에 CodePostComment 와 TilPostComment의 entity, service, controller 레이어를 따로 작성해야 했다. 하지만 댓글 로직이 게시글마다 다른 기능이 있는게 아니기 때문에 비효율적으로 코드가 중복되는 것을 방지하기 위해서 Base로 둘 레이어들을 작성하고 상속받는 방식으로 작성했다.
TIL 관련 코드는 넣지않았다. 블로그 글이 너무 길어져서...
구현
1. Entity
Comment
package org.example.autoreview.domain.comment.base; import jakarta.persistence.Column; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.MappedSuperclass; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import org.example.autoreview.domain.comment.base.dto.request.CommentUpdateRequestDto; import org.example.autoreview.global.common.basetime.BaseEntity; import org.hibernate.annotations.ColumnDefault; @MappedSuperclass @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public abstract class Comment extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private Long writerId; @Column(nullable = false) private String writerNickName; @Column(nullable = true) private String targetNickName; @Column(nullable = false) private String body; // 댓글 공개 여부 컬럼 추가 @ColumnDefault("true") @Column(nullable = false) private boolean isPublic; // 공통 필드 및 메서드 protected Comment(String targetNickName, String body, Long writerId, String writerNickName) { this.targetNickName = targetNickName; this.body = body; this.writerId = writerId; this.writerNickName = writerNickName; } public void update(CommentUpdateRequestDto requestDto) { this.writerNickName = requestDto.writerNickName(); this.targetNickName = requestDto.targetNickName(); this.body = requestDto.body(); } public abstract Long getParentId(); }
CodePostComment
package org.example.autoreview.domain.comment.codepost.entity; import jakarta.persistence.*; import java.util.ArrayList; import java.util.List; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.example.autoreview.domain.codepost.entity.CodePost; import org.example.autoreview.domain.comment.base.Comment; @Getter @NoArgsConstructor @Entity public class CodePostComment extends Comment { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "code_post_id") private CodePost codePost; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(nullable = true) private CodePostComment parent; @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL) private List<CodePostComment> children = new ArrayList<>(); @Builder public CodePostComment(CodePost codePost, CodePostComment parent, String targetNickName, String body, Long writerId, String writerNickName) { super(targetNickName, body, writerId, writerNickName); this.codePost = codePost; this.parent = parent; } @Override public Long getParentId() { return this.parent != null ? this.parent.getId() : null; } }
2. Service
CommentService
package org.example.autoreview.domain.comment.base; import lombok.RequiredArgsConstructor; import org.example.autoreview.domain.comment.base.dto.request.CommentDeleteRequestDto; import org.example.autoreview.domain.comment.base.dto.request.CommentSaveRequestDto; import org.example.autoreview.domain.comment.base.dto.request.CommentUpdateRequestDto; import org.example.autoreview.domain.comment.base.dto.response.CommentListResponseDto; import org.example.autoreview.domain.comment.base.dto.response.CommentResponseDto; import org.example.autoreview.domain.member.entity.Member; import org.example.autoreview.domain.member.service.MemberCommand; import org.example.autoreview.global.exception.base_exceptions.CustomRuntimeException; import org.example.autoreview.global.exception.errorcode.ErrorCode; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.stream.Collectors; @RequiredArgsConstructor public abstract class CommentService<C extends Comment, R extends CommentRepository<C>> { protected final R commentRepository; protected final CommentCommand<C,R> commentCommand; protected final MemberCommand memberCommand; @Transactional public Long save(CommentSaveRequestDto requestDto, String email) { Member writer = memberCommand.findByEmail(email); if (requestDto.parentId() != null) { C parent = commentCommand.findById(requestDto.parentId()); return commentRepository.save(createReplyEntity(requestDto, parent, writer)).getId(); } return commentRepository.save(createCommentEntity(requestDto, writer)).getId(); } protected abstract C createReplyEntity(CommentSaveRequestDto requestDto, C parent, Member writer); protected abstract C createCommentEntity(CommentSaveRequestDto requestDto, Member writer); // 상위 댓글 조회 public CommentListResponseDto findByCommentPage(Long postId, Pageable pageable) { Page<C> commentPage = commentRepository.findByCommentPage(postId, pageable); List<CommentResponseDto> dtoList = convertPageToListDto(commentPage); return new CommentListResponseDto(dtoList, commentPage.getTotalPages()); } // 하위 댓글 조회 public CommentListResponseDto findByReplyPage(Long postId, Long parentId, Pageable pageable) { Page<C> replyPage = commentRepository.findByReplyPage(postId, parentId, pageable); List<CommentResponseDto> dtoList = convertPageToListDto(replyPage); return new CommentListResponseDto(dtoList, replyPage.getTotalPages()); } private List<CommentResponseDto> convertPageToListDto(Page<C> page) { return page.stream() .map(CommentResponseDto::new) .collect(Collectors.toList()); } // 수정 @Transactional public Long update(CommentUpdateRequestDto requestDto, String email) { C comment = commentCommand.findById(requestDto.commentId()); memberValidator(comment.getWriterId(), email); comment.update(requestDto); return comment.getId(); } // 삭제 @Transactional public Long delete(CommentDeleteRequestDto requestDto, String email) { C comment = commentCommand.findById(requestDto.commentId()); memberValidator(comment.getWriterId(), email); commentRepository.delete(comment); // deleteById 는 내부적으로 findById가 존재해서 조회가 한 번 더 일어남 return requestDto.commentId(); } private void memberValidator(Long writerId, String email) { Member writer = memberCommand.findById(writerId); if (!writer.getEmail().equals(email)) { throw new CustomRuntimeException(ErrorCode.UNMATCHED_EMAIL); } } }
CodePostCommentService
package org.example.autoreview.domain.comment.codepost.service; import lombok.extern.slf4j.Slf4j; import org.example.autoreview.domain.codepost.entity.CodePost; import org.example.autoreview.domain.codepost.service.CodePostCommand; import org.example.autoreview.domain.comment.base.CommentService; import org.example.autoreview.domain.comment.base.dto.request.CommentSaveRequestDto; import org.example.autoreview.domain.comment.codepost.entity.CodePostComment; import org.example.autoreview.domain.comment.codepost.entity.CodePostCommentRepository; import org.example.autoreview.domain.member.entity.Member; import org.example.autoreview.domain.member.service.MemberCommand; import org.springframework.stereotype.Service; @Slf4j @Service public class CodePostCommentService extends CommentService<CodePostComment, CodePostCommentRepository> { private final CodePostCommand codePostCommand; public CodePostCommentService(CodePostCommentRepository codePostCommentRepository, CodePostCommentCommand codePostCommentCommand, CodePostCommand codePostCommand, MemberCommand memberCommand) { super(codePostCommentRepository, codePostCommentCommand, memberCommand); this.codePostCommand = codePostCommand; } @Override protected CodePostComment createReplyEntity(CommentSaveRequestDto requestDto, CodePostComment parent, Member writer) { CodePost codePost = codePostCommand.findById(requestDto.postId()); return requestDto.toCodePostReplyEntity(codePost, parent, writer); } @Override protected CodePostComment createCommentEntity(CommentSaveRequestDto requestDto, Member writer) { CodePost codePost = codePostCommand.findById(requestDto.postId()); return requestDto.toCodePostCommentEntity(codePost, writer); } }
2-2 Command
command 클래스는 Service 레이어를 하나만 두기 위해 Repository에서 데이터를 받아오는 역할을 한다.
service 레이어와 다른 점은 트랜잭션을 마음대로 적용할 수 있다는 것이다.
CommentCommand
package org.example.autoreview.domain.comment.base; import lombok.RequiredArgsConstructor; import org.example.autoreview.global.exception.base_exceptions.CustomRuntimeException; import org.example.autoreview.global.exception.errorcode.ErrorCode; @RequiredArgsConstructor public abstract class CommentCommand<C extends Comment, R extends CommentRepository<C>> { protected final R repository; public C findById(Long id) { return repository.findById(id).orElseThrow( () -> new CustomRuntimeException(ErrorCode.NOT_FOUND_COMMENT) ); } }
CodePostCommentCommand
package org.example.autoreview.domain.comment.codepost.service; import org.example.autoreview.domain.comment.base.CommentCommand; import org.example.autoreview.domain.comment.codepost.entity.CodePostComment; import org.example.autoreview.domain.comment.codepost.entity.CodePostCommentRepository; import org.springframework.stereotype.Component; @Component public class CodePostCommentCommand extends CommentCommand<CodePostComment, CodePostCommentRepository> { public CodePostCommentCommand(CodePostCommentRepository repository) { super(repository); } }
3. Repository
CommentRepository
package org.example.autoreview.domain.comment.base; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.repository.NoRepositoryBean; import org.springframework.data.repository.query.Param; @NoRepositoryBean public interface CommentRepository<C extends Comment> extends JpaRepository<C, Long> { Page<C> findByCommentPage(@Param("postId") Long postId, Pageable pageable); Page<C> findByReplyPage(@Param("postId") Long postId, @Param("parentId") Long parentId, Pageable pageable); }
CodePostCommentRepository
package org.example.autoreview.domain.comment.codepost.entity; import io.lettuce.core.dynamic.annotation.Param; import org.example.autoreview.domain.comment.base.CommentRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; public interface CodePostCommentRepository extends CommentRepository<CodePostComment> { @Query("SELECT c FROM CodePostComment c WHERE c.codePost.id = :postId AND c.parent is null ORDER BY c.createDate ASC") Page<CodePostComment> findByCommentPage(@Param("postId") Long postId, Pageable pageable); @Query("SELECT c FROM CodePostComment c WHERE c.codePost.id = :postId AND c.parent.id = :parentId ORDER BY c.createDate ASC") Page<CodePostComment> findByReplyPage(@Param("postId") Long postId, @Param("parentId") Long parentId, Pageable pageable); }
추상화에 대해 정확히 알아보지 않고 적용했기 때문에 부족한 부분이 많을 수 있다.
좀 더 찾아보고 나중에 부족해 보이는 부분은 리팩토링 하겠다.
'포트폴리오 > AutoReview' 카테고리의 다른 글
K6로 토큰 인증 절차가 필요한 API 테스트 하기 (0) 2025.02.24 [feature] 댓글 공개 여부 기능 추가(1) (0) 2025.02.21 트랜잭션(Transaction) 분리하기 (0) 2025.02.11 [트러블 슈팅] FCM 오류 해결(2024.11.29) (1) 2025.02.02 [트러블 슈팅] 배포 서버 CI 자동화 적용 이후 오류 발생 (0) 2025.01.22 댓글