점프 투 스프링부트 추가 기능 구현 - 질문 및 답변 댓글 기능 구현

박철현·2023년 8월 9일
0

점프투스프링부트

목록 보기
9/14
  • 점프 투 스프링부트 추가 기능 구현 여덟번째, 질문 및 답변 댓글 기능 구현

  • 구현한 기능

    • 질문 및 댓글에 "00개의 댓글이 있습니다"를 클릭하면 댓글 창이 나타남
    • 비밀 댓글, 대댓글 기능 구현
    • 댓글 페이징 구현
    • 대댓글이 1개고, 부모 댓글이 삭제 상태에서 대댓글을 삭제한다면 부모 댓글도 같이 삭제
    • 모든 기능 Ajax화 완료
    • 관리자의 경우 모든 댓글 수정 및 삭제 가능
  • 2, 6번의 경우 타임리프 조건으로 구현하였습니다.

  • 위 기능 중 4번 삭제 관련해서 자세히 다루고, 나머지 기능 및 코드들은 GitHub PR에 설명을 남겨두었으니 참고하시고 코드 보시면 이해가 가지 않을까 싶습니다!

  • 누르면 아래와 같습니다. 로그인을 해야 댓글 작성이 가능합니다.

  • 댓글 삭제 관련한 로직은 아래 이미지와 같습니다.

  • JPA에서 영속성 컨텍스트를 활용하기 때문에 하나의 URI 요청내에서 데이터를 변경하고 다시 조회할 때 영속성 컨텍스트를 활용하여 데이터를 가져옵니다.

    • (1) 부모 댓글에 달린 자식 댓글(대댓글) 삭제
      • Repository를 통한 delete() 메서드를 사용하면 영속성 컨텍스트에 존재하는 값 자체를 삭제하긴 하지만, 컬렉션 자체는 자동으로 갱신되지 않음.
      • 따라서 자식을 삭제했지만, 새로 URI 요청을 보내서 갱신하지 않는 한 삭제되지 않고 남아있는 상태로 보임
    • (2) 해당 질문 혹은 답변에 달린 댓글 조회
    • (3) 기존 영속성 컨텍스트에 존재하는 1차 캐시값 조회
      • 동일 URI 요청 내 조회 요청이기 때문 -> 영속성 컨텍스트 1차 캐시 조회
    • (4) 부모 나오면서 삭제된 자식도 같이 나옴
      • 컬렉션은 자동 갱신하지 않기에
    • 요약 : 대댓글만 삭제한다고 했을 때, 댓글을 조회하는 새로운 URI요청(새로고침 등)이 있지 않으면 댓글에 연결된 대댓글 리스트에서 제거되지 않은 댓글 목록 조회됨
  • 따라서 댓글을 삭제하고 조회하여 댓글 목록을 반환하는 두 기능을 하나의 메서드에서 처리하기 위해 부모 댓글의 자식 댓글 List에서 remove를 수행하여 리스트에서 제거된 목록을 반환하였습니다.

  • case 4, 5의 경우만 연관관계를 고려하면 됩니다!

    • 부모 댓글의 경우 영속성컨텍스트에서 컬렉션으로 관리되지 않아 바로 반영된 데이터를 가져올 수 있습니다.
  • CommentController.java

// 댓글 삭제 메서드
	@PreAuthorize("isAuthenticated()")
	@PostMapping("/delete/{type}")
	public String delete(Model model, CommentForm commentForm, Principal principal, @PathVariable String type) {
		Question question = questionService.getQuestion(commentForm.getQuestionId());
		SiteUser user = userService.getUser(principal.getName());

		// 관리자가 아니거나 현재 로그인한 사용자가 작성한 댓글이 아니면 삭제 불가
		if (!(user.isAdmin()) && (user.getId() != commentForm.getCommentWriter())) {
			throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제 권한이 없습니다");
		}

		Comment comment = commentService.getComment(commentForm.getId());


		// 댓글이 속한 페이지 번호
		int page = 0;

		Answer answer = null;
		if (type.equals("question")) {
			page = commentService.getPageNumberByQuestion(question, comment, PAGESIZE);
		}

		else {
			answer = answerService.getAnswer(commentForm.getAnswerId());
			page = commentService.getPageNumberByAnswer(answer, comment, PAGESIZE);
		}

		// 부모(댓글)이 있을 경우 연관관계 끊어주기 -> 삭제되더라도 GET 등으로 새로 요청을 보내는 것이 아니기에
		// 이 작업은 꼭 해줘야 대댓글 리스트도 수정된다!

		// 부모댓글이 삭제 되지 않았다면 연관관계 끊어주기만 하면 됨
		// => Ajax 비동기 리스트화를 위해 리스트에서 명시적 삭제
		if (comment.getParent() != null && !comment.getParent().isDeleted()) {
			comment.getParent().getChildren().remove(comment);
		}
		// 부모댓글이 삭제 상태이고 부모의 자식 댓글이 본인 포함 2개 이상이라면
		// 자식 댓글의 삭제가 부모 댓글 객체 삭제에 영향을 주지 않으니 연관관계만 끊어주기
		// => Ajax 비동기 리스트화를 위해 리스트에서 명시적 삭제
		else if (comment.getParent() != null && comment.getParent().isDeleted()
			&& comment.getParent().getChildren().size() > 1) {
			comment.getParent().getChildren().remove(comment);
		}

		commentService.delete(comment);

		model.addAttribute("question", question);
		Page<Comment> paging;

		if (type.equals("question")) {
			paging = commentService.getCommentPageByQuestion(page, question);
			// 만일 삭제 전이 6개 -> 삭제하면 5개 -> 0패이지가 보여져야 하는데, 삭제 전에 page를 계산하여 1페이지가 보여짐.
			// 여기서 조건을 검사해줘야 함, 현재 페이지 개수가 0개면 이전 페이지 이동, 단 현재 페이지가 0인데도 개수가 0개?
			// 그러면 댓글이 아에 없으니 그냥 0페이지 표기!
			// 삭제하기 전에 page를 구한 이유는 댓글이 삭제되면 삭제한 댓글이 원래 어디 페이지에 있는지 검사가 안되기 때문
			if(page !=0 && paging.getNumberOfElements() == 0)
				paging = commentService.getCommentPageByQuestion(page-1, question);
			model.addAttribute("questionCommentPaging", paging);
			// 전체 댓글 수 갱신
			model.addAttribute("totalCount", paging.getTotalElements());
			return "comment/question_comment :: #question-comment-list";
		} else {
			paging = commentService.getCommentPageByAnswer(page, answer);
			if((page !=0 && paging.getNumberOfElements() == 0))
				paging = commentService.getCommentPageByAnswer(page-1, answer);

			model.addAttribute("answer", answer);
			model.addAttribute("answerCommentPaging", paging);
			// 전체 댓글 수 갱신
			model.addAttribute("totalCount", paging.getTotalElements());
			return "comment/answer_comment :: #answer-comment-list";
		}
	}
  • CommentService.java
@Transactional
	public void delete(Comment comment) {
		if (comment == null) {
			throw new IllegalArgumentException("Comment cannot be null");
		}
		if (comment.getChildren().size() != 0) {
			// 자식이 있으면 삭제 상태만 변경
			comment.deleteParent();
		} else { // 자식이 없다 -> 대댓글이 없다 -> 객체 그냥 삭제해도 된다.
			// 삭제 가능한 조상 댓글을 구해서 삭제
			// ex) 할아버지 - 아버지 - 대댓글, 3자라 했을 때 대댓글 입장에서 자식이 없으니 삭제 가능
			// => 삭제하면 아버지도 삭제 가능 => 할아버지도 삭제 가능하니 이런식으로 조상 찾기 메서드
			Comment tmp = getDeletableAncestorComment(comment);
			commentRepository.delete(tmp);
		}
	}

	@Transactional
	public Comment getDeletableAncestorComment(Comment comment) {
		Comment parent = comment.getParent(); // 현재 댓글의 부모를 구함
		if (parent != null && parent.getChildren().size() == 1 && parent.isDeleted() == true) {
			// 부모가 있고, 부모의 자식이 1개(지금 삭제하는 댓글)이고, 부모의 삭제 상태가 TRUE인 댓글이라면 재귀
			// 삭제가능 댓글 -> 만일 댓글의 조상(대댓글의 입장에서 할아버지 댓글)도 해당 댓글 삭제 시 삭제 가능한지 확인
			// Ajax로 비동기로 리스트 가져오기에, 대댓글 1개인거 삭제할 때 연관관계 삭제하고 부모 댓글 삭제하기 필요
			// 컨트롤러가 아닌 서비스의 삭제에서 처리해주는 이유는 연관관계를 삭제해주면 parent를 구할 수 없기에 여기서 끊어줘야 함
			parent.getChildren().remove(comment);

			return getDeletableAncestorComment(parent);
		}

		return comment;
	}
  • getDeletableAncestorComment 메서드를 통해 삭제하려는 댓글이 대댓글이며, 부모 댓글이 삭제상태인 댓글을 찾습니다.

    • 대댓글은 Lazy방식으로 조회되기에 리스트에서 명시적으로 제거 해줍니다.
      • 같은 Transaction 내에서 다시 객체 List 조회하기 때문에 동일한 영속성컨텍스트 사용
    • 만일 대댓글이 해당 메서드에 매개변수로 호출 되더라도 그대로 자기 자신을 반환하기 때문에 문제가 안됩니다.
  • 부모 객체를 삭제하면 자식 객체는 고아 객체가 되어 자동 삭제 됩니다.

    • orphanRemoval : 부모 엔티티가 삭제되면 고아 객체가 된 자식 객체 모두 삭제
  • Comment.java

	// OrphanRemoval로 부모 댓글이 삭제되면 자식 댓글 모두 삭제되도록 구현
	@OneToMany(mappedBy = "parent", orphanRemoval = true)
	@ToString.Exclude
	@Builder.Default // 빌더패턴 리스트시 초기화
	private List<Comment> children = new ArrayList<>();
profile
비슷한 어려움을 겪는 누군가에게 도움이 되길

0개의 댓글