#14 : 검색
01. 검색 기능
- 질문의 제목, 내용, 작성자, 답변의 내용, 답변 작성자로 검색을 할 수 있게 해보자.
* 위의 기능을 수행하려면 아래와 같은 쿼리가 필요하다.
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 테이블을 기준으로 나머지 테이블을 아우터 조인하여 검색한다. (아우터가 아닌 이너 조인 시 합집합이 아닌 교집합이 되어 결과가 누락될 수 있으니 주의)
* 그리고 3개의 테이블을 모두 검색 시 중복된 결과가 나올 가능성을 배제하기 위해 distinct를 사용했다.
- Specification
* 위와 같이 여러 테이블에서 데이터를 검색해야 할 때 JPA에서 제공하는 Spesification 인터페이스를 사용하는 것이 편하다. 보다 정교한 쿼리의 작성을 도와주는 JPA의 도구이다.
* QuestionService.java에 seach 메서드 추가
import com.mysite.sbb.entity.Answer;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import org.springframework.data.jpa.domain.Specification;
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 + "%")); // 답변 작성자
}
};
}
* 주의 : import jakarta.persistence.criteria.*;의 형태로 한번에 임포트 시 빨간 줄이 뜨니 주의
* 검색어(kw)를 입력받아 쿼리의 조인문과 where문을 생성하여 반환하는 메서드
* q : Root, 기준을 의미하는 Question 엔티티의 객체(질문 제목, 내용을 검색하기 위해 필요)
* u1 : Question 엔티티와 SiteUser 엔티티를 아우터 조인(JoinType.LEFT)하여 만든 SiteUser 엔티티의 객체, Question 엔티티와 SiteUser 엔티티는 author 속성으로 연결되어 있어 q.join("author")와 같이 조인해야 한다.(작성자 검색 시 필요)
* a : Quesition 엔티티와 Answer 엔티티를 아우터 조인하여 만든 Answer 엔티티의 객체, Quesition 엔티티와 Answer 엔티티는 answerList 속성으로 연결되어 있기 때문에 q.join("answerList")와 같이 조인해야 한다.
* u2 : 위의 a객체와 SiteUser 객체를 다시 한번 아우터 조인한 SiteUser 엔티티의 객체(답변 작성자 검색 시 필요)
* 검색어가 포함되어 있는지를 like로 검색하기 위해 cb.like를 각각의 항목에 사용하고 cb.or로 감싸주었다.
- QuestionRepository.java 수정
Page<Question> findAll(Specification<Question> spec, Pageable pageable);
- QuestionService.java의 getList 수정
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);
}
* 검색어를 받아오는 매개변수를 추가하고 검색어 값으로 Specification 객체를 생성하여 findAll 메서드 호출 시 전달되도록 한다.
- QuesitonController.java 수정
@GetMapping("/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);
//1부터 시작하도록
int nowPage = paging.getPageable().getPageNumber()+1;
int startPage = Math.max(nowPage-4,1);
int endPage = Math.min(nowPage+5,paging.getTotalPages());
model.addAttribute("paging",paging);
model.addAttribute("nowPage",nowPage);
model.addAttribute("startPage",startPage);
model.addAttribute("endPage",endPage);
model.addAttribute("kw",kw);
return "question_list";
}
* @RequestParam으로 요청이 호출될 때 템플릿에서 kw 값을 받아온 후 모델 객체를 이용해 목록으로 넘겨준다. 기본값을 을 빈 문자열로 설정한다.
02. 검색 화면
- question_list.html에 검색창 추가
<!--검색창-->
<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}">
<button class="btn btn-outline-secondary" type="button" id="btn_search">찾기</button>
</div>
</div>
</div>
* 밑에 있던 질문 등록하기 버튼은 검색창의 좌측으로 이동
* 텍스트 창에 입력된 내용을 읽기 위해 텍스트창 id 속성에 search_kw 라는 값을 추가
* 검색폼 추가 (page와 kw를 동시에 GET 요청)
<!--paging end-->
<!--검색폼-->
<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>
* 이전에 요청했던 값을 기억하고 있도록 value 속성 설정
* POST요청으로 처리할 경우, 뒤로가기를 했을 때 중복 요청을 방지하기 위한 오류가 발생되므로 권장하지 않는다.
- 페이징
* question_list.html 수정
기존의 페이징 처리 부분도 ?page=1 처럼 직접 URL을 링크하는 방식에서 값을 읽어 폼에 설정할 수 있도록 변경한다.
(검색어가 있을 경우 함께 전송해야 하기 때문)
<!--paging start-->
<!--페이지가 비어있지 않으면 조건문 수행-->
<div th:if="${!paging.isEmpty}">
<ul class="pagination justify-content-center">
<!--이전 페이지가 없으면 class 에 disable 추가-->
<li class="page-item" th:classappend="${paging.number == startPage-1} ? 'disable'">
<!--이전을 누르면 한 페이지 전으로 이동-->
<a class="page-link"
href="javascript:void(0)" th:data-page="${paging.number-1}">
<span>이전</span>
</a>
</li>
<!--page 변수에 0부터 전체 페이지-1 만큼 1씩 증가해서 저장-->
<!--page 변수가 현재 페이지 번호와 같으면 active 클래스 추가-->
<li th:each="page : ${#numbers.sequence(startPage, endPage)}"
th:if="${page >= paging.number-5 and page <= paging.number+5}"
th:classappend="${page == nowPage} ? 'active'"
class="page-item">
<!--현재 페이지 값으로 링크-->
<a th:text="${page}" class="page-link" href="javascript:void(0)" th:data-page="${page-1}"></a>
</li>
<!--다음 페이지가 없으면 class 에 disable 추가-->
<li class="page-item" th:classappend="${paging.number == endPage} ? 'disable'">
<!--다음을 누르면 한 페이지 앞으로 이동-->
<a class="page-link" href="javascript:void(0)" th:data-page="${paging.number+1}">
<span>다음</span>
</a>
</li>
</ul>
</div>
<!--paging end-->
- 검색 스크립트
<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 = 1; // 검색버튼을 클릭할 경우 0페이지부터 조회한다.
document.getElementById('searchForm').submit();
});
</script>
* page-link라는 클래스를 가지고 있는 링크를 클릭하면 해당 링크의 data-page 속성값을 읽는다.
->해당 값을 searchform의 page 필드에 설정하여 searchform을 요청한다.
-> 검색 버튼을 클릭하면 검색어 텍스트창에 입력된 값을 searchform의 kw 필드에 설정하여 searchform을 요청한다.
-> 이후 검색버튼을 클릭 시엔 새로운 검색에 해당, page를 항상 0으로 설정하여 요청한다.(첫페이지부터, 제 경우엔 1이 첫페이지이므로 1로 설정)
03. @Query
쿼리에 익숙하다면 직접 쿼리를 작성할 수 있게 설정할 수 있다.
- QuestionRepository.java에 메서드 추가
@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를 작성할 때는 반드시 테이블 기준이 아닌 엔티티 기준으로 작성해야 한다.(테이블명 대신 엔티티명 사용, 컬럼명 대신 엔티티의 속성명 사용)
* 파라미터로 전달할 kw 문자열은 메서드의 매개변수에 @Param 어노테이션을 이용하여 전달한다. 해당 변수는 @Query 안에서 :kw로 참조된다.
- QuestionService.java getList 메서드 수정
return this.questionRepository.findAllByKeyword(kw, pageable);
Specification을 사용할 때와 동일하게 동작한다.
'T-I-L > [책] 요약&정리' 카테고리의 다른 글
| [점프 투 스프링부트] 3장 SBB 서비스 개발(서버 스크립트) - 2023. 09. 14. (0) | 2023.09.14 |
|---|---|
| [점프 투 스프링부트] 3장 SBB 서비스 개발(추가기능, 서버, AWS 라이트세일, 서버 접속 설정, 서버 접속 프로그램, SBB 오픈) - 2023. 09. 13. (0) | 2023.09.13 |
| [점프 투 스프링부트] 3장 SBB 서비스 개발(마크다운) - 2023. 09. 11. (0) | 2023.09.11 |
| [점프 투 스프링부트] 3장 SBB 서비스 개발(추천, 앵커) - 2023. 09. 07. (0) | 2023.09.07 |
| [점프 투 스프링부트] 3장 SBB 서비스 개발(수정과 삭제) - 2023. 09. 05. ~ 2023. 09. 06. (0) | 2023.09.05 |
