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 |