(17) 기능구현 - 추천

질문과 답변에 추천 기능을 구현하자


시작하기 전에

개요 : 질문과 답변을 할 수 있는 게시판 서비스를 스프링부트를 통해 만들어 본다.

학습사이트 : https://wikidocs.net/book/7601

예제 코드 : https://github.com/pahkey/sbb


엔티티 변경

질문과 답변 엔티티에 추천한 사람에 대한 정보(SiteUser 객체)가 있어야 한다.


질문 엔티티 변경

Question 엔티티에 추천인(voter) 속성을 추가해 보자.

한 사람이 여러개의 게시물을 추천할 수 있고, 한 게시물은 여러 명에게 추천을 받을 수 있으므로

voter속성은 ManyToMany


  • Question.java 수정
// 경로 : sbb/src/main/java/com/mysite/sbb/question/Question.java

package com.mysite.sbb.question;

(... 생략 ...)
import java.util.Set;
import javax.persistence.ManyToMany;
(... 생략 ...)

@Getter
@Setter
@Entity
public class Question {
	
	(... 생략 ...)
	
	// 추천인
    @ManyToMany
    Set<SiteUser> voter;
}

한 사람이 한개의 게시물에 여러 개의 추천을 주면 안되기 때문에 Set 자료형 사용.

Set 자료형은 중복을 허용하지 않음.


답변 엔티티 변경

Answer 엔티티에도 추천인(voter) 속성을 추가.


  • Answer.java 수정
// 경로 : sbb/src/main/java/com/mysite/sbb/answer/Answer.java

package com.mysite.sbb.answer;

(... 생략 ...)
import java.util.Set;
import javax.persistence.ManyToMany;
(... 생략 ...)

@Getter
@Setter
@Entity
public class Answer {
	
	(... 생략 ...)
	
	// 추천인
    @ManyToMany
    Set<SiteUser> voter;
}


엔티티 변경 확인

1

새로운 테이블 ANSWER_VOTER와 QUESTION_VOTER가 생성된다.

근데 왜 테이블이 만들어질까?

@ManyToMany 관계로 속성을 생성하면 새로운 테이블을 생성하여 데이터를 관리한다.

테이블에는 서로 연관된 엔티티의 고유번호(id) 2개가 프라이머리 키로 되어 있기 때문에 다대다(N:N) 관계가 성립하는 구조이다.



질문 추천

Question 엔티티에 추천인 속성을 추가 했으니 이제 질문 추천 기능을 만들어 보자.


질문 추천 버튼

질문 상세 화면에 추천버튼을 만들어주자.


  • question_detail.html 수정
<!-- 경로 : sbb/src/main/resources/templates/question_detail.html -->

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">

    (... 생략 ...)
            
            <!-- 추천, 수정, 삭제 버튼 -->
            <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>
    
    <!-- 답변을 확인할 수 있는 영역 -->

  (... 생략 ...)

</html>

수정 버튼 왼쪽에 추천 버튼을 추가하고, 추천 수도 표시되게 하였다.

추천 확인 알람창을 띄워주기 위해 자바스크립트 링크를 추가해주었다. 바로 구현해보자.


  • 버튼 확인

2


추천 확인

추천 버튼을 눌렀을 때 확인 알람창을 띄워보자


  • question_detail.html 수정
<!-- 경로 : sbb/src/main/resources/templates/question_detail.html -->

<html layout:decorate="~{layout}">

(... 생략 ...)

<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>

삭제 확인 알람 밑에 추가해주었다.

이제 버튼 class가 recommend이면 추천 확인 알람이 뜨고, 확인을 선택하면 data-uri속성에 정의한 URL이 호출될 것이다.


  • 추천 알람 확인

3


추천인 저장

추천인을 저장하여 추천버튼을 카운트시켜보자


  • QuestionService.java 수정
// 경로 : sbb/src/main/java/com/mysite/sbb/question/QuestionService.java

(... 생략 ...)

@RequiredArgsConstructor
@Service
public class QuestionService {

    (... 생략 ...)
    
    // 질문 추천 메서드
    public void vote(Question question, SiteUser siteUser) {
        question.getVoter().add(siteUser);
        this.questionRepository.save(question);
    }
    
}

질문 엔티티에 사용자를 추천인으로 저장하는 vote 메서드 추가.


추천 URL 처리

  • QuestionController.java 수정
// 경로 : sbb/src/main/java/com/mysite/sbb/question/QuestionController.java
package com.mysite.sbb.question;

(... 생략 ...)

@RequiredArgsConstructor
@Controller
public class QuestionController {
	
	  (... 생략 ...)
    
    // 추천 URL 매핑 (GET)
    @PreAuthorize("isAuthenticated()") // 로그인한 사람만 추천 가능
    @GetMapping("/question/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);
    }
    
}

추천알림 후에 호출된 URL을 처리하기위한 questionVote메서드 추가.

QuestionService의 vote 메서드를 호출하여 추천인을 저장.


  • 추천 기능 확인

4



코드 수정

이 부분은 학습 사이트와 다른 부분이다.

테스트를 하다보니, 맘에 안드는 점이 있었다.

추천을 이미 했어도 알림 메시지가 뜨고, 또 이미 준 추천을 취소하지도 못한다.

따라서 추천을 다시 누르면 취소가 되고, 알림 메시지를 제거하려고 한다.


알림 메시지 취소

  • question_detail.html 수정
<!-- 경로 : sbb/src/main/resources/templates/question_detail.html -->

<html layout:decorate="~{layout}">

       (... 생략 ...)
     
            <!-- 질문 - 추천, 수정, 삭제 버튼 -->
            <div class="my-3">
            
	            <!-- 추천 -->
	            <a th:href="@{|/question/vote/${question.id}|}" class="btn btn-sm btn-outline-secondary">
	                추천
	                <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>
    
  (... 생략 ...)

<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;
        };
    });
});

</script>
</html>

추천의 href속성을 지우고 th:href 속성에 기존 URL을 매핑시켜주었다.

추천확인 자바스크립트 코드도 지워주었다.


  • QuestionService.java 수정
// 경로 : sbb/src/main/java/com/mysite/sbb/question/QuestionService.java

package com.mysite.sbb.question;

(... 생략 ...)

@RequiredArgsConstructor
@Service
public class QuestionService {

    (... 생략 ...)
    
    // 질문 추천 취소 메서드
    public void votedel(Question question, SiteUser siteUser) {
    	question.getVoter().remove(siteUser);
    	this.questionRepository.save(question);
    }
    
}

질문 추천을 취소하는 votedel 메서드를 추가.


  • QuestionController.java 수정
// 경로 : sbb/src/main/java/com/mysite/sbb/question/QuestionController.java
package com.mysite.sbb.question;

(... 생략 ...)

@RequiredArgsConstructor
@Controller
public class QuestionController {
	
	(... 생략 ...)
    
    // 추천 URL 매핑 (GET)
    @PreAuthorize("isAuthenticated()") // 로그인한 사람만 추천 가능
    @GetMapping("/question/vote/{id}")
    public String questionVote(Principal principal, @PathVariable("id") Integer id) {
        Question question = this.questionService.getQuestion(id);
        SiteUser siteUser = this.userService.getUser(principal.getName());
        
        // 추천 중복검사
        if (question.getVoter().contains(siteUser) == true) {
        	this.questionService.votedel(question, siteUser);
        }
        else {
        	this.questionService.vote(question, siteUser);
        }
        return String.format("redirect:/question/detail/%s", id);
    }
    
}

Set 자료구조의 값을 검색하는 contains를 사용하여 추천 여부를 판단한다.



답변 추천

질문 추천 기능과 동일!


답변 추천 버튼

  • question_detail.html 수정
<!-- 경로 : sbb/src/main/resources/templates/question_detail.html -->

<html layout:decorate="~{layout}">

    (... 생략 ...)
            
    <!-- 답변 반복 시작 -->
    <div th:each="answer : ${question.answerList}">
    	<div class="card my-3">
        	<div class="card-body">
            	<div class="card-text"
             	th:utext="${@commonUtil.markdown(answer.content)}"></div>
            	<div class="d-flex justify-content-end">
            
            		<!-- 수정 일시 표시 -->
	            	<div th:if="${answer.modifyDate != null}"
	             		 class="badge bg-light text-dark p-2 text-start mx-3">
	                <div class="mb-2">수정됨</div>
	                <div th:text="${#temporals.format(answer.modifyDate, 'yyyy-MM-dd HH:mm')}"></div>
	           		</div>

					<!-- 글쓴이, 작성시간 표시 -->
                	<div class="badge bg-light text-dark p-2 text-start">	
	                	<div class="mb-2">
	                    	<span th:if="${answer.author != null}"
	                    	 th:text="${answer.author.username}"></span>
	                	</div>
	                    <div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
	                </div>
	            </div>
            
	        <!-- 답변 추천, 수정, 삭제 버튼 -->
	        <div class="my-3">
	        
	            <!-- 추천 -->
	            <a th:href="@{|/answer/vote/${answer.id}|}" class="btn btn-sm btn-outline-secondary">
	                추천
	                <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>
    </div>
    <!-- 답변 반복 끝  -->
    
  (... 생략 ...)

</html>

답변 추천 버튼도 마찬가지로, 알림을 지우고, 추천취소 기능도 추가하였다.


추천인 저장


  • AnswerService.java 수정
// 경로 : sbb/src/main/java/com/mysite/sbb/answer/AnswerService.java
package com.mysite.sbb.answer;

(... 생략 ...)

@RequiredArgsConstructor
@Service
public class AnswerService {

    (... 생략 ...)
    
    // 답변 추천 메서드
    public void vote(Answer answer, SiteUser siteUser) {
    	answer.getVoter().add(siteUser);
        this.answerRepository.save(answer);
    }
    
    // 답변 추천 취소 메서드
    public void votedel(Answer answer, SiteUser siteUser) {
    	answer.getVoter().remove(siteUser);
    	this.answerRepository.save(answer);
    }
}

Answer엔티티에 사용자를 저장 / 삭제하는 메서드를 추가.


추천 URL 처리


  • AnswerController.java 수정
// 경로 : sbb/src/main/java/com/mysite/sbb/answer/AnswerController.java
package com.mysite.sbb.answer;

(... 생략 ...)

@RequestMapping("/answer") // URL 프리픽스
@RequiredArgsConstructor
@Controller
public class AnswerController {

    (... 생략 ...)
    
    // 추천 URL 매핑 (GET)
    @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());
        
        if(answer.getVoter().contains(siteUser) == true) {
        	this.answerService.votedel(answer, siteUser);
        }
        else {
        	this.answerService.vote(answer, siteUser);
        }
        return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
    }

}

question과 마찬가지로 똑같은 로직을 주어 처리했다


SBB테스트

5

다른 아이디를 만들어 추천을 하고 취소를 해보자!






Leave a comment