토이 프로젝트 스터디 #19

appti·2022년 7월 15일
0

토이 프로젝트 스터디 #19

  • 스터디 진행 날짜 : 7/15
  • 스터디 작업 날짜 : 7/11 ~ 7/15

토이 프로젝트 진행 사항

  • 댓글 관련 코드 작성

내용

  • 댓글의 경우 대댓글 기능까지 구현하기로 함
    • RDB의 경우 Nested Set을 활용해 구현하기로 함
  • 페이지 처리
    • PageMaker
    • PageRequest

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을 활용하기 위해 leftNoderightNode 추가
  • 해당 댓글의 ParentRoot를 손쉽게 접근할 수 있도록 컬럼 추가
  • 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 발생)
  • 페이징 시 루트 댓글뿐만 아니라 자식 댓글까지 페이징의 기준이 됨
    • 유튜브처럼 루트 댓글만 보여주고 자식 댓글의 유무에 따라 따로 요청하는 방식도 고려할 만한 것 같음

PageRequest

@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를 통해 바인딩하는 객체
profile
안녕하세요

0개의 댓글