Follow Work/SpringbootBoard

[StringBoot] 게시판 검색 (27)

ReCode.B 2022. 8. 18. 16:08
728x90

참고 링크 : https://wikidocs.net/book/7601

오라클DB와 intelliJ로 작업하였습니다


검색 기능

SBB는 질문과 답변에 대한 데이터가 계속 쌓여가는 게시판이므로 검색기능은 필수라고 할 수 있다.

검색의 대상은 질문의 제목, 질문의 내용, 질문 작성자, 답변의 내용, 답변 작성자 정도로 하면 적당할 것이다.

즉, "스프링"이라고 검색을 하면 "스프링" 이라는 문자열이 제목, 내용, 질문 작성자, 답변, 답변 작성자에 존재하는지

찾아보고 그 결과를 화면에 보여주어야 한다.

이런 조건으로 검색하려면 다음과 같은 SQL 쿼리가 실행되어야 한다.

SELECT
    DISTINCT Q.ID,
    Q.AUTHOR_ID,
    Q.CONTENT,
    Q.CREATE_DATE,
    Q.MODIFY_DATE,
    Q.SUBJECT 
FROM QUESTION Q 
LEFT OUTER JOIN SITE_USER U1 ON Q.AUTHOR_ID=U1.ID 
LEFT OUTER JOIN ANSWER A ON Q.ID=A.QUESTION_ID 
LEFT OUTER JOIN SITE_USER U2 ON A.AUTHOR_ID=U2.ID 
WHERE
    Q.SUBJECT LIKE '%스프링%' 
    OR Q.CONTENT LIKE '%스프링%' 
    OR U1.USERNAME LIKE '%스프링%' 
    OR A.CONTENT LIKE '%스프링%' 
    OR U2.USERNAME LIKE '%스프링%'

쿼리에 익숙하지 않다면 위 쿼리를 이해하기 힘들수도 있다. 잠시 위의 쿼리에 대해서 알아보자.

위 쿼리는 "스프링" 이라는 문자열이 포함된 데이터를 question, answer, site_user 테이블을 대상으로 검색하는 쿼리이다. 그리고 question 테이블을 기준으로 answer, site_user 테이블을 아우터 조인하여 "스프링" 이라는 문자열을 검색한다.

아우터(outer) 조인 대신 이너(inner) 조인을 사용하면 합집합이 아닌 교집합으로 검색되어 결과가 누락될 수 있어 주의해야 한다. 그리고 총 3개의 테이블을 대상으로 아우터 조인하여 검색하면 중복된 결과가 나올수 있기 때문에 select 문에 distinct를 주어 중복을 제거했다.

JPA를 사용하면 위의 쿼리를 자바코드로 만들수 있다. 다음을 따라해 보자.

Specification

import org.springframework.data.jpa.domain.Specification;

위의 쿼리에서 본 것과 같이 여러 테이블에서 데이터를 검색해야 할 경우에는

JPA가 제공하는 Specification 인터페이스를 사용하는 것이 편리하다.

Specification은 보다 정교한 쿼리의 작성을 도와주는 JPA의 도구이다. 보다 자세한 내용은 다음의 문서를 참고해 보자.

 

다음과 같이 QuestionService에 search 메서드를 추가해 보자.

(... 생략 ...)
import com.mysite.sbb.answer.Answer;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Join;
import javax.persistence.criteria.JoinType;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import org.springframework.data.jpa.domain.Specification;
(... 생략 ...)
public class QuestionService {

    private final QuestionRepository questionRepository;

    private Specification<Question> search(String kw) {
        return new Specification<>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Predicate toPredicate(Root<Question> q, CriteriaQuery<?> query, CriteriaBuilder cb) {
                query.distinct(true);  // 중복을 제거 
                Join<Question, SiteUser> u1 = q.join("author", JoinType.LEFT);
                Join<Question, Answer> a = q.join("answerList", JoinType.LEFT);
                Join<Answer, SiteUser> u2 = a.join("author", JoinType.LEFT);
                return cb.or(cb.like(q.get("subject"), "%" + kw + "%"), // 제목 
                        cb.like(q.get("content"), "%" + kw + "%"),      // 내용 
                        cb.like(u1.get("username"), "%" + kw + "%"),    // 질문 작성자 
                        cb.like(a.get("content"), "%" + kw + "%"),      // 답변 내용 
                        cb.like(u2.get("username"), "%" + kw + "%"));   // 답변 작성자 
            }
        };
    }

    (... 생략 ...)
}

 

추가한 search 메서드는 검색어(kw)를 입력받아 쿼리의 조인문과 where문을 생성하여 리턴하는 메서드이다. 코드를 자세히 보면 위에서 알아본 쿼리를 자바 코드로 그대로 재현한 것임을 알수 있다.

위 코드에서 사용한 변수들에 대해서 자세히 살펴보자.

  •  q  - Root, 즉 기준을 의미하는 Question 엔티티의 객체 (질문 제목과 내용을 검색하기 위해 필요)
  •  u1  - Question 엔티티와 SiteUser 엔티티를 아우터 조인(JoinType.LEFT)하여 만든 SiteUser 엔티티의 객체. Question 엔티티와 SiteUser 엔티티는 author 속성으로 연결되어 있기 때문에 q.join("author")와 같이 조인해야 한다. (질문 작성자를 검색하기 위해 필요)
  •  a  - Question 엔티티와 Answer 엔티티를 아우터 조인하여 만든 Answer 엔티티의 객체. Question 엔티티와 Answer 엔티티는 answerList 속성으로 연결되어 있기 때문에 q.join("answerList")와 같이 조인해야 한다. (답변 내용을 검색하기 위해 필요)
  •  u2  - 바로 위에서 작성한 a 객체와 다시 한번 SiteUser 엔티티와 아우터 조인하여 만든 SiteUser 엔티티의 객체 (답변 작성자를 검색하기 위해서 필요)

그리고 검색어(kw)가 포함되어 있는지를 like로 검색하기 위해 제목, 내용, 질문 작성자, 답변 내용, 답변 작성자 각각에 cb.like를 사용하고 최종적으로 cb.or로 OR 검색되게 하였다. 위에서 예시로 든 쿼리와 비교해 보면 코드가 어떻게 구성되었는지 쉽게 이해될 것이다.

 

 

QuestionRepository

그리고 위에서 작성한 Specification을 사용하기 위해서 QuestionRepository를 다음과 같이 수정하자.

package com.gosari.repick_project.question;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification; //추가
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface QuestionRepository extends JpaRepository<Question, Integer> {

    Question findBySubject(String subject);
    Question findBySubjectAndContent(String subject, String content);
    List<Question> findBySubjectLike(String subject);
    Page<Question> findAll(Pageable pageable);
    Page<Question> findAll(Specification<Question> spec, Pageable pageable); //추가
}

Specification과 Pageable 객체를 입력으로 Question 엔티티를 조회하는 findAll 메서드를 선언했다.

 

QuestionService

그리고 QuestionService의 getList 메서드를 다음과 같이 수정하자.

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

    (... 생략 ...)

    public Page<Question> getList(int page, String kw) { //kw추가
        List<Sort.Order> sorts = new ArrayList<>();
        sorts.add(Sort.Order.desc("createDate"));
        Pageable pageable = PageRequest.of(page, 10, Sort.by(sorts));
        Specification<Question> spec = search(kw); //추가
        return this.questionRepository.findAll(spec, pageable); //spec추가
    }

    (... 생략 ...)
}

검색어를 의미하는 매개변수 kw를 getList에 추가하고

kw 값으로 Specification 객체를 생성하여 findAll 메서드 호출시 전달하였다.

 

여기까지의 QuestionService 코드↓

package com.gosari.repick_project.question;
import com.gosari.repick_project.answer.Answer;
import com.gosari.repick_project.user.SiteUser;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Join;
import javax.persistence.criteria.JoinType;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;

import com.gosari.repick_project.exception.DataNotFoundException;

import javax.persistence.criteria.*;

@RequiredArgsConstructor //QuestionRepository 생성자 생성해줌
@Service
public class QuestionService {
    private final QuestionRepository questionRepository;

    public List<Question> getList(){
        return this.questionRepository.findAll();
    }

    /*Question 데이터 조사*/
    public Question getQuestion(Integer id){
        Optional<Question> question = this.questionRepository.findById(id);
        if(question.isPresent()){
            return question.get();
        }else{
            throw new DataNotFoundException("question not found");
        }
    }

    /*페이징*/
    public Page<Question> getList(int page, String kw){
        List<Sort.Order> sorts = new ArrayList<>();
        sorts.add(Sort.Order.desc("createDate"));
        Pageable pageable = PageRequest.of(page, 10, Sort.by(sorts));
        Specification<Question> spec = search(kw);
        return this.questionRepository.findAll(spec, pageable);
    }

    /*질문데이터를 저장하는 create메서드*/
    public void create(String subject, String content, SiteUser user){
        Question q = new Question();
        q.setSubject(subject);
        q.setContent(content);
        q.setCreateDate(new Date());
        q.setAuthor(user);
        this.questionRepository.save(q);
    }

    /*질문데이터 수정*/
    public void modify(Question question, String subject, String content) {
        question.setSubject(subject);
        question.setContent(content);
        question.setModifyDate(new Date());

        this.questionRepository.save(question);
    }

    /*질문데이터 삭제*/
    public void delete(Question question){
        this.questionRepository.delete(question);
    }

    /*추천인 저장*/
    public void vote(Question question, SiteUser siteUser) {
        question.getVoter().add(siteUser);
        this.questionRepository.save(question);
    }

    /*검색 메서드*/
    private Specification<Question> search(String kw) {
        return new Specification<>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Predicate toPredicate(Root<Question> q, CriteriaQuery<?> query, CriteriaBuilder cb) {
                query.distinct(true);  // 중복을 제거
                Join<Question, SiteUser> u1 = q.join("author", JoinType.LEFT);
                Join<Question, Answer> a = q.join("answerList", JoinType.LEFT);
                Join<Answer, SiteUser> u2 = a.join("author", JoinType.LEFT);
                return cb.or(cb.like(q.get("subject"), "%" + kw + "%"), // 제목
                        cb.like(q.get("content"), "%" + kw + "%"),      // 내용
                        cb.like(u1.get("username"), "%" + kw + "%"),    // 질문 작성자
                        cb.like(a.get("content"), "%" + kw + "%"),      // 답변 내용
                        cb.like(u2.get("username"), "%" + kw + "%"));   // 답변 작성자
            }
        };
    }

}

 

QuestionController

그리고 QuestionService의 getList 메서드의 입력항목이 변경되었으므로 QuestionController도 다음과 같이 수정해야 한다.

package com.gosari.repick_project.question;

import com.gosari.repick_project.answer.AnswerForm;
import com.gosari.repick_project.user.SiteUser;
import com.gosari.repick_project.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;


import javax.validation.Valid;
import java.security.Principal;
import java.util.List;


@RequestMapping("/question") //URL프리픽스(prefix)
@RequiredArgsConstructor
@Controller
public class QuestionController {

    private final QuestionService questionService;
    private final UserService userService;

    /*질문리스트*/
    @RequestMapping("/list")
    public String list(Model model,
                       @RequestParam(value="page", defaultValue="0") int page,
                       @RequestParam(value="kw",defaultValue = "")String kw){
        Page<Question> paging = this.questionService.getList(page, kw);
        model.addAttribute("paging", paging); /*페이징*/
        model.addAttribute("kw", kw); /*kw:검색어*/
        return "question_list";
    }

    /*질문상세*/
    @RequestMapping(value = "/detail/{id}")
    public String detail(Model model, @PathVariable("id") Integer id,
                         AnswerForm answerForm) { /*AnswerForm추가*/
        Question question = this.questionService.getQuestion(id);
        model.addAttribute("question", question);
        return "question_detail";
    }

    /*질문등록하기*/
    @PreAuthorize("isAuthenticated()") //추가
    @GetMapping("/create")
    public String questionCreate(QuestionForm questionForm) {
        return "question_form";
    }

    /*질문등록하기*/
    @PreAuthorize("isAuthenticated()") //추가
    @PostMapping("/create")
    public String questionCreate(@Valid QuestionForm questionForm,
                                 BindingResult bindingResult,
                                 Principal principal) {

        if (bindingResult.hasErrors()) {
            return "question_form";
        }

        SiteUser siteUser = this.userService.getUser(principal.getName());

        this.questionService.create(questionForm.getSubject(), questionForm.getContent(), siteUser);
        return "redirect:/question/list";
    }

    /*질문 수정하기 GET*/
    @PreAuthorize("isAuthenticated()")
    @GetMapping("/modify/{id}")
    public String questionModify(QuestionForm questionForm,
                                 @PathVariable("id") Integer id, Principal principal) {

        Question question = this.questionService.getQuestion(id);

        /*로그인한 사용자와 질문의 작성자가 동일하지않은 경우*/
        if(!question.getAuthor().getUsername().equals(principal.getName())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
        }
        /*수정할 질문의 제목과 내용을 보여줌*/
        questionForm.setSubject(question.getSubject());
        questionForm.setContent(question.getContent());
        return "question_form";
    }

    /*질문 수정하기 POST*/
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/modify/{id}")
    public String questionModify(@Valid QuestionForm questionForm, BindingResult bindingResult,
                                 Principal principal, @PathVariable("id") Integer id) {
        if (bindingResult.hasErrors()) {
            return "question_form";
        }
        Question question = this.questionService.getQuestion(id);
        if (!question.getAuthor().getUsername().equals(principal.getName())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
        }
        this.questionService.modify(question, questionForm.getSubject(), questionForm.getContent());
        return String.format("redirect:/question/detail/%s", id);
    }

    /*질문 삭제하기*/
    @PreAuthorize("isAuthenticated()")
    @GetMapping("/delete/{id}")
    public String questionModify(Principal principal,
                                 @PathVariable("id") Integer id) {

        Question question = this.questionService.getQuestion(id);

        /*로그인한 사용자와 질문의 작성자가 동일하지않은 경우*/
        if(!question.getAuthor().getUsername().equals(principal.getName())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제권한이 없습니다.");
        }

        this.questionService.delete(question);
        return "redirect:/";
    }
    /*질문추천*/
    @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);
    }
}

검색어에 해당하는 kw 파라미터를 추가했고 디폴트값으로 빈 문자열을 설정했다.

그리고 화면에서 입력한 검색어를 화면에 유지하기 위해 model.addAttribute("kw", kw)로 kw 값을 저장했다.

이제 화면에서 kw 값이 파라미터로 들어오면 해당 값으로 질문 목록이 검색되어 조회될 것이다.


검색 화면

이제 화면에 검색기능을 추가해 보자.

검색 창

검색어를 입력할 수 있는 텍스트창을 다음과 같이 질문 목록 템플릿에 추가하자.

    <div class="row my-3">
        <div class="col-6">
            <a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
        </div>
        <div class="col-6">
            <div class="input-group">
                <input type="text" id="search_kw" class="form-control" th:value="${kw}">
                <div class="input-group-append">
                    <button class="btn btn-outline-secondary" type="button" id="btn_search">찾기</button>
                </div>
            </div>
        </div>
    </div>

<table> 태그 상단 우측에 검색어를 입력할 수 있는 텍스트창을 생성하였다. 맨 밑에 있던 "질문 등록하기" 버튼은 검색 창의 좌측으로 이동했다. 그리고 자바 스크립트에서 이 텍스트창에 입력된 값을 읽기 위해 다음처럼 텍스트창 id 속성에 "search_kw"라는 값을 추가한 점에 주목하자.

<input type="text" id="search_kw" class="form-control" th:value="${kw}">

 

검색 폼

그리고 page와 kw를 동시에 GET으로 요청할 수 있는 searchForm을 다음과 같이 추가하자.

(... 생략 ...)
    <!-- 페이징처리 끝 -->
    <form th:action="@{/question/list}" method="get" id="searchForm">
        <input type="hidden" id="kw" name="kw" th:value="${kw}">
        <input type="hidden" id="page" name="page" th:value="${paging.number}">
    </form>
</div>
</html>

GET 방식으로 요청해야 하므로 method 속성에 "get"을 설정했다.

kw와 page는 이전에 요청했던 값을 기억하고 있어야 하므로 value에 값을 유지할수 있도록 했다.

이전에 요청했던 kw와 page의 값은 컨트롤러로부터 다시 전달 받는다.

그리고 action 속성은 "폼이 전송되는 URL"이므로 질문 목록 URL인 /question/list를 지정했다.

GET 방식을 사용하는 이유
page, kw를 GET이 아닌 POST 방식으로 전달하는 방법은 추천하고 싶지 않다.
만약 GET이 아닌 POST 방식으로 검색과 페이징을 처리한다면 웹 브라우저에서 "새로고침" 또는 "뒤로가기"를 했을 때 "만료된 페이지입니다."라는 오류를 종종 만나게 될 것이다. 왜냐하면 POST 방식은 동일한 POST 요청이 발생할 경우 중복 요청을 방지하기 위해 "만료된 페이지입니다." 라는 오류를 발생시키기 때문이다. 2페이지에서 3페이지로 갔다가 뒤로가기를 했을 때 2페이지로 가는것이 아니라 오류가 발생한다면 엉망이 될 것이다.
이러한 이유로 여러 파라미터를 조합하여 게시물 목록을 조회할 때는 GET 방식을 사용하는 것이 좋다.

 

페이징

그리고 기존 페이징을 처리하는 부분도 ?page=1 처럼 직접 URL을 링크하는 방식에서 값을 읽어 폼에 설정할 수 있도록 다음처럼 변경해야 한다. 왜냐하면 검색어가 있을 경우 검색어와 페이지 번호를 함께 전송해야 하기 때문이다.

(... 생략 ...)
<!-- 페이징처리 시작 -->
<div th:if="${!paging.isEmpty()}">
  <ul class="pagination justify-content-center">
    <li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
      <a class="page-link" href="javascript:void(0)" th:data-page="${paging.number-1}">
        <span>이전</span>
      </a>
    </li>
    <li th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}"
      th:if="${page >= paging.number-5 and page <= paging.number+5}"
      th:classappend="${page == paging.number} ? 'active'" class="page-item">
      <a th:text="${page}" class="page-link" href="javascript:void(0)" th:data-page="${page}"></a>
    </li>
    <li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
      <a class="page-link" href="javascript:void(0)" th:data-page="${paging.number+1}">
        <span>다음</span>
      </a>
    </li>
  </ul>
</div>
<!-- 페이징처리 끝 -->
(... 생략 ...)

모든 페이지 링크를 href 속성에 직접 입력하는 대신 data-page 속성으로 값을 읽을 수 있도록 변경했다.

즉, 다음과 같은 링크를

<a class="page-link" th:href="@{|?page=${paging.number-1}|}">
  <span>이전</span>
</a>

다음처럼 수정했다.

<a class="page-link" href="javascript:void(0)" th:data-page="${paging.number-1}">
  <span>이전</span>
</a>

 

검색 스크립트

그리고 page, kw 파라미터를 동시에 요청할 수 있는 자바스크립트를 다음과 같이 추가하자.

<script layout:fragment="script" type='text/javascript'>
const page_elements = document.getElementsByClassName("page-link");
Array.from(page_elements).forEach(function(element) {
    element.addEventListener('click', function() {
        document.getElementById('page').value = this.dataset.page;
        document.getElementById('searchForm').submit();
    });
});
const btn_search = document.getElementById("btn_search");
btn_search.addEventListener('click', function() {
    document.getElementById('kw').value = document.getElementById('search_kw').value;
    document.getElementById('page').value = 0;  // 검색버튼을 클릭할 경우 0페이지부터 조회한다.
    document.getElementById('searchForm').submit();
});
</script>

위에 추가한 자바스크립트 코드를 자세히 살펴보자.

만약 다음과 같이 class 속성값으로 "page-link"라는 값을 가지고 있는 링크를 클릭하면

<a class="page-link" href="javascript:void(0)" th:data-page="${paging.number-1}">
  <span>이전</span>
</a>

이 링크의 data-page 속성값을 읽어 searchForm의 page 필드에 설정하여 searchForm을 요청하도록

다음과 같은 스크립트를 추가했다.

const page_elements = document.getElementsByClassName("page-link"); Array.from(page_elements).forEach(function(element) {
element.addEventListener('click', function() {
document.getElementById('page').value = this.dataset.page;
document.getElementById('searchForm').submit();
});
});

그리고 검색버튼을 클릭하면 검색어 텍스트창에 입력된 값을 searchForm의 kw 필드에 설정하여

searchForm을 요청하도록 다음과 같은 스크립트를 추가했다.

const btn_search = document.getElementById("btn_search");
btn_search.addEventListener('click', function()
{ document.getElementById('kw').value = document.getElementById('search_kw').value; document.getElementById('page').value = 0; // 검색버튼을 클릭할 경우 0페이지부터 조회한다. document.getElementById('searchForm').submit(); });

그리고 검색버튼을 클릭하는 경우는 새로운 검색에 해당되므로 page에 항상 0을 설정하여 첫 페이지로 요청하도록 했다.

 

 

여기까지의 question_list.html 코드↓

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
      layout:decorate="~{layout}">
<!--layout:decorate 속성 : 템플릿의 레이아웃(부모템플릿)으로 사용할 템플릿을 설정-->


<div layout:fragment="content" class="container my-3">

    <!--검색창start-->
    <div class="row my-3">
    <div class="col-6">
        <a th:href="@{/question/create}" class="btn btn-primary">질문등록하기</a>
    </div>
        <div class="col-6">
            <div class="input-group">
                <input type="text" id="search_kw" class="form-control" th:value="${kw}">
                <div class="input-group-append">
                    <button class="btn btn-outline-secondary" type="button" id="btn_search">찾기</button>
                </div>
            </div>
        </div>
    </div>
    <!--검색창end-->

    <table class="table">
        <thead class="table-dark">
        <tr class="text-center">
            <th>번호</th>
            <th style="width:50%">제목</th>
            <th>글쓴이</th>
            <th>작성일시</th>
        </tr>
        </thead>

        <tbody>
        <tr class="text-center" th:each="question, loop : ${paging}">
            <td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
            <!--<td th:text="${loop.count}"></td>테이블항목에 번호추가-->

        <td class="text-start">
            <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
            <!--해당질문에 달린답변개수 표시-->
            <span class="text-danger small ms-2"
            th:if="${#lists.size(question.answerList) > 0}"
            th:text="${#lists.size(question.answerList)}"></span>
        </td>

            <td>
            <span th:if="${question.author != null}" th:text="${question.author.username}"></span>
            </td>

            <!--날짜객체 날짜포맷에맞게 변환-->
            <td th:text="${#dates.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
        </tr>
        </tbody>
    </table>

    <!-- 페이징처리 시작 -->
    <div th:if="${!paging.isEmpty()}"><!--paging.isEmpty:페이지존재여부(게시물있으면 false,없으면 true)-->
        <ul class="pagination justify-content-center">
            <li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
            <!-- !paging.hasPrevious - disabled : 이전페이지가 없으면 비활성화-->

                <a class="page-link" href="javascript:void(0)" th:data-page="${paging.number-1}">
                <!--이전페이지 링크-->
                    <span>이전</span>
                </a>
            </li>

            <!--th:each :페이지 리스트 루프--> <!--#numbers.sequence(시작, 끝)-->
            <li th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}"

                th:if="${page >= paging.number-5 and page <= paging.number+5}"

            th:classappend="${page == paging.number} ? 'active'" class="page-item">
            <!--page == paging.number : 현재페이지와 같으면 active 적용-->

                <a th:text="${page}" class="page-link" href="javascript:void(0)" th:data-page="${page}"></a>
            </li>

            <li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
            <!-- !paging.hasNext - disabled : 다음페이지 없으면 비활성화-->

                <a class="page-link" href="javascript:void(0)" th:data-page="${paging.number+1}">
                <!--다음페이지 링크-->
                    <span>다음</span>
                </a>
            </li>
        </ul>
    </div>
    <!-- 페이징처리 끝 -->

    <!--검색 폼-->
    <form th:action="@{/question/list}" method="get" id="searchForm">
        <input type="hidden" id="kw" name="kw" th:value="${kw}">
        <input type="hidden" id="page" name="page" th:value="${paging.number}">
    </form>

</div>

<script layout:fragment="script" type='text/javascript'>
    const page_elements = document.getElementsByClassName("page-link");
    Array.from(page_elements).forEach(function(element) {
        element.addEventListener('click', function() {
            document.getElementById('page').value = this.dataset.page;
            document.getElementById('searchForm').submit();
        });
    });
    const btn_search = document.getElementById("btn_search");
    btn_search.addEventListener('click', function() {
        document.getElementById('kw').value = document.getElementById('search_kw').value;
        document.getElementById('page').value = 0;  // 검색버튼을 클릭할 경우 0페이지부터 조회한다.
        document.getElementById('searchForm').submit();
    });
</script>

</html>

 

검색 확인

이제 검색창에 "스프링"이라는 검색어로 조회하면 다음과 같이 해당 문장이 있는 게시물만 조회될 것이다.


@Query

쿼리에 익숙하다면 복잡한 쿼리는 자바코드로 생성하기 보다는 직접 쿼리를 작성하는게 훨씬 편하게 여겨질 것이다.

이번에는 Specification 대신 직접 쿼리를 작성하여 수행하는 방법에 대해서 알아보자.

 

QuestionRepository에 다음과 같은 메서드를 추가해 보자.

package com.gosari.repick_project.question;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface QuestionRepository extends JpaRepository<Question, Integer> {

    Question findBySubject(String subject);
    Question findBySubjectAndContent(String subject, String content);
    List<Question> findBySubjectLike(String subject);
    Page<Question> findAll(Pageable pageable);
    Page<Question> findAll(Specification<Question> spec, Pageable pageable);

    /*service에 작성했던 Specification 대신 직접 쿼리를 작성하여 수행*/
    @Query("select "
            + " distinct q "
            + " from Question q "
            + " left outer join SiteUser u1 on q.author=u1 "
            + " left outer join Answer a on a.question=q "
            + " left outer join SiteUser u2 on a.author=u2 "
            + " where "
            + " q.subject like %:kw% "
            + " or q.content like %:kw% "
            + " or u1.username like %:kw% "
            + " or a.content like %:kw% "
            + " or u2.username like %:kw% ")
    Page<Question> findAllByKeyword(@Param("kw") String kw, Pageable pageable);
}

@Query 애너테이션이 적용된 findAllByKeyword 메서드를 추가했다. 위에서 알아본 쿼리를 @Query에 구현한 것이다. 그리고 @Query를 작성할 때에는 반드시 테이블 기준이 아닌 엔티티 기준으로 작성해야 한다.

즉, site_user와 같은 테이블명 대신 SiteUser처럼 엔티티명을 사용해야 하고 조인문에서 보듯이 q.author_id=u1.id와 같은 컬럼명 대신 q.author=u1처럼 엔티티의 속성명을 사용해야 한다.

그리고 @Query에 파라미터로 전달할 kw 문자열은 메서드의 매개변수에 @Param("kw")처럼 @Param 애너테이션을 사용해야 한다. 검색어를 의미하는 kw 문자열은 @Query 안에서 :kw로 참조된다.

 

 

작성한 findAllByKeyword 메서드를 사용하기 위해 QuestionService를 다음과 같이 수정하자.

package com.gosari.repick_project.question;
import com.gosari.repick_project.answer.Answer;
import com.gosari.repick_project.user.SiteUser;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Join;
import javax.persistence.criteria.JoinType;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;

import com.gosari.repick_project.exception.DataNotFoundException;

import javax.persistence.criteria.*;


@RequiredArgsConstructor //QuestionRepository 생성자 생성해줌
@Service
public class QuestionService {
    private final QuestionRepository questionRepository;


    public List<Question> getList(){
        return this.questionRepository.findAll();
    }

    /*Question 데이터 조사*/
    public Question getQuestion(Integer id){
        Optional<Question> question = this.questionRepository.findById(id);
        if(question.isPresent()){
            return question.get();
        }else{
            throw new DataNotFoundException("question not found");
        }
    }

    /*페이징*/
    public Page<Question> getList(int page, String kw){  //검색어:kw
        List<Sort.Order> sorts = new ArrayList<>();
        sorts.add(Sort.Order.desc("createDate"));
        Pageable pageable = PageRequest.of(page, 10, Sort.by(sorts));

        return this.questionRepository.findAllByKeyword(kw,  pageable);
        /*질문레파지토리의 Query 애너테이션 - findAllByKeyword 매서드를 사용하기 위해 수정 */
    }

    /*질문데이터를 저장하는 create메서드*/
    public void create(String subject, String content, SiteUser user){
        Question q = new Question();
        q.setSubject(subject);
        q.setContent(content);
        q.setCreateDate(new Date());
        q.setAuthor(user);
        this.questionRepository.save(q);
    }

    /*질문데이터 수정*/
    public void modify(Question question, String subject, String content) {
        question.setSubject(subject);
        question.setContent(content);
        question.setModifyDate(new Date());

        this.questionRepository.save(question);
    }

    /*질문데이터 삭제*/
    public void delete(Question question){
        this.questionRepository.delete(question);
    }

    /*추천인 저장*/
    public void vote(Question question, SiteUser siteUser) {
        question.getVoter().add(siteUser);
        this.questionRepository.save(question);
    }

    /*검색 메서드*/
    private Specification<Question> search(String kw) {
        return new Specification<>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Predicate toPredicate(Root<Question> q, CriteriaQuery<?> query, CriteriaBuilder cb) {
                query.distinct(true);  // 중복을 제거
                Join<Question, SiteUser> u1 = q.join("author", JoinType.LEFT);
                Join<Question, Answer> a = q.join("answerList", JoinType.LEFT);
                Join<Answer, SiteUser> u2 = a.join("author", JoinType.LEFT);
                return cb.or(cb.like(q.get("subject"), "%" + kw + "%"), // 제목
                        cb.like(q.get("content"), "%" + kw + "%"),      // 내용
                        cb.like(u1.get("username"), "%" + kw + "%"),    // 질문 작성자
                        cb.like(a.get("content"), "%" + kw + "%"),      // 답변 내용
                        cb.like(u2.get("username"), "%" + kw + "%"));   // 답변 작성자
            }
        };
    }

}

Specification을 사용할때와 동일하게 동작할 것이다.

728x90