게시판 - 좋아요, 조회수 기능 구현 (23.08.23)

·2023년 8월 24일
1

Spring

목록 보기
20/36
post-thumbnail

🌷 게시글 좋아요


이번 포스팅에서는 게시글을 상세 조회했을 때 좋아요를 누를 수 있는 기능을 구현해 보자!


👀 코드로 살펴보기

🌼 VS Code

좋아요 기능을 구현하기 위해서는 누가(로그인한 회원 번호) 어떤 게시글(현재 게시글 번호) 좋아요를 클릭/취소했는지 알아야 한다.
따라서 로그인한 회원 번호를 얻어와야 하는데, 여기에는 아래 3가지 방법이 있다.

💭 로그인한 회원 번호 얻어오는 방법

1) ajax로 session에 있는 loginMember의 memberNo를 반환받기
2) HTML 요소에 로그인한 회원의 번호를 숨겨 놓고 JS로 얻어오기
3) jsp 파일 제일 위에 있는 script 태그에 JS + EL 이용해서 전역 변수로 선언해 두기

이번 포스팅에서는 3번째 방법을 사용해 볼 것이다.

그런데 로그인한 회원 번호를 전역 변수로 선언하면 작성한 EL 구문이 null일 경우 빈칸으로 출력되어 변수에 값이 대입되지 않는 문제가 발생할 수 있다.

이때 EL 구문을 '', "" 문자열로 감싸면 이 문제를 해결할 수 있다.
EL 값이 null이어도 ""(빈문자열)로 출력되기 때문이다.

🌱 boardDetail.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"  %>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"  %>

<c:forEach items="${boardTypeList}" var="boardType">
    <c:if test="${boardType.BOARD_CODE == boardCode}" >
        <c:set var="boardName" value="${boardType.BOARD_NAME}"/>
    </c:if>
</c:forEach>

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>${boardName}</title>

    <link rel="stylesheet" href="/resources/css/board/boardDetail-style.css">
    <link rel="stylesheet" href="/resources/css/board/comment-style.css">

</head>
<body>
    <main>
        <jsp:include page="/WEB-INF/views/common/header.jsp"/>

        <section class="board-detail">  
            <!-- 제목 -->
            <h1 class="board-title">${board.boardTitle}  <span> - ${boardName}</span>    </h1>

            <!-- 프로필 + 닉네임 + 작성일 + 조회수 -->
            <div class="board-header">
                <div class="board-writer">

                    <!-- 프로필 이미지 -->
                    <c:choose>
                        <c:when test="${empty board.profileImage}">
                            <%-- 프로필 이미지가 없을 경우 기본 이미지 출력 --%>
                            <img src="/resources/images/user.png">
                        </c:when>

                        <c:otherwise>
                            <%-- 프로필 이미지가 있을 경우 출력 --%>
                            <img src="${board.profileImage}">
                        </c:otherwise>
                    </c:choose>

                    <span>${board.memberNickname}</span>

                    
                    <!-- 좋아요 하트 -->
                    <span class="like-area">

                        <!-- 좋아요를 누른 적이 없거나, 로그인 X -->
                        <c:if test="${empty likeCheck}">
                            <i class="fa-regular fa-heart" id="boardLike"></i>
                        </c:if>
                        
                        <!-- 좋아요를 누른 적이 있을 때 -->
                        <c:if test="${!empty likeCheck}">
                            <i class="fa-solid fa-heart" id="boardLike"></i>
                        </c:if>

                        <span>${board.likeCount}</span>
                    </span>

                </div>

                <div class="board-info">
                    <p> <span>작성일</span> ${board.boardCreateDate} </p>     

                    <!-- 수정한 게시글인 경우 -->
                    <c:if test="${!empty board.boardUpdateDate}">
                        <p> <span>마지막 수정일</span> ${board.boardUpdateDate} </p>   
                    </c:if>

                    <p> <span>조회수</span> ${board.readCount} </p>                    
                </div>
            </div>

            <!-- 이미지가 있을 경우 -->
            <c:if test="${!empty board.imageList}">
                <!-- 썸네일 영역(썸네일이 있을 경우) -->

                <%-- 
                    - 이미지는 IMG_ORDER 오름차순으로 정렬된다.
                    - IMG_ORDER의 값이 0인 이미지가 썸네일이다.
                    -> imageList에 썸네일이 있다면
                        조회되었을 때 IMG_ORDER가 0인 이미지가
                        imageList[0]에 저장되었을 것이다.
                 --%>

                <c:if test="${board.imageList[0].imageOrder == 0}">
                    <h5>썸네일</h5>
                    <div class="img-box">
                        <div class="boardImg thumbnail">
                            <img src="${board.imageList[0].imagePath}${board.imageList[0].imageReName}">
                            <a href="${board.imageList[0].imagePath}${board.imageList[0].imageReName}"
                                download="${board.imageList[0].imageOriginal}">다운로드</a>         
                        </div>
                    </div>
                </c:if>

            </c:if>

            <%-- 썸네일을 제외하고 나머지 이미지의 시작 인덱스 번호 --%>
            <%-- 썸네일이 있을 경우 --%>
            <c:if test="${board.imageList[0].imageOrder == 0}">
                <c:set var="start" value="1"/>
            </c:if>

            <%-- 썸네일이 없을 경우 --%>
            <c:if test="${board.imageList[0].imageOrder == 0}">
                <c:set var="start" value="0"/>
            </c:if>

            <%-- ${fn:length(board.imageList)} : imageList의 길이 --%>

            <!-- 일반 이미지가 있는 경우 -->
            <c:if test="${fn:length(board.imageList) > start}">
                <!-- 업로드 이미지 영역 -->
                <h5>업로드 이미지</h5>
                <div class="img-box">
                    
                    <c:forEach var="i" begin="${start}" end="${fn:length(board.imageList)-1}">
                        <div class="boardImg">
                            <c:set var="path"
                                value="${board.imageList[i].imagePath}${board.imageList[i].imageReName}"/>

                            <img src="${path}">
                            <a href="${path}"
                                download="${board.imageList[i].imageOriginal}">다운로드</a>                
                        </div>
                    </c:forEach>

                </div>
            </c:if>

            <!-- 내용 -->
            <div class="board-content">${board.boardContent}</div>


            <!-- 버튼 영역-->
            <div class="board-btn-area">

                <!-- 로그인한 회원과 게시글 작성자 번호가 같은 경우-->
                <c:if test="${loginMember.memberNo == board.memberNo}">
                    <button id="updateBtn">수정</button>
                    <button id="deleteBtn">삭제</button>
                </c:if>

                <button id="goToListBtn">목록으로</button>
            </div>


        </section>

        <!-- 댓글 include-->
        <jsp:include page="comment.jsp"/>
    </main>

    <jsp:include page="/WEB-INF/views/common/footer.jsp"/>

    <script>
        // JSP에서 작성 가능한 언어/라이브러리
        // -> html, css, js, java, EL, JSTL

        // JSP 해석 우선 순위 : Java/EL/JSTL > HTML, CSS, JS

        // 게시글 번호 전역 변수로 선언
        const boardNo = "${board.boardNo}";

        // 로그인한 회원 번호를 전역 변수로 선언
        // -> 작성한 EL 구문이 null일 경우 빈칸으로 출력되어
        //    변수에 값이 대입되지 않는 문제가 발생할 수 있음!
        // 해결 방법 : EL 구문을 '', "" 문자열로 감싸면 해결
        //            -> EL 값이 null이어도 ""(빈문자열)로 출력
        const loginMemberNo = "${loginMember.memberNo}";

        console.log(boardNo);
        console.log(loginMemberNo);
    </script>

    <%-- boardDetail.js 연결 --%>
    <script src="/resources/js/board/boardDetail.js"></script>

</body>
</html>

🌱 boardDetail.js

// 좋아요 버튼이 클릭되었을 때
const boardLike = document.getElementById("boardLike");

boardLike.addEventListener("click", e => {
    
    // 로그인 여부 검사
    if(loginMemberNo == ""){
        alert("로그인 후 이용해 주세요.");
        return;
    }

    let check; // 기존에 좋아요 체크 X (빈하트)   : 0
               // 기존에 좋아요 체크 O (꽉찬하트) : 1

    // contains("클래스명"): 클래스가 있으면 true, 없으면 false
    if(e.target.classList.contains("fa-regular")){ // 좋아요 체크 X (빈하트)
        check = 0;
    } else{ // 좋아요 체크 O (꽉찬하트)
        check = 1;
    }

    // ajax로 서버로 제출할 파라미터를 모아둔 JS 객체
    // 로그인한 회원 번호, 게시글 번호, check
    const data = { "boardNo" : boardNo,
                    "memberNo" : loginMemberNo,
                    "check" : check};

    // ajax 코드 작성
    fetch("/board/like", {
        method : "POST",
        headers : {"Content-Type" : "application/json"},
        body : JSON.stringify(data)
    })
    .then(response => response.text()) // 응답 객체를 필요한 형태로 파싱하여 리턴
    .then(count => {
        console.log("count : " + count);

        if(count == -1){ // INSERT, DELETE 실패 시
            console.log("좋아요 처리 실패");
            return;
        }

        // toggle() : 클래스가 있으면 없애고, 없으면 추가하고
        e.target.classList.toggle("fa-regular");
        e.target.classList.toggle("fa-solid");

        // 현재 게시글의 좋아요 수를 화면에 출력
        e.target.nextElementSibling.innerText = count;

    }) // 파싱된 데이터를 받아서 처리하는 코드 작성

    .catch(err => {
        console.log("예외 발생");
        console.log(err);

    }) // 예외 발생 시 처리하는 부분
})

🌼 Spring

💭 @PathVariable 사용 시 문제점

요청 주소와 @PathVariable로 가져다 쓸 주소의 레벨이 같다면 구분하지 않고 모두 매핑되는 문제가 발생한다.
즉, 요청을 했는데 원하는 메소드가 실행이 안 된다.... 🤔

💡 해결 방법

@PathVariable 지정 시 정규 표현식 사용
{키:정규표현식}

🌱 BoardController.java

...
	// 게시글 목록 조회
	@GetMapping("/{boardCode:[0-9]+}") // boardCode는 1자리 이상의 숫자
	public String selectBoardList(@PathVariable("boardCode") int boardCode
			, @RequestParam(value = "cp", required = false, defaultValue = "1") int cp
			, Model model) {
		
		// boardCode 확인
		// System.out.println("boardCode : " + boardCode);
		
		// 게시글 목록 조회 서비스 호출
		Map<String, Object> map = service.selectBoardList(boardCode, cp);
		
		// 조회 결과를 request scope에 세팅 후 forward
		model.addAttribute("map", map);
		
		return "board/boardList";
	}
    
    	// @PathVariable : 주소에 지정된 부분을 변수에 저장
	//					+ request scope 세팅
	
	// 게시글 상세 조회
	@GetMapping("/{boardCode}/{boardNo}")
	public String boardDetail(
			@PathVariable("boardCode") int boardCode
			, @PathVariable("boardNo") int boardNo
			, Model model // 데이터 전달용 객체
			, RedirectAttributes ra // 리다이렉트 시 데이터 전달용 객체
			, @SessionAttribute(value="loginMember", required=false) Member loginMember
				// 세션에서 loginMember를 얻어오는데 없으면 null, 있으면 회원 정보 저장
			
			// 쿠키를 이용한 조회 수 증가에서 사용
			, HttpServletRequest req
			, HttpServletResponse resp
			) throws ParseException {
			
		Map<String, Object> map = new HashMap<String, Object>();
		map.put("boardCode", boardCode);
		map.put("boardNo", boardNo);
		
		// 게시글 상세 조회 서비스 호출
		Board board = service.selectBoard(map);
		
		String path = null;
		
		if(board != null) { // 조회 결과가 있을 경우
			
			// ------------------------------------------------
			// 현재 로그인 상태인 경우
			// 로그인한 회원이 해당 게시글에 좋아요를 눌렀는지 확인
			
			if(loginMember != null) { // 로그인 상태인 경우
				// 회원 번호를 map 추가
				// map(boardCode, boardNo, memberNo)
				map.put("memberNo", loginMember.getMemberNo());

				// 좋아요 여부 확인 서비스 호출
				int result = service.boardLikeCheck(map);
				
				// 누른 적이 있을 경우
				if(result > 0) model.addAttribute("likeCheck", "on");
			}
            
            // ------------------------------------------------
            
            			path = "board/boardDetail"; // forward 할 jsp 경로
			model.addAttribute("board", board);
			
		} else { // 조회 결과가 없을 경우
			path = "redirect:/board/" + boardCode; // 게시판 첫 페이지로 리다이렉트
			
			ra.addFlashAttribute("message", "해당 게시글이 존재하지 않습니다.");
		}
		
		return path;
	}
	
	// 좋아요 처리
	@PostMapping("/like")
	@ResponseBody // 반환되는 값이 비동기 요청한 곳으로 돌아가게 함
	public int like(@RequestBody Map<String, Integer> paramMap) {
		// System.out.println(paramMap);
		
		return service.like(paramMap);
	}
}

🌱 BoardService.java

...
	/** 좋아요 여부 확인
	 * @param map
	 * @return result
	 */
	int boardLikeCheck(Map<String, Object> map);

	/** 좋아요 처리 서비스
	 * @param paramMap
	 * @return count
	 */
	int like(Map<String, Integer> paramMap);

🌱 BoardServiceImpl.java

...
	// 좋아요 여부 확인 서비스
	@Override
	public int boardLikeCheck(Map<String, Object> map) {
		return dao.boardLikeCheck(map);
	}

	// 좋아요 처리 서비스
	@Transactional(rollbackFor = Exception.class)
	@Override
	public int like(Map<String, Integer> paramMap) {
		
		int result = 0;
		
		if(paramMap.get("check") == 0) { // 좋아요 상태 X
			// BOARD_LIKE 테이블 INSERT
			result = dao.insertBoardLike(paramMap);
			
		} else { // 좋아요 상태 O
			// BOARD_LIKE 테이블 DELETE
			result = dao.deleteBoardLike(paramMap);
		}

		// SQL 수행 결과가 0 == DB 또는 파라미터에 문제가 있다.
		// 1) 에러를 나타내는 임의의 값을 반환 (-1)
		
		if(result == 0) return -1;
		
		// 현재 게시글의 좋아요 개수 조회
		int count = dao.countBoardLike(paramMap.get("boardNo"));
		
		return count;
		
	}

🌱 BoardDAO.java

...
	/** 좋아요 여부 확인
	 * @param map
	 * @return result
	 */
	public int boardLikeCheck(Map<String, Object> map) {
		return sqlSession.selectOne("boardMapper.boardLikeCheck", map);
	}

	/** 좋아요 테이블 삽입
	 * @param paramMap
	 * @return result
	 */
	public int insertBoardLike(Map<String, Integer> paramMap) {
		return sqlSession.insert("boardMapper.insertBoardLike", paramMap);
	}

	/** 좋아요 테이블 삭제
	 * @param paramMap
	 * @return result
	 */
	public int deleteBoardLike(Map<String, Integer> paramMap) {
		return sqlSession.delete("boardMapper.deleteBoardLike", paramMap);
	}

	/** 좋아요 개수 조회
	 * @param boardNo
	 * @return count
	 */
	public int countBoardLike(Integer boardNo) {
		return sqlSession.selectOne("boardMapper.countBoardLike", boardNo);
	}

🌱 board-mapper.xml

...
	<!-- 좋아요 여부 확인 -->
	<select id="boardLikeCheck" resultType="_int">
		SELECT COUNT(*) FROM BOARD_LIKE
		WHERE BOARD_NO = #{boardNo}
		AND MEMBER_NO = #{memberNo}
	</select>
	
	<!-- 좋아요 테이블 삽입 -->
	<insert id="insertBoardLike">
		INSERT INTO BOARD_LIKE VALUES(#{boardNo}, #{memberNo})
	</insert>
	
	<!-- 좋아요 테이블 삭제 -->
	<delete id="deleteBoardLike">
		DELETE FROM BOARD_LIKE
		WHERE BOARD_NO = #{boardNo}
		AND MEMBER_NO = #{memberNo}
	</delete>
	
	<!-- 좋아요 개수 조회 -->
	<select id="countBoardLike" resultType="_int">
		SELECT COUNT(*) FROM BOARD_LIKE
		WHERE BOARD_NO = #{boardNo}
	</select>

🌷 게시글 조회수


드디어 좋아요 기능 구현이 끝났다. 다음으로는 게시글 조회수 기능을 구현해 보자!
게시글 조회수 조회 및 증가 기능에 대한 코드를 작성해 볼 것이다.


👀 코드로 살펴보기

🌼 Spring

💡 Cookie.getValue()

쿠키에 저장된 모든 값을 읽어와 String으로 반환함

💡 String.indexOf(문자열)

찾는 문자열이 몇 번 인덱스에 존재하는지 반환함
-> 단, 없으면 -1을 반환함!

🌱 BoardController.java

...
	// 게시글 상세 조회
	@GetMapping("/{boardCode}/{boardNo}")
	public String boardDetail(
			@PathVariable("boardCode") int boardCode
			, @PathVariable("boardNo") int boardNo
			, Model model // 데이터 전달용 객체
			, RedirectAttributes ra // 리다이렉트 시 데이터 전달용 객체
			, @SessionAttribute(value="loginMember", required=false) Member loginMember
				// 세션에서 loginMember를 얻어오는데 없으면 null, 있으면 회원 정보 저장
			
			// 쿠키를 이용한 조회 수 증가에서 사용
			, HttpServletRequest req
			, HttpServletResponse resp
			) throws ParseException {
			
		Map<String, Object> map = new HashMap<String, Object>();
		map.put("boardCode", boardCode);
		map.put("boardNo", boardNo);
		
		// 게시글 상세 조회 서비스 호출
		Board board = service.selectBoard(map);
		
		String path = null;
		
		if(board != null) { // 조회 결과가 있을 경우
			
		...

			// 쿠키를 이용한 조회 수 증가 처리
			
			// 1) 비회원 또는 로그인한 회원의 글이 아닌 경우
			if(loginMember == null || // 비회원
					board.getMemberNo() != loginMember.getMemberNo()) {
				
				// 2) 쿠키 얻어오기
				Cookie c = null;
				
				// 요청에 담겨 있는 모든 쿠키 얻어오기
				Cookie[] cookies = req.getCookies();
				
				if(cookies != null) { // 쿠키가 존재할 경우
					
					// 쿠키 중 "readBoardNo"라는 쿠키를 찾아서 c에 대입
					for(Cookie cookie : cookies) {
						if(cookie.getName().equals("readBoardNo")) {
							c = cookie;
							break;
						}
					}
				}
				
				// 3) 기존 쿠키가 없거나(c == null)
				//	  존재는 하지만 현재 게시글 번호가
				//	  쿠키에 저장되지 않은 경우(오늘 해당 게시글을 본 적 없음)
				int result = 0;
				
				if(c == null) {
					
					// 쿠키가 존재 X -> 하나 새로 생성
					// 톰캣 8.5 이상부터는 쿠키에 >> , / = 띄어쓰기 << 작성 불가
					c = new Cookie("readBoardNo", "|" + boardNo + "|");
					
					// 조회 수 증가 서비스 호출
					result = service.updateReadCount(boardNo);
					
				} else {
					// 현재 게시글 번호가 쿠키에 있는지 확인
					
					// Cookie.getValue() : 쿠키에 저장된 모든 값을 읽어옴
					// 					   -> String으로 반환
					
					// String.indexOf(문자열)
					// : 찾는 문자열이 몇 번 인덱스에 존재하는지  반환
					//   단, 없으면 -1 반환
					if(c.getValue().indexOf("|" + boardNo + "|") == -1) {
						// 쿠키에 현재 게시글 번호가 없다면
						
						// 기존 값에 게시글 번호 추가해서 다시 세팅
						c.setValue(c.getValue() + "|" + boardNo + "|");
						
						// 조회수 증가 서비스 호출
						result = service.updateReadCount(boardNo);
					}
					
				} // 3) 종료
				
				// 4) 조회 수 증가 성공 시
				// 쿠키가 적용되는 경로, 수명(당일 23시 59분 59초) 지정
				
				if(result > 0) {
					
					// 조회된 board 조회 수와 DB 조회 수를 동기화
					board.setReadCount(board.getReadCount() +1);
					
					// 적용 경로 설정
					c.setPath("/"); // "/" 이하 경로 요청 시 쿠키 서버로 전달
					
					// 수명 지정
					Calendar cal = Calendar.getInstance(); // 싱글톤 패턴
					cal.add(cal.DATE, 1);
					
					// 날짜 표기법 변경 객체 (DB의 TO_CHAR()와 비슷)
					SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
					
					// java.util.Date
					Date a = new Date(); // 현재 시간
					
					Date temp = new Date(cal.getTimeInMillis()); // 내일 (24시간 후)
					// 2023-08-24 12:17:40
					
					Date b = sdf.parse(sdf.format(temp)); // 내일 0시 0분 0초
					
					// 내일 0시 0분 0초 - 현재 시간
					long diff = (b.getTime() - a.getTime()) / 1000;
					// -> 내일 0시 0분 0초까지 남은 시간을 초 단위로 반환
					
					c.setMaxAge((int)diff); // 수명 설정
					 
					resp.addCookie(c); // 응답 객체를 이용해서 클라이언트에게 전달
				}
			}
			
			path = "board/boardDetail"; // forward 할 jsp 경로
			model.addAttribute("board", board);
			
		} else { // 조회 결과가 없을 경우
			path = "redirect:/board/" + boardCode; // 게시판 첫 페이지로 리다이렉트
			
			ra.addFlashAttribute("message", "해당 게시글이 존재하지 않습니다.");
		}
		
		return path;
	}
...

🌱 BoardService.java

...
	/** 조회수 증가 서비스
	 * @param boardNo
	 * @return
	 */
	int updateReadCount(int boardNo);

🌱 BoardServiceImpl.java

...
	// 조회 수 증가 서비스
	@Transactional(rollbackFor = Exception.class)
	@Override
	public int updateReadCount(int boardNo) {
		return dao.updateReadCount(boardNo);
	}

🌱 BoardDAO.java

...
	/** 조회 수 증가
	 * @param boardNo
	 * @return result
	 */
	public int updateReadCount(int boardNo) {
		return sqlSession.update("boardMapper.updateReadCount", boardNo);
	}

🌱 board-mapper.xml

...
	<update id="updateReadCount">
		UPDATE BOARD SET
		READ_COUNT = READ_COUNT + 1
		WHERE BOARD_NO = #{boardNo}
	</update>

💻 구현 화면

게시글 상세 조회 화면에서 하트 아이콘을 클릭하면 빈 하트가 꽉 찬 하트로 바뀌며 좋아요 수가 +1 증가한다.

만약 게시글 작성자가 아닌 회원이 게시글을 상세 조회하면 어떻게 될까?

조회수가 1 증가한다. 이때 console 창의 cookie를 살펴보자.

비회원 또는 게시글 작성자가 아니기 때문에 "readBoardNo" key 값으로 방금 조회한 게시글 번호가 |게시글번호| 형태로 저장된 모습을 볼 수 있다.

이 쿠키는 코드로 작성한 대로 24시간 후에 삭제가 된다. 👍

profile
풀스택 개발자 기록집 📁

0개의 댓글