필요 사항
- 댓글 저장 시 필요한 내용
- 제목(타이틀)
- 댓글 내용
- 부모 댓글 ID(부모 댓글이 없을 경우 - 최상위 댓글 Null)
- 작성자 ID(유저 ID)
- 게시글 ID
- 댓글 삭제 시 필요한 내용
- 댓글 ID
- 부모 댓글 ID(대댓글 일 경우에)
- 자식 댓글이 있다면 삭제 상태만 변경(부모 댓글 내용을 "삭제된 댓글입니다." 등으로 표시)
- 삭제 상태인 부모 댓글은 속해 있는 자식 댓글이 없을 경우 삭제(부모 댓글 삭제 상태가 True이고 해당 부모 댓글에 속한 자식 댓글이 모두 삭제 되면 부모 댓글도 DB에서 삭제)
댓글에 대한 대댓글 기능 구현 시 설계 접근 방식
1. 자기 참조를 사용한 엔티티 설계
댓글 엔티티를 자기 자신을 참조하도록 설계하여, 각 댓글이 부모 댓글을 가리킬 수 있도록 합니다.
이 방식은 하나의 댓글 테이블만을 사용하여 간단하게 대댓글 관계를 표현할 수 있습니다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Table(name = "comment")
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long commentId;
@Column(length = 100)
@Schema(name = "Comment Content", description = "댓글 내용", example = "와 너무 유익해요!")
@NotNull(message = "빈 댓글은 쓸 수 없습니다.")
private String commentContent;
@ManyToOne(cascade = CascadeType.PERSIST)
@JoinColumn(name = "community_id")
@Schema(name = "커뮤니티 게시글 ID")
private Community community;
@ManyToOne(cascade = CascadeType.PERSIST)
@JoinColumn(name = "user_id")
@Schema(name = "유저 ID")
private User user;
@CreatedDate
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
@Schema(name = "댓글 생성 시간")
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_comment_id")
@Schema(name = "부모 댓글 여부 확인")
private Comment parentComment;
@OneToMany(mappedBy = "parentComment", orphanRemoval = true)
@Schema(name = "대댓글 목록")
private List<Comment> childComments = new ArrayList<>();
@Column(name = "is_deleted", nullable = false)
@Schema(name = "부모 댓글 삭제 상태")
private boolean isDeleted = false;
@Builder
public Comment(String commentContent, Community community, User user, Comment parentComment) {
this.commentContent = commentContent;
this.community = community;
this.user = user;
this.parentComment = parentComment;
}
public void markAsDeleted() {
this.isDeleted = true;
this.updateComment("삭제된 댓글입니다");
}
public boolean isDeleted() {
return isDeleted;
}
public void updateComment(String commentContent) {
this.commentContent = commentContent;
}
}
- isDeleted : 자식 댓글이 있는 부모 댓글을 삭제 시 DB에서 삭제되지 않고 "삭제된 메시지 입니다."로 내용이 바뀌고 삭제 됨 상태가 true로 변경 됨, 자식 댓글이 모두 삭제되었을 때 isDeleted가 true면 db에서 해당 댓글을 삭제 처리
- parentComment : 대댓글의 경우 부모 댓글 ID 정보를 저장함, 최상위 댓글(부모가 없는 댓글의 경우 Null 값으로 저장 됨)
2. 계층 쿼리 활용
데이터베이스 쿼리에서 계층적 데이터를 효율적으로 조회하기 위해, SQL의 계층 쿼리 기능(예: CTE, Common Table Expressions)을 사용할 수 있습니다.
JPA에서는 직접적으로 지원하지 않으므로, 네이티브 쿼리를 사용해야 할 수 있습니다.
QueryDSL 구현 예제
CustomRepository
public interface CommentCustomRepository {
Optional<Comment> findByCommunityAndCommentId(Community community, Long commentId);
Page<Comment> findByCommunityAndParentCommentIsNull(Community community, Pageable pageable);
}
CustomRepositoryImpl
@RequiredArgsConstructor
public class CommentRepositoryImpl implements CommentCustomRepository {
private final JPAQueryFactory queryFactory;
@Override
public Optional<Comment> findByCommunityAndCommentId(Community community, Long commentId) {
QComment comment = QComment.comment;
Comment result = queryFactory
.selectFrom(comment)
.where(comment.community.eq(community), comment.commentId.eq(commentId))
.fetchOne();
return Optional.ofNullable(result);
}
@Override
public Page<Comment> findByCommunityAndParentCommentIsNull(Community community, Pageable pageable) {
QComment comment = QComment.comment;
/* 부모 댓글이 null인 조건을 추가하여 QueryDSL 쿼리 구성*/
JPAQuery<Comment> query = queryFactory
.selectFrom(comment)
.where(comment.community.eq(community)
.and(comment.parentComment.isNull()));
/* 페이지네이션을 위한 정렬 및 페이징 적용*/
Sort.Order sortOrder = pageable.getSort().isSorted() ? pageable.getSort().iterator().next() : null;
OrderSpecifier<?> orderBySpecifier = sortOrder != null
? (sortOrder.isAscending() ? comment.createdAt.asc() : comment.createdAt.desc())
: comment.createdAt.asc();
query.orderBy(orderBySpecifier);
/* 페이징 처리를 적용하여 결과 조회 */
List<Comment> results = query
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
/* 전체 개수 조회를 위한 쿼리
* fetchCount() deprecated 로 인해 사용 불가*/
Long total = query.select(comment.count())
.from(comment)
.where(comment.community.eq(community)
.and(comment.parentComment.isNull()))
.fetchOne();
/* null 체크를 통해 NullPointerException 방지 */
long totalCount = total != null ? total : 0L;
return new PageImpl<>(results, pageable, totalCount);
}
}
3. 서비스 계층에서의 계층 구조 구성
데이터베이스로부터 댓글과 대댓글을 평평하게(fetch join등을 사용하여) 조회한 후, 애플리케이션의 서비스 계층에서 이를 계층적 구조로 재구성할 수 있습니다.
이는 로직이 복잡해질 수 있지만, 데이터베이스의 계층 쿼리 기능에 의존하지 않아도 됩니다.
4. DTO 사용
클라이언트에 계층적 데이터를 전달하기 위해 DTO(Data Transfer Object)를 사용할 수 있습니다. 각 댓글 DTO는 자식 댓글 목록을 포함할 수 있으며, 이를 통해 클라이언트는 댓글과 대댓글의 관계를 쉽게 이해할 수 있습니다.
RequestDto
@Getter
@NoArgsConstructor
public class CommentRequestDto {
private String commentContent;
private Long parentCommentId;
@Builder
public CommentRequestDto(String commentContent, Long parentCommentId) {
this.commentContent = commentContent;
this.parentCommentId = parentCommentId;
}
}
ResponseDto
@Getter
public class CommentResponseDto {
private Long commentId;
private String commentContent;
private String nickname;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
private LocalDateTime createdAt;
private List<CommentResponseDto> childComments; /* 대댓글 목록*/
@Builder
public CommentResponseDto(Comment comment) {
this.commentId = comment.getCommentId();
this.commentContent = comment.getCommentContent();
this.nickname = comment.getUser().getNickname();
this.createdAt = comment.getCreatedAt();
}
/* 대댓글 조회용*/
@Builder
public CommentResponseDto(Comment comment, List<CommentResponseDto> childComments) {
this.commentId = comment.getCommentId();
this.commentContent = comment.getCommentContent();
this.nickname = comment.getUser().getNickname();
this.createdAt = comment.getCreatedAt();
this.childComments = childComments; /* 대댓글 목록 할당*/
}
}
5. 댓글 깊이 제한 고려
대댓글 기능을 구현할 때는 댓글의 깊이(대댓글의 대댓글의 대댓글...)에 제한을 두는 것이 좋을 수 있습니다.
이는 데이터베이스와 애플리케이션 성능에 영향을 미칠 수 있으며, 사용자 인터페이스의 복잡성을 증가시키기도 합니다.
이러한 설계 접근 방식을 통해 댓글과 대댓글 기능을 효율적으로 구현할 수 있으며, 애플리케이션의 요구사항과 환경에 따라 적절한 방식을 선택하면 됩니다.
대댓글 수정 로직 구현 시 고려사항
- 권한 검증 : 대댓글을 수정할 수 있는 권한이 있는지 확인해야 함, 일반적으로 대댓글을 작성한 사용자만이 해당 댓글을 수정할 수 있음
- 대댓글 식별 : 수정하고자 하는 대댓글을 식별할 수 있는 정보(예: 대댓글의 ID)가 필요함
- 데이터 검증 : 수정 요청된 내용이 유효한지 검증해야 함(예: 댓글 내용이 비어 있으면 안 됨)
- 업데이트 로직: 대댓글 엔티티를 찾아 요청된 내용으로 업데이트하고, 데이터베이스에 반영함
대댓글 삭제 로직 구현 시 고려사항
- 권한 검증 : 대댓글을 삭제할 수 있는 권한이 있는지 확인해야 함, 일반적으로 대댓글을 작성한 사용자나 관리자만이 해당 대댓글을 삭제할 수 있어야 함
- 대댓글 식별 : 삭제하고자 하는 대댓글을 식별할 수 있는 정보가 필요함
- 하위 대댓글 식별 : 대댓글을 삭제할 때, 해당 대댓글에 종속된 하위 대댓글들을 어떻게 처리할지 결정해야 함, 하위 댓글들을 모두 삭제할지, 아니면 상태를 변경하여 보존할지 결정해야 합니다.
- 삭제 로직 : 대댓글 엔티티를 찾아 데이터베이스에서 삭제하거나, 삭제된 상태로 표시합니다.
/* 커뮤니티 게시글 댓글 삭제 - 부모 댓글*/
@Transactional
public void deleteComment(Long communityId, Long commentId, UserDetailsImpl userDetails) {
/* 유저 정보 검증*/
User user = findAuthenticatedUser(userDetails);
/* 커뮤니티 게시글, 댓글 및 댓글에 대한 유저 권한 검증*/
Comment comment = validateCommentOwnership(communityId, commentId, user);
/* 대댓글이 있는 부모 댓글인 경우, 내용만 "삭제된 댓글입니다"로 변경*/
if (!comment.getChildComments().isEmpty()) {
comment.markAsDeleted();
} else {
/* 자식 댓글이 없는 경우(단독 댓글이거나 모든 자식 댓글이 이미 삭제된 상태), 댓글을 실제로 삭제*/
commentRepository.delete(comment);
}
}
/* 커뮤니티 게시글 댓글 삭제 - 자식 댓글(대댓글)*/
@Transactional
public void deleteChildComment(Long childCommentId, UserDetailsImpl userDetails) {
User user = findAuthenticatedUser(userDetails);
/* 대댓글 조회*/
Comment childComment = commentRepository.findById(childCommentId)
.orElseThrow(() -> new CustomException(NOT_EXIST_COMMENT));
/* 대댓글의 작성자가 현재 사용자와 일치하는지 확인*/
if (!childComment.getUser().getId().equals(user.getId())) {
throw new CustomException(NOT_YOUR_COMMENT);
}
/* 자식 댓글(대댓글) 삭제*/
commentRepository.delete(childComment);
/* 부모 댓글이 삭제 상태일 경우 자식 댓글이 모두 삭제되면 부모 댓글을 DB에서 삭제*/
Comment parentComment = childComment.getParentComment();
if (parentComment != null && parentComment.isDeleted()) {
/* 모든 자식 댓글이 삭제되었는지 확인 */
long remainingChildren = parentComment.getChildComments().stream()
.filter(c -> !c.getCommentId().equals(childCommentId) && !c.isDeleted())
.count();
if (remainingChildren == 0) {
commentRepository.delete(parentComment);
}
}
부모 댓글 삭제 로직과 대댓글 삭제 로직 분리 이유
- 로직의 명확성과 관리 용이성 : 부모 댓글과 대댓글을 처리하는 로직을 분리함으로써 각 로직이 수행하는 역할을 더 명확하게 할 수 있음, 이는 코드의 가독성을 향상시키고 유지보수를 용이하게 하며, 각 기능의 경계가 명확해지면 추후 수정이나 기능 추가 시 해당 로직만을 집중적으로 검토하고 변경할 수 있음
- 기능의 세분화 : 부모 댓글과 대댓글을 삭제하는 상황은 각기 다른 사례를 다루기 때문에, 이에 대한 처리를 구분하는 것이 합리적임, 부모 댓글의 삭제는 하위 대댓글들의 처리 방식을 고려해야 하지만, 단일 대댓글의 삭제는 보다 단순한 경우가 많음(각 경우에 대해 최적화된 처리 방식을 적용할 수 있음)
- 확장성 고려 : 시스템이 확장되어 댓글과 대댓글에 대한 추가적인 기능이나 복잡한 로직이 필요해질 수 있음, 이때 각각의 처리 로직이 분리되어 있으면 새로운 요구사항을 통합하기 더 수월함. 대댓글에 대한 특별한 규칙이나 예외 처리가 필요해질 경우, 해당 로직만을 수정하거나 확장하면 됨
- 성능 최적화 : 특정 상황에서는 부모 댓글과 대댓글 삭제 로직의 실행 경로가 다를 수 있음, 각각의 로직을 분리함으로써 필요한 데이터 조회나 처리 과정을 최적화할 수 있으며, 이는 전체 시스템의 성능에 긍정적인 영향을 미칠 수 있음
주의할 점
- 부모 댓글이 자식 댓글을 참조하고 자식 댓글이 다시 부모 댓글을 참조하는 무한 순환 참조가 발생할 수 있음(순환 참조는 데이터를 JSON 형식으로 변환하거나 엔티티를 DTO로 변환할 때 문제를 일으킬 수 있음)
- Comment 엔티티와 CommentDto를 변환하는 과정에서 Comment 엔티티의 toDto 메서드와 CommentDto의 toEntity 메서드가 서로를 호출하게 되면 무한 순환 참조 오류가 발생할 수 있음
해결 방법
- @JsonManagedReference, @JsonBackReference 어노테이션을 사용하는 방법
- @JsonManagedReference는 순환 참조를 직렬화할 쪽에, @JsonBackReference는 그렇지 않을 쪽에 붙여 사용
- DTO 클래스를 사용하는 방법
- @JsonIgnore 어노테이션을 사용하는 방법
- 특정 필드를 JSON 직렬화 대상에서 제외하기 위해 사용되는 어노테이션
프로젝트 댓글, 대댓글 기능 구현 코드
Comment Entity
package com.sparta.market.domain.comment.entity;
import com.fasterxml.jackson.annotation.*;
import com.sparta.market.domain.community.entity.Community;
import com.sparta.market.domain.user.entity.User;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Table(name = "comment")
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long commentId;
@Column(length = 100)
@Schema(name = "Comment Content", description = "댓글 내용", example = "와 너무 유익해요!")
@NotNull(message = "빈 댓글은 쓸 수 없습니다.")
private String commentContent;
@ManyToOne(cascade = CascadeType.PERSIST)
@JoinColumn(name = "community_id")
@Schema(name = "커뮤니티 게시글 ID")
private Community community;
@ManyToOne(cascade = CascadeType.PERSIST)
@JoinColumn(name = "user_id")
@Schema(name = "유저 ID")
private User user;
@CreatedDate
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
@Schema(name = "댓글 생성 시간")
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_comment_id")
@Schema(name = "부모 댓글 여부 확인")
private Comment parentComment;
@OneToMany(mappedBy = "parentComment", orphanRemoval = true)
@Schema(name = "대댓글 목록")
private List<Comment> childComments = new ArrayList<>();
@Column(name = "is_deleted", nullable = false)
@Schema(name = "부모 댓글 삭제 상태")
private boolean isDeleted = false;
@Builder
public Comment(String commentContent, Community community, User user, Comment parentComment) {
this.commentContent = commentContent;
this.community = community;
this.user = user;
this.parentComment = parentComment;
}
public void markAsDeleted() {
this.isDeleted = true;
this.updateComment("삭제된 댓글입니다");
}
public boolean isDeleted() {
return isDeleted;
}
public void updateComment(String commentContent) {
this.commentContent = commentContent;
}
}
CommentRequestDto
package com.sparta.market.domain.comment.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
public class CommentRequestDto {
private String commentContent;
private Long parentCommentId;
@Builder
public CommentRequestDto(String commentContent, Long parentCommentId) {
this.commentContent = commentContent;
this.parentCommentId = parentCommentId;
}
}
CommentResponseDto
package com.sparta.market.domain.comment.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.sparta.market.domain.comment.entity.Comment;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.List;
@Getter
public class CommentResponseDto {
private Long commentId;
private String commentContent;
private String nickname;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
private LocalDateTime createdAt;
private List<CommentResponseDto> childComments; /* 대댓글 목록*/
@Builder
public CommentResponseDto(Comment comment) {
this.commentId = comment.getCommentId();
this.commentContent = comment.getCommentContent();
this.nickname = comment.getUser().getNickname();
this.createdAt = comment.getCreatedAt();
}
/* 대댓글 조회용*/
@Builder
public CommentResponseDto(Comment comment, List<CommentResponseDto> childComments) {
this.commentId = comment.getCommentId();
this.commentContent = comment.getCommentContent();
this.nickname = comment.getUser().getNickname();
this.createdAt = comment.getCreatedAt();
this.childComments = childComments; /* 대댓글 목록 할당*/
}
}
CommentRepository
package com.sparta.market.domain.comment.repository;
import com.sparta.market.domain.comment.entity.Comment;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CommentRepository extends JpaRepository<Comment, Long>, CommentCustomRepository {
}
CommentCustomRepository
package com.sparta.market.domain.comment.repository;
import com.sparta.market.domain.comment.entity.Comment;
import com.sparta.market.domain.community.entity.Community;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Optional;
public interface CommentCustomRepository {
Optional<Comment> findByCommunityAndCommentId(Community community, Long commentId);
Page<Comment> findByCommunityAndParentCommentIsNull(Community community, Pageable pageable);
}
CommentRespositoryImpl
package com.sparta.market.domain.comment.repository;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.sparta.market.domain.comment.entity.Comment;
import com.sparta.market.domain.comment.entity.QComment;
import com.sparta.market.domain.community.entity.Community;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import java.util.List;
import java.util.Optional;
@RequiredArgsConstructor
public class CommentRepositoryImpl implements CommentCustomRepository {
private final JPAQueryFactory queryFactory;
@Override
public Optional<Comment> findByCommunityAndCommentId(Community community, Long commentId) {
QComment comment = QComment.comment;
Comment result = queryFactory
.selectFrom(comment)
.where(comment.community.eq(community), comment.commentId.eq(commentId))
.fetchOne();
return Optional.ofNullable(result);
}
@Override
public Page<Comment> findByCommunityAndParentCommentIsNull(Community community, Pageable pageable) {
QComment comment = QComment.comment;
/* 부모 댓글이 null인 조건을 추가하여 QueryDSL 쿼리 구성*/
JPAQuery<Comment> query = queryFactory
.selectFrom(comment)
.where(comment.community.eq(community)
.and(comment.parentComment.isNull()));
/* 페이지네이션을 위한 정렬 및 페이징 적용*/
Sort.Order sortOrder = pageable.getSort().isSorted() ? pageable.getSort().iterator().next() : null;
OrderSpecifier<?> orderBySpecifier = sortOrder != null
? (sortOrder.isAscending() ? comment.createdAt.asc() : comment.createdAt.desc())
: comment.createdAt.asc();
query.orderBy(orderBySpecifier);
/* 페이징 처리를 적용하여 결과 조회 */
List<Comment> results = query
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
/* 전체 개수 조회를 위한 쿼리
* fetchCount() deprecated 로 인해 사용 불가*/
Long total = query.select(comment.count())
.from(comment)
.where(comment.community.eq(community)
.and(comment.parentComment.isNull()))
.fetchOne();
/* null 체크를 통해 NullPointerException 방지 */
long totalCount = total != null ? total : 0L;
return new PageImpl<>(results, pageable, totalCount);
}
}
CommentController
package com.sparta.market.domain.comment.controller;
import com.sparta.market.domain.comment.dto.CommentRequestDto;
import com.sparta.market.domain.comment.dto.CommentResponseDto;
import com.sparta.market.domain.comment.service.CommentService;
import com.sparta.market.global.security.config.UserDetailsImpl;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Comment Controller", description = "댓글 기능 컨트롤러")
@Slf4j(topic = "댓글 컨트롤러")
@RestController
@RequestMapping("/community")
public class CommentController {
private final CommentService commentService;
public CommentController(CommentService commentService) {
this.commentService = commentService;
}
@PostMapping("/{communityId}/comment")
@Operation(summary = "Create Comment", description = "커뮤니티 게시글에 댓글을 등록합니다, 대댓글이 아닐 경우 parentId 값은 null 로 입력.")
public ResponseEntity<?> createComment(@PathVariable Long communityId, @RequestBody CommentRequestDto requestDto,
@AuthenticationPrincipal UserDetailsImpl userDetails) {
CommentResponseDto responseDto = commentService.createComment(communityId, requestDto, userDetails);
return ResponseEntity.ok().body(responseDto);
}
@PutMapping("/{communityId}/comment/{commentId}")
@Operation(summary = "Update Comment", description = "커뮤니티 게시글의 댓글을 수정합니다, 대댓글이 아닐 경우 parentId 값은 null 로 입력.")
public ResponseEntity<?> updateComment(@PathVariable Long communityId,
@PathVariable Long commentId,
@RequestBody CommentRequestDto requestDto,
@AuthenticationPrincipal UserDetailsImpl userDetails) {
CommentResponseDto responseDto = commentService.updateComment(communityId, commentId, requestDto, userDetails);
return ResponseEntity.ok().body(responseDto);
}
@DeleteMapping("/{communityId}/comment/{commentId}")
@Operation(summary = "Delete Comment", description = "커뮤니티 게시글의 댓글을 삭제합니다, 대댓글이 있을 경우 댓글 내용이 삭제된 댓글입니다로 변경 됨.")
public ResponseEntity<?> deleteComment(@PathVariable Long communityId, @PathVariable Long commentId,
@AuthenticationPrincipal UserDetailsImpl userDetails) {
commentService.deleteComment(communityId, commentId, userDetails);
return ResponseEntity.ok().body("커뮤니티 댓글 삭제 완료");
}
@DeleteMapping("/{communityId}/comment/{commentId}/child/{childCommentId}")
@Operation(summary = "Delete Child Comment", description = "커뮤니티 게시글의 대댓글을 삭제합니다, 커뮤니티 아이디, 부모 댓글 아이디, 대댓글 아이디 입력 필요")
public ResponseEntity<?> deleteChildComment(@PathVariable Long communityId,
@PathVariable Long commentId,
@PathVariable Long childCommentId,
@AuthenticationPrincipal UserDetailsImpl userDetails) {
commentService.deleteChildComment(childCommentId, userDetails);
return ResponseEntity.ok().body("대댓글 삭제 완료");
}
@GetMapping("/{communityId}/comments")
@Operation(summary = "Get Comments", description = "커뮤티니 게시글의 댓글 목록을 조회합니다.")
public ResponseEntity<?> getComments(@PathVariable Long communityId,
@RequestParam("isAsc") boolean isAsc,
@RequestParam(value = "page", defaultValue = "1") int page) {
return ResponseEntity.ok().body(commentService.getComments(communityId, page -1, isAsc));
}
}
CommentService
package com.sparta.market.domain.comment.service;
import com.sparta.market.domain.comment.dto.CommentRequestDto;
import com.sparta.market.domain.comment.dto.CommentResponseDto;
import com.sparta.market.domain.comment.entity.Comment;
import com.sparta.market.domain.comment.repository.CommentRepository;
import com.sparta.market.domain.community.entity.Community;
import com.sparta.market.domain.community.repository.CommunityRepository;
import com.sparta.market.domain.user.entity.User;
import com.sparta.market.domain.user.repository.UserRepository;
import com.sparta.market.global.common.exception.CustomException;
import com.sparta.market.global.security.config.UserDetailsImpl;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static com.sparta.market.global.common.exception.ErrorCode.*;
@Tag(name = "커뮤니티 게시글 댓글 기능", description = "커뮤니티 게시글에 댓글 작성(추가), 수정, 조회, 삭제 기능")
@Slf4j(topic = "댓글 생성, 수정, 삭제")
@Service
public class CommentService {
private final CommentRepository commentRepository;
private final UserRepository userRepository;
private final CommunityRepository communityRepository;
public CommentService(CommentRepository commentRepository, UserRepository userRepository, CommunityRepository communityRepository) {
this.commentRepository = commentRepository;
this.userRepository = userRepository;
this.communityRepository = communityRepository;
}
/* 커뮤니티 게시글 댓글 추가*/
@Transactional
public CommentResponseDto createComment(Long communityId, CommentRequestDto requestDto, UserDetailsImpl userDetails) {
/* 유저 정보 검증*/
User user = findAuthenticatedUser(userDetails);
/* 커뮤니티 게시글 검증*/
Community community = validateCommunity(communityId);
/* 부모 댓글 여부 확인*/
Comment parentComment = null;
if (requestDto.getParentCommentId() != null) {
parentComment = commentRepository.findById(requestDto.getParentCommentId())
.orElseThrow(() -> new CustomException(NOT_EXIST_COMMENT));
}
/* 댓글 추가(생성)*/
Comment comment = Comment.builder()
.commentContent(requestDto.getCommentContent())
.community(community)
.user(user)
.parentComment(parentComment)
.build();
commentRepository.save(comment);
return new CommentResponseDto(comment);
}
/* 커뮤니티 게시글 댓글 수정*/
@Transactional
public CommentResponseDto updateComment(Long communityId, Long commentId, CommentRequestDto requestDto, UserDetailsImpl userDetails) {
/* 유저 정보 검증*/
User user = findAuthenticatedUser(userDetails);
/* 커뮤니티 게시글, 댓글 및 댓글에 대한 유저 권한 검증*/
Comment comment = validateCommentOwnership(communityId, commentId, user);
comment.updateComment(requestDto.getCommentContent());
return new CommentResponseDto(comment);
}
/* 커뮤니티 게시글 댓글 삭제 - 부모 댓글*/
@Transactional
public void deleteComment(Long communityId, Long commentId, UserDetailsImpl userDetails) {
/* 유저 정보 검증*/
User user = findAuthenticatedUser(userDetails);
/* 커뮤니티 게시글, 댓글 및 댓글에 대한 유저 권한 검증*/
Comment comment = validateCommentOwnership(communityId, commentId, user);
/* 대댓글이 있는 부모 댓글인 경우, 내용만 "삭제된 댓글입니다"로 변경*/
if (!comment.getChildComments().isEmpty()) {
comment.markAsDeleted();
} else {
/* 자식 댓글이 없는 경우(단독 댓글이거나 모든 자식 댓글이 이미 삭제된 상태), 댓글을 실제로 삭제*/
commentRepository.delete(comment);
}
}
/* 커뮤니티 게시글 댓글 삭제 - 자식 댓글(대댓글)*/
@Transactional
public void deleteChildComment(Long childCommentId, UserDetailsImpl userDetails) {
User user = findAuthenticatedUser(userDetails);
/* 대댓글 조회*/
Comment childComment = commentRepository.findById(childCommentId)
.orElseThrow(() -> new CustomException(NOT_EXIST_COMMENT));
/* 대댓글의 작성자가 현재 사용자와 일치하는지 확인*/
if (!childComment.getUser().getId().equals(user.getId())) {
throw new CustomException(NOT_YOUR_COMMENT);
}
/* 자식 댓글(대댓글) 삭제*/
commentRepository.delete(childComment);
/* 부모 댓글이 삭제 상태일 경우 자식 댓글이 모두 삭제되면 부모 댓글을 DB에서 삭제*/
Comment parentComment = childComment.getParentComment();
if (parentComment != null && parentComment.isDeleted()) {
/* 모든 자식 댓글이 삭제되었는지 확인 */
long remainingChildren = parentComment.getChildComments().stream()
.filter(c -> !c.getCommentId().equals(childCommentId) && !c.isDeleted())
.count();
if (remainingChildren == 0) {
commentRepository.delete(parentComment);
}
}
}
/* 커뮤니티 게시글 댓글 목록 조회*/
@Transactional(readOnly = true)
public Page<CommentResponseDto> getComments(Long communityId, int page, boolean isAsc) {
/* 조회 시에는 유저 정보 검증 x */
/* 커뮤니티 게시글 검증*/
Community community = validateCommunity(communityId);
/* pageable 객체 생성*/
Pageable pageable = validateAndCreatePageable(page, isAsc);
/* 페이징 처리*/
Page<Comment> commentPage = commentRepository.findByCommunityAndParentCommentIsNull(community, pageable);
/* 대댓글 정보를 포함하여 DTO 변환*/
return commentPage.map(comment -> {
List<CommentResponseDto> childComments = comment.getChildComments().stream()
.map(child -> new CommentResponseDto(child, Collections.emptyList())) // 대댓글의 대댓글은 고려하지 않음
.collect(Collectors.toList());
return new CommentResponseDto(comment, childComments);
});
}
/* 검증 메서드 필드*/
/*유저 정보 검증 메서드*/
private User findAuthenticatedUser(UserDetailsImpl userDetails) {
return userRepository.findByEmail(userDetails.getUsername())
.orElseThrow(() -> new CustomException(NOT_EXIST_USER));
}
/* 게시글 검증 메서드*/
private Community validateCommunity(Long communityId) {
return communityRepository.findByCommunityId(communityId)
.orElseThrow(() -> new CustomException(NOT_EXIST_POST));
}
/* 댓글 및 댓글에 대한 유저 권한 검증 메서드*/
private Comment validateCommentOwnership(Long communityId, Long commentId, User user) {
/* 게시글 검증*/
Community community = validateCommunity(communityId);
/* 댓글 검증*/
Comment comment = commentRepository.findByCommunityAndCommentId(community, commentId)
.orElseThrow(() -> new CustomException(NOT_EXIST_COMMENT));
/* 댓글에 대한 유저 권한 검증*/
if (!comment.getUser().getId().equals(user.getId())) {
throw new CustomException(NOT_YOUR_COMMENT);
}
return comment;
}
/* 입력 값 검증과 페이지 설정 메서드*/
private Pageable validateAndCreatePageable(int page, boolean isAsc) {
if (page < 0) {
throw new CustomException(VALIDATION_ERROR);
}
Sort.Direction direction = isAsc ? Sort.Direction.ASC : Sort.Direction.DESC;
return PageRequest.of(page, 10, Sort.by(direction, "createdAt"));
}
}
'항해 99 > Spring' 카테고리의 다른 글
Redis (1) | 2024.03.25 |
---|---|
MapStruct (0) | 2024.03.22 |
S3를 이용한 파일 업로드 (0) | 2024.03.20 |
CORS 에러 해결 방법, CORS 보안 취약점 예방 가이드 (0) | 2024.03.19 |
CORS (1) | 2024.03.18 |