Today I Learned
아웃소싱 프로젝트
#댓글 조회 / 수정 / 삭제 API 구현
현재 진행중인 팀 프로젝트에서 댓글 도메인을 담당하고 있다.
이번 프로젝트는 이전과 달리 테스트 코드 작성이 필수이고, 팀 컨벤션과 여러 개발 원칙(단일 책임 원칙 등)을 철저히 지켜야 했기에 짧은 코드라도 작성에 많은 시간이 걸렸다.
특히 조회 기능은 단순히 데이터를 불러오는 것을 넘어서 요청사항에 맞춰 여러 조건들을 만족하도록 구현하다보니 많은 고민이 필요했다.
처음엔 `CommentResponse` 내부에 `ReplyReponse` DTO를 포함시켜 대댓글을 계층적으로 출력하려 했다.
하지만 요청사항은 부모 댓글 사이사이에 대댓글들이 출력되야하는 구조여서 위와 같은 방식으로는 해결이 어려웠다.
또한 전체 댓글을 페이징 처리하면 마지막 부모 댓글에 달린 대댓글이 다음 페이지로 넘어가버리는 문제가 발생할 수 있었다.
그래서 부모 댓글만 페이징 처리하고, 각 부모 댓글 뒤에 대댓글들을 나열하는 방식으로 구현했다.
@Transactional
public Page<CommentResponse> getComments(Long taskId, Pageable pageable, String sort) {
taskService.findByTaskId(taskId);
Sort.Direction orderBy = sort.equals("oldest") ? Sort.Direction.ASC : Sort.Direction.DESC;
Sort sortByCreatedAt = Sort.by(orderBy, "createdAt");
Pageable parentPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortByCreatedAt);
// 1. 부모 댓글 페이징 조회
Page<Comment> parentComments = commentRepository.findByTask_TaskIdAndParentIdIsNull(taskId, parentPageable);
List<CommentResponse> allComments = new ArrayList<>();
for (Comment parentComment : parentComments.getContent()) {
UserResponse parentUserResponse = userService.toUserResponse(parentComment.getUser());
CommentResponse parentResponse = commentMapper.toCommentResponse(parentComment, parentUserResponse);
// 2. 대댓글 조회
List<Comment> childComments = commentRepository.findByParentId(parentComment.getCommentId(), sortByCreatedAt);
List<CommentResponse> replies = childComments.stream()
.map(childComment -> {
UserResponse childUserResponse = userService.toUserResponse(childComment.getUser());
return commentMapper.toCommentResponse(childComment, childUserResponse);
})
.toList();
allComments.add(parentResponse);
allComments.addAll(replies);
}
PageImpl<CommentResponse> comments = new PageImpl<>(allComments, pageable, parentComments.getTotalElements());
return comments;
}
1. 작업(Task) 존재 여부를 확인해서 존재하지 않으면 예외를 발생시킨다.
2. `newest`와 `oldest`를 입력받아 최신순 또는 오래된 순으로 정렬되도록 처리한다.
3. `parentId`가 `null`인 댓글만 조회해서, 부모 댓글만 페이징 처리한다.
4. 각 부모 댓글에 대해 작성자 정보를 포함한 `CommentResponse` 생성한다.
5. 부모 댓글 ID를 기준으로 대댓글들을 조회하고, 각각 응답 DTO형태로 변환한다.
6. 부모 댓글 뒤에 대댓글들을 순서대로 추가하여 `List<CommentResponse`를 구성한다.
7. 부모 댓글 기준의 페이징 정보를 유지하면서, 대댓글이 포함된 전체 리스트를 반환한다.
직접 작성한 코드를 읽어보며 글로 흐름을 정리해보니, 기능의 구조와 의도도 더 명확하게 기억에 남는 것 같다.
비즈니스 요구사항을 만족시키면서도 누가 봐도 이해하기 쉬운 코드를 짜기 위해 노력했다.
특히 대댓글인데 다음 페이지로 넘어가거나, 오래된 순으로 정렬했을 때 대댓글들의 순서를 어떻게 처리해야 할지 고민하면서, 사용자의 관점에서 기능을 설계하려고 했던 점이 좋은 경험이었다고 생각한다.
@Transactional
public int deleteComment(Long taskId, Long commentId, Long userId) {
Comment comment = validateCommentAccess(taskId, commentId, userId);
comment.delete();
int deleteCount = 1;
List<Comment> childComments = commentRepository.findByParentId(comment.getCommentId(), Sort.unsorted());
for (Comment childComment : childComments) {
childComment.delete();
deleteCount++;
}
return deleteCount;
}
@DeleteMapping("/{commentId}")
public ResponseEntity<ApiResponse<Void>> deleteComment(@PathVariable Long taskId,
@PathVariable Long commentId,
@Auth User user) {
int deleteCount = commentService.deleteComment(taskId, commentId, user.getUserId());
String message = (deleteCount > 1)
? "댓글과 대댓글들이 삭제되었습니다."
: "댓글이 삭제되었습니다.";
return ApiResponse.noContent(message);
}
댓글 삭제에선 soft delete 방식을 사용하기로 했는데, 그 이유는 추후 복구하거나 기록을 유지할 수 있기 때문이다.
또 댓글이 삭제되면 해당 댓글에 달린 대댓글들도 함께 삭제되도록 구현해야 했고, 요청사항에 따라 댓글만 삭제되었을 경우와 대댓글까지 함께 삭제되었을 경우에 출력되는 메시지를 다르게 처리해야 했다.
그래서 삭제된 댓글수를 카운트해서 부모 댓글 외에 더 삭제된 댓글이 있다면 함께 삭제됐다는 메시지가 출력되도록 만들어줬다.
마치며
이번 작업을 통해 단순한 CRUD를 넘어서, 사용자 경험과 데이터 구조를 함께 고려한 설계를 해볼 수 있었다.
특히 페이징과 계층 구조가 충돌하는 상황에서 어떻게 데이터를 구성해야 할지 고민한 과정이 인상 깊었다.
앞으로도 기능을 구현할 때 단순히 동작만 맞추는 것이 아니라, 왜 이렇게 설계해야 하는지, 사용자는 어떤 흐름을 기대할지, 이런 관점에서 더 깊이 고민하는 개발자가 되고 싶다.