[Spring] 11/10 팀프로젝트 구현(댓글 구현, 좋아요, 북마크 기능 구현, 중복 조회수 해결, 인기게시물 띄우기)
지난번 마이페이지 구현 이후 배분받은 역할은 댓글 작성 폼, 댓글 목록, 좋아요 등록/해제, 북마크 등록/해제, 중복 조회수 해결(새로고침할 때마다 계속 올라감), 인기게시물 선정하는 기능이였다. 댓글부터 살펴보자.
AJAX
ajax는 페이지 전체를 새로고침하지 않고 특정 데이터를 서버와 주고받을 수 있도록 하는 기술이다. 즉, 비동기적으로 서버와 통신하여 사용자가 편리하게 피이지를 사용할 수 있도록 만들어준다. 우연찮게 내가 맡게 된 댓글, 좋아요, 북마크 기능 모두 비동기 기술에 해당하는 기능을 구현하게 되었다.
댓글
댓글의 controller, service, repository는 크게 특별한것 없이 댓글을 생성하는 메서드, 특정 게시물의 댓글을 조회하는 메서드를 구현하면 된다. 댓글의 html, JavaScript 부분을 살펴보자.
여기서 dto는 게시물의 dto변수, loggedInUser는 로그인한 유저 정보를 받아온 변수, reply는 댓글을 받아온 변수이다.
코드
<div class="mt-5">
<h4>댓글</h4>
<form id="replyForm">
<input type="hidden" name="boardId" th:value="${dto.boardId}">
<input type="hidden" name="userId" th:value="${loggedInUser.userId}">
<div class="form-group">
<textarea class="form-control" name="content" rows="3" placeholder="댓글을 입력하세요"></textarea>
</div>
<button type="button" onclick="submitReply()" class="btn btn-primary mt-2">댓글 작성</button>
</form>
<hr>
<!-- 댓글 목록 -->
<div id="replyList">
<div th:each="reply : ${replies}">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title"><span th:text="${reply.userNickname}"></span></h5>
<p class="card-text" th:text="${reply.content}"></p>
<p class="text-muted">작성일: <span th:text="${#temporals.format(reply.createdDate, 'yyyy-MM-dd HH:mm:ss')}"></span></p>
</div>
</div>
</div>
</div>
</div>
<script>
function submitReply() {
const formData = $('#replyForm').serialize(); // id명이 replyForm에서 입력한 모든 데이터를 서버로 전송할 수 있는 형태로 변환
$.ajax({ // ajax 요청
url: "/create", // controller에서의 요청경로
type: "POST", // POST 메서드로 설정
data: formData,
dataType: "json", // JSON 형식으로 응답 받기
success: function(reply) { // 성공시 실행할 함수. reply는 서버에서 반환된 json 데이터이다.
const replyHtml = `// 새 댓글을 HTML 구조로 만들어 삽입한다.
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">${reply.userNickname}</h5>
<p class="card-text">${reply.content}</p>
<p class="text-muted">작성일: ${reply.createdDate}</p>
</div>
</div>`;
// 댓글 목록의 맨 위에 새 댓글 추가
$('#replyList').prepend(replyHtml);
// 댓글 작성란 초기화
$('#replyForm textarea[name="content"]').val('');
},
error: function(xhr) { // 에러 예외처리
alert("댓글 작성에 실패했습니다. 상태 코드: " + xhr.status + ", 에러 메시지: " + xhr.responseText);
console.error("에러 응답:", xhr.responseText);
}
});
}
function loadReplies() { // 댓글목록 불러오는 함수
const boardId = $("input[name='boardId']").val(); // boardId값을 폼에서 가져와 변수에 저장
$.get("/board/" + boardId, function(data) { // get 요청을 작성된 URL로 보낸다.
$('#replyList').empty(); // 댓글 목록을 담고 있는 #replyList를 비운다.
data.forEach(function(reply) {
const replyHtml = `
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">${reply.userNickname}</h5>
<p class="card-text">${reply.content}</p>
<p class="text-muted">작성일: ${reply.createdDate}</p>
</div>
</div>`;
$('#replyList').append(replyHtml); // replyList 요소에 추가
});
});
}
$(document).ready(function() { // 문서가 완전히 로드된 후 실행할 함수
loadReplies();
});
</script>
동작화면
댓글을 입력은 잘 됐고 테이블에 들어가는지 확인해보면
테이블에도 잘 입력되었다.
북마크 & 좋아요
우리가 흔히 아는 SNS에 있는 북마크와 좋아요 기능이다.
코드
파일 계층 구조를 간단히 그림으로 그리면 다음과 같다.
BoardController
├── toggleLike()
│ └── BoardServiceImpl
│ ├── toggleLike(boardId, userId)
│ │ └── BoardLikeRepository
│ │ └── findByBoardBoardIdAndUserUserId()
│ │ └── save()
│ └── isLikedByUser()
│ └── BoardLikeRepository
│ └── findByBoardBoardIdAndUserUserId()
│
└── toggleBookmark()
└── BoardServiceImpl
├── toggleBookmark(boardId, userId)
│ └── BookmarkRepository
│ └── findByBoardBoardIdAndUserUserId()
│ └── save()
└── isBookmarkedByUser()
└── BookmarkRepository
└── findByBoardBoardIdAndUserUserId()
<div class="d-flex justify-content-end">
<!-- 좋아요 버튼 -->
<button type="button" class="btn btn-link p-0"
th:data-board-id="${board.boardId}"
th:data-user-id="${loggedInUser.userId}"
onclick="toggleIcon(this, 'like')">
<img th:src="@{/images/like-icon.png}"
data-src-checked="/images/like-icon-checked.png"
data-src-unchecked="/images/like-icon.png"
class="icon" alt="좋아요"
style="width: 24px; height: 24px;">
</button>
<!-- 북마크 버튼 -->
<button type="button" class="btn btn-link p-0 ml-3"
th:data-board-id="${board.boardId}"
th:data-user-id="${loggedInUser.userId}"
onclick="toggleIcon(this, 'bookmark')">
<img th:src="@{/images/bookmark-icon.png}"
data-src-checked="/images/bookmark-icon-checked.png"
data-src-unchecked="/images/bookmark-icon.png"
class="icon" alt="북마크"
style="width: 24px; height: 24px;">
</button>
</div>
<script>
function toggleIcon(button, type) { // type : 좋아요 또는 북마크
const img = $(button).find('img');
const boardId = $(button).data('board-id'); // 데이터 속성에서 boardId 가져오기
const userId = $(button).data('user-id'); // 데이터 속성에서 userId 가져오기
console.log("boardId: ", boardId, "userId: ", userId);
if (!userId) { // 로그인 한 유저만 버튼을 누를 수 있게 설정
alert("로그인이 필요합니다");
return;
}
const url = type === 'like' ? '/toggleLike' : '/toggleBookmark';
$.ajax({
url: url,
type: 'POST',
contentType: 'application/x-www-form-urlencoded',
data: { // 요청 데이터
boardId: boardId,
userId: userId
},
success: function (isActive) { // 성공시 실행할 콜백함수
// true면 활성화, false면 비활성화 이미지로 전환
const checkedSrc = img.attr('data-src-checked');
const uncheckedSrc = img.attr('data-src-unchecked');
img.attr('src', isActive ? checkedSrc : uncheckedSrc); // 버튼이 활성회 됐을때 비활성화 됐을때 띄울 이미지
},
error: function (xhr, status, error) {
console.error('Error:', error);
console.error("status: ", status);
console.error("Response: ", xhr.responseText);
}
});
}
</script>
동작화면
-
좋아요 테이블
-
북마크 테이블
아이콘 클릭시 활성화, 비활성화 아이콘으로 잘 바뀌고, 테이블에도 잘 입력되는것을 확인했다.
오류
이 기능을 구현하면서 이틀동안 Bad Request 에러가 나서 못찾았었는데 너무 허무한 에러였다. 아직 JavaScript 문법에 익숙하지 않아서 단순 문법 오류였다. 원래는 이렇게 작성해야 하지만
<button th:data-user-id="${loggedInUser.userId}">
이렇게 작성해서 발생한 오류였다.
<button data-user-id="[[${loggedInUser.userId}]]">
중복 조회수
처음에 조회수를 올리는 로직은 단순히 클릭했을 때 올라갈 수 있도록 만들어놔서 새로고침을 계속 하면 조회수가 제한없이 계속해서 올라갔다. 실제 웹사이트에서는 그런 일이 발생하면 안되기 때문에 세션에 저장된 페이지 방문 목록을 이용해 방문했던 게시물의 조회수는 더이상 올라가지 않도록 수정했다.
@GetMapping("/read/{boardId}")
public String read(@PathVariable Long boardId, Model model, HttpSession session) {
// 세션에서 이미 조회한 게시물 ID 목록을 가져옴
Set<Long> viewedBoards = (Set<Long>) session.getAttribute("viewedBoards");
if (viewedBoards == null) { // 조회한 게시물 리스트가 없으면 새로 생성
viewedBoards = new HashSet<>();
session.setAttribute("viewedBoards", viewedBoards);
}
// 조회한 적 없는 게시물일 경우 조회수 증가
if (!viewedBoards.contains(boardId)) {
boardService.visitCount(boardId);
viewedBoards.add(boardId);
session.setAttribute("viewedBoards", viewedBoards); // 세션에 업데이트
log.info("조회수를 증가시켰습니다. 현재 viewedBoards: " + viewedBoards);
} else {
log.info("이미 조회한 게시물입니다. 조회수를 증가하지 않습니다.");
}
// 게시글 읽기
BoardDTO boardDTO = boardService.readOne(boardId);
log.info(boardDTO);
// 세션에서 로그인된 사용자 정보 가져오기
UserDTO loggedInUser = (UserDTO) session.getAttribute("user");
model.addAttribute("board", boardDTO);
if (loggedInUser != null) {
model.addAttribute("loggedInUser", loggedInUser);
}
return "board/read";
}
인기 게시물
우선 인기 게시물을 선정하기 위한 지표는 북마크 수, 조회수, 좋아요 수의 가중합을 이용하기로 했다. 구체적으로 어떤 식으로 구현을 할까 생각하다가 엔터티와 DTO 단에서 직접 값을 DB에서 불러와 일일이 계산해서 게시물 테이블에 인기도 점수를 넣어서 삽입, 수정하는 방식으로 일단 구현했다. 하지만 이 방식은 계속해서 DB 테이블에 접근해야 하기 때문에 게시물이 많아지고, 사용자가 많아졌을 때 성능이 안좋아진다. 그래서 생각해낸 방법이 Repository에서 직접 쿼리문을 날려서 구현하기로 했다.
선정하는 기준은 4주 이내 작성된 게시글 중, 인기도 점수(2 * 조회수 + 4 * 좋아요 수 + 4 * 북마크 수)가 높은 상위 10개의 게시물이 인기 게시물이다.
- Repository
public interface BoardRepository extends JpaRepository<Board, Long> {
@Query(value = """
select b
from Board b
left join b.boardLikes bl
left join b.bookmarks bm
where b.regDate >= :fourWeeksAgo
group by b
order by (4 * coalesce(sum(case when bl.isDeleted = false then 1 else 0 end), 0)
+ 4 * coalesce(count(bm), 0)
+ 2 * b.visitCount) desc
limit 10
""")
List<Board> findTop10ByPopularity(LocalDateTime fourWeeksAgo);
}
- Service
public class BoardServiceImpl implements BoardService {
private final BoardRepository boardRepository;
@Override
public List<BoardDTO> getPopularBoards() {
LocalDateTime fourWeeksAgo = LocalDateTime.now().minusWeeks(4);
return boardRepository.findTop10ByPopularity(fourWeeksAgo)
.stream()
.map(this::convertEntityToDTO)
.collect(Collectors.toList());
}
}
- Controller
public class BoardController {
private final BoardService boardService;
@GetMapping("/popularBoards")
public String getPopularBoards(Model model) {
List<BoardDTO> popularBoards = boardService.getPopularBoards();
model.addAttribute("popularBoards", popularBoards);
return "/layout/basic";
}
}
댓글남기기