검색기능을 구현하는 방법 두가지를 살펴보려고 한다.
1. JPA의 Specification 인터페이스 사용하기
2. @Query 사용하기
검색 기능 구현하기
보통 검색 대상으로는 질문 제목, 질문 내용, 질문 작성자, 답변 내용, 답변 작성자 정도가 있다.
만약 이러한 조건으로 검색하려면 아래와 같은 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 3개의 테이블을 대상으로 '스프링' 문자열이 포함된 데이터를 검색한다.
검색된 데이터는 중복되지 않아야 하므로 distinct를 사용하여 제거했다.
이 쿼리를 이용하여 JPA를 사용해 자바 코드로 만들 것이다.
JPA의 Specification 인터페이스 사용하기
Specification 인터페이스는 DB검색을 더 유연하게 하고, 복잡한 검색을 처리할 수 있다.
먼저 서비스에 search메서드를 추가하자.
/question/QuestionService.java
private Specification<Question> search(String keyword){
return new Specification<Question>() {
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"), "%"+keyword+"%"), //제목
cb.like(q.get("content"), "%"+keyword+"%"), //내용
cb.like(u1.get("username"), "%"+keyword+"%"), //질문 작성자
cb.like(a.get("content"), "%"+keyword+"%"), //답변 내용
cb.like(u2.get("username"), "%"+keyword+"%")); //답변 작성자
}
};
}
search 메서드는 검색어인 keyword를 받아 쿼리 조인문과 where절을 Specification 객체로 생성하여 리턴하는 메서드이다.
아래는 사용된 변수 설명이다.
- q: Root자료형으로 기준이 되는 Question 엔티티의 객체를 말한다.
- u1: Question 엔티티와 SiteUser 엔티티를 left outer join하여 만든 SiteUser 엔티티의 객체이다.
- a: Question 엔티티와 Answer 앤티티를 left outer join하여 만든 Answer 엔티티의 객체이다.
- u2: 앞에 작성한 답변 엔티티인 a와 다시 SiteUser 엔티티와 left outer join하여 만든 SiteUser엔티티이다.
그리고 검색어가 포함되어 있는지 like로 확인하기 위해 각각 cb.like를 사용하고 이들을 최종적으로 cb.or로 OR검색을 한다.
질문 리포지터리 수정하기
/question/QuestionRepository.java
...
Page<Question> findAll(Specification<Question> spec, Pageable pageable);
...
위처럼 추가한 findAll 메서드는 Specification과 Pageable 객체를 사용하여 Question엔티티를 조회한 결과를 페이징하여 반환해준다.
질문 서비스 수정하기
검색어를 포함한 질문 목록을 반환하기 위해 getList 메서드를 수정하자.
/question/QuestionService.java
...
public Page<Question> getList(int page, String keyword){
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(keyword);
return this.questionRepostiory.findAll(pageable);
}
...
검색어를 의미하는 keyword를 getList 매개변수로 추가하고 keyword값으로 Specification 객체를 생성하여 questionRepository의 위에서 생성한 findAll 메서드에 전달했다.
질문 컨트롤러 수정하기
앞서 서비스를 수정했으니 이에 맞춰 컨트롤러도 수정하자.
/question/QuestionController.java
...
@GetMapping("/list")
public String list(Model model, @RequestParam(value = "page", defaultValue = "0")int page,
@RequestParam(value="keyword", defaultValue = "") String keyword) {
Page<Question> paging = this.questionService.getList(page, keyword);
model.addAttribute("paging",paging);
model.addAttribute("keyword", keyword);
return "question_list";
}
...
일단 검색어인 keyword를 매개변수에 추가했고, questionService.getList 메서드의 매개변수를 위에서 변경했으므로 전달값을 수정해준다.
getList는 검색어가 있을수도, 없을수도 있기 때문에 기본값으로는 빈 문자열로 지정했다.
또 화면에서 검색 시 검색 키워드를 유지하기 위해 model.addAttribute("keyword", keyword)로 키워드 값을 저장했다.
검색 화면 구현하기
이제 화면에서 검색 기능이 동작하도록 템플릿을 수정하자.
질문 등록하기 버튼을 상단으로 옮기기 위해 제거해준다.
/templates/question_list.html
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<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="${keyword}">
<button class="btn btn-outline-secondary" type="button" id="btn_search">찾기</button>
</div>
</div>
</div>
...
질문 목록 상단에 검색창이 있도록 했다.
오른쪽에는 입력창과 검색버튼, 왼쪽에는 기존에 있던 질문 등록하기 버튼이 배치되도록 했다.
또 자바스크립트에서 검색창에 입력된 값을 읽을 수 있도록 id속성에 'search_kw'를 지정했다.
검색 폼 만들기
page와 keyword를 동시에 GET방식으로 요청하기 위해 searchForm 을 추가해보자.
/templates/question_list.html
...
<!-- 페이징 처리 끝 -->
<form th:action="@{/question/list}" method="get" id="searchForm">
<input type="hidden" id="kw" name="kw" th:value="${keyword}">
<input type="hidden" id="page" name="page" th:value="${paging.number}">
</form>
</div>
</html>
GET방식으로 요청하기 위해 method값을 'get'으로 지정했다.
kw와 page값은 검색하기 이전의 값을 기억해야 하므로 value값에 유지하도록 했다. 이 값들은 컨트롤러부터 다시 전달받는다.
그리고 action 속성에는 폼이 전송되는 URL인 '/question/list'를 지정했다.
POST방식이 아닌 GET으로 전달하는 이유
만약 POST방식으로 전달했다면 '새로고침'이나 '뒤로가기' 버튼을 클릭하게 되면
'만료된 페이지입니다.'라는 오류가 난다.
그렇기 때문에 GET방식을 통해 주소로 keyword와 page 값을 전달하는 것이 좋다.
페이징 수정하기
페이징 부분도 기존의 ?page=3과 같이 직접 URL을 통해 링크하는 방식이 아니라 값을 읽어 폼에 설정할 수 있도록 변경해야한다.
왜냐하면 검색어가 있을 경우 검색어와 페이지 번호를 함께 전달해야 하기 때문!
/templates/question_list.html
...
<!-- 페이징 처리 시작 -->
<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>
<!-- 페이징 처리 끝 -->
...
모든 링크 a태그의 href속성에 직접 입력하지 않고 dat-page 속성으로 값을 읽도록 수정했다.
검색 스크립트 추가하기
page값과 keyword값을 동시에 요청할 수 있는 스크립트를 추가해보자.
/templates/question_list.html
...
<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('keyword').value = document.getElementById('search_kw').value;
document.getElementById('page').value = 0; //검색은 0페이지부터 조회
document.getElementById('searchForm').submit();
});
</script>
</html>
결과 확인하기
@Query 애너테이션 사용하기
직접 쿼리를 작성하는 방법이다.
/question/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.subject like %:kw% ")
Page<Question> findAllByKeyword(@Param("kw") String kw, Pageable pageable);
/question/QuestionService.java
...
public Page<Question> getList(int page, String keyword){
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(keyword);
return this.questionRepostiory.findAllByKeyword(keyword, pageable);
}
...
더 간단하게 구현된다.
'IT > Spring Boot' 카테고리의 다른 글
[Spring Boot] 40. 서버 배포 - (2)서버 접속 설정 (0) | 2024.05.27 |
---|---|
[Spring Boot] 39. 서버 배포 - (1) AWS 라이트세일 알아보기 (0) | 2024.05.27 |
[Spring Boot] 36. 마크 다운 적용하기 (0) | 2024.05.21 |
[Spring Boot] 35. 앵커 기능 추가하기(스크롤 자동 이동) (0) | 2024.05.21 |
[Spring Boot] 34. 게시글 추천, 좋아요 기능 구현하기 (0) | 2024.05.20 |