[팀프로젝트] 비동기 댓글 CRUD💬, 대댓글 관계 매핑 - 1

정상희·2023년 3월 4일
0
post-thumbnail

📍 이번 포스트에서는 비동기 방식으로 댓글 crud 기능을 구현하고,
댓글 엔터티 self join으로 대댓글을 구현하기 위해 관계를 매핑하는 과정을 담았다.

개발환경
개발 툴 : SpringBoot 2.7.7
자바 : JAVA 11
빌드 : Gradle
템플릿 엔진 : Thymeleaf
spring MVC 구조

✔ self join이란?

이름그대로 자기자신 테이블과 조인을 하는 것을 말한다. 원래 조인이 두개의 테이블에 대해 연관된 행들을 조인 칼럼을 기준으로 비교하여 새로운 행 집합을 만드는 것인데 두개의 테이블이 같은 테이블인 경우를 Self Join 이라고 한다.

즉, 한 개의 테이블을 두 개의 별도의 테이블처럼 이용하여 서로 조인 하는 형태로, 대댓글을 위한 테이블을 만들지는 않고 댓글 테이블을 참조한다.



✔ 순환 참조 관계 맺기

한 개의 부모 댓글에 여러 답글이 달릴 수 있으므로 1:N 관계로 매핑해준다.
-> 하나의 Comment를 부모로 가지고 List<Comment>를 자식으로 가져야한다.
-> Comment 엔티티 안에서 @ManyToOne@OneToMany 관계를 정의해야한다.

parentId 칼럼을 추가하고 자식댓글인 경우 부모댓글의 아이디를 저장한다.

Comment Entity

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
@Where(clause = "deleted_at is null")
public class Comment extends BaseEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String comment;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "crew_id")
    Crew crew;

    // 부모 정의
    @Setter
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id") // parent_id 이름으로 칼럼 추가
    private Comment parent;
    
    // 자식 정의
    @Setter
    @OneToMany(mappedBy = "parent", orphanRemoval = true)
    private List<Comment> children = new ArrayList<>();
  

    public void setComment(String comment) {
        this.comment = comment;
    }
    public static List<CommentViewResponse> from(List<Comment> comments) {
        return comments.stream()
                .map(CommentViewResponse::of)
                .collect(Collectors.toList());
    }
}

✔ 부모 댓글 CRUD

가장 먼저 상세 페이지에 들어가면
👆 이전에 작성된 댓글리스트가 바로 출력되고
✌ 댓글 입력창에서 추가할 수 있도록 구성하고자 했다.

read-crew.html

<p class="comment">댓글</p>
	<!--댓글 작성 부분-->
<form method="post">
   <div class="input-group" style="width:auto">
      <label class="form-label mt-4" hidden>댓글 작성</label>
      <input type="text"  class="form-control" id="commentContent" name="commentContent" required minlength="2" maxlength="100"placeholder="댓글을 입력해주세요">
      <input type="text" hidden th:value="${crewId}" id="crewIdComment">
      <input type="text" hidden th:value="${#authentication.getName()}" id="principal">
      <button type="button" id="commentBtn"  onclick="commentCheck()" class="btn btn-light">작성</button>
      <br>
   </div>
</form>
<p class="field-error commentContentCheck"></p>
<br>
	<!--댓글 출력 부분-->
<div id="commentList"> 댓글</div>
const crewId = [[${crewId}]];
    const username = [[${#authentication.name}]];

    $(function() {
        getComment();
    });

read-crew의 javascript 부분으로,
위의 코드에 의해 상세 페이지에 들어가자마자 댓글 출력 함수가 실행된다.

✔️ 부모 댓글 입력 로직

가장 먼저 댓글 입력을 했을 때 데이터를 전송하면 /view/v1/crews/{crewID}/comments POST가 서버로 요청된다.
CommentViewController

	// 댓글 작성
    @PostMapping("/view/v1/crews/{crewId}/comments")
    public ResponseEntity addComment(@RequestBody CommentRequest commentRequest,@PathVariable Long crewId, Authentication authentication) {
        CommentResponse commentResponse = commentService.addComment(commentRequest, crewId, authentication.getName());
        return new ResponseEntity<>(commentResponse, HttpStatus.OK);
    }

CommentService

public CommentResponse addComment(CommentRequest commentRequest, Long crewId, String userName) {
        User user = getUser(userName);
        Crew crew = getCrew(crewId);

        Comment comment = commentRepository.save(commentRequest.toEntity(user, crew));
        alarmRepository.save(Alarm.toEntity(user, crew, AlarmType.ADD_COMMENT, comment.getComment()));

        ...
        }

        return CommentResponse.of(comment);
    }

CommentResponse

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class CommentResponse {
    private Long id;
    private String comment;
    private String userName;
    private Long crewId;
    private LocalDateTime createdAt;
    public static CommentResponse of(Comment comment) {
        return CommentResponse.builder()
                .id(comment.getId())
                .comment(comment.getComment())
                .userName(comment.getUser().getUsername())
                .crewId(comment.getCrew().getId())
                .createdAt(comment.getCreatedAt())
                .build();
    }
}

ajax

function commentCheck() {
        const content = $("#commentContent").val();
        const crewId = $("#crewIdComment").val();
        $.ajax({
            type : "POST",
            url: '/view/v1/crews/'+ crewId +'/comments',
            async: false,
            data: JSON.stringify({
                "comment": content,
                "crewId": crewId,
                "parentId" : null
            }),
            contentType : "application/json; charset=utf-8",
            success: function(data) {
                alert("등록 되었습니다.") ;
                getComment();
                $('.commentContentCheck').text('');
                $('.commentContentCheck').css('display', 'none');
            },
            error: function (status) {
                alert("로그인 후 작성이 가능합니다.");
                $(status.responseJSON).each(function(){
                    $('.commentContentCheck').text(this.message);
                    $('.commentContentCheck').css('display', 'block');
                })
            }
        })
    }


✔️ 부모 댓글 출력 로직

getComment() 메소드가 실행되면서 /view/v1/crews/{crewId}/comments GET URL을 요청.
아래는 서버에서 실행되는 로직이다.
CommentViewController

	// 댓글 리스트 출력
    @GetMapping("/view/v1/crews/{crewId}/comments")
    public ResponseEntity getCommentList(@PathVariable Long crewId) {
        List<CommentViewResponse> list = commentViewService.getCommentViewList(crewId);
        return new ResponseEntity<>(list, HttpStatus.OK);
    }

CommentViewService
comment entity의 from 메소드를 통해 List<Comment>를 List<CommentViewResponse>로 매핑해준다.

	// 댓글 리스트
    @Transactional(readOnly = true)
    public List<CommentViewResponse> getCommentViewList(Long crewId) {
        List<Comment> list = commentRepository.findByCrewId(crewId);
        return Comment.from(list);
    }

CommentViewResponse

package teamproject.pocoapoco.domain.dto.comment.ui;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import teamproject.pocoapoco.domain.entity.Comment;

import java.time.LocalDateTime;
import java.util.LinkedList;
import java.util.List;

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class CommentViewResponse {
    private Long id;
    private String comment;
    private String userName;
    private String nickName;
    private Long crewId;
    private boolean isParent;
    private boolean isDeleted;
    private List<CommentViewResponse> children;
    private LocalDateTime createdAt;

    public static CommentViewResponse of(Comment comment) {
        return CommentViewResponse.builder()
                .id(comment.getId())
                .comment(comment.getComment())
                .userName(comment.getUser().getUsername())
                .nickName(comment.getUser().getNickName())
                .crewId(comment.getCrew().getId())
                .isParent(comment.getParent()==null)    // true라면 부모댓글
                .isDeleted(comment.isSoftDeleted()==true) // true라면 삭제된 댓글
                .children(comment.getChildren() != null ? Comment.from(comment.getChildren()) : new LinkedList<>())
                .createdAt(comment.getCreatedAt())
                .build();
    }
}

ajax
data값은 List<commentViewResponse>

  • $(data).each를 통해 댓글 리스트 하나하나가 아래의 html코드형식에 맞게 출력된다.
  • 부모 댓글 parent과 삭제되지 않은 댓글 !deleted만 출력되도록 조건을 걸었다.
    -> 조건설정을 위해 dto인 commentViewResponse에 추가한 필드
  • 작성자와 로그인한 사용자가 같은 user일 경우 삭제, 수정 버튼이 출력되도록 조건을 걸었다.
	// 부모댓글 리스트 출력
    function getComment(){

        $("#commentList").empty();  // commentList가 비어있는지 확인
        const principal = $("#principal").val(); // username
        const crewId = document.getElementById("crewIdComment").value;
        $.ajax({
            type:"get",
            url:"/view/v1/crews/"+ crewId +"/comments",
            dataType:"json",
            success:function (data) {
                var html = " ";
                $(data).each(function(){ // data == commentViewList
                    if(this.parent===true && this.deleted ===false){
                        html += "<ul class='list-group'>";
                        html += "<li class='list-group-item comments'>";
                        html += "<div class='comment' id='"+this.id+"comment'>";
                        html += "<a href='javascript:; class='userImg'>";
                        html += "</a>";
                        html += "<a href='javascript:;' class='writer' style='display:inline'>" + this.nickName + "</a>";
                        html += "<div class='comment-info'>";
                        html += "<span class='comment4 date'>" + getFormatDate(new Date(this.createdAt)) + "</span>";
                        html += "<div class='comment-text' id='"+this.id+"content'> " + this.comment + "</div>";
                        html += "<div class='comment_etc'>";
                        html += "<div class='comment-info'>";
                        html += "<a href='javascript:;' class='btn btn-secondary btn-icon-split comment_delete' id='"+this.id+"getChildrenBtn' >";
                        html += "<button id='"+this.id+"getChildrenBtn' onclick='getChildrenComment("+this.id+")' class='btn btn'>답글("+this.children.length+")</button>";
                        html += "</a>";
                        if(principal === this.userName) {
                            html += "<a href='javascript:;'>";
                            html += "<button type='button' onclick='commentDelete(" + this.id + ")' class='delete btn btn-outline-danger'  id='" + this.id + "deleteBtn'>삭제";
                            html += "</button>";
                            html += "</a>";
                        }if(principal === this.userName) {
                            html += "<a href='javascript:;'>";
                            html += "<button type='button'  onclick='commentUpdateForm(" + this.id + ")' class='btn btn-outline-secondary'  id='" + this.id + "updateBtn'>수정";
                            html += "</button>";
                            html += "</a>";
                        }
                        html += "</div>";
                        html += "</div>";
                        html += "</div>";
                        html += "</li>";
                        html += "<div id='"+this.id+"children' class='children'></div>";
                    }

                });
                html += "</ul>";

                $("#commentList").append(html);

            }
        });
    }



✔️ 댓글 수정 및 삭제

  • 수정, 삭제 로직은 댓글, 대댓글 api가 동일하다
    CommentViewController
	// 댓글, 대댓글 수정
    @PutMapping("/view/v1/crews/{crewId}/comments/{commentId}")
    public ResponseEntity modifyComment(@RequestBody CommentRequest commentRequest, @PathVariable Long crewId, @PathVariable Long commentId , Authentication authentication) {
        commentService.modifyComment(commentRequest, crewId, commentId, authentication.getName());
        return new ResponseEntity<>("수정되었습니다.", HttpStatus.OK);

    }
    // 댓글, 대댓글 삭제
    @DeleteMapping("/view/v1/crews/{crewId}/comments/{commentId}")
    public ResponseEntity<String> deleteComment(@PathVariable Long crewId, @PathVariable Long commentId , Authentication authentication) {
        try {
            if (commentService.getDetailComment(crewId, commentId).getUserName().equals(authentication.getName()) ||
                    authentication.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
                commentService.deleteComment(crewId, commentId, authentication.getName());
                return new ResponseEntity<>("articleCommentDeleting Success", HttpStatus.OK);
            } else {
                return new ResponseEntity<>(ErrorCode.INVALID_PERMISSION.getMessage(), HttpStatus.BAD_REQUEST);
            }
        }
        catch (EntityNotFoundException e){
            return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
        }
    }

CommentService

	// 수정
    public CommentResponse modifyComment(CommentRequest commentRequest, Long crewId, Long commentId, String userName) {
        Comment comment = checkCommentAndCrew(crewId, commentId);
        // 본인이 작성한 댓글이 아니면 에러
        isWriter(userName, comment);

        comment.setComment(commentRequest.getComment());
        return CommentResponse.of(comment);
    }
	// 삭제
    public CommentDeleteResponse deleteComment(Long crewId, Long commentId, String userName) {
        Comment comment = checkCommentAndCrew(crewId, commentId);
        // 본인이 작성한 댓글이 아니면 에러
        isWriter(userName, comment);

        comment.deleteSoftly(LocalDateTime.now());
        commentRepository.deleteAll(comment.getChildren());
        return CommentDeleteResponse.of(commentId);
    }


  • 아래는 view

getComment() 수정 부분
수정 버튼을 누르면 수정 폼을 보여주는 commentUpdateForm()이 실행된다.
- text area
- 수정 버튼
- 수정 취소 버튼이 있다.

html += "<a href='javascript:;'>";
html += "<button type='button'  onclick='commentUpdateForm(" + this.id + ")' class='btn btn-outline-secondary'  id='" + this.id + "updateBtn'>수정";
html += "</button>";
html += "</a>";

ajax

	function commentUpdateForm(id){
        $("#"+id+"updateBtn").hide();
        $.ajax({
            type: "GET",
            url: "/view/v1/crews/"+crewId+"/comments/"+id,
            dataType: "json",
            success: function (data) {
                var html = "<div class='comment-update'>";
                html += "<div class='comment-update-form'>";
                html += "<textarea class='form-control' id='"+id+"ucommentContent' rows='3' placeholder='댓글을 입력하세요.'>"+data.comment+"</textarea>";
                html += "<div class='field-error "+id+"ucommentContentCheck'>";
                html += "</div>";
                html += "<div class='comment-update-btn'>";
                html += "<button type='button' class='btn btn-secondary'  onclick='commentUpdate("+data.id+")'>수정</button>";
                html += "<button type='button' class='btn btn-secondary' onclick='commentUpdateCancel("+data.id+")'>취소</button>";
                html += "</div>";
                html += "</div>";
                html += "</div>";
                $("#"+data.id+"content").html(html);
            },
            error: function (xhr, status, error) {
                alert(status.message);
            }
        });
    }
	function commentUpdate (id) {
        {
            const crewId = $('#crewIdComment').val();
            const ucommentContent = $('#'+id+'ucommentContent').val();
            $.ajax({
                type: "PUT",
                url: "/view/v1/crews/"+ crewId +"/comments/"+id,
                data: JSON.stringify({"crewId": crewId,
                    "comment": ucommentContent,
                }),
                contentType: "application/json; charset=utf-8",
                success: function(data) {
                    alert(data) ;
                    getComment();
                    $('.'+id+'ucommentContentCheck').text('');
                },
                error: function (status) {
                    $(status.responseJSON).each(function(){
                        $('.'+id+'ucommentContentCheck').text(this.message);
                    })
                }
            });
        }
    }
    function commentUpdateCancel(id){
        $(".comment-update").html("");
        $("#"+id+"updateBtn").show();
        getComment();
    }

getComment() 삭제 부분
삭제 버튼을 누르면 commentDelete()가 실행된다.
이때 id는 commentViewResponse의 id이다.

html += "<a href='javascript:;'>";
html += "<button type='button' onclick='commentDelete(" + this.id + ")' class='delete btn btn-outline-danger'  id='" + this.id + "deleteBtn'>삭제";
html += "</button>";
html += "</a>";

ajax

function confirmDelete() {
        if(confirm("정말 삭제하시겠습니까?")) {
            $("#form").submit();
        }
        return false;
    }
function commentDelete (id) {
        {
            $.ajax({
                type: "DELETE",
                url: "/view/v1/crews/"+crewId+"/comments/"+ id,
                contentType: "application/json; charset=utf-8",
                success: function () {
                    alert('댓글이 삭제되었습니다.');
                    getComment();
                }
                ,
                error: function (xhr, status, error) {
                    alert(status.message);
                }
            });
        }
    }

여기까지 부모댓글 CRUD, 대댓글 update,delete까지 구현되었다.
분량이 길어지는 것 같아 대댓글 생성 및 조회 기능은 다음 포스트에서 마무리하려고 한다!

참고문헌

[JPA] Self Reference (순환 참조, 셀프 참조)
[Thymeleaf] ajax를 이용해 비동기식 화면 수정

0개의 댓글