[Spring] 댓글, 답글 기능 구현

yunSeok·2024년 1월 8일
0

사이드 프로젝트

목록 보기
12/14
post-thumbnail

코드는 깃허브 참고해주시면 감사하겠습니다.

지금 진행하고 있는 프로젝트의 마지막 기능이 될 거 같습니다.

프로젝트를 진행하면서 많진 않지만.. 여러 기능들을 구현해보면서 느끼는 점이 참 많은 거 같아요. 내가 하고 있는 이 방식이 맞는지에 대해 의문이들고, 그렇다고 찾아보면 어느정도는 비슷한 구현방식은 있지만 거의 유사한 구현 방식은 찾아보기 힘들었습니다. 현재는 내가 하고 있는게 맞는걸까? 하며 스스로에게 끊임없이 질문하며 한 걸음이라도 나아가보려고 하는 거 같아요.

"코드에 정답은 없다" 라고들 하지만, 성능이나 가독성 등등 좋은 코드는 있다고 생각합니다. 좋은 코드가 정답에 가깝지않을까? 라는 생각으로 나만의 방식을 만들게 되는 과정이 재미있는거 같아요. 그렇지만 또 다음 기능을 구현해보며 이전에 만들었던 방식이 얼마나 바보같았는지 돌이켜보며 나의 방식이 다시 부서지는.. 이런 과정도 참 재미있는거 같아요 🙂🙂

프로젝트 거의 처음 시작할때는 어떤걸 구현하려고 할 때, 무턱대고 코드 작성해본 거 같은데, 지금은 구현하기 전에 어떤식으로 설계해야할까? 어떤식으로 구현 방식을 잡아야 더 효율적으로 할 수 있을까? 에 대한 시간을 더 갖게 되는 거 같습니다.

물론 시간이 조금 지난 후 다시보면 왜이렇게 멍청하게 코드를 만들었지? 하는 순간이 올거라고 생각합니다... 하지만 저의 역량을 최대한 사용해!! 구현해보려고 합니다..


설계

댓글 기능을 구현할 때
댓글 기능만 만들건지, 댓글에 답글 기능을 만들것인지, 무한 답글 기능으로 만들건지 생각해 보셔야 할 거 같아요. 저는 네이버 웹툰에 있는 댓글 기능이 깔끔하다고 생각해서 비슷한 느낌으로 구현해보려고 합니다.


이런식으로 네이버 웹툰에선 댓글 목록들이 있고 각각의 댓글에 답글의 개수가 보이는 버튼이 있다.


답글 버튼을 누르게 되면 답글의 목록과 답글을 작성할 수 있는 목록이 보여지게 된다.

출처 : 네이버웹툰


댓글관련 출력, 수정, 삭제, 삽입 모두 Ajax를 이용해 구현했습니다.
댓글 목록을 출력하는 Ajax의 for문 안에 답글을 출력하는 Ajax를 위치시켜서
답글 목록이 해당 부모댓글 idx를 찾아가도록 해줬습니다.

❗️❗️ Ajax안에 Ajax 반복 처리

루프 내의 중첩된 AJAX 호출은 비동기식입니다.
Ajaxrk 독립적으로 실행되며 순차적으로 실행되지는 않았습니다..
이로 인해 결과가 무작위 순서로 출력되는 상황이 발생했습니다.

위에서 보는 것 처럼 원래는 1,2,3... 으로 순차적으로 실행되어야 하지만 비동기방식이라 무작위로 출력되는 것을 볼 수 있습니다.

이런 비동기 문제를 해결하려면 promise 또는 async/await를 사용할 수도 있지만

<div id='reply-"+replyNum+"'   ....

저는 이런식으로 태그 id를 고유pk번호로 지정해주고
함수 실행의 순서 상관없이 지정된 고유아이디로 삽입되도록 해주어 해결했습니다.


구현 순서

  1. 테이블 생성
  2. DTO, DAO, Service, mapper 작성
  3. 컨트롤러 작성
  4. JSP 작성 (구현화면)

1. 테이블 생성 (MySQL ver.)

CREATE TABLE comment (
        comment_idx int AUTO_INCREMENT primary key
        , post_idx int
        , userinfo_id varchar(1000)
        , comment_regdate DATETIME DEFAULT (CURRENT_DATE)
        , parent_idx int
        , comment_content varchar(4000)
        , comment_status char(1) DEFAULT 1
        
        , FOREIGN KEY .... ON DELETE CASCADE ON UPDATE CASCADE
        , FOREIGN KEY .... ON DELETE CASCADE ON UPDATE CASCADE
);

comment_idx : 고유 idx (pk)
post_idx : 댓글을 작성하는 게시물 번호 (fk)
userinfo_id : 작성자 id (fk)
comment_regdate : 작성일
parent_idx : (답글인 경우) 부모 댓글 idx
comment_content : 댓글 내용
comment_status : 댓글 상태 (1은 작성, 0은 삭제 등)

아래는 FOREIGN KEY 제약조건입니다.

계층을 따로 설정하진 않았고, parent_idx를 설정함으로써 대댓글인 경우 부모 idx를 찾아가서 그 안에서 출력되도록 해주었습니다.


2. DTO, DAO, Service, mapper

DTO

@Data
public class Comment {
	private int commentIdx;  // pk
	private int postIdx; // fk (Post 클래스) 
	private String userinfoId; // fk(Userinfo 클래스 id) 작성자 ID
	private String nickname; // fk(Userinfo 클래스 nickname) 작성자 닉네임
	private int parentIdx; // 부모 댓글의 commentIdx
	private String commentContent;  // 내용 
	private String commentRegdate;  // 작성일 
	private int commentStatus; // 상태등록(1:작성글, 0: 삭제글...)

}

Mapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.project.mapper.CommentMapper">

	<!-- 댓글 등록 -->
	<insert id="insertComment" parameterType="com.project.dto.Comment">
		INSERT INTO comment(
	        comment_idx
            , post_idx 
            , userinfo_id 
            , parent_idx
            , comment_content
            , comment_regdate
            , comment_status
	        )
	    VALUES(
	        #{commentIdx} 
	        , #{postIdx}
	        , #{userinfoId}
	        
	        <!-- 댓글인 경우 -->
	        <if test="parentIdx == 0">
	        , null
	        </if>
	        
	        <!-- 대댓글인 경우 -->
	        <if test="parentIdx != 0">
	        , #{parentIdx}
	        </if>
	        
	        , #{commentContent}
	        , NOW()
	        , 1
	        )
	</insert>
	
	<!-- 댓글 수정 -->
	<update id="updateComment" parameterType="com.project.dto.Comment">
		UPDATE comment SET
			comment_content=#{commentContent} 
        WHERE comment_idx=#{commentIdx}
	</update>
	
	<!-- 댓글 삭제 -->
	<update id="deleteComment" parameterType="com.project.dto.Comment">
		UPDATE comment SET
			comment_status=0
		WHERE comment_idx=#{commentIdx}
	</update>
	
	<!-- 댓글 목록 조회 -->
	<select id="selectCommentList" resultType="com.project.dto.Comment">
		SELECT 
			c.comment_idx
			, c.userinfo_id
			, u.nickname
			, c.comment_content
			, c.comment_regdate
		FROM comment c
		LEFT JOIN userinfo u 
	   		ON c.userinfo_id = u.id
		WHERE post_idx = #{postIdx} 
			AND comment_status = 1
		    AND parent_idx IS NULL
	    ORDER BY comment_regdate 
	</select>
	
	<!-- 답글 목록 조회 -->
	<select id="selectReplyList" resultType="com.project.dto.Comment">
		SELECT 
			c.comment_idx
			, c.userinfo_id
			, c.parent_idx
			, u.nickname
			, c.comment_content
			, c.comment_regdate
		FROM comment c
		LEFT JOIN userinfo u 
	   		ON c.userinfo_id = u.id
		WHERE parent_idx = #{parentIdx} 
			AND comment_status = 1
	    ORDER BY comment_regdate 
	</select>
</mapper>

댓글 등록 mapper 동작시
댓글인 경우(parentIdx를 0으로 넘김 -> null 저장)
답글인 경우(parentIdx를 부모 댓글의 idx로 넘김 -> 부모 댓글의 idx 저장)
나눠서 받도록 하였습니다.

댓글 조회 mapper에는 댓글 테이블의 작성자 id유저 테이블의 id를 조인하여 작성자의 닉네임을 출력하기 위해 JOIN을 이용하였습니다.

DTO, DAO, Service, mapper는 CRUD 만드는거와 사실 크게 다른 부분이 없기 때문에 GitHub 참고 해주시면 감사하겠습니다!!


3. 컨트롤러

@RestController
@RequestMapping("/comments")
@RequiredArgsConstructor
public class CommentRestController {
	
	@Autowired
	private final CommentService commentService;
	
	// 댓글 리스트 출력 (/게시물 번호)
	@GetMapping("/comment-list/{postIdx}")
	public ResponseEntity<List<Comment>> commentList(@RequestParam("postIdx") int postIdx) {
		
		try {
	        List<Comment> comment = commentService.getCommentList(postIdx);
	        return new ResponseEntity<>(comment, HttpStatus.OK);
	    } catch (Exception e) {
	    	return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
	    }
	}
	
	// 답글 리스트 출력 (/부모 댓글 번호)
	@GetMapping("/reply-list/{parentIdx}")
	public ResponseEntity<List<Comment>> replyList(@RequestParam("parentIdx") String parentIdx) {
		
		try {
	        List<Comment> reply = commentService.getReplyList(parentIdx);
	        return new ResponseEntity<>(reply, HttpStatus.OK);
	    } catch (Exception e) {
	    	return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
	    }
	}
	
	// 댓글,답글 등록	
	@PostMapping
	public ResponseEntity<Comment> commentAdd(Comment comment) {
		try {
			commentService.addComment(comment);
			return new ResponseEntity<Comment>(HttpStatus.OK);
		} catch (Exception e) {
			return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
		}
	}
	
	// 댓글, 답글 수정
	@PatchMapping("/{commentIdx}")
	public ResponseEntity<Comment> commentModify(@PathVariable("commentIdx") String commentIdx, @RequestBody Comment comment) {
		try {
			commentService.modifyComment(comment);
			return new ResponseEntity<Comment>(comment, HttpStatus.OK);
		} catch (Exception e) {
			return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
		}
		
	}
	
	// 댓글, 답글 삭제
	@DeleteMapping("/{commentIdx}")
	public ResponseEntity<?> commentDelete(@PathVariable("commentIdx") String commentIdx) {
		try {
			commentService.removeComment(commentIdx);
			return new ResponseEntity<>(HttpStatus.NO_CONTENT);
		} catch (Exception e) {
			return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
		}
	}
}

게시글 Idx를 받아와서 댓글의 목록을 출력하는 컨트롤러와,
부모댓글의 Idx를 가져와서 답글의 목록을 출력하는 컨트롤러로 나눠서 작성하였습니다.

컨트롤러는 최대한 RestAPI 방식을 지키려고 노력했습니다.

계층형 쿼리를 사용해 하나의 컨트롤러에서 한번의 쿼리만 실행해 전체 댓글과 답글의 목록 모두 출력하는 방식도 있겠습니다만... 위에서 봤듯 저는 네이버 웹툰의 댓글 기능 처럼 하나의 부모 댓글이 자식 답글들을 관리하는 느낌이라면 서로 독립적이고 정확한 정보를 출력해줄 수 있을거같아서 이런한 방식을 사용하게 되었습니다.

MySQL은 계층형 쿼리가 조금 까다로운 면이 있더라구요..하하


4. JSP

댓글 기능 전체 코드입니다.

<div class="" style="margin-top: 300px;">
   <hr style="width: 100%; margin-left: auto; margin-right: auto;">
     
   <div class="">
      <span class="" id="comment-number">댓글 수 : </span>
      <span class="" id="comment-count"></span>
   </div>	
        	
   <div class="comment-input">
	  <sec:authorize access="isAnonymous()">
	     <textarea name="commentContent" id="commentContent" rows="3" placeholder=" 댓글을 입력해주세요." disabled></textarea>
	     <button type="button" id="commentAdd" class="btn btn-primary" onclick="login()">댓글 등록</button>
	  </sec:authorize>
	  <sec:authorize access="hasAnyRole('ROLE_USER', 'ROLE_ADMIN', 'ROLE_SOCIAL', 'ROLE_MASTER')">
	      <textarea name="commentContent" id="commentContent" rows="3" placeholder=" 댓글을 입력해주세요." maxlength="300"></textarea>
	      <button type="button" id="commentAdd" class="btn btn-primary" onclick="commentAdd()">댓글 등록</button>
	   </sec:authorize>
	</div>
  
    <!-- 댓글 목록 출력 -->
	<div class="post-comment" id="comment-list">
	</div>	
</div>

Ajax로 댓글 목록, 답글 목록을 응답받아
<div class="post-comment" id="comment-list"> 태그안에 출력되도록 구조를 만들었기 때문에 지금은 짧아 보일 수 있지만 아래에서 보여드릴 Ajax가 응답해주는 코드가 길..예정입니다.

저는 어떤 게시물을 참고하진 않고
이렇게 하면 좋을 거 같은데? 라고 생각한 저만의 구조로 설계했기 때문에 하드코딩이 되버린 느낌도 있는 거 같습니다.


1. 댓글, 답글 목록 출력

<!-- 댓글 목록 출력 -->
<div class="post-comment" id="comment-list">
</div>

위에서 본 이 태그에 댓글과 답글 목록이 들어가게 됩니다.

$(document).ready(function() {
	commentList();
});  

페이지가 로딩될때 댓글+답글을 출력하는 함수가 동작되도록 했습니다.

댓글 출력 Ajax

// 댓글 목록 출력
function commentList() {
	
    $.ajax({
        method: "GET",
        url: "<c:url value='/comments/comment-list'/>/" + postIdx,
        data: {"postIdx": postIdx},
        dataType: "json",
        success: function(result) {
        	$("#comment-count").text(result.length);
        	
        	// 댓글이 1개 이상일때
        	if (result.length > 0) { 
        		
				// 댓글 목록 출력            	
	            for (var i = 0; i < result.length; i++) {
	                var commentList = result[i];
	                var commnetElement = 
	                	$("<div class='col-12 mb-4' id='comment-"+commentList.commentIdx+"' data-aos='fade-up' data-aos-delay='10'>" +
	                          "<div class='media-entry' id=''>" +
	                              "<img src='${pageContext.request.contextPath}/assets/images/login.png' class='comment-image'>" +
	                              "<p class='comment-nickname' id='comment-nickname-"+commentList.commentIdx+"'>" + (commentList.nickname === null || commentList.nickname === "" ? "닉네임없음" : commentList.nickname + " (" + commentList.userinfoId.substring(0, 3) + "***)") + "</p>" +
	                              "<p class='comment-date' id='comment-date-"+commentList.commentIdx+"' title='"+commentList.commentRegdate+"'>" + commentList.commentRegdate + "</p>" +
	                              "<div id='comment-modify-"+commentList.commentIdx+"'>" +
	                                "<p class='comment-content' id='comment-content-"+commentList.commentIdx+"' title='"+commentList.commentContent+"'>" + commentList.commentContent + "</p>" +
	                                (function() {
	                                    if (userinfoId === commentList.userinfoId) {
	                                        return "<button type='button' id='comment-modify-button' class='btn btn-danger' data-comment-modifyButton-idx='"+commentList.commentIdx+"' onclick='commentModifyButton();'>수정</button>" +
	              					        	   "<button type='button' id='comment-delete-button' class='btn btn-danger' data-comment-delete-idx='"+commentList.commentIdx+"' onclick='commentDelete();'>삭제</button>";
	                                    } else {
	                                	    // else 처리하지 않으면 undefined 출력됨
	                                        return "";
	                                    }
	                                 })() +
	                              "</div>" +
	                              
	                              "<div class='commentArea' id='comment-textarea-"+commentList.commentIdx+"'>" +
	                                "<textarea class='comment-content-modify' id='comment-modify-content-"+commentList.commentIdx+"' rows='3' maxlength='300'>"+commentList.commentContent+"</textarea>" +
	                                "<button type='button' id='comment-modify-button' class='btn btn-danger' data-comment-modifyCancel-idx='"+commentList.commentIdx+"' onclick='commentModifyCancel();'>취소</button>" +
            				        "<button type='button' id='comment-delete-button' class='btn btn-danger' data-comment-modify-idx='"+commentList.commentIdx+"' onclick='commentModify();'>등록</button>" +
	                              "</div>" +
	                              (function() {
	                                  if (userinfoId === commentList.userinfoId) {
	                                      return "<button type='button' id='comment-button-"+commentList.commentIdx+"' class='btn btn-success'  data-comment-replyShow-idx='"+commentList.commentIdx+"' onclick='replyShowButton()'><span>답글 </span><span id='reply-count-"+commentList.commentIdx+"'></span></button>";
	                                  } else {
	                                	  // else 처리하지 않으면 undefined 출력됨
	                                      return "<button type='button' id='comment-button1-"+commentList.commentIdx+"' class='btn btn-success'  data-comment-replyShow-idx='"+commentList.commentIdx+"' onclick='replyShowButton()'><span>답글 </span><span id='reply-count-"+commentList.commentIdx+"'></span></button>";
	                                  }
	                              })() +
	                           "</div>" +
	                           
	                           "<div class='' id='reply-button-"+commentList.commentIdx+"'>" +
	                              "<div class='' id='reply-list-"+commentList.commentIdx+"'>" +
	                              "</div>" +
	                           "</div>" +
	                           
	                        "<hr class='comment-line'>" +
	                    "</div>");
	                
	                $("#comment-list").append(commnetElement);
	                
	                var replyNum = commentList.commentIdx;
	                
	                replyList(replyNum);
	                
	                // 답글 출력
	                function replyList(replyNum) {
	                	
		                $.ajax({
					        method: "GET",
					        url: "<c:url value='/comments/reply-list'/>/" + replyNum,
					        data: {"parentIdx": replyNum},
					        dataType: "json",
					        success: function(result) {
					        	 
					        	if (result.length > 0) { 
									// 답글 목록 출력            	
						            for (var i = 0; i < result.length; i++) {
						                var replytList = result[i];
						                var replyElement = 
						                	$("<div class='reply-list' id='replyList-"+replytList.commentIdx+"' data-aos='fade-up' data-aos-delay='10'>" +
						                          "<div class='media-entry'>" +
						                              "<hr class='reply-line'>" +
						                              "<img src='${pageContext.request.contextPath}/assets/images/login.png' class='reply-image'>" +
						                              "<p class='reply-nickname' id='reply-nickname-"+replytList.commentIdx+"'>" + (replytList.nickname === null || replytList.nickname === "" ? "닉네임없음" : replytList.nickname + " (" + replytList.userinfoId.substring(0, 3) + "***)") + "</p>" +
						                              "<p class='reply-date' id='reply-date-"+replytList.commentIdx+"' title='"+replytList.commentRegdate+"'>" + replytList.commentRegdate + "</p>" +
						                              
						                              // 이건 댓글에서 쓰던거
						                              "<div id='comment-modify-"+replytList.commentIdx+"'>" +
						                              "<p class='reply-content' id='comment-content-"+replytList.commentIdx+"' title='"+replytList.commentContent+"'>" + replytList.commentContent + "</p>" +
						                              (function() {
						                                  if (userinfoId === replytList.userinfoId) {
						                                	  return "<button type='button' id='reply-modify-button' class='btn btn-danger' data-comment-modifyButton-idx='"+replytList.commentIdx+"' onclick='commentModifyButton();'>수정</button>" +
						                                             "<button type='button' id='reply-delete-button' class='btn btn-danger' data-reply-delete-idx='"+replytList.commentIdx+"' data-reply-parent-idx='"+replytList.parentIdx+"' onclick='replyDelete();'>삭제</button>";
						                                  } else {
						                                	  // else 처리하지 않으면 undefined 출력됨
						                                      return "";
						                                  }
						                              })() +
						                          "</div>" +
						                              
						                          "<div id='comment-textarea-"+replytList.commentIdx+"'>" +
					                                "<textarea class='reply-content-modify' id='comment-modify-content-"+replytList.commentIdx+"' rows='2' maxlength='300'>"+replytList.commentContent+"</textarea>" +
					                                "<button type='button' id='reply-modify-button' class='btn btn-danger' data-comment-modifyCancel-idx='"+replytList.commentIdx+"' onclick='commentModifyCancel();'>취소</button>" +
				            				        "<button type='button' id='reply-delete-button' class='btn btn-danger' data-reply-modify-idx='"+replytList.commentIdx+"' onclick='replyModify();'>등록</button>" +
					                              "</div>" +
					                              "</div>" +
						                    "</div>");
						                
						                $("#reply-list-" + replytList.parentIdx).append(replyElement);
						                
						              }
									
						            
					                var replyButton = 
					                	$("<div class='reply-input' id='reply-"+replyNum+"' data-aos='fade-up' data-aos-delay='10'>" +
					                		  "<hr class='reply-line'>" +
					                		  "<sec:authorize access='isAnonymous()'>" +
					                		  "<textarea name='commentContent' id='reply-content-' rows='3' placeholder=' 답글을 입력해주세요.' disabled></textarea>" +
					                		  "<button type='button' id='replyAdd' class='btn btn-primary' onclick='login()'>답글 등록</button>" +
					                		  "</sec:authorize>" +
					                		  "<sec:authorize access='hasAnyRole(\"ROLE_USER\", \"ROLE_ADMIN\", \"ROLE_SOCIAL\", \"ROLE_MASTER\")'>" +
					                		  "<textarea name='commentContent' id='reply-content-"+replytList.parentIdx+"' rows='3' placeholder=' 답글을 입력해주세요.' maxlength='300'></textarea>" +
					                		  "<button type='button' id='replyAdd' class='btn btn-primary' onclick='replyAdd()' data-comment-replyAdd-idx='"+replytList.parentIdx+"'>답글 등록</button>" +
					                		  "</sec:authorize>" +
					                    "</div>");
					                
					                $("#reply-button-" + replytList.parentIdx).append(replyButton);
		 		 	
						            $("#reply-count-" + replytList.parentIdx).text(result.length);
						            
					            } else {
					            	
					            	var replyButton1 = 
					                	$("<div class='reply-input' id='reply-"+replyNum+"' data-aos='fade-up' data-aos-delay='10'>" +
					                		  "<hr class='reply-line'>" +
					                		  "<sec:authorize access='isAnonymous()'>" +
					                		  "<textarea name='commentContent' id='reply-content-' rows='3' placeholder=' 댓글을 입력해주세요.' disabled></textarea>" +
					                		  "<button type='button' id='replyAdd' class='btn btn-primary' onclick='login()'>답글 등록</button>" +
					                		  "</sec:authorize>" +
					                		  "<sec:authorize access='hasAnyRole(\"ROLE_USER\", \"ROLE_ADMIN\", \"ROLE_SOCIAL\", \"ROLE_MASTER\")'>" +
					                		  "<textarea name='commentContent' id='reply-content-"+replyNum+"' rows='3' placeholder=' 댓글을 입력해주세요.' maxlength='300'></textarea>" +
					                		  "<button type='button' id='replyAdd' class='btn btn-primary' onclick='replyAdd()' data-comment-replyAdd-idx='"+replyNum+"'>답글 등록</button>" +
					                		  "</sec:authorize>" +
					                    "</div>");
					                
					                $("#reply-button-" + replyNum).append(replyButton1);
					                
					            	$("#reply-count-" + replyNum).text(result.length);
					            }
					        	 
					        }, error: function(error) {
					            console.log("comment-Error:", error);
					        }
					    });
	                }
	                
	            }
            } 
		 
        }, error: function(error) {
            console.log("comment-Error:", error);
        }
    });
}  

정리를 하자면 Ajax안에 Ajax를 위치해서 댓글하나가 출력될때 그 댓글의 Idx를 받아와서
댓글Idx답글의 부모Idx가 같은 답글을 출력되는 댓글의

"<div class='' id='reply-button-"+commentList.commentIdx+"'>" +
	 "<div class='' id='reply-list-"+commentList.commentIdx+"'>" +
	 "</div>" +
"</div>" +                                                        

이 부분에 넣어줄겁니다.

구현 화면

이런식으로 댓글아래에 해당 댓글의Idx부모Idx로 가지고있는 답글이 출력되게 됩니다.


                                                         <br>

2. 댓글 등록

function commentAdd() {  
	
	var commentContent = $("#commentContent").val();
	
    var formData = new FormData();
    formData.append("postIdx", postIdx);
    formData.append("userinfoId", $("#userinfoId").val());
    formData.append("parentIdx", 0);
    formData.append("commentContent", commentContent);
    
    $.ajax({
        type: "POST",
        url: "<c:url value='/comments'/>",
        data: formData,
        contentType: false,
        processData: false,
        dataType: "text",
        success: function (data, textStatus, xhr) {
        	
            if (xhr.status == 200) {
            	// 입력 값 지워주기
			    var replyInput = document.getElementById('commentContent');
			    replyInput.value = '';
			    
			    // 댓글 개수 +1
				var commentCount = document.getElementById("comment-count").textContent;
				commentCount++;
				document.getElementById("comment-count").textContent = commentCount;
				
				// 댓글목록 재출력
				$("#comment-list").empty();
				commentList();
				
            }
        }, error: function(error) {
            console.log("comment-Error:", error);
        }
    });
}

댓글의 내용을 commentContent로 받아와서 formData로 보내주었습니다.

댓글 등록 방식을

$("#comment-list").empty();
commentList();

이런식으로 두 줄로 간단하게 작성해서 표시하는 방법도 있지만 댓글을 등록 후 전체댓글이 다시 출력되도록 하면 사용자가 느끼기에 직관적이지 않아 불편하게 느낄 수 있겠다는 생각이 들었습니다.. 그래서 등록한 댓글의 데이터만 기존 댓글 아래에 추가되어지는 방식으로 구현하고 싶었습니다.

이런식으로 구현한다면,
등록된 댓글에 수정, 삭제 버튼에 idx를 추가해줘야 했습니다.

이게 무슨 말이냐면.. 현재는 formData로 Ajax 요청을 보내면
comment_idx int AUTO_INCREMENT primary key
DB에 insert되는 과정에서 위의 방식으로 삽입되어 지기 때문에 방금 등록한 댓글의 idx를 받아올 수 없었습니다.

그래서 중복되지 않는 고유한 idx를 formData에 같이 보내줘야했습니다.


📌 테이블 수정 + 등록 Ajax 수정 + DTO 수정 + Mapper 수정

1. Ajax 수정

    var random32 = Math.random().toString(32).substr(2, 8);
	var randomNum = postIdx + "-" + random32;
	
    var formData = new FormData();
    formData.append("commentIdx", randomNum);
    formData.append("postIdx", postIdx);
    formData.append("userinfoId", $("#userinfoId").val());
    formData.append("parentIdx", replyIdx);
    formData.append("commentContent", replycontent);  

기존 formData에는 commentIdx를 추가하지 않았었는데 Math.random()를 이용해서 중복되지 않는 idx를 생성했고,
randomNum를 게시물번호-랜덤숫자로 설정해주었습니다.

이런식으로 등록할때마다 고유 idx가 생성되어집니다.

$.ajax({
        type: "POST",
        url: "<c:url value='/comments'/>",
        data: formData,
        contentType: false,
        processData: false,
        dataType: "text",
        success: function (data, textStatus, xhr) {
        	var parsedData = JSON.parse(data);
            console.log(parsedData);
            if (xhr.status == 200) {
            	// 입력 값 지워주기
			    var replyInput = document.getElementById('commentContent');
			    replyInput.value = '';
			    
			    // 댓글 개수 +1
				var commentCount = document.getElementById("comment-count").textContent;
				commentCount++;
				document.getElementById("comment-count").textContent = commentCount;
				
				// 등록한 댓글 출력
				var commnetElement = 
	                	$("<div class='col-12 mb-4' id='comment-"+parsedData.commentIdx+"' data-aos='fade-up' data-aos-delay='10'>" +
	                          "<div class='media-entry' id=''>" +
	                              "<img src='${pageContext.request.contextPath}/assets/images/login.png' class='comment-image'>" +
	                              "<p class='comment-nickname' id='comment-nickname-"+parsedData.commentIdx+"'>" + (parsedData.nickname === null || parsedData.nickname === "" ? "닉네임없음" : parsedData.nickname + " (" + parsedData.userinfoId.substring(0, 3) + "***)") + "</p>" +
	                              "<p class='comment-date' id='comment-date-"+parsedData.commentIdx+"' title='"+parsedData.commentRegdate+"'>" + parsedData.commentRegdate + "</p>" +
	                              "<div id='comment-modify-"+parsedData.commentIdx+"'>" +
	                                "<p class='comment-content' id='comment-content-"+parsedData.commentIdx+"' title='"+parsedData.commentContent+"'>" + parsedData.commentContent + "</p>" +
	                                "<button type='button' id='comment-modify-button' class='btn btn-danger' data-comment-modifyButton-idx='"+parsedData.commentIdx+"' onclick='commentModifyButton();'>수정</button>" +
	              					"<button type='button' id='comment-delete-button' class='btn btn-danger' data-comment-delete-idx='"+parsedData.commentIdx+"' onclick='commentDelete();'>삭제</button>" +
	                              "</div>" +
	                              
	                              "<div class='commentArea' id='comment-textarea-"+parsedData.commentIdx+"'>" +
	                                "<textarea class='comment-content-modify' id='comment-modify-content-"+parsedData.commentIdx+"' rows='3' maxlength='300'>"+parsedData.commentContent+"</textarea>" +
	                                "<button type='button' id='comment-modify-button' class='btn btn-danger' data-comment-modifyCancel-idx='"+parsedData.commentIdx+"' onclick='commentModifyCancel();'>취소</button>" +
            				        "<button type='button' id='comment-delete-button' class='btn btn-danger' data-comment-modify-idx='"+parsedData.commentIdx+"' onclick='commentModify();'>등록</button>" +
	                              "</div>" +
	                              
	                              "<button type='button' id='comment-button-"+parsedData.commentIdx+"' class='btn btn-success'  data-comment-replyShow-idx='"+parsedData.commentIdx+"' onclick='replyShowButton()'><span>답글 </span><span id='reply-count-"+parsedData.commentIdx+"'>0</span></button>" +
	                           "</div>" +
	                           
	                           "<div class='' id='reply-button-"+parsedData.commentIdx+"'>" +
	                              "<div class='' id='reply-list-"+parsedData.commentIdx+"'>" +
	                              "</div>" +
	                           "</div>" +
	                           
	                        "<hr class='comment-line'>" +
	                    "</div>");
	                
	                $("#comment-list").append(commnetElement);
	            
            }
        }, error: function(error) {
            console.log("comment-Error:", error);
        }
    });  

이 idx를 이용해 출력되어지도록했습니다.


2. 테이블 수정

기존 테이블

CREATE TABLE comment (
        comment_idx int AUTO_INCREMENT primary key
        , post_idx int
        , userinfo_id varchar(1000)
        , comment_regdate DATETIME DEFAULT (CURRENT_DATE)
        , parent_idx int
        , comment_content varchar(4000)
        , comment_status char(1) DEFAULT 1
        
        , FOREIGN KEY .... ON DELETE CASCADE ON UPDATE CASCADE
        , FOREIGN KEY .... ON DELETE CASCADE ON UPDATE CASCADE
);  

수정 후 테이블

CREATE TABLE comment (
        comment_idx varchar(500)  primary key
        , post_idx int
        , userinfo_id varchar(1000)
        , comment_regdate DATETIME DEFAULT (CURRENT_DATE)
        , parent_idx varchar(500)
        , comment_content varchar(4000)
        , comment_status char(1) DEFAULT 1
        
        , FOREIGN KEY .... ON DELETE CASCADE ON UPDATE CASCADE
        , FOREIGN KEY .... ON DELETE CASCADE ON UPDATE CASCADE
);  

comment_idx와 parent_idx가 더이상 int 형태가 아니기때문에 수정해주었습니다.


3. DTO 수정

@Data
public class Comment {
	private String commentIdx;  // pk
	                .
                    .
	private String parentIdx; // 부모 댓글의 commentIdx

}

마찬가지로 String 형태로 바꾸어주었습니다.


4. mapper 수정

parentIdx를 String으로 변경하면서 계속 Ajax 요청시 오류가 발생하더라구요.. 한참 오류를 찾아보니 mapper가 문제였습니다.

기존 mapper

<insert id="insertComment" parameterType="com.project.dto.Comment">
	INSERT INTO comment(
        comment_idx
           , post_idx 
           , userinfo_id 
           , parent_idx
           , comment_content
           , comment_regdate
           , comment_status
        )
    VALUES(
        #{commentIdx} 
        , #{postIdx}
        , #{userinfoId}
  
        <!-- 댓글인 경우 -->
        <if test="parentIdx == 0">
        , null
        </if>
	        
        <!-- 답글인 경우 -->
        <if test="parentIdx != 0">
        , #{parentIdx}
        </if>
  
        , #{commentContent}
        , NOW()
        , 1
        )
</insert>
  

이런 방식으로 구현하게 되면 parentIdx가 int 형태일 때만 적용이 가능해서

수정 mapper

<insert id="insertComment" parameterType="com.project.dto.Comment">
	INSERT IGNORE INTO comment(
        comment_idx
           , post_idx 
           , userinfo_id 
           , parent_idx
           , comment_content
           , comment_regdate
           , comment_status
        )
    VALUES(
        #{commentIdx} 
        , #{postIdx}
        , #{userinfoId}
        , IF(#{parentIdx} = 0, null, #{parentIdx})
        , #{commentContent}
        , NOW()
        , 1
        )
</insert>

이 방식으로 변경해주었습니다.
혹시 중복 난수의 삽입을 방지하기 위하여 IGNORE도 추가해주었습니다.


구현 화면

권한이있는 사용자가 댓글을 등록하게 된다면 기존 댓글 아래에 작성한 댓글 내용이 등록되고, 수정, 삭제 기능이 있는 댓글이 추가되도록 해주었습니다.


3. 댓글 삭제

댓글 삭제나 수정시에는 버튼에 idx를 선언한 것을 이용했습니다.

개발자 도구의 Element를 봐보면 버튼마다 data- 형태의 pk값을 저장해주어서

function commentDelete() {  
	
	var deleteCommentIdx = event.currentTarget.getAttribute("data-comment-delete-idx");
	
    if (confirm("댓글을 삭제 하시겠습니까?")) {
    	console.log(postIdx);
        $.ajax({
            type: "DELETE",
            url: "<c:url value='/comments'/>/" + deleteCommentIdx + "/" + postIdx,
            data: {'commentIdx' : deleteCommentIdx
            	,'postIdx' : postIdx},
            contentType: false,
            success: function(data, textStatus, xhr) {
            	
                if (xhr.status == 204) {
                	$("#comment-" + deleteCommentIdx).remove();
                	// 댓글 개수 -1
					var commentCount = document.getElementById("comment-count").textContent;
					commentCount--;
					document.getElementById("comment-count").textContent = commentCount;
                } else {
                	alert("댓글 삭제에 실패했습니다.");
                } 
            },
            error: function(error) {
            	console.log(error);
                alert("오류가 발생했습니다.");
    	    }
       });
    } 
}  

event.currentTarget.getAttribute("data-comment-delete-idx") 이런식으로 idx를 가져와서 이용하였습니다.

구현화면

Ajax 응답 성공시 태그를 지우는 구조라서 조금 느린 느낌이지만.. 이 부분은 추후 개선해봐야할 숙제인 거 같습니다...!!


4. 댓글 수정

function commentModify() {  
	
	var commentIdx = event.currentTarget.getAttribute("data-comment-modify-idx");
	var commentModifyContent = $("#comment-modify-content-"+commentIdx).val();
	
    $.ajax({
        type: "PATCH",
        url: "<c:url value='/comments/'/>" + commentIdx,
        data: JSON.stringify({'commentIdx': commentIdx,'commentContent' : commentModifyContent}),
        contentType: 'application/json',
        dataType: "text",
        success: function (data, textStatus, xhr) {
            if (xhr.status == 200) {
            	
			    // 기존 댓글 내용 변경
			    var pTags = $("p[id='comment-content-" + commentIdx + "']");
			    pTags.text(commentModifyContent);
			    
				var commentInput = document.getElementById("comment-textarea-"+commentIdx);
				var comment = document.getElementById("comment-modify-"+commentIdx);
				
                // 수정 입력칸 숨기기
				commentInput.style.display = "none";
				comment.style.display = "block";
			    
            }
        }, error: function(error) {
            console.log("comment-Error:", error);
        }
    });
}  

입력 내용을 받아와서 원래의 태그에 넣어주도록 구현했습니다.

구현 화면

0개의 댓글