Spring Framework (16) 조회수, 좋아요, 댓글기능 구현 - Day 63

jaehoon ahn·2025년 12월 17일

Spring boot

목록 보기
14/20
post-thumbnail

조회수 증가하는 방법

  1. 조회할 때마다 증가
  2. DB에 누가 어떤글을 조회했는가를 일정 기간 단위로 확인하여 증가
  3. local 또는 session storage → JS 사용 가능, 서버 사용 불가
  4. 쿠키를 이용한 조회수 증가
  5. In memory DB (Redis)

쿠키

  • 클라이언트에서 저장/관리하는 작은 데이터 조각
  • 특징
    1. 서버나 클라이언트에서 생성할 수 있다.
    2. 클라이언트가 브라우저에 저장 및 관리를 함
    3. 이후에 클라이언트가 서버에 요청을 보낼 때, 자동으로 쿠키 첨부
    4. 서버쪽으로 온 쿠키를 서버는 이용
    • 단점
      • 브라우저에서 저장 및 관리를 하기 때문에, 쉽게 조작이 가능하고 보안상 취약하다

BoardController.java

if (loginMember == null || loginMember.getMemberNo() != board.getMemberNo()) {
				Cookie[] cookies = req.getCookies();

				Cookie c = null;

				for (Cookie temp : cookies) {
					// 쿠키 중에 "readBoardNo" 가 존재할 때
					if (temp.getName().equals("readBoardNo")) {
						c = temp;
						break;
					}
				}

				int result = 0; // 조회수 증가 결과를 저장할 변수

				if (c == null) {
					// "readBoardNo" 쿠키가 쿠키에 없을 때

					// 새 쿠키 생성("readBoardNo", [2000][3000][1000] => [게시글번호])
					// [2000] : boardNo
					c = new Cookie("readBoardNo", "[" + boardNo + "]");
					result = service.updateReadCount(boardNo);

					if (result > 0) {
						// 성공
					} else {
						// 실패
					}

				} else {
					// "readBoardNo" 쿠키가 쿠키에 있을 때
					// k : v
					// readBoardNo : [2][30][400]

					// 현재 글을 처음 읽는 경우
					if (c.getValue().indexOf("[" + boardNo + "]") == -1) {
						// indexOf가 -1이면 없는 경우
						// 해당 글 쿠키에 누적 + 서비스 호출
						// 즉, 현재 읽고있는 게시글을 처음 읽는 경우
						c.setValue(c.getValue() + "[" + boardNo + "]");
						// ex) [2][30][400][2000][4000]
						result = service.updateReadCount(boardNo);
						// 조회수 업데이트
					}
				}
				// 조회수 증가 성공 / 조회 성공 시
				if (result > 0) {
					// 조회수 성공 및 조회수 가져왔을 때
					// 앞서 조회했던 board의 readCount 값을
					// 현재 가져온 result로 가져와서 다시 세팅해준다.
					board.setReadCount(result);

					// 쿠키 적용 경로 설정
					// "/" 이하 경로 요청 시 쿠키를 서버로 전달
					c.setPath("/");

					// 쿠키 수명 지정
					// 현재 시간을 얻어오기
					LocalDateTime now = LocalDateTime.now();
					// 다음날 자정 지정
					LocalDateTime nextDayMidnight = now.plusDays(1).withHour(0).withMinute(0).withSecond(0).withNano(0);

					// 다음날 자정까지 남은 시간 계산 (초단위)
					// Duration은 남은 시간을 계산
					long secondsUntilNextDay = Duration.between(now, nextDayMidnight).getSeconds();
					
					// 쿠키 수명 설정
					c.setMaxAge((int)secondsUntilNextDay);
					
					// 응답객체(resp)을 이용해서 클라이언트에게 전달
					resp.addCookie(c);
				}
			}

			/* ------------------ 쿠키를 이용한 조회수 끝 ------------------ */

			path = "board/boardDetail";
			// src/main/resources/tempaltes/board/boardDetail.html

			// board - 게시글 일반 내용 + imageList + commentList
			model.addAttribute("board", board);

			// 조회된 이미지 목록(imageList)이 있을 경우
			if (!board.getImageList().isEmpty()) {
				BoardImg thumbnail = null;
				// imageList의 0번 인덱스 == IMG_ORDER가 가장 빠른 순서

				// 만약 이미지 목록의 0번째 요소의 IMG_ORDER가 0이면 == 썸네일
				if (board.getImageList().get(0).getImgOrder() == 0) {
					thumbnail = board.getImageList().get(0);
					// board.getImageList() -> List<BoardImg>
				}
				// thumbnail 변수에는
				// - 이미지 목록의 0번째 요소가 썸네일이면 썸네일 이미지의 BoardImg 객체
				// - 썸네일이 아니라면 null
				model.addAttribute("thumbnail", thumbnail);

				// start라는 key에 thumbnail이 null이 아닐 때 1 저장, null이면 0 저장
				model.addAttribute("start", thumbnail != null ? 1 : 0);
				// 썸네일 있을 때 : start=1
				// 썸네일 없을 때(일반이미지만 있거나, 등록된 이미지가 아예 없을 때)
				// : start=0
			}
		}

쿠키 정리

  • cookie.getValue()
    • 쿠키 값 가져오기
  • cookie.setPath(경로)
    • “경로” 이하 경로 요청 시 쿠키를 서버로 전달
  • c.setMaxAge
    • 쿠키 수명 수정

service

// 조회수 1 증가
	@Override
	public int updateReadCount(int boardNo) {
		// 1. 조회수 1증가 (UPDATE)
		int result = mapper.updateReadCount(boardNo);
		
		// 2. 현재 조회 수 조회
		if(result > 0) {
			return mapper.selectReadCount(boardNo);
		} 
		
		// UPDATE에 실패한 경우 -1 반환
		return -1;
	}

좋아요 체크/해제

boardDetail.js

// 1. #boardLike 가 클릭 되었을 때
document.querySelector("#boardLike").addEventListener("click", (e) => {
  // 2. 로그인 상태가 아닌 경우 동작 X
  if (loginMemberNo == null) {
    alert("로그인 후 이용해주세요~");
    return;
  }

  // 3. 준비된 3개의 변수를 JS 객체 형태로 생성 (JSON 변환 예정)
  const obj = {
    memberNo: loginMemberNo,
    boardNo: boardNo,
    likeCheck: likeCheck,
  };

  // 4. 좋아요 INSERT/DELETE 비동기 요청
  fetch("/board/like", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(obj), // JSON으로 json 형태로 전달
  })
    .then((resp) => resp.text())
    .then((count) => {
      if (count == -1) {
        console.log("좋아요 처리 실패...");
        return;
      }

      // 5. likeCheck 값을 0 -> 1, 1 -> 0
      // -> 클릭될 때마다 INSERT/DELETE 동작을 번갈아 가면서 할 수 있게끔
      likeCheck = likeCheck == 0 ? 1 : 0;

      // 6. 하트를 채우기/비우기 바꾸기
      // 현재 하트가 눌렸을 때가 e.target
      e.target.classList.toggle("fa-regular");
      e.target.classList.toggle("fa-solid");

      // 7. 게시글 좋아요 수 수정
      e.target.nextElementSibling.innerText = count;
    });
});

BoardController.java

/** 게시글 좋아요 체크/해제 (비동기)
	 * @param map : boardNo, memberNo, likeCheck가 들어있는 map
	 * @return
	 */
	@PostMapping("like")
	@ResponseBody // /board/like (POST) 요청 매핑
	public int boardLike(@RequestBody Map<String, Integer> map) {
		// Board DTO로 받을 수도 있고, Map으로 받을 수 있다.
		
		return service.boardLike(map);
	}

BoardServiceImpl.java

/**
	 * map : boardNo, memberNo, likeCheck likeCheck : 좋아요 여부 1이면 누른 것, 0이면 안누른 것 =>
	 * 이걸 토대로 insert or delete 여부 결정
	 */
	@Override
	public int boardLike(Map<String, Integer> map) {
		int result = 0;

		if (map.get("likeCheck") == 1) {
			// 1. 좋아요가 체크된 상태된 경우(likeCheck == 1)
			// -> BOARD_LIKE 테이블에 DELETE
			result = mapper.deleteBoardLike(map);
		} else {
			// 2. 좋아요가 해제된 상태인 경우(likeCheck == 0)
			// -> BOARD_LIKE 테이블에 INSERT
			result = mapper.insertBoardLike(map);
		}
		// 3. INSERT/DELETE 둘 중 성공했다면 해당 게시글의 좋아요 갯수 조회해서 반환
		if(result > 0) {
			return mapper.selectLikeCount(map.get("boardNo"));
		}
		
		return -1;
	}

board-mapper.xml

<!-- 좋아요 해제 SQL (DELETE) -->
   <delete id="deleteBoardLike">
   		DELETE FROM "BOARD_LIKE"
   		WHERE MEMBER_NO = #{memberNo}
   		AND BOARD_NO = #{boardNo}
   </delete>
   <!-- 좋아요 체크 SQL (INSERT) -->
   <insert id="insertBoardLike">
   		INSERT INTO "BOARD_LIKE"
   		VALUES(#{memberNo}, #{boardNo})
   </insert>
   <!-- 게시글 좋아요 갯수 조회 (SELECT) -->
   <select id="selectLikeCount">
   		SELECT COUNT(*)
   		FROM "BOARD_LIKE"
   		WHERE BOARD_NO = #{boardNo}
   </select>

조회수 증가하는 방법

  1. 조회할 때마다 증가
  2. DB에 누가 어떤글을 조회했는가를 일정 기간 단위로 확인하여 증가
  3. local 또는 session storage → JS 사용 가능, 서버 사용 불가
  4. 쿠키를 이용한 조회수 증가
  5. In memory DB (Redis)

쿠키

  • 클라이언트에서 저장/관리하는 작은 데이터 조각
  • 특징
    1. 서버나 클라이언트에서 생성할 수 있다.
    2. 클라이언트가 브라우저에 저장 및 관리를 함
    3. 이후에 클라이언트가 서버에 요청을 보낼 때, 자동으로 쿠키 첨부
    4. 서버쪽으로 온 쿠키를 서버는 이용
    • 단점
      • 브라우저에서 저장 및 관리를 하기 때문에, 쉽게 조작이 가능하고 보안상 취약하다

BoardController.java

if (loginMember == null || loginMember.getMemberNo() != board.getMemberNo()) {
				Cookie[] cookies = req.getCookies();

				Cookie c = null;

				for (Cookie temp : cookies) {
					// 쿠키 중에 "readBoardNo" 가 존재할 때
					if (temp.getName().equals("readBoardNo")) {
						c = temp;
						break;
					}
				}

				int result = 0; // 조회수 증가 결과를 저장할 변수

				if (c == null) {
					// "readBoardNo" 쿠키가 쿠키에 없을 때

					// 새 쿠키 생성("readBoardNo", [2000][3000][1000] => [게시글번호])
					// [2000] : boardNo
					c = new Cookie("readBoardNo", "[" + boardNo + "]");
					result = service.updateReadCount(boardNo);

					if (result > 0) {
						// 성공
					} else {
						// 실패
					}

				} else {
					// "readBoardNo" 쿠키가 쿠키에 있을 때
					// k : v
					// readBoardNo : [2][30][400]

					// 현재 글을 처음 읽는 경우
					if (c.getValue().indexOf("[" + boardNo + "]") == -1) {
						// indexOf가 -1이면 없는 경우
						// 해당 글 쿠키에 누적 + 서비스 호출
						// 즉, 현재 읽고있는 게시글을 처음 읽는 경우
						c.setValue(c.getValue() + "[" + boardNo + "]");
						// ex) [2][30][400][2000][4000]
						result = service.updateReadCount(boardNo);
						// 조회수 업데이트
					}
				}
				// 조회수 증가 성공 / 조회 성공 시
				if (result > 0) {
					// 조회수 성공 및 조회수 가져왔을 때
					// 앞서 조회했던 board의 readCount 값을
					// 현재 가져온 result로 가져와서 다시 세팅해준다.
					board.setReadCount(result);

					// 쿠키 적용 경로 설정
					// "/" 이하 경로 요청 시 쿠키를 서버로 전달
					c.setPath("/");

					// 쿠키 수명 지정
					// 현재 시간을 얻어오기
					LocalDateTime now = LocalDateTime.now();
					// 다음날 자정 지정
					LocalDateTime nextDayMidnight = now.plusDays(1).withHour(0).withMinute(0).withSecond(0).withNano(0);

					// 다음날 자정까지 남은 시간 계산 (초단위)
					// Duration은 남은 시간을 계산
					long secondsUntilNextDay = Duration.between(now, nextDayMidnight).getSeconds();
					
					// 쿠키 수명 설정
					c.setMaxAge((int)secondsUntilNextDay);
					
					// 응답객체(resp)을 이용해서 클라이언트에게 전달
					resp.addCookie(c);
				}
			}

			/* ------------------ 쿠키를 이용한 조회수 끝 ------------------ */

			path = "board/boardDetail";
			// src/main/resources/tempaltes/board/boardDetail.html

			// board - 게시글 일반 내용 + imageList + commentList
			model.addAttribute("board", board);

			// 조회된 이미지 목록(imageList)이 있을 경우
			if (!board.getImageList().isEmpty()) {
				BoardImg thumbnail = null;
				// imageList의 0번 인덱스 == IMG_ORDER가 가장 빠른 순서

				// 만약 이미지 목록의 0번째 요소의 IMG_ORDER가 0이면 == 썸네일
				if (board.getImageList().get(0).getImgOrder() == 0) {
					thumbnail = board.getImageList().get(0);
					// board.getImageList() -> List<BoardImg>
				}
				// thumbnail 변수에는
				// - 이미지 목록의 0번째 요소가 썸네일이면 썸네일 이미지의 BoardImg 객체
				// - 썸네일이 아니라면 null
				model.addAttribute("thumbnail", thumbnail);

				// start라는 key에 thumbnail이 null이 아닐 때 1 저장, null이면 0 저장
				model.addAttribute("start", thumbnail != null ? 1 : 0);
				// 썸네일 있을 때 : start=1
				// 썸네일 없을 때(일반이미지만 있거나, 등록된 이미지가 아예 없을 때)
				// : start=0
			}
		}

쿠키 정리

  • cookie.getValue()
    • 쿠키 값 가져오기
  • cookie.setPath(경로)
    • “경로” 이하 경로 요청 시 쿠키를 서버로 전달
  • c.setMaxAge
    • 쿠키 수명 수정

service

// 조회수 1 증가
	@Override
	public int updateReadCount(int boardNo) {
		// 1. 조회수 1증가 (UPDATE)
		int result = mapper.updateReadCount(boardNo);
		
		// 2. 현재 조회 수 조회
		if(result > 0) {
			return mapper.selectReadCount(boardNo);
		} 
		
		// UPDATE에 실패한 경우 -1 반환
		return -1;
	}

좋아요 체크/해제

boardDetail.js

// 1. #boardLike 가 클릭 되었을 때
document.querySelector("#boardLike").addEventListener("click", (e) => {
  // 2. 로그인 상태가 아닌 경우 동작 X
  if (loginMemberNo == null) {
    alert("로그인 후 이용해주세요~");
    return;
  }

  // 3. 준비된 3개의 변수를 JS 객체 형태로 생성 (JSON 변환 예정)
  const obj = {
    memberNo: loginMemberNo,
    boardNo: boardNo,
    likeCheck: likeCheck,
  };

  // 4. 좋아요 INSERT/DELETE 비동기 요청
  fetch("/board/like", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(obj), // JSON으로 json 형태로 전달
  })
    .then((resp) => resp.text())
    .then((count) => {
      if (count == -1) {
        console.log("좋아요 처리 실패...");
        return;
      }

      // 5. likeCheck 값을 0 -> 1, 1 -> 0
      // -> 클릭될 때마다 INSERT/DELETE 동작을 번갈아 가면서 할 수 있게끔
      likeCheck = likeCheck == 0 ? 1 : 0;

      // 6. 하트를 채우기/비우기 바꾸기
      // 현재 하트가 눌렸을 때가 e.target
      e.target.classList.toggle("fa-regular");
      e.target.classList.toggle("fa-solid");

      // 7. 게시글 좋아요 수 수정
      e.target.nextElementSibling.innerText = count;
    });
});

BoardController.java

/** 게시글 좋아요 체크/해제 (비동기)
	 * @param map : boardNo, memberNo, likeCheck가 들어있는 map
	 * @return
	 */
	@PostMapping("like")
	@ResponseBody // /board/like (POST) 요청 매핑
	public int boardLike(@RequestBody Map<String, Integer> map) {
		// Board DTO로 받을 수도 있고, Map으로 받을 수 있다.
		
		return service.boardLike(map);
	}

BoardServiceImpl.java

/**
	 * map : boardNo, memberNo, likeCheck likeCheck : 좋아요 여부 1이면 누른 것, 0이면 안누른 것 =>
	 * 이걸 토대로 insert or delete 여부 결정
	 */
	@Override
	public int boardLike(Map<String, Integer> map) {
		int result = 0;

		if (map.get("likeCheck") == 1) {
			// 1. 좋아요가 체크된 상태된 경우(likeCheck == 1)
			// -> BOARD_LIKE 테이블에 DELETE
			result = mapper.deleteBoardLike(map);
		} else {
			// 2. 좋아요가 해제된 상태인 경우(likeCheck == 0)
			// -> BOARD_LIKE 테이블에 INSERT
			result = mapper.insertBoardLike(map);
		}
		// 3. INSERT/DELETE 둘 중 성공했다면 해당 게시글의 좋아요 갯수 조회해서 반환
		if(result > 0) {
			return mapper.selectLikeCount(map.get("boardNo"));
		}
		
		return -1;
	}

board-mapper.xml

<!-- 좋아요 해제 SQL (DELETE) -->
   <delete id="deleteBoardLike">
   		DELETE FROM "BOARD_LIKE"
   		WHERE MEMBER_NO = #{memberNo}
   		AND BOARD_NO = #{boardNo}
   </delete>
   <!-- 좋아요 체크 SQL (INSERT) -->
   <insert id="insertBoardLike">
   		INSERT INTO "BOARD_LIKE"
   		VALUES(#{memberNo}, #{boardNo})
   </insert>
   <!-- 게시글 좋아요 갯수 조회 (SELECT) -->
   <select id="selectLikeCount">
   		SELECT COUNT(*)
   		FROM "BOARD_LIKE"
   		WHERE BOARD_NO = #{boardNo}
   </select>

image.png

0개의 댓글