[Spring Boot 실전]10. 게시글 추천 기능을 만들어보자
이번 시간에는 댓글을 수정할 수 있는 기능과 사용자가 게시글을 추천, 비추천을 할 수 있는 기능을 추가해보도록 하겠다.
댓글 수정
public Optional<Comment> findOne(Long id) {
return commentRepository.findById(id);
}
public Long update(Long id, Comment newComment) {
Optional<Comment> optionalComment = commentRepository.findById(id);
if (optionalComment.isPresent()) {
Comment originComment = optionalComment.get();
originComment.setContent(newComment.getContent());
return originComment.getId();
} else {
return null;
}
}
@GetMapping("/comments/update/{id}-{commentId}")
public String updateForm(@PathVariable Long id, @PathVariable Long commentId, Model model) {
Optional<Article> article = articleService.findOne(id);
Optional<Comment> comment = commentService.findOne(commentId);
model.addAttribute("article", article.orElse(null));
model.addAttribute("comment", comment.orElse(null));
return "comments/updateComment";
}
@PostMapping("/comments/update/{id}-{commentId}")
public String update(@PathVariable Long id, @PathVariable Long commentId, String content) {
Comment temp = new Comment();
temp.setContent(content);
commentService.update(commentId, temp);
return "redirect:/articles/"+id;
}
사실 해당 기능은 조금 간단하다. (이전에 댓글 삭제에서 같이 구현할 수 있을 정도로 간단했지만 너무 귀찮았다는 ...)
get, post요청에 맞게 Controller에 메소드를 추가하고 그에 맞는 update 관련 메소드를 Service에 추가하면 끝이다.
화면 및 DB에도 잘 적용된 것을 확인할 수 있다.
게시글 추천과 비추천
@Getter
@Setter
@Entity
@ToString
public class Recog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "article_id", referencedColumnName = "id", nullable = false, updatable = false)
private Article article;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false, updatable = false)
private User user;
private int type;
public Recog() {}
public Recog(Article article, User user, int type) {
this.article = article;
this.user = user;
this.type = type;
}
}
우선 다음과 같이 추천을 담당하는 Recog 엔티티를 작성한다.
해당 테이블은 추천한 게시글이나 사용자가 여러 개여도 접근할 수 있기 때문에 다대일 형식으로 외부키를 만들어준다.
그리고 주의해야할 점은 기존에는 Recog가 아닌 Like로 테이블명을 설정하였는데, 이는 잘못된 테이블명이다.
왜냐하면 MySQL에서 Like는 검색 기능이 있는 예약어이기 때문에 like명의 테이블을 생성하여 "select * from like;"등의 쿼리문을 실행하면 예약어와 동일한 테이블에 대한 오류가 나오는 것을 확인할 수 있다.
따라서 주의해야할 듯하다.
@Repository
public class JpaLikeRepository implements LikeRepository {
private final EntityManager em;
@Getter
public enum LikeType {
LIKE(1),
DISLIKE(-1);
private final int value;
LikeType(int value) {
this.value = value;
}
}
public JpaLikeRepository(EntityManager em) {
this.em = em;
}
@Override
public Optional<Recog> findById(Long articleId, String userId) {
String jpql = "SELECT l FROM Recog l WHERE l.article.id = :articleId AND l.user.id = :userId";
try {
Recog recog = em.createQuery(jpql, Recog.class)
.setParameter("articleId", articleId)
.setParameter("userId", userId)
.getSingleResult();
return Optional.ofNullable(recog);
} catch (NoResultException e) {
return Optional.empty();
}
}
@Override
public Long countLikes(Long articleId) {
String jpql = "SELECT COUNT(l) FROM Recog l WHERE l.article.id = :articleId AND l.type = :likeType";
try {
return em.createQuery(jpql, Long.class)
.setParameter("articleId", articleId)
.setParameter("likeType", LikeType.LIKE.getValue())
.getSingleResult();
} catch (NoResultException e) {
return 0L;
}
}
@Override
public Long countDislikes(Long articleId) {
String jpql = "SELECT COUNT(l) FROM Recog l WHERE l.article.id = :articleId AND l.type = :likeType";
try {
return em.createQuery(jpql, Long.class)
.setParameter("articleId", articleId)
.setParameter("likeType", LikeType.DISLIKE.getValue())
.getSingleResult();
} catch (NoResultException e) {
return 0L;
}
}
@Override
public void save(Recog recog) {
em.persist(recog);
}
@Override
public void delete(Recog recog) {
em.remove(recog);
}
}
그리고 다음과 같이 Repository를 작성한다.
필자의 경우 '추천' 버튼을 클릭할 시, 1을 반환하고 '비추천' 버튼 클릭시에는 -1을 반환하기 때문에 enum을 통해 추천 타입을 설정하였다.
그리고 각각의 데이터를 저장하고 삭제하고 갯수를 반환하는 메소드를 작성하였다.
@Service
@Transactional
public class LikeService {
private final LikeRepository likeRepository;
private final HttpSession session;
private final ArticleRepository articleRepository;
@Autowired
public LikeService(LikeRepository likeRepository, HttpSession session, ArticleRepository articleRepository) {
this.likeRepository = likeRepository;
this.session = session;
this.articleRepository = articleRepository;
}
public void likeDislike(Long articleId, int type) {
User user = (User) session.getAttribute("user");
Optional<Recog> likeOptional = likeRepository.findById(articleId, user.getId());
Optional<Article> articleOptional = articleRepository.findById(articleId);
if (likeOptional.isEmpty() && articleOptional.isPresent()) {
Recog temp = new Recog();
temp.setType(type);
temp.setUser(user);
temp.setArticle(articleOptional.get());
likeRepository.save(temp);
if (type == 1) {
articleRepository.like(articleId);
} else {
articleRepository.dislike(articleId);
}
} else if (likeOptional.isPresent() && articleOptional.isPresent()) {
Recog temp = likeOptional.get();
if (temp.getType() != type) {
temp.setType(type);
likeRepository.save(temp);
if (type == 1) {
articleRepository.like(articleId);
articleRepository.mdislike(articleId);
} else {
articleRepository.dislike(articleId);
articleRepository.mlike(articleId);
}
} else {
likeRepository.delete(temp);
if (type == 1) {
articleRepository.mlike(articleId);
} else {
articleRepository.mdislike(articleId);
}
}
}
}
public Long countLikes(Long articleId) {
return likeRepository.countLikes(articleId);
}
public Long countDislikes(Long articleId) {
return likeRepository.countDislikes(articleId);
}
}
다음은 레포지토리를 기반으로 작성한 Service 코드이다.
이 부분이 조금 복잡하다.
게시판을 이용해본 적이 있다면, 추천과 비추천은 한 종류만 최대 1개를 선택할 수 있다는 것을 알 것이다.
따라서 추천을 누른 상태에서 비추천을 누르게 되면, 추천이 제거되고 비추천이 누르게 되는 것이다.
그리고 추천을 누른 상태에서 한 번 더 추천을 누르게 되면, 추천이 제거되는 형식이다.
각각의 타입과 ArticleId, UserId를 통해 해당 유저가 해당 게시글에 추천이나 비추천을 눌렀는지, 만약 눌렀다면 해당 타입은 이전과 동일한지 등에 따라서 해당 Service 코드를 작성하였다.
그리고 likeDislike 메소드에서 articlerepository가 있는 것을 확인할 수 있다.
이는 각 게시글 화면에서 추천과 비추천의 갯수가 몇 개인지를 출력하기 위한 것이다.
Article을 거치지 않고 Recog 테이블에서 직접 해당 ArticleId에 해당하는 like, dislike의 갯수를 count(*)하여 작성할 수 있지만, 그렇게 하지 않고 Article에 따로 변수를 두었다.
그 이유는 쿼리문을 생성하지 않고 바로 변수를 접근한다는 점에서 효율적이지 않을까라는 생각 때문이다. (이전에 유튜브에서 백엔드 개발자 분께서 DB에 접근한다는 것 자체가 시간과 비용이다라는 말을 들은 적이 있다. 따라서 쿼리문으로 접근하는 것보다는 변수로 바로 접근하는 것이 좀 더 낫지 않을까라는 것이 필자의 의견이다.)
<button th:if="${user != null}" th:onclick="'likeDislike(' + ${article.id} + ', 1);'"
class="btn btn-success">추천</button>
<span th:text="${article.likeCount}"></span>
<button th:if="${user != null}" th:onclick="'likeDislike(' + ${article.id} + ', -1);'"
class="btn btn-danger">비추천</button>
<span th:text="${article.dislikeCount}"></span>
<script>
function likeDislike(articleId, type) {
$.ajax({
type: "POST",
url: "/like/" + articleId + "/" + type
});
}
</script>
@Controller
public class LikeController {
private final LikeService likeService;
@Autowired
public LikeController(LikeService likeService) {
this.likeService = likeService;
}
@PostMapping("/like/{articleId}/{type}")
public String likeDislike(@PathVariable Long articleId, @PathVariable int type) {
likeService.likeDislike(articleId, type);
return "redirect:/articles/"+articleId;
}
@Getter
@Setter
public static class LikeRequest {
private Long articleId;
private int type;
}
}
ArticleDetail.html을 수정하여 버튼 클릭시, Post 요청을 통해서 추천과 비추천을 관리하고 이를 적용하는 기능을 추가하였다.
하지만 여기서 문제점이 발생하였다.
추천을 눌렀을 때, 바로 text가 업데이트 되지 않고 새로고침을 해야 업데이트가 된다는 점이었다.
검색을 해보니, 현재에는 "${artcle.likeCount}" 형태로 해당 값을 출력하고 있어서 그런 것 같다.
그러고보니, 이전에 사용자가 댓글을 작성하고 댓글 수정 시, 바로 변경된 닉네임이 적용되었다.
이 때는 댓글 수정 화면이 다른 화면이라서 닫힌 후에 게시글 화면을 불러와서 자동으로 새로고침이 되어서 괜찮았던 것 같다.
아직도 해당 기능은 완전히 해결하지 못하였다.
따라서 추천을 누르면 "추천을 하시겠습니까?"라는 창이 따로 뜨게 해야할 듯하다.
그래도 결과는 다음과 같이 이상이 없는 것을 확인할 수 있다.
오늘은 이렇게 댓글 수정과 게시글 추천 기능을 만들어보았다.
벌써 'Spring Boot 실전' 카테고리의 글도 어느덧 10개를 넘기게 되었다.
기존의 crud 기능만을 구현할 때에도 상당히 오래걸렸던 것 같은데, 이제야 감이 조금은 생긴 듯하다.(그래도 아직은 꼬꼬마 실력이지만 ㅎ)
아무튼 이후로는 게시글을 검색할 수 있는 기능을 추가할 예정이다.
그렇기 위해서는 검색엔진과 관련된 기능을 좀 더 공부해야할 듯하다.
Elastic Search나 RabbitMQ와 같은 검색엔진 및 브로커와 같은 공부를 좀 한 후에 다시 개발을 진행할 것이다.
하하. 힘들어지겠지만 그래도 어찌하겠나. 그냥 해야지.