토이 프로젝트 스터디 #19
- 스터디 진행 날짜 : 7/15
- 스터디 작업 날짜 : 7/11 ~ 7/15
토이 프로젝트 진행 사항
내용
- 댓글의 경우 대댓글 기능까지 구현하기로 함
RDB
의 경우 Nested Set
을 활용해 구현하기로 함
- 페이지 처리
Entity
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment extends BaseTimeEntity {
private static final long DEFAULT_LEFT_FOR_ROOT = 1;
private static final long DEFAULT_RIGHT_FOR_ROOT = 2;
private static final long DEFAULT_DEPTH = 1;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "root_comment_id")
private Comment rootComment;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_comment_id")
private Comment parentComment;
@Column(nullable = false)
private Long leftNode;
@Column(nullable = false)
private Long rightNode;
@Column(nullable = false)
private Long depth;
@Lob
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "account_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Account account;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Post post;
@Column(nullable = false)
private Boolean isDeleted = false;
public void updateChildComment(Comment childComment) {
childComment.rootComment = this.rootComment;
childComment.parentComment = this;
childComment.depth = this.depth + 1L;
childComment.leftNode = this.rightNode;
childComment.rightNode = this.rightNode + 1;
}
public void updateAsRoot() {
this.rootComment = this;
this.leftNode = DEFAULT_LEFT_FOR_ROOT;
this.rightNode = DEFAULT_RIGHT_FOR_ROOT;
this.depth = DEFAULT_DEPTH;
}
public void updateContent(String content) {
this.content = content;
}
public void delete() {
this.isDeleted = true;
}
public Comment(String content, Post post, Account account) {
this.content = content;
this.post = post;
this.account = account;
}
public String getAuthorName() {
return account.getNickname();
}
}
Nested Set
을 활용하기 위해 leftNode
와 rightNode
추가
- 해당 댓글의
Parent
나 Root
를 손쉽게 접근할 수 있도록 컬럼 추가
getAuthorName
의 경우 기존에는 JOIN
의 수를 줄이기 위해 COMMENT Table
에 같이 저장
- 이 경우 회원이 닉네임을 변경할 경우 이미 작성한 수 많은 댓글의 닉네임 컬럼을 변경해야 함
- 이를 해결하기 위해
JOIN
으로 회원의 정보를 가져오도록 변경
Repository
@Repository
@RequiredArgsConstructor
public class CustomCommentRepositoryImpl implements CustomCommentRepository {
private static final QComment QCOMMENT = QComment.comment;
private final JPAQueryFactory queryFactory;
@Override
public List<Comment> findCommentsOrderByHierarchy(Pageable pageable, Post post) {
return selectCommentInnerFetchJoinAccount()
.where(isActiveCommentOf(post))
.orderBy(QCOMMENT.rootComment.id.asc(), QCOMMENT.leftNode.asc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
@Override
public Optional<Comment> findByIdWithAuthor(Long id) {
Comment comment = selectCommentInnerFetchJoinAccount()
.where(isActiveComment(id))
.fetchOne();
return Optional.ofNullable(comment);
}
@Override
public Optional<Comment> findByIdWithRootComment(Long id) {
Comment comment = queryFactory.selectFrom(QCOMMENT)
.innerJoin(QCOMMENT.rootComment)
.fetchJoin()
.where(isActiveComment(id))
.fetchOne();
return Optional.ofNullable(comment);
}
@Override
public Optional<Comment> findByIdWithRootCommentAndAuthor(Long id) {
Comment comment = selectCommentInnerFetchJoinAccount()
.innerJoin(QCOMMENT.rootComment)
.fetchJoin()
.where(isActiveComment(id))
.fetchOne();
return Optional.ofNullable(comment);
}
@Override
public void adjustHierarchyOrders(Comment newComment) {
queryFactory.update(QCOMMENT)
.set(QCOMMENT.leftNode, QCOMMENT.leftNode.add(2))
.where(QCOMMENT.leftNode.goe(newComment.getRightNode())
.and(isActiveCommentInSameGroupExceptNewComment(newComment)))
.execute();
queryFactory.update(QCOMMENT)
.set(QCOMMENT.rightNode, QCOMMENT.rightNode.add(2))
.where(QCOMMENT.rightNode.goe(newComment.getLeftNode())
.and(isActiveCommentInSameGroupExceptNewComment(newComment)))
.execute();
}
@Override
public Long countCommentsByPost(Post post) {
return queryFactory.select(QCOMMENT.count())
.from(QCOMMENT)
.where(isActiveCommentOf(post))
.fetchOne();
}
@Override
public void deleteChildComments(Comment parentComment) {
deleteComment()
.where(QCOMMENT.leftNode.gt(parentComment.getLeftNode())
.and(QCOMMENT.rightNode.lt(parentComment.getRightNode())
.and(QCOMMENT.rootComment.eq(parentComment.getRootComment()))
.and(isActiveComment())))
.execute();
}
@Override
public void deleteAllByPost(Post post) {
deleteComment()
.where(isActiveCommentOf(post))
.execute();
}
private JPAQuery<Comment> selectCommentInnerFetchJoinAccount() {
return queryFactory.selectFrom(QCOMMENT)
.innerJoin(QCOMMENT.account)
.fetchJoin();
}
private JPAUpdateClause deleteComment() {
return queryFactory.update(QCOMMENT)
.set(QCOMMENT.isDeleted, true);
}
private Predicate isActiveCommentInSameGroupExceptNewComment(Comment newComment) {
return QCOMMENT.rootComment.eq(newComment.getRootComment())
.and(QCOMMENT.ne(newComment))
.and(isActiveComment());
}
private BooleanExpression isActiveCommentOf(Post post) {
return QCOMMENT.post.eq(post).and(isActiveComment());
}
private BooleanExpression isActiveComment(Long id) {
return QCOMMENT.id.eq(id).and(isActiveComment());
}
private BooleanExpression isActiveComment() {
return QCOMMENT.isDeleted.eq(false);
}
}
Nested Set
의 경우 댓글 작성 시 각 노드를 정확히 업데이트 해야 함
- 노드를 통해 해당 댓글이 어떤 댓글(루트 / 부모 / 자식)인지 구분하기 때문
- 복잡한 쿼리를 처리하기 위해
Querydsl
사용
Service
@Service
@Transactional
@RequiredArgsConstructor
public class CommentService {
private final CommentRepository commentRepository;
private final AccountRepository accountRepository;
private final PostRepository postRepository;
public void addComment(AccountInfo accountInfo, CreateCommentRequest request) {
Account account = accountRepository.findById(accountInfo.getAccountId()).orElseThrow(AccountNotFoundException::new);
Post post = postRepository.findById(request.getPostId()).orElseThrow(PostNotFoundException::new);
Comment comment = new Comment(request.getContent(), post, account);
comment.updateAsRoot();
commentRepository.save(comment);
}
public void replyContent(AccountInfo accountInfo, ReplyCommentRequest request) {
Account account = accountRepository.findById(accountInfo.getAccountId()).orElseThrow(AccountNotFoundException::new);
Post post = postRepository.findById(request.getPostId()).orElseThrow(PostNotFoundException::new);
Comment parentComment = commentRepository.findByIdWithRootComment(request.getCommentId()).orElseThrow(CommentNotFoundException::new);
Comment comment = new Comment(request.getContent(), post, account);
parentComment.updateChildComment(comment);
commentRepository.save(comment);
commentRepository.adjustHierarchyOrders(comment);
}
public GetCommentsResponse getComments(Long postId, PageRequest pageRequest) {
Post post = postRepository.findById(postId).orElseThrow(PostNotFoundException::new);
List<Comment> comments = commentRepository.findCommentsOrderByHierarchy(pageRequest, post);
Long count = commentRepository.countCommentsByPost(post);
PageMaker pageMaker =
new PageMaker(
pageRequest.getPageNumber(),
pageRequest.getPageSize(),
3,
count);
return GetCommentsResponseConverter.convert(comments, pageMaker);
}
public void updateComment(AccountInfo accountInfo, UpdateCommentRequest request) {
Comment comment = commentRepository.findById(request.getCommentId()).orElseThrow(CommentNotFoundException::new);
comment.updateContent(request.getContent());
}
public void deleteComment(AccountInfo accountInfo, Long commentId) {
Comment comment = commentRepository.findById(commentId).orElseThrow(CommentNotFoundException::new);
comment.delete();
commentRepository.deleteChildComments(comment);
}
}
Controller
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/comments")
public class CommentController {
private final CommentService commentService;
@PostMapping("/create")
public ResponseEntity createComment(@LoginAccount AccountInfo accountInfo, @Validated @RequestBody CreateCommentRequest request) {
commentService.addComment(accountInfo, request);
return new ResponseEntity(HttpStatus.CREATED);
}
@PostMapping("/reply")
public ResponseEntity replyComment(@LoginAccount AccountInfo accountInfo, @Validated @RequestBody ReplyCommentRequest request) {
commentService.replyContent(accountInfo, request);
return new ResponseEntity(HttpStatus.CREATED);
}
@GetMapping("/{id}")
public ResponseEntity getComments(@PathVariable Long id, @ModelAttribute PageRequest pageRequest) {
return new ResponseEntity(commentService.getComments(id, pageRequest.of()), HttpStatus.OK);
}
@PostMapping("/update")
public ResponseEntity updateComment(@LoginAccount AccountInfo accountInfo, @Validated @RequestBody UpdateCommentRequest request) {
commentService.updateComment(accountInfo, request);
return new ResponseEntity(HttpStatus.OK);
}
@DeleteMapping("/delete/{id}")
public ResponseEntity deleteComment(@LoginAccount AccountInfo accountInfo, @PathVariable Long id) {
commentService.deleteComment(accountInfo, id);
return new ResponseEntity(HttpStatus.OK);
}
}
동작
요청
http://localhost:8080/api/comments/1?page=1&size=5
{
"comments": [
{
"id": 1,
"author": "nickname1",
"content": "a",
"depth": 1,
"createdDate": "2022-07-15 22:06:25",
"rootId": 1
},
{
"id": 2,
"author": "nickname1",
"content": "b",
"depth": 1,
"createdDate": "2022-07-15 22:06:28",
"rootId": 2
},
{
"id": 3,
"author": "nickname1",
"content": "c",
"depth": 2,
"createdDate": "2022-07-15 22:06:35",
"parentId": 2,
"rootId": 2
}
],
"startPage": 1,
"nowPage": 1,
"endPage": 1,
"prev": false,
"next": false
}
PageMaker
@Getter
public class PageMaker {
private int startPage;
private int endPage;
private int nowPage;
private boolean prev;
private boolean next;
public PageMaker(int page, int size, int pageBlockCounts, long totalItemCounts) {
endPage = (int) (Math.ceil((page + 1) / (double) pageBlockCounts) * pageBlockCounts);
startPage = (endPage - pageBlockCounts) + 1;
nowPage = page + 1;
int tempEndPage = (int) (Math.ceil(totalItemCounts / (double) size));
if (endPage > tempEndPage) {
endPage = tempEndPage;
}
prev = startPage != 1;
next = endPage * size < totalItemCounts;
}
}
- 페이지 처리 이후
ResponseDto
에 필요한 페이지 관련 정보를 처리하는 객체
개선사항
depth
로 깊이를 표현했지만 계층적인 구조가 아니기 때문에 눈으로 알아보기 어려움
- 기존에 작성한
NestedConvertUtils
의 경우 댓글은 페이징 처리를 해야하므로 사용이 불가능(NPE
발생)
- 페이징 시 루트 댓글뿐만 아니라 자식 댓글까지 페이징의 기준이 됨
- 유튜브처럼 루트 댓글만 보여주고 자식 댓글의 유무에 따라 따로 요청하는 방식도 고려할 만한 것 같음
@Getter
public class PageRequest {
private int page;
private int size;
private List<String> sort;
public void setPage(int page) {
this.page = page <= 0 ? 1 : page;
}
public void setSize(int size) {
int DEFAULT_SIZE = 10;
int MAX_SIZE = 50;
this.size = size > MAX_SIZE ? DEFAULT_SIZE : size;
}
public void setSort(List<String> sort) {
this.sort = sort;
}
public org.springframework.data.domain.PageRequest of() {
if (sort == null || sort.isEmpty()) {
return org.springframework.data.domain.PageRequest.of(page - 1, size);
}
return org.springframework.data.domain.PageRequest.of(page - 1, size, Sort.by(getOrders(sort)));
}
private List<Sort.Order> getOrders(List<String> sort) {
List<Sort.Order> orders = new ArrayList<>();
sort.forEach(str ->
orders.add(
new Sort.Order(Sort.Direction.valueOf(str.split("#")[1].toUpperCase(Locale.ROOT)),
str.split("#")[0])
)
);
return orders;
}
}
- 컨트롤러에서
Pageable
대신 @ModelAttribute
를 통해 바인딩하는 객체