게시판 기능 구현 5 : 글 보기 - 스프링, jsp, 오라클, mybatis
이번 포스팅에서는 2가지 일을 할것이다.
첫번째는 아래와 같은 게시판 홈에서 제목을 클릭했을 때, 게시글의 상세가 보여지는 과정을 구현해볼 것이다.
두번째는 위와 같이 제목을 클릭해서 들어간 게시글 상세페이지에서 추천을 누르는 걸 구현해 볼 것이다.
----------------------------------------------------------------------------------------------------------------------
일단, boardHome.jsp 파일(게시판홈) 중 아래 부분이 게시글들을 렌더링해주는 부분이었다.
<c:forEach var="post" items="${currentPagePosts}">
<div class="post-row"> <!-- 각 게시물을 감싸는 div 추가 -->
<span>${post.id}</span>
<a href="#" class="post-link" data-postid="${post.id}">
<c:if test="${post.isPrivate == 1}">
<i class="fa-solid fa-lock" style="color:red;"></i>
</c:if>
${post.title}
</a>
<span>${post.userId}</span>
<span>${post.writingDate}</span>
<span>${post.viewNumber}</span>
</div>
</c:forEach>
여기서 제목을 누르는 순간, fetch 문이 실행되도록 하였다. 아래와 같다.
post-link 라는 class 를 지닌 모든 요소를 querySelectorAll 로 선택한 다음에, .forEach 문을 이용해서 반복을 돌리고 있다.
만약, 그 a 태그(post-link 라는 class 를 지닌 요소. 즉 제목 을 말함)를 클릭하면, 기본 anchor 태그 동작인 get 요청 보내는 걸 막고, 그 a태그에 저장해둔 data-postid 라는 키에 담긴 값을 가져온다.
무슨 소리냐면, 위 코드 중 <a href="#" class="post-link" data-postid="${post.id}"> 이 부분에 빨강색 글씨 부분처럼 하면 데이터를 a 태그에 담아둘 수 있다.
data-* 문법은 HTML5 에서 나온 문법인데, 이렇게 태그에 값을 저장하고 꺼내서 쓸 수 있도록 해준다. 지금 data-postid 라는 키에 담긴 값은 그 게시글의 posts테이블의 기본키인 id 컬럼값이다.
그리고 나서는 fetch 문을 통해 ajax 통신을 한다.
이 fetch 문의 목적은 무엇이냐면, 만약, 비밀글인 경우, 비밀번호를 알고 있는지 확인받고 게시글을 보여주기 위함이다.
<script>
document.addEventListener('DOMContentLoaded', function(){
document.querySelectorAll('.post-link').forEach(function(element){
element.addEventListener('click', function(event){
event.preventDefault(); // 기본 앵커 태그 동작 방지.
var postId = this.getAttribute('data-postid');
//우선, 글을 클릭한 사용자가 글을 쓴 사람과 같은 사람인지부터 확인해줘야함.
//동일인이라면, 굳이 비밀번호를 안물어보기 위함이다.
fetch('/is-this-you?postId=' + postId)
.then(response => {
if(!response.ok){
throw new Error('Network response was not ok');
}
return response.text();
})
.then(data => {
console.log(data);
if(data == 'yes'){
window.location.href="/show-writing?postId="+postId;
}else{
//fetch 이용해서 통신. 비밀글이 있는지 여부를 확인해줄거임.
fetch('/is-this-secret?postId=' + postId)
.then(response => {
if(!response.ok){
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log(data.isThisSecret);
if(data.isThisSecret == 'no'){ // 비밀글이 아닌 경우
window.location.href="/show-writing?postId="+postId;
}else{ //비밀글인 경우
let input = prompt('게시물의 비밀번호를 입력하세요');
if(input == data.writingPassword){ //비밀번호가 일치한다면
window.location.href="/show-writing?postId="+postId;
} else{
alert('비밀번호가 틀렸습니다.');
}
}
})
.catch(error => console.error('There has been a problem with your fetch operation:', error));
}
})
.catch(error => console.error('There has been a problem with your fetch operation:', error));
});
})
});
</script>
fetch 문을 보면, localhost:8080/is-this-you 라는 url 로 get요청을 보내면서, 방금 얻은 posts 테이블의 id 컬럼값을 보내줬다.
이 get 요청을 받은 컨트롤러는 아래와 같이 생겼다.
@GetMapping("/is-this-you")
@ResponseBody
public String isThisYou(@RequestParam String postId, HttpServletRequest request){
//게시글을 클릭한 사람이 본인인지 확인.
PostsDTO post = postsMapper.findPostById(Integer.parseInt(postId));
int memberId = post.getMemberId();
int loginedMember = FindLoginedMemberIdUtil.findLoginedMember(request);
if (memberId == loginedMember) {
return "yes";
} else{
return "no";
}
}
간단하게 말하자면, 넘겨받은 posts 테이블의 id 컬럼으로 posts 테이블에서 그 행을 조회한다.
그리고 그 행에서 member_id 컬럼의 값을 꺼내와서, 현재 HTTP 요청(get요청)을 보낸 사람과 일치하는지 확인한다.
일치한다면 yes 를 리턴해주고, 일치하지 않는다면 no 를 리턴해준다.
참고로, findPostById 라는 메서드로 인해 실행되는 쿼리는 아래와 같고,
//PostsMapper (@Mapper 붙은 인터페이스) 내 메서드
public PostsDTO findPostById(Integer postId);
// PostsMapper.xml
<select id="findPostById" resultType="PostsDTO">
SELECT * FROM posts
WHERE id = #{postId}
AND DELETE_POST_FL = 'N'
</select>
FindLoginedMemberIdUtil 클래스는 아래와 같이 생겼다.
public class FindLoginedMemberIdUtil {
public static Integer findLoginedMember(HttpServletRequest request){
HttpSession session = request.getSession(false);
MemberJoinDTO loginedMember = (MemberJoinDTO) session.getAttribute("loginedMember");
Integer id = loginedMember.getId();
return id;
}
}
다시 fetch 문으로 돌아가보자
돌아오고 나서의 코드는 아래와 같다.
if(data == 'yes'){
window.location.href="/show-writing?postId="+postId;
}else{
//fetch 이용해서 통신. 비밀글이 있는지 여부를 확인해줄거임.
fetch('/is-this-secret?postId=' + postId)
.then(response => {
if(!response.ok){
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log(data.isThisSecret);
if(data.isThisSecret == 'no'){ // 비밀글이 아닌 경우
window.location.href="/show-writing?postId="+postId;
}else{ //비밀글인 경우
let input = prompt('게시물의 비밀번호를 입력하세요');
if(input == data.writingPassword){ //비밀번호가 일치한다면
window.location.href="/show-writing?postId="+postId;
} else{
alert('비밀번호가 틀렸습니다.');
}
}
})
.catch(error => console.error('There has been a problem with your fetch operation:', error));
}
만약, yes 를 리턴받은 경우에는, localhost:8080/show-writing 이라는 url 로 get 요청을 보내면서 posts 테이블의 id 컬럼값을 쿼리스트링으로 보내주고 있다. 해당 글의 작성자와 fetch 로 get요청 보낸 사람이 동일하다면 자신이 쓴 글이니까 비밀글이든 일반글이든 그냥 보여주도록 함.
근데, no 를 리턴받은 경우에는, 비밀글인지 여부를 확인해야된다. 그래서, 다시 한번 localhost:8080/is-this-secret 으로 get 요청을 보낸다. 이때 get요청의 쿼리스트링으로는 클릭한 그 게시글의 posts 테이블 id 컬럼값을 보내준다.
이 get 요청을 받는 컨트롤러는 아래와 같다.
@GetMapping("/is-this-secret")
@ResponseBody
public Map<String,String> isThisSecret(@RequestParam String postId){
//비밀글인지 여부를 조사.
PostPasswordDTO row = postPasswordMapper.findRow(Integer.parseInt(postId));
Map<String, String> map = new ConcurrentHashMap<>();
// 만약 처음에 비밀글로 생성하지 않았다면, post_password 테이블에 행이 아예 존재하지 않을 수 있거든.
// 그래서, row 가 null 이 아니라면 => 비밀글로 생성했다는 뜻.
// row 가 null 이면 => 비밀글로 생성하지 않았다.
if (row == null) {
// 공개글이다.
map.put("isThisSecret", "no");
} else{
// 비밀글이다.
String postPassword = row.getPostPassword();
map.put("isThisSecret", "yes");
map.put("writingPassword", postPassword);
}
return map; //비밀글 이다.
}
간단히 정리해두자면, POST_PASSWORD 테이블에서 행을 조회하는데, post_id 컬럼값이 매개변수로 넘겨받은 posts테이블의 id 컬럼과 동일한 값을 가지고 있는 행을 조회한다. 사용자가 게시글을 작성해서 insert 할 당시, 사용자가 공개글로 하겠다고 해서 게시글을 비밀번호를 안쓴 경우, POST_PASSWORD 테이블에 행이 안들어갔을 가능성이 있거든?
만약, 하나도 조회되지 않아서, row 변수가 null 인 경우(즉, 공개글로 작성된 경우), map 에 isThisSecret 이라는 키에 no 라는 값을 넣어주고, row 변수가 null 이 아닌 경우에는 비밀글이라는 뜻이기 때문에, isThisSecret 이라는 키에는 yes 를 담아주고, writingPassword 라는 키의 값으로는 그 게시글의 비밀번호를 담아주었다.
다시 fetch 문으로 돌아오면, 아래와 같다.
if(data.isThisSecret == 'no'){ // 비밀글이 아닌 경우
window.location.href="/show-writing?postId="+postId;
}else{ //비밀글인 경우
let input = prompt('게시물의 비밀번호를 입력하세요');
if(input == data.writingPassword){ //비밀번호가 일치한다면
window.location.href="/show-writing?postId="+postId;
} else{
alert('비밀번호가 틀렸습니다.');
}
}
만약 isThisSecret 에 "no" 가 들어있었을 경우, 즉 공개글인 경우에는 곧바로 해당 게시글을 보여달라 get요청을 했고,
isThisSecret 에 "yes" 가 들어있었을 경우, 즉 비공개글인 경우에는 prompt 창으로 비밀번호를 입력받아 만약 컨트롤러에서
map.put("writingPassword", postPassword); 이렇게 담아줬던 writingPassword 와 일치하는 경우에만 해당 게시글을 보여달라는 get 요청을 했다. 그리고, 비밀번호가 틀리다면 alert창으로 '비밀번호가 틀렸습니다' 라고 안내했다.
본인이 쓴 글이 아니면서, 비밀글인데 비밀번호를 틀린 경우를 제외하고서는 localhost:8080/show-writing 로 get 요청을 하면서 쿼리스트링으로 클릭한 게시글의 posts 테이블 id 컬럼값을 보내고 있다.
그렇다면, 이를 받은 /show-writing 이라는 url 을 매핑하는 컨트롤러는 어떤 일을 해주어야 할까?
그 컨트롤러가 해야 할 것들을 미리 정리해 둔다.
최종적으로 그 컨트롤러가 해야 하는 건, 클릭할 그 게시물을 사용자에게 보여주는 것이다.
아래 사진과 같이 말이다.
이 페이지를 보여주려면 뭘 model 에 담아줘야 할까?
1. 이 글을 클릭한 사용자의 회원 정보가 필요. 왜? 맨 밑에 보면, 좀 잘려있는데, 댓글 쓰는 창에 보면, 로그인 사용자의 아이디를 보여주거든. 아래 그림은 네이버의 댓글창인데, 여기서도 로그인한 나의 아이디를 보여주고 있다.
2. posts 테이블의 모든 컬럼이 필요.
3. 이 posts 테이블의 행과 1:1 관계인 post_like(좋아요, 추천) 테이블의 like_number 컬럼 값이 필요하다. 그게 있어야, 지금 추천수를 렌더링해줄 수 있기 때문이다. 그런데, 게시글 작성자가 게시글을 작성할 때에, db 낭비를 안하려고 추천을 눌렀을 때에야 비로소 그 posts테이블의 행과 연관된 post_like 테이블 행을 insert 해주기로 했었거든.
그래서 이 컨트롤러에서 어떤 작업을 해줘야 하냐면, 매개변수로 받은 posts 테이블의 id 컬럼값을 가지고 post_like 에 행을 조회한다. 만약 행이 없어서 null 값이라면, 0이 렌더링 될 수 있도록 0을 넣어주는 작업을 해야한다.
이 외에도 이 posts 테이블에 얽혀있는 comments 테이블의 행들도 가져와야 하는데, 그건 이후의 포스팅에서 하도록 하고, 일단 이렇게 3가지의 일을 해야 한다.
그리고 한 가지 더 해야한다. 뭐냐면, 조회수 올리는 작업.
일단, localhost:8080/show-writing 이라는 url 을 매핑하는 컨트롤러는 아래와 같이 생겼다.
다른 건 신경쓸 필요가 아직 없고, showPostService 라는 서비스계층의 showPost 메서드를 호출하고 나서, showPost.jsp 파일로 이동하는 걸 확인할 수 있다. model 에 뭐 담고, 조회수 올리는 작업은 showPostService 클래스의 showPost 메서드가 행한다.
@GetMapping("show-writing")
public String showWritingControllerMethod(@RequestParam(required = false, defaultValue = "1") Integer page,
@RequestParam Integer postId,
Model model,
HttpServletRequest request) {
showPostService.showPost(page, postId, model, request);
return "/contact/showPost";
}
ShowPostService 클래스의 showPost 메서드
public void showPost(Integer page, Integer postId, Model model, HttpServletRequest request){
// 1. 이 글을 클릭한 사용자의 회원정보를 세션객체에서 얻어와서 model 에 담아주도록 한다.
HttpSession session = request.getSession(false);
MemberJoinDTO loginedMember = (MemberJoinDTO) session.getAttribute("loginedMember");
model.addAttribute("loginedMemberId", loginedMember);
// 2-1. 매개변수로 받은 postId(posts 테이블의 id 컬럼값) 으로 post테이블에서 행을 조회한다.
PostsDTO findPost = postsMapper.findPostById(postId);
// 2-2. 현재 findPost 에는 member_id 컬럼값이라고 해서 게시글을 작성한 사용자의 member테이블 id 컬럼값이 들어있다.
// 그런데, 이 id 컬럼값은 시퀀스 값으로서 아무런 의미가 없는 값((ex: 237 이런 값이다))이기 때문에,
// member테이블에서 이 id 컬럼값을 가진 행의 user_id 컬럼값(ex : wowns590 이런 값이다)을 가져와서, findPost의 userId 필드에 바인딩한다.
int memberId = findPost.getMemberId();
String userId = memberMapper.findUserIdById(memberId);
findPost.setUserId(userId);
// 2-3. model 에 담아준다.
model.addAttribute("findPost", findPost);
// 3. 조회수와 관련된 작업.
// 처음 게시글이 작성되어 posts 테이블에 행을 insert 할 때, post_view 테이블에 행을 넣지 않았다.
// 따라서, 현재 이 게시글을 클릭한 것이 처음인 경우, 이 게시글에 대한 post_view 테이블의 행은 존재하지 않는다.
// 3-1. 매개변수로 넘어온 posts 테이블의 id 컬럼값을 이용해서 post_view 테이블에서 행을 조회한다.
Integer viewNumber = postViewMapper.findPostViewCountByPostId(postId); // postId 로 post_view 테이블의 view_number 컬럼값을 조회.
// 3-2.
// 만약, null 이라면, 최초로 이 게시글이 조회된 것이므로, post_view 테이블에 행을 삽입하고, 조회수를 1 올린다.
// 만약, null 이 아니라면, 이미 바로 위의 과정을 거친것이기 때문에, 조회수만 1 올려주면 된다.
if (viewNumber == null) {
// 최초 조회.
Integer sequence = postViewMapper.selectNextSequenceValue();
postViewMapper.initPostView(sequence, postId);
}
// 3-3. 새로 post_view 테이블에 행을 삽입했든, 원래 있었든, 조회수 1을 올려줄 것이다. 근데, 조건이 있다.
// "게시글을 클릭한 사용자 != 게시글을 작성한 사용자" 이어야 한다.
if (memberId != loginedMember.getId()) {
postViewMapper.updateByPostId(postId);
}
// 4. 좋아요수와 관련된 작업
// 좋아요 수도 조회수와 마찬가지로 처음 게시글이 작성되어 posts 테이블에 행을 insert 할 때, post_like 테이블에 행을 삽입하도록 하지 않았다.
// 따라서, 아직 누구도 추천(좋아요)을 누르지 않은 경우라면, post_like 테이블에 행이 존재하지 않음.
// 4-1. 매개변수로 넘어온 posts 테이블의 id 컬럼값을 이용해서 post_like 테이블에서 행을 조회한다.
Integer findPostLikeNumber = postLikeMapper.findPostLikeCountByPostId(postId);
// 4-2.
// 만약, null 이라면 model 에 likeNumber 라는 키에 0을 넣어준다.
// 만약, null 이 아니라면, model 에 likeNumber 라는 키에 조회된 post_like 테이블의 행에서 like_number 값을 넣어준다.
if (findPostLikeNumber == null) {
model.addAttribute("likeNumber", 0);
} else{
model.addAttribute("likeNumber", findPostLikeNumber);
}
}
showPost.jsp 파일은 아래와 같이 생겼다.
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<c:import url="/jsp/bootstrapconfig/index.jsp"/>
<html>
<head>
<link rel="stylesheet" href="../../css/board/showPost.css">
</head>
<body>
<!--네브 바 -->
<c:import url="/jsp/common/loginedNavbar2.jsp" />
<!--네브 바 종료 -->
<div id="writing-area">
<div id="page-title">
${findPost.title}
</div>
<div id="first">
<!-- 비밀글로 할건지 여부 + 비밀번호 input, 등록 버튼 -->
<div></div>
<div>
<div>
</div>
</div>
<div>
<div style="width:100%; ${show ? 'display:block;' : 'display:none;'} " id="hiddenPasswordInput">
</div>
</div>
<div>
<a href="/board-home" class="button-in-middle-container" id="board-home-btn" style="margin-top:0.3vh;"> 글 목록으로 </a>
</div>
<div></div>
</div>
<div id="second">
<!-- 작성자(유저 아이디) + 제목 -->
<div></div>
<div>
<div>
작성자 : ${findPost.userId}
</div>
<div>
</div>
</div>
<div></div>
</div>
<div id="third">
<div></div>
<div>
<textarea path="content" style="width:100%; height:60%; border:1px solid lightgray; resize: none;" readOnly>${findPost.content}</textarea>
<div style="width: 100%; height: 10%;"></div>
<div id="thumb-up-div"><span><i class="fa-solid fa-thumbs-up"></i>   추천</span>     <span id="like-number">${likeNumber}</span> </div>
</div>
<div></div>
</div>
</div>
<div id="post-update-and-delete">
<div></div>
<div>
<c:if test="${loginedMemberId.id==findPost.memberId}">
<a href="/update-post?postId=${findPost.id}" >글 수정</a>
<a href="/delete-post?postId=${findPost.id}" id="delete-post-btn">글 삭제</a> <!-- delete 는 ajax 로 처리함-->
</c:if>
</div>
<div></div>
</div>
<!-- ---------------------------comment------------------------------ -->
<div id="comment-container">
<div id="comment-insert-form-div">
<div></div>
<div>
<div id="comment-id-div">
<div>
</div>
<div>
${loginedMemberId.userId}
</div>
</div>
<div id="comment-content-writing-div">
<form action="/insert-comment" method="post">
<textarea name="content" id="comment-textarea" style="resize:none;"></textarea>
<input type="hidden" name="postId" value="${findPost.id}"/>
<input type="hidden" name="memberId" value="${loginedMemberId.userId}"/>
</div>
<div id="comment-register-btn-div">
<div>
<button type="submit" class="button-register" id="comment-btn" style="margin-top:0.3vh;"> 등록 </button>
</div>
</form>
</div>
</div>
<div></div>
</div>
<!-- 댓글 대댓글 표시하기 -->
<div id="comment-reply-area">
<div></div>
<div>
<c:forEach var="comment" items="${currentPagePosts}" >
<div style="width:100%; margin-top: 1vh; border-top: 1px solid lightgray;">
<div>
<span style="font-weight: bold; font-size: 17px;">
${comment.userId}
</span>
<span style="font-size: 9px;">
${comment.stringCreateDate}
</span>
</div>
<div>
<!-------------------------------------------------------------------------------------------->
<!-- 조건문 추가: 로그인한 사용자가 댓글 작성자와 같다면 "삭제" 표시 -->
<c:if test="${loginedMemberId.userId == comment.userId}">
<div>
<div style="font-size: 12px;" class="comment-content-div">
${comment.content}
</div>
<div style="display:none;">
<form action="/update-comment" method="post" class="update-form">
<textarea name="content" class="update-comment-textarea">${comment.content}</textarea>
<input type="hidden" name="postId" value="${findPost.id}">
<input type="hidden" name="commentId" value="${comment.id}" >
<button class="update-btn">수정하기</button>
</form>
</div>
<span type="button" style="color:red; font-size: 12px; cursor:pointer;" class="update-comment">수정</span>
<span style="color:red; font-size: 12px; cursor:pointer;" class="delete-comment" data-comment-id="${comment.id}">삭제</span>
</div>
</c:if>
<!-------------------------------------------------------------------------------------------->
<button class="click-me" >댓글 쓰기</button>
<div class="textarea-reply" style="display:none;">
<form action="/reply-save" method="post">
<textarea name="content" style="width:60%; height: 11vh; resize:none;"></textarea>
<input type="hidden" name="postId" value="${findPost.id}">
<input type="hidden" name="memberId" value="${loginedMemberId.id}">
<input type="hidden" name="parentCommentId" value="${comment.id}">
<button type="submit" class="button-register">등록</button>
</form>
</div>
<div style="width:100%; height: 1vh;"></div>
<c:forEach var="reply" items="${comment.replies}">
<div class="reply" style="margin-left: 2vw; background-color: #fafafa; >
  <span style="font-weight: bold; font-size: 17px;"> ${reply.userId}</span> <span style="font-size: 9px; ">${reply.stringCreateDate}</span>
</div>
<!-------------------------------------------------------------------------------------------->
<c:if test="${loginedMemberId.userId == reply.userId}">
<div>
<div class="reply" style="margin-left: 2vw; font-size: 12px; background-color: #fafafa; border-bottom:0.5px solid lightgray;">
${reply.content}
</div>
<div style="display:none;">
<form action="/update-comment" method="post" class="update-form">
<textarea name="content" class="update-comment-textarea">${reply.content}</textarea>
<input type="hidden" name="commentId" value="${reply.id}" >
<button class="update-btn">수정하기</button>
</form>
</div>
<span style="color:red; font-size: 12px; margin-left: 2vw; cursor:pointer;" class="update-reply">수정</span>
<span style="color:red; font-size: 12px; cursor:pointer;" class="delete-comment" data-comment-id="${reply.id}">삭제</span>
</div>
</c:if>
<!-------------------------------------------------------------------------------------------->
</c:forEach>
</div>
</div>
</c:forEach>
</div>
<div>
</div>
</div>
<!-- pagination -->
<div >
</div>
</div>
<div id="pagination">
<c:if test="${currentGroupFirstPage != 1}">
<a href="/show-writing?page=${currentGroupFirstPage-pageGroupSize}&postId=${findPost.id}"> « 이전</a>
</c:if>
   
<c:forEach var="i" begin="${currentGroupFirstPage}" end="${currentGroupLastPage}">
<c:choose>
<c:when test="${i == currentPage}">
<span>${i}</span>
</c:when>
<c:otherwise>
<a href="/show-writing?page=${i}&postId=${findPost.id}" style="color:black;">${i}</a>
</c:otherwise>
</c:choose>
 
</c:forEach>
   
<c:if test="${currentGroupLastPage != totalPageCount}">
<a href="/show-writing?page=${currentGroupLastPage + 1}&postId=${findPost.id}">다음 »</a>
</c:if>
</div>
<script>
// 모든 'click-me' 버튼에 대해 이벤트 리스너를 설정합니다.
document.querySelectorAll('.click-me').forEach(function(button) {
button.addEventListener('click', function() {
// 현재 클릭된 버튼과 관련된 'textarea-reply' 요소만 선택합니다.
// 이 예제에서는 버튼이 'textarea-reply' 요소 바로 전에 있다고 가정합니다.
let textareaReply = this.nextElementSibling;
// 'textarea-reply' 요소의 display 상태를 토글합니다.
textareaReply.style.display = textareaReply.style.display === 'block' ? 'none' : 'block';
});
});
//추천 누르면, 숫자 올라가게 하면서, ajax 통신해서 db에 +1 증가시키기.
document.addEventListener("DOMContentLoaded", function(){
$("#thumb-up-div").click(function() {
fetch('/update-and-find-like-num', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
postId: ${findPost.id},
}),
})
.then(response => {
if(!response.ok){
throw new Error('Network response not ok');
}
console.log('여기 문제있음2');
return response.json();
})
.then(data => {
if(data.updatedLikeNumber == -1){
alert('좋아요 는 1번만 누르실 수 있습니다');
}else{
// 여기서 가져온 updatedLikeNumber 를 출력한다.
document.querySelector('#like-number').innerText = data.updatedLikeNumber;
}
})
.catch(error => console.error('There has been a problem with your fetch operation:', error));
})
});
// [comment + reply] update form ajax
document.addEventListener("DOMContentLoaded", function(){
//지금 form 이 하나가 아니므로, 반복을 돌리면서, form 이 submit 됬을 때를 생각.
document.querySelectorAll('.update-form').forEach(function(element){
element.addEventListener('submit', function(e){
e.preventDefault();
const formData = new FormData(element);
//json 통신 시도.
const data = {};
formData.forEach( (value, key) => { //(key, value) 가 아님을 주의
data[key] = value;
});
fetch('/update-comment', {
method: 'POST',
headers: {
'Content-Type' : 'application/json', //서버에 json 형식임을 알리는 것.
},
body: JSON.stringify(data), //직렬화된 JSON 문자열
})
.then(response =>{
return response.json();
})
.then(data => {
element.parentElement.style.display = 'none';
element.parentElement.previousElementSibling.style.display = 'block';
element.parentElement.previousElementSibling.innerText = data.content;
})
.catch(error => {
console.error('There has been a problem with your fetch operation:', error);
});
});
});
});
// [comment + reply] delete span ajax
document.addEventListener("DOMContentLoaded", function() {
document.querySelectorAll('.delete-comment').forEach(function(element) {
element.addEventListener('click', function() {
let commentId= element.dataset.commentId;
const data = {id : commentId};
fetch('/delete-comment', {
method: 'POST',
headers: {
'Content-Type' : 'application/json',
},
body: JSON.stringify(data),
})
.then(response => {
return response.json();
})
.then(data => {
alert('댓글이 삭제되었습니다.');
element.innerText = ' ';
element.previousElementSibling.innerText = ' ';
element.previousElementSibling.previousElementSibling.previousElementSibling.innerText = data.text;
})
});
});
});
// [comment] 삭제된 댓글입니다 표시
document.querySelectorAll('.comment-content-div').forEach(comment => {
const content = comment.innerText;
if (content.trim() === "삭제된 댓글입니다.") {
comment.nextElementSibling.nextElementSibling.innerText = ' ';
comment.nextElementSibling.nextElementSibling.nextElementSibling.innerText = ' ';
}
});
// [reply] 삭제된 댓글입니다 표시
document.querySelectorAll('.reply').forEach(comment => {
const content = comment.innerText;
if (content.trim() === "삭제된 댓글입니다.") {
comment.nextElementSibling.nextElementSibling.innerText = ' ';
comment.nextElementSibling.nextElementSibling.nextElementSibling.innerText = ' ';
}
});
// 게시글 delete 버튼 눌렀을 때
document.addEventListener("DOMContentLoaded", function() {
let deleteBtn = document.getElementById('delete-post-btn');
if(deleteBtn != null){
document.getElementById('delete-post-btn').addEventListener('click', function(e) {
e.preventDefault(); // <a> 태그의 기본 동작을 방지합니다.
// fetch를 사용하여 서버에 비동기 요청을 보냅니다.
fetch('/delete-post?postId=${findPost.id}')
.then(response => {
if(!response.ok) {
throw new Error('Network response was not ok');
}
return response.text();
})
.then(data => {
if(data == 'success'){
// 삭제요청을 한 사용자와 게시글의 작성자가 동일인물인 경우
if(confirm('정말 삭제하시겠습니까?')){
//사용자가 확인을 클릭한 경우
fetch('/delete-post-real?postId=${findPost.id}')
.then(response => {
if(!response.ok) {
throw new Error('Network response was not ok');
}
return response.text();
})
.then(data => {
if(data == 'success'){
alert('게시글이 삭제되었습니다');
window.location.href="/board-home";
} else{
alert('게시글 삭제 수행 중 오류가 발생하여 정상 수행되지 못했습니다.');
}
})
.catch(error => console.error('There has been a problem with your fetch operation:', error));
}else{
//사용자가 취소를 클릭한 경우
alert('삭제를 취소하셨습니다');
return;
}
} else{
alert('부적절한 접근 : 작성자만 삭제할 수 있습니다.');
window.location.href="/board-home";
}
})
.catch(error => console.error('There has been a problem with your fetch operation:', error));
});
}
});
</script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="../../js/contact/showPost.js"></script>
</body>
</html>
이 전체코드가 필요한 건 아니고, 현재 2단계 : 추천 누르는 기능 구현함에 있어서 필요한 부분만 추리자면 아래와 같다.
<div id="thumb-up-div">
<span>
<i class="fa-solid fa-thumbs-up"></i>   추천
</span>
<span id="like-number">
${likeNumber}
</span>
</div>
이 부분과 , fetch 하는 부분 중, 아래부분이다.
document.addEventListener("DOMContentLoaded", function(){
$("#thumb-up-div>span:nth-child(1)").click(function() {
fetch('/update-and-find-like-num', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
postId: ${findPost.id},
}),
})
.then(response => {
if(!response.ok){
throw new Error('Network response not ok');
}
console.log('여기 문제있음2');
return response.json();
})
.then(data => {
if(data.updatedLikeNumber == -1){
alert('좋아요 는 1번만 누르실 수 있습니다');
}else{
// 여기서 가져온 updatedLikeNumber 를 출력한다.
document.querySelector('#like-number').innerText = data.updatedLikeNumber;
}
})
.catch(error => console.error('There has been a problem with your fetch operation:', error));
})
});
fetch 구문을 보면,
이 부분에서, 추천버튼을 누르면, 이 게시글의 posts 테이블 id 컬럼값과 함께 localhost:8080/update-and-find-like-num 이라는 url 로 post 요청을 보내고 있다. 이를 매핑하는 컨트롤러는 아래와 같이 생겼다.
@PostMapping("update-and-find-like-num")
@ResponseBody // 이 어노테이션 추가
public Map<String, Integer> updateAndFindLikeNum(@RequestBody Map<String, String> map, HttpServletRequest request) {
String postId = map.get("postId");
int pi = Integer.parseInt(postId); // 현재 pi 에는 추천을 누른 해당 게시글의 posts 테이블의 id 컬럼값이 들어있다.
Integer id = FindLoginedMemberIdUtil.findLoginedMember(request); // 현재 id 에는 추천을 누른 사용자의 member 테이블 id 컬럼값이 들어있다.
// 지금 추천을 누른 사용자가 해당 게시글에 추천을 누른적이 있는지를 확인하기 위해서,
// POST_ID_MEMBER_ID_FOR_NOT_DUPLICATE_LIKE 라는 테이블에서 pi 와 id 로 조회를 하고 있다.
Integer rowCount = postIdMemberIdForNotDuplicateLikeMapper.findRowCountByPostIdAndMemberId(pi, id);
Map<String, Integer> response = new HashMap<>();
if(rowCount == 0){
// POST_ID_MEMBER_ID_FOR_NOT_DUPLICATE_LIKE 테이블에서 조회된 행이 없다
// == 지금 추천 누른 사람이 이 글에 좋아요를 처음 누른 경우.
// 뭘 해줄거냐면, POST_LIKE 테이블에 행을 삽입해줘야 함.
// 게시글을 만드는 과정에서도, 그리고 사용자가 게시판 홈에서 제목을 클릭해서 게시글의 상세조회를 보여주는 과정에서도,
// POST_LIKE 테이블에 행을 넣은 적이 없음.
// 1번. pi 로 POST_LIKE 테이블에서 like_number 컬럼값 을 조회한다.
// 2번. 만약, 조회된 값이 null이라면(= 조회된 행이 없다면), 해당 글에 대해 최초로 추천(좋아요)가 눌린 상황이다. 따라서, POST_LIKE 테이블에 행을 삽입해주고, like_number 컬럼값을 + 1 해줘야함.
// 3번. 만약, 조회된 값이 null이 아니라면(= 조회된 행이 있다면), 이미 위의 과정을 거쳤다고 판단하고, like_number 컬럼값만 + 1 해주면 됨.
Integer likeNumber = postLikeMapper.findPostLikeCountByPostId(pi);
if (likeNumber == null) {
//조회된 컬럼값이 비어있다면, 해당 글에 대해 최초로 추천(좋아요)이 눌린 상황.
Integer sequence = postLikeMapper.selectNextSequenceValue(); //시퀀스 미리 가져오고,
postLikeMapper.initPostLike(pi); // 행을 삽입.
}
// 2번의 상황이든, 3번의 상황이든, 공통적으로 like_num 을 +1 해줘야함.
postLikeMapper.updateByPostId(pi);
// 2번의 상황이든, 3번의 상황이든, 공통적으로 post_id_member_id_for_not_duplicate_like 테이블에 행을 넣어줘야 함.
postIdMemberIdForNotDuplicateLikeMapper.insertRow(pi, id);
// 다시 조회. 왜? likeNumber 에 들어있는 값은, like_number 컬럼을 +1 해주기 이전의 값이기 때문.
Integer updatedNumber = postLikeMapper.findPostLikeCountByPostId(pi);
response.put("updatedLikeNumber", updatedNumber);
return response; // JSON 형식의 맵 반환
} else{
// 조회된 행이 1 이상인 경우
// == 지금 추천을 누른 사람이 이 글에 추천(좋아요)을 이전에 누른 경우.
// == 이미 POST_LIKE 테이블에 행이 있다는 것도 의미하는 것이다.
response.put("updatedLikeNumber", -1); //jsp 파일에서 1이면 alert 창으로 "좋아요 는 1번만 누르실 수 있습니다" 라는 문구 띄워줘.
return response;
}
}
전체적인 흐름에 대해서 정리해두자면,
POST_ID_MEMBER_ID_FOR_NOT_DUPLICATE_LIKE 라는 테이블에 대한 설명을 하는 것에서부터 시작해야 한다.
어떤 사용자가 추천(좋아요) 를 누른 순간, POST_ID_MEMBER_ID_FOR_NOT_DUPLICATE_LIKE 라는 테이블에 그 게시물의 POSTS 테이블 id 컬럼과 그걸 누른 사용자의 MEMBER 테이블 id 컬럼을 저장하는 테이블이다.
그래서, 해당 게시글에 대해서 동일한 사용자가 추천(좋아요)를 다시 한번 누르는 경우, POST_ID_MEMBER_ID_FOR_NOT_DUPLICATE_LIKE 라는 테이블에서 그 사용자의 member테이블 id 컬럼과 추천(좋아요)를 누른 게시글의 POSTS 테이블 id 컬럼값으로 조회되는 행이 있는지를 보는것이다.
있다 -> 이미 그 게시글에 그 사용자가 추천(좋아요)를 눌렀다 -> 좋아요 수 + 1 안해줌.
없다 -> 그 사용자가 그 게시글에 처음 추천(좋아요)를 눌렀다 -> 좋아요 수 + 1 해줌.
이런 원리로 돌아가는 거고, 그래서, 이 컨트롤러가 updatedLikeNumber 라는 키에 -1을 담아줬으면, fetch 의 then 에서 alert() 로 좋아요는 1번만 누를 수 있습니다. 라고 안내해주는것이다.
그리고, -1 이 아니라, 그 게시글의 업데이트된 좋아요수를 리턴했다면,
document.querySelector('#like-number').innerText = data.updatedLikeNumber;
이 코드로
이 부분에 업데이트된 좋아요 수를 렌더링해주면 된다.
끝.
다음 포스팅은 게시판 홈에서 아래와 같이 좋아요순으로 게시글을 조회하기, 조회수순으로 게시글을 조회하기 두 기능을 구현해볼 것이다.
그리고, 게시판 홈에서, 아래와 같이, 제목 또는 작성자 또는 내용으로 게시물을 조회할 수 있도록 해두었는데, 이를 구현해볼 것이다.