📍 이번 포스트에서는 비동기 방식으로 댓글 crud 기능을 구현하고,
댓글 엔터티 self join으로 대댓글을 구현하기 위해 관계를 매핑하는 과정을 담았다.
개발환경
개발 툴 : SpringBoot 2.7.7
자바 : JAVA 11
빌드 : Gradle
템플릿 엔진 : Thymeleaf
spring MVC 구조
이름그대로 자기자신 테이블과 조인을 하는 것을 말한다. 원래 조인이 두개의 테이블에 대해 연관된 행들을 조인 칼럼을 기준으로 비교하여 새로운 행 집합을 만드는 것인데 두개의 테이블이 같은 테이블인 경우를 Self Join 이라고 한다.
즉, 한 개의 테이블을 두 개의 별도의 테이블처럼 이용하여 서로 조인 하는 형태로, 대댓글을 위한 테이블을 만들지는 않고 댓글 테이블을 참조한다.
한 개의 부모 댓글에 여러 답글이 달릴 수 있으므로 1:N 관계로 매핑해준다.
-> 하나의 Comment
를 부모로 가지고 List<Comment>
를 자식으로 가져야한다.
-> Comment 엔티티 안에서 @ManyToOne
과 @OneToMany
관계를 정의해야한다.
parentId 칼럼을 추가하고 자식댓글인 경우 부모댓글의 아이디를 저장한다.
@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());
}
}
가장 먼저 상세 페이지에 들어가면
👆 이전에 작성된 댓글리스트가 바로 출력되고
✌ 댓글 입력창에서 추가할 수 있도록 구성하고자 했다.
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
>
parent
과 삭제되지 않은 댓글 !deleted
만 출력되도록 조건을 걸었다.삭제
, 수정
버튼이 출력되도록 조건을 걸었다. // 부모댓글 리스트 출력
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);
}
});
}
// 댓글, 대댓글 수정
@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);
}
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를 이용해 비동기식 화면 수정