[스프링부트 시리즈29] 추천기능 추가하기

Featured image

추천기능 추가하기


우리는 SNS에서 마음에 드는 게시물이나 콘텐츠에 ‘좋아요’나, ‘추천’ 등과 같은 표시를 남긴다. SBB 게시판에도 [추천] 버튼을 통해 질문이나 답변을 본 다른 사용자들이 반응을 남길 수 있도록 ‘추천’ 기능을 구현해 보자.

엔티티에 속성 추가하기


질문 또는 답변의 ‘추천’ 기능을 구현하려면 질문이나 답변을 추천한 사용자(SiteUser)가 DB에 저장될 수 있도록 관련 속성을 질문, 답변 엔티티에 추가해야 한다.

1) 먼저, 질문 엔티티에 추천인([추천] 버튼을 클릭한 사용자)을 저장하기 위한 voter라는 이름의 속성을 추가해 보자. 하나의 질문에 여러 사람이 추천할 수 있고 한 사람이 여러 개의 질문을 추천할 수 있다. 따라서 @ManyToMany 애너테이션을 사용해야 한다.

[파일명:/question/Question.java]

(... 생략 ...)
import java.util.Set;
import jakarta.persistence.ManyToMany;
(... 생략 ...)
public class Question {
    (... 생략 ...)

    @ManyToMany
    Set<SiteUser> voter;
}

@ManyToMany 애너테이션과 함께 Set<SiteUser> voter를 작성해 voter 속성을 다대다 관계로 설정하여 질문 엔티티에 추가했다. 이때 다른 속성과 달리 Set 자료형으로 작성한 이유는 voter 속성값이 서로 중복되지 않도록 하기 위해서이다. List 자료형과 달리 여기서는 Set 자료형이 voter 속성을 관리하는데 효율적이다.

2) 답변 엔티티에도 같은 방법으로 voter 속성을 추가해 보자.

[파일명:/answer/Answer.java]

(... 생략 ...)
import java.util.Set;
import jakarta.persistence.ManyToMany;
(... 생략 ...)
public class Answer {
    (... 생략 ...)

    @ManyToMany
    Set<SiteUser> voter;
}

3) 질문과 답변 엔티티에 voter 속성을 추가하였으니 다음과 같이 H2 콘솔을 확인해 보자. 콘솔

author 속성을 추가할 때와 달리 QUESTION_VOTER, ANSWER_VOTER라는 테이블이 생성된 것을 확인할 수 있다. 이렇게 @ManyToMany 애너테이션을 사용해 다대다 관계로 속성을 생성하면 새로운 테이블을 만들어 관련 데이터를 관리한다. 여기서 생성된 테이블의 인덱스 항목을 펼쳐 보면 서로 연관된 엔티티의 고유 번호(즉, ID)가 기본키로 설정되어 다대다 관계임을 알 수 있다.

질문 추천 기능 생성하기


질문 엔티티에 voter 속성을 추가했으니 이번에는 질문 추천 기능을 만들어 보자.

3-09절에서 수정과 삭제 기능을 추가할 때와 비슷한 실습 과정이 진행된다. 이 점을 참고하자.

1) 질문을 추천할 수 있는 버튼 위치는 어디가 좋을까? 질문 상세 화면이 적절할 것이다. 질문 상세 템플릿을 다음과 같이 수정해 보자.

[파일명: /templates/question_detail.html]

(... 생략 ...)
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
    <div class="card-body">
        (... 생략 ...)
        <div class="my-3">
            <a href="javascript:void(0);" class="recommend btn btn-sm btn-outline-secondary"
                th:data-uri="@{|/question/vote/${question.id}|}">
                추천
                <span class="badge rounded-pill bg-success" th:text="${#lists.size(question.voter)}"></span>
            </a>
            <a th:href="@{|/question/modify/${question.id}|}" class="btn btn-sm btn-outline-secondary"
                sec:authorize="isAuthenticated()"
                th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
                th:text="수정"></a>
            <a href="javascript:void(0);" th:data-uri="@{|/question/delete/${question.id}|}"
                class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
                th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
                th:text="삭제"></a>
        </div>
    </div>
</div>
(... 생략 ...)

[추천] 버튼을 [수정] 버튼 왼쪽에 추가하기 위한 코드를 작성했다. lists.size 메서드에 question.voter를 사용하여 추천 수도 함께 보이도록 했다. [추천] 버튼을 클릭하면 href의 속성이 javascript:void(0)으로 되어 있어서 아무런 동작도 하지 않는다. 하지만 class 속성에 recommend를 적용해 자바스크립트로 data-uri에 정의된 URL이 호출되도록 할 것이다. 따라서 [삭제] 버튼과 마찬가지로 [추천] 버튼을 눌렀을 때 메시지가 적힌 팝업 창을 통해 추천을 진행할 것이다.

class="recommend btn btn-sm btn-outline-secondary" 에서 recommend는 추천 버튼을 클릭하는 이벤트를 얻기 위한 클래스이다.

2) 이어서 [추천] 버튼을 클릭했을 때 ‘정말로 추천하시겠습니까?’라는 메시지 창이 나타나도록 다음과 같이 자바스크립트 코드를 추가해 보자.

[파일명:/templates/question_detail.html]

(... 생략 ...)
<script layout:fragment="script" type='text/javascript'>
const delete_elements = document.getElementsByClassName("delete");
Array.from(delete_elements).forEach(function(element) {
    element.addEventListener('click', function() {
        if(confirm("정말로 삭제하시겠습니까?")) {
            location.href = this.dataset.uri;
        };
    });
});
const recommend_elements = document.getElementsByClassName("recommend");
Array.from(recommend_elements).forEach(function(element) {
    element.addEventListener('click', function() {
        if(confirm("정말로 추천하시겠습니까?")) {
            location.href = this.dataset.uri;
        };
    });
});
</script>
</html>

[추천] 버튼에 recommend 클래스가 적용되어 있으므로 [추천] 버튼을 클릭하면 ‘정말로 추천하시겠습니까?’라는 메시지가 담긴 팝업 창이 나타나고, [확인]을 선택하면 data-uri 속성에 정의한 URL인 @{|/question/vote/${question.id}|}이 호출될 것이다.

3) 추천인을 저장할 수 있도록 추천 기능을 다음과 같이 QuestionSerivce에 추가해 보자.

[파일명:/question/QuestionService.java]

(... 생략 ...)
public class QuestionService {

    (... 생략 ...)

    public void vote(Question question, SiteUser siteUser) {
        question.getVoter().add(siteUser);
        this.questionRepository.save(question);
    }
}

이와 같이 로그인한 사용자를 질문 엔티티에 추천인으로 저장하기 위해 vote 메서드를 추가했다.

4) [추천] 버튼을 눌렀을 때 GET 방식으로 호출되는 @{|/question/vote/${question.id}|} URL을 처리하기 위해 다음과 같이 QuestionController에 코드를 추가해 보자.

[파일명:/question/QuestionController.java]

(... 생략 ...)
public class QuestionController {

    (... 생략 ...)

    @PreAuthorize("isAuthenticated()")
    @GetMapping("/vote/{id}")
    public String questionVote(Principal principal, @PathVariable("id") Integer id) {
        Question question = this.questionService.getQuestion(id);
        SiteUser siteUser = this.userService.getUser(principal.getName());
        this.questionService.vote(question, siteUser);
        return String.format("redirect:/question/detail/%s", id);
    }
}

이와 같이 questionVote 메서드를 추가했다. 다른 기능과 마찬가지로 추천 기능도 로그인한 사람만 사용할 수 있도록 @PreAuthorize("isAuthenticated( )") 애너테이션을 적용했다. 그리고 앞서 작성한 QuestionServicevote 메서드를 호출하여 사용자(siteUser)를 추천인(voter)으로 저장했다. 오류가 없다면 추천인을 저장한 후 질문 상세 화면으로 리다이렉트한다.

5) 질문 상세 화면의 질문 내용 부분을 보면 [추천] 버튼이 생겼을 것이다. 이 버튼을 클릭하여 버튼이 잘 작동하는지 확인해 보자. 추천

6) [추천] 버튼을 클릭하면 다음과 같이 메시지 창이 등장한다. 메시지 창의 [확인] 버튼을 클릭하면 다시 질문 상세 화면으로 돌아가고 [추천] 버튼에 추천인 숫자가 변경된다. 추천확인

답변 추천기능 생성하기


답변 추천 기능은 질문 추천 기능과 동일하므로 빠르게 작성해 보자.

1) 답변의 추천 수를 표시하고, 답변을 추천할 수 있는 버튼을 질문 상세 템플릿에 다음과 같이 추가해 보자.

[파일명:/templates/question_detail.html]

(... 생략 ...)
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
    <div class="card-body">
        (... 생략 ...)
        <div class="my-3">
            <a href="javascript:void(0);" class="recommend btn btn-sm btn-outline-secondary"
                th:data-uri="@{|/answer/vote/${answer.id}|}">
                추천
                <span class="badge rounded-pill bg-success" th:text="${#lists.size(answer.voter)}"></span>
            </a>
            <a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-sm btn-outline-secondary"
                sec:authorize="isAuthenticated()"
                th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                th:text="수정"></a>
            <a href="javascript:void(0);" th:data-uri="@{|/answer/delete/${answer.id}|}"
                class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
                th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                th:text="삭제"></a>
        </div>
    </div>
</div>
<!-- 답변 반복 끝  -->
(... 생략 ...)

질문 추천 기능을 만들 때와 마찬가지로 답변 영역의 상단에 답변을 추천할 수 있는 버튼을 생성했다. 이 역시 추천 버튼에 class=”recommend”가 적용되어 있으므로 추천 버튼을 클릭하면 ‘정말로 추천하시겠습니까?’라는 메시지가 적힌 팝업 창이 나타나고 [확인]을 선택하면 data-uri 속성에 정의한 URL이 호출될 것이다.

2) 답변을 추천한 사람을 저장하기 위해 다음과 같이 AnswerService를 수정해 보자.

[파일명:/answer/AnswerService.java]

(... 생략 ...)
public class AnswerService {

    (... 생략 ...)

    public void vote(Answer answer, SiteUser siteUser) {
        answer.getVoter().add(siteUser);
        this.answerRepository.save(answer);
    }
}

AnswerService에 추천인을 저장하는 vote 메서드를 추가했다.

3) [추천] 버튼을 눌렀을 때 GET 방식으로 호출되는 @{|/answer/vote/${answer.id}|} URL을 처리하기 위해 다음과 같이 AnswerController에 코드를 추가해 보자.

[파일명:/answer/AnswerController.java]

(... 생략 ...)
public class AnswerController {

    (... 생략 ...)

    @PreAuthorize("isAuthenticated()")
    @GetMapping("/vote/{id}")
    public String answerVote(Principal principal, @PathVariable("id") Integer id) {
        Answer answer = this.answerService.getAnswer(id);
        SiteUser siteUser = this.userService.getUser(principal.getName());
        this.answerService.vote(answer, siteUser);
        return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
    }
}

이와 같이 answerVote 메서드를 추가했다. 추천은 로그인한 사람만 가능해야 하므로 @PreAuthorize("isAuthenticated( )") 애너테이션을 적용했다. 그리고 앞서 작성한 AnswerServicevote 메서드를 호출하여 추천인을 저장한다. 오류가 없다면 추천인을 저장한 후 질문 상세 화면으로 리다이렉트한다.

4) 질문 상세 화면에서 답변 추천 기능도 확인해 보자. 답변의 [추천] 버튼을 누르면 메시지 창이 등장하고, [확인] 버튼을 누르면 [추천] 버튼의 숫자가 변경된다. 답변추천