56일차(3)/Spring(19) : 게시판 댓글 기능 구현
table.sql에 추가

- Oracle DB에 Cafe Comment 관련 새 테이블, 시퀀스 추가
- ref_group은 원래 글의 글번호

- comment_group : 한 덩어리로 몰려있는 댓글 (댓글의 댓글 묶음)
묶음 맨 위의 lead되는 코멘트의 번호를 그룹 번호로 부여한다.
ex) comment1,2,3의 그룹번호는 1 / comment5,6,7의 그룹번호는 5
- deleted는 댓글을 완전히 삭제하는 것이 아니고, 그냥 삭제된 댓글은 보이지 않게 하려고 한다.
- deleted에서 눈에 보이는 조건을 yes로 바꾸어서 보이지 않게 하는 것.
- 완전히 삭제하면 댓글구조가 망가질 수 있다.
- 댓글하나에 정보를 담을 dto를 만들 예정
CafeCommentDto 생성
package com.sy.spring04.cafe.dto;
public class CafeCommentDto {
private int num;
private String writer;
private String content;
private String target_id;
private int ref_group;
private int comment_group;
private String deleted;
private String regdate;
private String profile;
private int startRowNum;
private int endRowNum;
public CafeCommentDto() {};
public CafeCommentDto(int num, String writer, String content, String target_id, int ref_group, int comment_group,
String deleted, String regdate, String profile, int startRowNum, int endRowNum) {
super();
this.num = num;
this.writer = writer;
this.content = content;
this.target_id = target_id;
this.ref_group = ref_group;
this.comment_group = comment_group;
this.deleted = deleted;
this.regdate = regdate;
this.profile = profile;
this.startRowNum = startRowNum;
this.endRowNum = endRowNum;
}
public int getNum() {
return num;
}
public void setNum(int num) {
this.num = num;
}
public String getWriter() {
return writer;
}
public void setWriter(String writer) {
this.writer = writer;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getTarget_id() {
return target_id;
}
public void setTarget_id(String target_id) {
this.target_id = target_id;
}
public int getRef_group() {
return ref_group;
}
public void setRef_group(int ref_group) {
this.ref_group = ref_group;
}
public int getComment_group() {
return comment_group;
}
public void setComment_group(int comment_group) {
this.comment_group = comment_group;
}
public String getDeleted() {
return deleted;
}
public void setDeleted(String deleted) {
this.deleted = deleted;
}
public String getRegdate() {
return regdate;
}
public void setRegdate(String regdate) {
this.regdate = regdate;
}
public String getProfile() {
return profile;
}
public void setProfile(String profile) {
this.profile = profile;
}
public int getStartRowNum() {
return startRowNum;
}
public void setStartRowNum(int startRowNum) {
this.startRowNum = startRowNum;
}
public int getEndRowNum() {
return endRowNum;
}
public void setEndRowNum(int endRowNum) {
this.endRowNum = endRowNum;
};
}
CafeCommentDao 인터페이스
package com.sy.spring04.cafe.dao;
import java.util.List;
import com.sy.spring04.cafe.dto.CafeCommentDto;
public interface CafeCommentDao {
//댓글 목록 얻어오기
public List<CafeCommentDto> getList(CafeCommentDto dto);
//댓글 삭제
public void delete(int num);
//댓글 추가
public void insert(CafeCommentDto dto);
//추가할 댓글의 글번호를 리턴하는 메소드
public int getSequence();
//댓글 수정
public void update(CafeCommentDto dto);
//댓글 하나의 정보를 리턴하는 메소드
public CafeCommentDto getData(int num);
//댓글의 갯수를 리턴하는 메소드
public int getCount(int ref_group);
}
CafeCommentDaoImpl 클래스
package com.sy.spring04.cafe.dao;
import java.util.List;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
import com.sy.spring04.cafe.dto.CafeCommentDto;
@Repository
public class CafeCommentDaoImpl implements CafeCommentDao{
@Autowired
private SqlSession session;
@Override
public List<CafeCommentDto> getList(CafeCommentDto dto) {
return session.selectList("cafeComment.getList", dto);
}
@Override
public void delete(int num) {
session.update("cafeComment.delete", num);
}
@Override
public void insert(CafeCommentDto dto) {
session.insert("cafeComment.insert", dto);
}
//저장될 예정인 댓글의 글번호를 얻어내서 리턴해주는 메소드
@Override
public int getSequence() {
return session.selectOne("cafeComment.getSequence");
}
@Override
public void update(CafeCommentDto dto) {
session.update("cafeComment.update", dto);
}
@Override
public CafeCommentDto getData(int num) {
return session.selectOne("cafeComment.getData", num);
}
@Override
public int getCount(int ref_group) {
return session.selectOne("cafeComment.getCount", ref_group);
}
}
CafeCommentMapper.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="cafeComment">
<!--
댓글에 프로필 이미지도 같이 출력하기위해 users 테이블과 join을 한다.
댓글도 paging 처리가 필요하므로 select 할때 startRowNum과 endRowNum이 있어야 한다.
-->
<select id="getList" parameterType="cafeCommentDto" resultType="cafeCommentDto">
SELECT *
FROM
(SELECT result1.*, ROWNUM AS rnum
FROM
(SELECT num, writer, content, target_id, ref_group,
comment_group, deleted, board_cafe_comment.regdate, profile
FROM board_cafe_comment
INNER JOIN users
ON board_cafe_comment.writer=users.id
WHERE ref_group=#{ref_group}
ORDER BY comment_group ASC, num ASC) result1)
WHERE rnum BETWEEN #{startRowNum} AND #{endRowNum}
</select>
<!-- 댓글은 실제로 삭제하지 않고 deleted 칼럼에 저장된 값을 no에서 yes로 수정하는 작업만 한다. -->
<update id="delete" parameterType="int">
UPDATE board_cafe_comment
SET deleted='yes'
WHERE num=#{num}
</update>
<!-- 댓글을 저장할때 댓글의 글번호도 미리 CafeCommentDto 객체에 담아와야 한다. -->
<insert id="insert" parameterType="cafeCommentDto">
INSERT INTO board_cafe_comment
(num, writer, content, target_id, ref_group, comment_group, regdate)
VALUES(#{num}, #{writer}, #{content}, #{target_id}, #{ref_group},
#{comment_group}, SYSDATE)
</insert>
<!-- 저장 예정인 댓글의 글번호를 미리 얻어내기 -->
<select id="getSequence" resultType="int">
SELECT board_cafe_comment_seq.NEXTVAL
FROM DUAL
</select>
<update id="update" parameterType="cafeCommentDto">
UPDATE board_cafe_comment
SET content=#{content}
WHERE num=#{num}
</update>
<select id="getData" parameterType="int" resultType="cafeCommentDto">
SELECT num,writer,content,ref_group,comment_group,deleted,regdate
FROM board_cafe_comment
WHERE num=#{num}
</select>
<select id="getCount" parameterType="int" resultType="int">
SELECT NVL(MAX(ROWNUM), 0)
FROM board_cafe_comment
WHERE ref_group=#{ref_group}
</select>
</mapper>

- 번호를 이용한 삭제 기능은 deleted 칼럼을 'yes'로 바꿔주는 것으로 대체한다.

- 댓글을 저장하기 전에 미리 글번호를 얻어와서 저장해야 할 때가 있다.
- 댓글의 글번호와 comment 그룹번호를 똑같이 넣어서 저장해야 한다.

- 번호가 무엇인지를 알아야 같이 부여할 수 있기 때문에!!
- 다 넣어서 준비해 와야 된다.

- getSequence를 사용해서 얻어낸 글번호가 여기에 들어간다.
- 원글의 댓글인 경우에는 num=comment_group 번호이고, 원글의 댓글이 아닌 경우에는 두 값이 다르다.
- 이 번호가 같은가 다른가에 따라서 원글의 댓글인지 대댓글인지가 구분된다.
- 대댓글은 들여쓰기해서 보여줄 것이기 때문에! 원글의 댓글과는 구분해주어야 한다.
- 숫자이므로 resultType은 int이다.

- getData : 패러미터로 숫자를 가져와서 특정 값을 가져오도록 select하기
- getCount : 페이징 처리를 위해 전체 글 수를 읽어와서, 패러미터 값으로 가져온 숫자로 추려내기 (ref_group을사용)

- join 일어난다. 댓글에서 프로필 이미지를 가져오기 때문에...! 이미지 경로를 출력하려고.
- 댓글에 프로필사진을 같이 출력하려면 댓글 작성자(writer)와 유저 테이블의 아이디(id)를 join해서 가져와야 한다.

- 정렬된, 페이지 처리된 댓글 목록을 얻어오려면
원글의 글번호를 넣어서 select
→ 코멘트 그룹, 번호에 따라 두번 정렬
→ 필요한 row만 where절을 사용해서 select하는 과정이 필요하다.
1) select, 2) rowNum부여, 3) 원하는 row만 남기기

- dto는 3가지 값만 남긴다.

- configuration에 새로 추가한 CafeCommentMapper, alias 추가해주기
daoImpl - 메소드

- getSequence : 글번호를 미리 가지고 가서 리턴해주는 메소드
- 나머지는 인자 값을 가져가서 리턴해주는 메소드들이다.
- CommentService를 따로 만들지는 않고, cafeService에 합칠 예정
CafeService interface
package com.sy.spring04.cafe.service;
import javax.servlet.http.HttpServletRequest;
import com.sy.spring04.cafe.dto.CafeCommentDto;
import com.sy.spring04.cafe.dto.CafeDto;
public interface CafeService {
public void getList(HttpServletRequest request); //목록불러오기
public void getDetail(HttpServletRequest request);
public void saveContent(CafeDto dto); //글 저장
public void updateContent(CafeDto dto);
public void deleteContent(int num, HttpServletRequest request);
public void getData(HttpServletRequest request); //글 수정하기 위해 정보 불러오는 기능
public void saveComment(HttpServletRequest request); //댓글 저장
public void deleteComment(HttpServletRequest request); //댓글 삭제
public void updateComment(CafeCommentDto dto); //댓글 수정
public void moreCommentList(HttpServletRequest request); //댓글 더보기 기능
}

- 추상메소드 추가
- 해당 Service를 구현한 CafeServiceImpl에도 메소드 추가

- @Autowired, CommentDao 추가해주기
- 이렇게 한 클래스에서 여러 개의 DAO에 의존할 수도 있다.
- saveComment 메소드
@Override
public void saveComment(HttpServletRequest request) {
//폼 전송되는 파라미터 추출
int ref_group=Integer.parseInt(request.getParameter("ref_group")); //원글의 글번호
String target_id=request.getParameter("target_id"); //댓글 대상자의 아이디
String content=request.getParameter("content"); //댓글의 내용
/*
* 원글의 댓글은 comment_group 번호가 전송이 안되고
* 댓글의 댓글은 comment_group 번호가 전송이 된다.
* 따라서 null 여부를 조사하면 원글의 댓글인지 댓글의 댓글인지 판단할수 있다.
*/
String comment_group=request.getParameter("comment_group");
//댓글 작성자는 session 영역에서 얻어내기
String writer=(String)request.getSession().getAttribute("id");
//댓글의 시퀀스 번호 미리 얻어내기
int seq=cafeCommentDao.getSequence();
//저장할 댓글의 정보를 dto 에 담기
CafeCommentDto dto=new CafeCommentDto();
dto.setNum(seq);
dto.setWriter(writer);
dto.setTarget_id(target_id);
dto.setContent(content);
dto.setRef_group(ref_group);
//원글의 댓글인경우
if(comment_group == null){
//댓글의 글번호를 comment_group 번호로 사용한다.
dto.setComment_group(seq);
}else{
//전송된 comment_group 번호를 숫자로 바꾸서 dto 에 넣어준다.
dto.setComment_group(Integer.parseInt(comment_group));
}
//댓글 정보를 DB 에 저장하기
cafeCommentDao.insert(dto);
}

- 3가지 값의 정보는 확실히 넘어오고,
아래의 댓글 값(comment_group)은 경우에 따라 넘어올 수도 있고 아닐 수도 있다.

- session에서 id값을 구해 writer 에 넣어준다.
- 댓글의 글 번호를 미리 알아낸 이유!! 여기에서 사용하기 위해.
- 원글의 댓글은 댓글번호와 comment_group 번호가 같다. 댓글 자기의 글번호가 자기자신의 그룹번호도 된다!
- comment group 번호가 넘어오므로 이 번호를 사용해서 DB에 전달한다.
delete
@Override
public void deleteComment(HttpServletRequest request) {
int num=Integer.parseInt(request.getParameter("num"));
//삭제할 댓글 정보를 읽어와서
CafeCommentDto dto=cafeCommentDao.getData(num);
String id=(String)request.getSession().getAttribute("id");
//글 작성자와 로그인된 아이디와 일치하지 않으면
if(!dto.getWriter().equals(id)) {
throw new NotDeleteException("남의 댓글 지우면 혼난당!");
}
cafeCommentDao.delete(num);
}
- int값으로 댓글 정보를 읽어와서 id와 비교하고, 본인이 쓴 댓글이 아니면 예외를 발생시킨다.
- dao delete 메소드를 사용해서 삭제작업(deleted)를 yes로 바꾸기
글 자세히보기(getDetail)에서 댓글보기 사용
- getDetail에 내용추가
@Override
public void getDetail(HttpServletRequest request) {
//자세히 보여줄 글번호를 읽어온다.
int num=Integer.parseInt(request.getParameter("num"));
//조회수 올리기
cafeDao.addViewCount(num);
/*
[ 검색 키워드에 관련된 처리 ]
-검색 키워드가 파라미터로 넘어올수도 있고 안넘어 올수도 있다.
*/
String keyword=request.getParameter("keyword");
String condition=request.getParameter("condition");
//만일 키워드가 넘어오지 않는다면
if(keyword==null){
//키워드와 검색 조건에 빈 문자열을 넣어준다.
//클라이언트 웹브라우저에 출력할때 "null" 을 출력되지 않게 하기 위해서
keyword="";
condition="";
}
//CafeDto 객체를 생성해서
CafeDto dto=new CafeDto();
//자세히 보여줄 글번호를 넣어준다.
dto.setNum(num);
//만일 검색 키워드가 넘어온다면
if(!keyword.equals("")){
//검색 조건이 무엇이냐에 따라 분기 하기
if(condition.equals("title_content")){//제목 + 내용 검색인 경우
//검색 키워드를 CafeDto 에 담아서 전달한다.
dto.setTitle(keyword);
dto.setContent(keyword);
}else if(condition.equals("title")){ //제목 검색인 경우
dto.setTitle(keyword);
}else if(condition.equals("writer")){ //작성자 검색인 경우
dto.setWriter(keyword);
} // 다른 검색 조건을 추가 하고 싶다면 아래에 else if() 를 계속 추가 하면 된다.
}
//글 하나의 정보를 얻어온다.
CafeDto resultDto=cafeDao.getData(dto);
//특수기호를 인코딩한 키워드를 미리 준비한다.
String encodedK=URLEncoder.encode(keyword);
/*
* [ 댓글 페이징 처리에 관련된 로직 ]
*/
//한 페이지에 몇개씩 표시할 것인지
final int PAGE_ROW_COUNT=10;
//detail.jsp 페이지에서는 항상 1페이지의 댓글 내용만 출력한다.
int pageNum=1;
//보여줄 페이지의 시작 ROWNUM
int startRowNum=1+(pageNum-1)*PAGE_ROW_COUNT;
//보여줄 페이지의 끝 ROWNUM
int endRowNum=pageNum*PAGE_ROW_COUNT;
//원글의 글번호를 이용해서 해당글에 달린 댓글 목록을 얻어온다.
CafeCommentDto commentDto=new CafeCommentDto();
commentDto.setRef_group(num);
//1페이지에 해당하는 startRowNum 과 endRowNum 을 dto 에 담아서
commentDto.setStartRowNum(startRowNum);
commentDto.setEndRowNum(endRowNum);
//1페이지에 해당하는 댓글 목록만 select 되도록 한다.
List<CafeCommentDto> commentList=cafeCommentDao.getList(commentDto);
//원글의 글번호를 이용해서 댓글 전체의 갯수를 얻어낸다.
int totalRow=cafeCommentDao.getCount(num);
//댓글 전체 페이지의 갯수
int totalPageCount=(int)Math.ceil(totalRow/(double)PAGE_ROW_COUNT);
//request scope에 글 하나의 정보 담기
request.setAttribute("dto", resultDto);
request.setAttribute("condition", condition);
request.setAttribute("keyword", keyword);
request.setAttribute("encodedK", encodedK);
request.setAttribute("tSotalRow", totalRow);
request.setAttribute("commentList", commentList);
request.setAttribute("totalPageCount", totalPageCount);
}

- 페이징처리, dto에 원글의 글번호를 담고, dto를 사용해서 댓글을 select하는 것이다.
- totalRow를 사용해서 댓글이 전체 몇 페이지까지 있는지 알아내고, view page에 전달한다.

- request Scope에 3개의 내용을 추가로 담아준다.(totalRow, commentList, totalPageCount)
- 댓글 더보기 기능은 ajax로 구현할 예정
Service메소드- moreCommentList
@Override
public void moreCommentList(HttpServletRequest request) {
//로그인된 아이디
String id=(String)request.getSession().getAttribute("id");
//ajax 요청 파라미터로 넘어오는 댓글의 페이지 번호를 읽어낸다
int pageNum=Integer.parseInt(request.getParameter("pageNum"));
//ajax 요청 파라미터로 넘어오는 원글의 글 번호를 읽어낸다
int num=Integer.parseInt(request.getParameter("num"));
/*
[ 댓글 페이징 처리에 관련된 로직 ]
*/
//한 페이지에 몇개씩 표시할 것인지
final int PAGE_ROW_COUNT=10;
//보여줄 페이지의 시작 ROWNUM
int startRowNum=1+(pageNum-1)*PAGE_ROW_COUNT;
//보여줄 페이지의 끝 ROWNUM
int endRowNum=pageNum*PAGE_ROW_COUNT;
//원글의 글번호를 이용해서 해당글에 달린 댓글 목록을 얻어온다.
CafeCommentDto commentDto=new CafeCommentDto();
commentDto.setRef_group(num);
//1페이지에 해당하는 startRowNum 과 endRowNum 을 dto 에 담아서
commentDto.setStartRowNum(startRowNum);
commentDto.setEndRowNum(endRowNum);
//pageNum에 해당하는 댓글 목록만 select 되도록 한다.
List<CafeCommentDto> commentList=cafeCommentDao.getList(commentDto);
//원글의 글번호를 이용해서 댓글 전체의 갯수를 얻어낸다.
int totalRow=cafeCommentDao.getCount(num);
//댓글 전체 페이지의 갯수
int totalPageCount=(int)Math.ceil(totalRow/(double)PAGE_ROW_COUNT);
//view page 에 필요한 값 request 에 담아주기
request.setAttribute("commentList", commentList);
request.setAttribute("num", num); //원글의 글번호
request.setAttribute("pageNum", pageNum); //댓글의 페이지 번호
}
- 댓글 리스트를 리턴하는 메소드
- dto를 이용해서 댓글목록 얻어오기
- 댓글리스트, 원글의 글번호, 댓글의 페이지 번호를 request 영역에 넣어주기
detail.jsp 페이지 내용 추가
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>/views/cafe/detail.jsp</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"/>
<style>
.content{
border: 1px dotted gray;
}
/* 댓글 프로필 이미지를 작은 원형으로 만든다. */
.profile-image{
width: 50px;
height: 50px;
border: 1px solid #cecece;
border-radius: 50%;
}
/* ul 요소의 기본 스타일 제거 */
.comments ul{
padding: 0;
margin: 0;
list-style-type: none;
}
.comments dt{
margin-top: 5px;
}
.comments dd{
margin-left: 50px;
}
.comment-form textarea, .comment-form button{
float: left;
}
.comments li{
clear: left;
}
.comments ul li{
border-top: 1px solid #888;
}
.comment-form textarea{
width: 84%;
height: 100px;
}
.comment-form button{
width: 14%;
height: 100px;
}
/* 댓글에 댓글을 다는 폼과 수정폼은 일단 숨긴다. */
.comments .comment-form{
display: none;
}
/* .reply_icon 을 li 요소를 기준으로 배치 하기 */
.comments li{
position: relative;
}
.comments .reply-icon{
position: absolute;
top: 1em;
left: 1em;
color: red;
}
pre {
display: block;
padding: 9.5px;
margin: 0 0 10px;
font-size: 13px;
line-height: 1.42857143;
color: #333333;
word-break: break-all;
word-wrap: break-word;
background-color: #f5f5f5;
border: 1px solid #ccc;
border-radius: 4px;
}
.loader{
/* 로딩 이미지를 가운데 정렬하기 위해 */
text-align: center;
/* 일단 숨겨 놓기 */
display: none;
}
.loader svg{
animation: rotateAni 1s ease-out infinite;
}
@keyframes rotateAni{
0%{
transform: rotate(0deg);
}
100%{
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="container">
<%-- 만일 이전글(더 옛날글)의 글번호가 0 가 아니라면(이전글이 존재 한다면) --%>
<c:if test="${dto.prevNum ne 0}">
<a href="detail?num=${dto.prevNum }&condition=${condition}&keyword=${encodedK}">이전글</a>
</c:if>
<%-- 만일 다음글(더 최신글)의 글번호가 0 가 아니라면(다음글이 존재 한다면) --%>
<c:if test="${dto.nextNum ne 0 }">
<a href="detail?num=${dto.nextNum }&condition=${condition}&keyword=${encodedK}">다음글</a>
</c:if>
<%-- 만일 검색 키워드가 있다면 --%>
<c:if test="${not empty keyword }">
<p>
<strong>${condition }</strong> 조건
<strong>${keyword }</strong> 검색어로 검색된 내용 자세히 보기
</p>
</c:if>
<h3>글 상세 보기</h3>
<table>
<tr>
<th>글번호</th>
<td>${dto.num }</td>
</tr>
<tr>
<th>작성자</th>
<td>${dto.writer }</td>
</tr>
<tr>
<th>제목</th>
<td>${dto.title }</td>
</tr>
<tr>
<th>조회수</th>
<td>${dto.viewCount }</td>
</tr>
<tr>
<th>작성일</th>
<td>${dto.regdate }</td>
</tr>
<tr>
<td colspan="2">
<div>${dto.content }</div>
</td>
</tr>
</table>
<c:if test="${sessionScope.id eq dto.writer }">
<a href="updateform?num=${dto.num }">수정</a>
<a href="javascript:" onclick="deleteConfirm()">삭제</a>
<script>
function deleteConfirm(){
const isDelete=confirm("이 글을 삭제 하겠습니까?");
if(isDelete){
location.href="delete?num=${dto.num}";
}
}
</script>
</c:if>
<!-- 댓글 목록 -->
<div class="comments">
<ul>
<c:forEach var="tmp" items="${commentList }">
<c:choose>
<c:when test="${tmp.deleted eq 'yes' }">
<li>삭제된 댓글 입니다.</li>
</c:when>
<c:otherwise>
<c:if test="${tmp.num eq tmp.comment_group }">
<li id="reli${tmp.num }">
</c:if>
<c:if test="${tmp.num ne tmp.comment_group }">
<li id="reli${tmp.num }" style="padding-left:50px;">
<svg class="reply-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-return-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 0 1 2v4.8a2.5 2.5 0 0 0 2.5 2.5h9.793l-3.347 3.346a.5.5 0 0 0 .708.708l4.2-4.2a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 8.3H3.5A1.5 1.5 0 0 1 2 6.8V2a.5.5 0 0 0-.5-.5z"/>
</svg>
</c:if>
<dl>
<dt>
<c:if test="${ empty tmp.profile }">
<svg class="profile-image" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-circle" viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
</svg>
</c:if>
<c:if test="${not empty tmp.profile }">
<img class="profile-image" src="${pageContext.request.contextPath}${tmp.profile }"/>
</c:if>
<span>${tmp.writer }</span>
<c:if test="${tmp.num ne tmp.comment_group }">
@<i>${tmp.target_id }</i>
</c:if>
<span>${tmp.regdate }</span>
<a data-num="${tmp.num }" href="javascript:" class="reply-link">답글</a>
<c:if test="${ (id ne null) and (tmp.writer eq id) }">
<a data-num="${tmp.num }" class="update-link" href="javascript:">수정</a>
<a data-num="${tmp.num }" class="delete-link" href="javascript:">삭제</a>
</c:if>
</dt>
<dd>
<pre id="pre${tmp.num }">${tmp.content }</pre>
</dd>
</dl>
<form id="reForm${tmp.num }" class="animate__animated comment-form re-insert-form" action="comment_insert" method="post">
<input type="hidden" name="ref_group" value="${dto.num }"/>
<input type="hidden" name="target_id" value="${tmp.writer }"/>
<input type="hidden" name="comment_group" value="${tmp.comment_group }"/>
<textarea name="content"></textarea>
<button type="submit">등록</button>
</form>
<c:if test="${tmp.writer eq id }">
<form id="updateForm${tmp.num }" class="comment-form update-form" action="comment_update" method="post">
<input type="hidden" name="num" value="${tmp.num }" />
<textarea name="content">${tmp.content }</textarea>
<button type="submit">수정</button>
</form>
</c:if>
</li>
</c:otherwise>
</c:choose>
</c:forEach>
</ul>
</div>
<div class="loader">
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
</div>
<!-- 원글에 댓글을 작성할 폼 -->
<form class="comment-form insert-form" action="comment_insert" method="post">
<!-- 원글의 글번호가 댓글의 ref_group 번호가 된다. -->
<input type="hidden" name="ref_group" value="${dto.num }"/>
<!-- 원글의 작성자가 댓글의 대상자가 된다. -->
<input type="hidden" name="target_id" value="${dto.writer }"/>
<textarea name="content">${empty id ? '댓글 작성을 위해 로그인이 필요 합니다.' : '' }</textarea>
<button type="submit">등록</button>
</form>
</div>
<script src="${pageContext.request.contextPath}/resources/js/gura_util.js"></script>
<script>
//클라이언트가 로그인 했는지 여부
let isLogin=${ not empty id };
document.querySelector(".insert-form")
.addEventListener("submit", function(e){
//만일 로그인 하지 않았으면
if(!isLogin){
//폼 전송을 막고
e.preventDefault();
//로그인 폼으로 이동 시킨다.
location.href=
"${pageContext.request.contextPath}/users/loginform?url=${pageContext.request.contextPath}/cafe/detail?num=${dto.num}";
}
});
/*
detail
페이지 로딩 시점에 만들어진 1 페이지에 해당하는
댓글에 이벤트 리스너 등록 하기
*/
addUpdateFormListener(".update-form");
addUpdateListener(".update-link");
addDeleteListener(".delete-link");
addReplyListener(".reply-link");
//댓글의 현재 페이지 번호를 관리할 변수를 만들고 초기값 1 대입하기
let currentPage=1;
//마지막 페이지는 totalPageCount 이다.
let lastPage=${totalPageCount};
//추가로 댓글을 요청하고 그 작업이 끝났는지 여부를 관리할 변수
let isLoading=false; //현재 로딩중인지 여부
/*
window.scrollY => 위쪽으로 스크롤된 길이
window.innerHeight => 웹브라우저의 창의 높이
document.body.offsetHeight => body 의 높이 (문서객체가 차지하는 높이)
*/
window.addEventListener("scroll", function(){
//바닥 까지 스크롤 했는지 여부
const isBottom =
window.innerHeight + window.scrollY >= document.body.offsetHeight;
//현재 페이지가 마지막 페이지인지 여부 알아내기
let isLast = currentPage == lastPage;
//현재 바닥까지 스크롤 했고 로딩중이 아니고 현재 페이지가 마지막이 아니라면
if(isBottom && !isLoading && !isLast){
//로딩바 띄우기
document.querySelector(".loader").style.display="block";
//로딩 작업중이라고 표시
isLoading=true;
//현재 댓글 페이지를 1 증가 시키고
currentPage++;
/*
해당 페이지의 내용을 ajax 요청을 통해서 받아온다.
"pageNum=xxx&num=xxx" 형식으로 GET 방식 파라미터를 전달한다.
*/
ajaxPromise("ajax_comment_list","get",
"pageNum="+currentPage+"&num=${dto.num}")
.then(function(response){
//json 이 아닌 html 문자열을 응답받았기 때문에 return response.text() 해준다.
return response.text();
})
.then(function(data){
//data 는 html 형식의 문자열이다.
console.log(data);
// beforebegin | afterbegin | beforeend | afterend
document.querySelector(".comments ul")
.insertAdjacentHTML("beforeend", data);
//로딩이 끝났다고 표시한다.
isLoading=false;
//새로 추가된 댓글 li 요소 안에 있는 a 요소를 찾아서 이벤트 리스너 등록 하기
addUpdateListener(".page-"+currentPage+" .update-link");
addDeleteListener(".page-"+currentPage+" .delete-link");
addReplyListener(".page-"+currentPage+" .reply-link");
//새로 추가된 댓글 li 요소 안에 있는 댓글 수정폼에 이벤트 리스너 등록하기
addUpdateFormListener(".page-"+currentPage+" .update-form");
//로딩바 숨기기
document.querySelector(".loader").style.display="none";
});
}
});
//인자로 전달되는 선택자를 이용해서 이벤트 리스너를 등록하는 함수
function addUpdateListener(sel){
//댓글 수정 링크의 참조값을 배열에 담아오기
// sel 은 ".page-xxx .update-link" 형식의 내용이다
let updateLinks=document.querySelectorAll(sel);
for(let i=0; i<updateLinks.length; i++){
updateLinks[i].addEventListener("click", function(){
//click 이벤트가 일어난 바로 그 요소의 data-num 속성의 value 값을 읽어온다.
const num=this.getAttribute("data-num"); //댓글의 글번호
document.querySelector("#updateForm"+num).style.display="block";
});
}
}
function addDeleteListener(sel){
//댓글 삭제 링크의 참조값을 배열에 담아오기
let deleteLinks=document.querySelectorAll(sel);
for(let i=0; i<deleteLinks.length; i++){
deleteLinks[i].addEventListener("click", function(){
//click 이벤트가 일어난 바로 그 요소의 data-num 속성의 value 값을 읽어온다.
const num=this.getAttribute("data-num"); //댓글의 글번호
const isDelete=confirm("댓글을 삭제 하시겠습니까?");
if(isDelete){
// gura_util.js 에 있는 함수들 이용해서 ajax 요청
ajaxPromise("comment_delete.do", "post", "num="+num)
.then(function(response){
return response.json();
})
.then(function(data){
//만일 삭제 성공이면
if(data.isSuccess){
//댓글이 있는 곳에 삭제된 댓글입니다를 출력해 준다.
document.querySelector("#reli"+num).innerText="삭제된 댓글입니다.";
}
});
}
});
}
}
function addReplyListener(sel){
//댓글 링크의 참조값을 배열에 담아오기
let replyLinks=document.querySelectorAll(sel);
//반복문 돌면서 모든 링크에 이벤트 리스너 함수 등록하기
for(let i=0; i<replyLinks.length; i++){
replyLinks[i].addEventListener("click", function(){
if(!isLogin){
const isMove=confirm("로그인이 필요 합니다. 로그인 페이지로 이동 하시겠습니까?");
if(isMove){
location.href=
"${pageContext.request.contextPath}/users/loginform?url=${pageContext.request.contextPath}/cafe/detail?num=${dto.num}";
}
return;
}
//click 이벤트가 일어난 바로 그 요소의 data-num 속성의 value 값을 읽어온다.
const num=this.getAttribute("data-num"); //댓글의 글번호
const form=document.querySelector("#reForm"+num);
//현재 문자열을 읽어온다 ( "답글" or "취소" )
let current = this.innerText;
if(current == "답글"){
//번호를 이용해서 댓글의 댓글폼을 선택해서 보이게 한다.
form.style.display="block";
form.classList.add("animate__flash");
this.innerText="취소";
form.addEventListener("animationend", function(){
form.classList.remove("animate__flash");
}, {once:true});
}else if(current == "취소"){
form.classList.add("animate__fadeOut");
this.innerText="답글";
form.addEventListener("animationend", function(){
form.classList.remove("animate__fadeOut");
form.style.display="none";
},{once:true});
}
});
}
}
function addUpdateFormListener(sel){
//댓글 수정 폼의 참조값을 배열에 담아오기
let updateForms=document.querySelectorAll(sel);
for(let i=0; i<updateForms.length; i++){
//폼에 submit 이벤트가 일어 났을때 호출되는 함수 등록
updateForms[i].addEventListener("submit", function(e){
//submit 이벤트가 일어난 form 의 참조값을 form 이라는 변수에 담기
const form=this;
//폼 제출을 막은 다음
e.preventDefault();
//이벤트가 일어난 폼을 ajax 전송하도록 한다.
ajaxFormPromise(form)
.then(function(response){
return response.json();
})
.then(function(data){
if(data.isSuccess){
/*
document.querySelector() 는 html 문서 전체에서 특정 요소의
참조값을 찾는 기능
특정문서의 참조값.querySelector() 는 해당 문서 객체의 자손 요소 중에서
특정 요소의 참조값을 찾는 기능
*/
const num=form.querySelector("input[name=num]").value;
const content=form.querySelector("textarea[name=content]").value;
//수정폼에 입력한 value 값을 pre 요소에도 출력하기
document.querySelector("#pre"+num).innerText=content;
form.style.display="none";
}
});
});
}
}
</script>
</body>
</html>
- ajax사용을 위해 sy_util.js 로딩해주기
- ajax로직 추가
- 댓글 작성 폼 추가
- style 요소 추가
CafeController에 댓글 관련 메소드 추가
//새로운 댓글 저장 요청 처리
@RequestMapping("/cafe/comment_insert")
public String commentInsert(HttpServletRequest request, int ref_group) {
service.saveComment(request);
return "redirect:/cafe/detail?num="+ref_group;
}
//댓글 더보기 요청 처리
@RequestMapping("/cafe/ajax_comment_list")
public String commentList(HttpServletRequest request) {
//테스트를 위해 시간 지연시키기
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
service.moreCommentList(request);
return "cafe/ajax_comment_list";
}
//댓글 삭제 요청 처리
@RequestMapping("/cafe/comment_delete")
@ResponseBody
public Map<String, Object> commentDelete(HttpServletRequest request) {
service.deleteComment(request);
Map<String, Object> map=new HashMap<String, Object>();
map.put("isSuccess", true);
// {"isSuccess":true} 형식의 JSON 문자열이 응답되도록 한다.
return map;
}
//댓글 수정 요청처리 (JSON 을 응답하도록 한다)
@RequestMapping("/cafe/comment_update")
@ResponseBody
public Map<String, Object> commentUpdate(CafeCommentDto dto, HttpServletRequest request){
service.updateComment(dto);
Map<String, Object> map=new HashMap<String, Object>();
map.put("isSuccess", true);
// {"isSuccess":true} 형식의 JSON 문자열이 응답되도록 한다.
return map;
}

- 컨트롤러에 추가한 ajax_comment_list 를 interceptor에서 제외해 주어야 한다.(/cafe/detail 페이지도)
- 이렇게 하면 로그인하지 않아도 댓글목록은 볼 수 있게 된다.

- 댓글이 이렇게 보인다.
뷰 페이지 추가
ajax_comment_list.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<c:forEach var="tmp" items="${commentList }">
<c:choose>
<c:when test="${tmp.deleted eq 'yes' }">
<li>삭제된 댓글 입니다.</li>
</c:when>
<c:otherwise>
<c:if test="${tmp.num eq tmp.comment_group }">
<li id="reli${tmp.num }" class="page-${pageNum }">
</c:if>
<c:if test="${tmp.num ne tmp.comment_group }">
<li id="reli${tmp.num }" class="page-${pageNum }" style="padding-left:50px;" >
<svg class="reply-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-return-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.5 1.5A.5.5 0 0 0 1 2v4.8a2.5 2.5 0 0 0 2.5 2.5h9.793l-3.347 3.346a.5.5 0 0 0 .708.708l4.2-4.2a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 8.3H3.5A1.5 1.5 0 0 1 2 6.8V2a.5.5 0 0 0-.5-.5z"/>
</svg>
</c:if>
<dl>
<dt>
<c:if test="${ empty tmp.profile }">
<svg class="profile-image" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-circle" viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
</svg>
</c:if>
<c:if test="${not empty tmp.profile }">
<img class="profile-image" src="${pageContext.request.contextPath}${tmp.profile }"/>
</c:if>
<span>${tmp.writer }</span>
<c:if test="${tmp.num ne tmp.comment_group }">
@<i>${tmp.target_id }</i>
</c:if>
<span>${tmp.regdate }</span>
<a data-num="${tmp.num }" href="javascript:" class="reply-link">답글</a>
<c:if test="${ (id ne null) and (tmp.writer eq id) }">
<a data-num="${tmp.num }" class="update-link" href="javascript:">수정</a>
<a data-num="${tmp.num }" class="delete-link" href="javascript:">삭제</a>
</c:if>
</dt>
<dd>
<pre id="pre${tmp.num }">${tmp.content }</pre>
</dd>
</dl>
<form id="reForm${tmp.num }" class="animate__animated comment-form re-insert-form" action="comment_insert" method="post">
<input type="hidden" name="ref_group" value="${num }"/>
<input type="hidden" name="target_id" value="${tmp.writer }"/>
<input type="hidden" name="comment_group" value="${tmp.comment_group }"/>
<textarea name="content"></textarea>
<button type="submit">등록</button>
</form>
<c:if test="${tmp.writer eq id }">
<form id="updateForm${tmp.num }" class="comment-form update-form" action="comment_update" method="post">
<input type="hidden" name="num" value="${tmp.num }" />
<textarea name="content">${tmp.content }</textarea>
<button type="submit">수정</button>
</form>
</c:if>
</li>
</c:otherwise>
</c:choose>
</c:forEach>
- ajax 요청을 html형식으로 작성할 예정
- react나 view를 사용하지 않으면 좀 복잡해진다...
- ajax 요청을 통해 댓글이 10개가 넘으면 스크롤하면 불러와지도록 만들었다.

- 3초 걸리도록 지연시켜보면 이렇게 나온다. 3초간 화살표가 돌아가며 로딩한 후에 다음 댓글이 노출된다.
- 한번에 10개씩 불러오게 했다.
- 댓글 기능 구현 코드리뷰
페이지 소스보기

- 댓글이 없는 글의경우, comment의 ul 안쪽이 비어있다.
- 댓글 하나에 <li> 하나가 추가되는 것이라고 보면 된다.

- loader는 로딩하는 이미지(화살표0이다.
- 평소에는 보이지 않게 되어있지만, 필요시에 나타난다.

- 댓글 폼이 하나 준비되어 있다.
- ref_group과 target_id 의 value 값이 미리 준비되어 있다.
여기에 내용content만 입력해서 등록버튼을 누르면 댓글이 등록된다.

- 댓글이 하나 추가되면 페이지가 전환되고, 다시 detail로 돌아온다.(리다이렉트 이동)
- 댓글이 하나 있는 상태이면 <li>가 하나 추가되는것을 볼 수 있다.

- 댓글은 dt, dd로 이루어져있다.
- 답글, 수정, 삭제 링크에 이벤트를 걸어서 javascript가 출력되도록 했다.
- dt : 개인정보(이미지, 닉네임, 등록날짜 등) 영역
- dd : 댓글 내용 content 영역
- 공백과 개행기호를 해석해주는 요소인 pre 요소를 사용해서 댓글을 출력하고 있다.

- 답글, 수정폼도 미리 다 만들어 놓았다. 단 숨겨놓았을 뿐이다.
- 답글/수정 링크를 클릭하면 보이게 하기/숨기기 만을 처리하고 있다.


- 대댓글 작성후에 소스보기로 보면, 대댓글에는 빨간색 화살표(reply-icon)를 출력, 왼쪽 패딩 추가 서식이 들어가있다.
- padding-left를 조건부로 부여한다.
- 대댓글에는 상대방의 아이디를 <i> 안에 출력해서 @id 로 표시하게 되어있다.
<c:if test="${tmp.num eq tmp.comment_group }">
- detail.jsp 에서 위 코드는 댓글 번호와 댓글 그룹번호가 같으면, 이라는 뜻!
- DB에서 댓글을 확인해보면 아래왁 같다.

- 표시한 부분은 2번 글(원글의 글번호)에 대한 댓글!
- 현 상태에서는 아직 순서가 없다. 무작위로 되어있다.
- 그러므로 위와같은 순서로 출력하면 안된다.

SELECT num, writer, content, target_id, ref_group, comment_group
FROM board_cafe_comment
WHERE ref_group=8;
- 이렇게 하면 8번 글의 댓글을 모아놓은 것. 정렬되지 않은 상태.

- order by comment_group ASC, num ASC; 를 추가.
- comment group 에 대해서 오름차순 정렬을 하면 같은 그룹끼리 몰려있게 된다.
- 댓글번호와 comment group번호가 같은 댓글이 있고, 아닌 것도 있다.
- 같으면=원글의 댓글 / 다르면=댓글의 댓글
→ 댓글의 댓글인 경우 들여쓰기가 되도록 구분해서 처리하기

- 원글의 댓글은 제일 위에, 대댓글은 작성 순서대로 되도록 하려면 두번 정렬해야 한다.
- 대댓글끼리 몰려 있게 하고, 그 안에서도 번호순으로 정렬될 수 있도록 하기.
- 오름차순 정렬하면 최신댓글이 위에 있게 된다.

ex) 부서번호에 대해 오름차순 : 같은 부서끼리 몰려있게 하는 효과! (deptno ASC)
그리고 몰려있는 상태에서 사원 이름에 따라 오름차순 정렬되도록 했다. (ename ASC)
- detail.jsp 코드

- 글번호와 댓글의 그룹번호가 같은지 다른지 판단해서,
패딩여부 처리+reply 아이콘(화살표) 나오도록 처리

- 어떤 댓글은 id값만 있고, 어떤 댓글은 id와 padding, 이미지도 같이 출력되어 있다.
- 이것을 c:if로 처리한 것이다. 댓글번호와 comment 그룹번호가 같은지 비교하는 것으로!
- 정렬을 다 해서 가져온 다음에, 출력할때 들여쓰기를 할 것인가 말것인가를 정했다.

- 대댓글의 폼은 미리 준비해 두었다.
- 대댓글의 그룹번호를 들고 갈 준비를 전부 해두었다.
- 일부 정보는 이미 정해진 상태에서 댓글을 추가하는 것!

- 원글의 댓글은 번호가 없다. 아직 부여되지 않은 상태이다.
댓글을 작성하면 그때 부여된다!

- service 메소드에서 보면 이 받아온 sequence 번호를 이렇게 사용하는 것을 볼 수 있다.
- 댓글이 여러개 있을때 대상자를 명시하는 이유: 없으면 누구에게 댓글을 작성하는 것인지 알 수 없으므로 필요하다!
- 들여쓰기는 한번만 하므로 id가 표시되지 않았으면 대상이 누구인지 명확히 알 수 없다.
- 로그아웃한 상태에서 글목록보기, 댓글보기는 가능하도록
(글쓰기, 댓글쓰기는 안된다.)

- 로그인하지 않았을 경우에는 댓글 작성이 막혀있다.
javascript에서 로그인여부 값을 읽어와서 사용하고 있다.
'국비교육(22-23)' 카테고리의 다른 글
57일차(2)/Spring(21) : Transaction, DataAccessException 활용 예제 (1) | 2022.12.28 |
---|---|
57일차(1)/Spring(20) : 게시판 댓글 기능 코드 리뷰 (0) | 2022.12.27 |
56일차(2)/Spring(18) : 예외 클래스, Exception Controller (0) | 2022.12.26 |
56일차(1)/Spring Boot(2) : Bean 생성하는 방법, AOP 활용 실습 (0) | 2022.12.26 |
55일차(4)/Spring Boot(1) : Spring Boot 기초, AOP의 구조 익히기 (0) | 2022.12.26 |