해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.
[ 검색 기능 ]
Specification
여러 테이블에서 데이터를 검색해야 할 경우에는 JPA가 제공하는 Specification 인터페이스를 사용하는 것이 편리하다. Specification을 어떻게 사용할 수 있는지 예제를 통해서 알아보자.
Specification
Specification은 보다 정교한 쿼리의 작성을 도와주는 JPA의 도구이다.
https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#specifications
QuestionService에 search 메서드를 추가하고, getList메서드에 키워드(kw)를 추가하자.
(...생략...)
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;
import com.example.board.answer.Answer;
(...생략...)
public class QuestionService {
private final QuestionRepository questionRepository;
private Specification<Question> search(String kw) {
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"), "%" + 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 + "%")); // 답변 작성자
}
};
}
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);
}
(...생략...)
}
search 메서드는 검색어(kw)를 입력받아 쿼리의 조인문과 where문을 생성하여 리턴하는 메서드이다.
q
Root, 즉 기준을 의미하는 Question 엔티티의 객체 (질문 제목과 내용을 검색하기 위해 필요)
u1
Question 엔티티와 SiteUser 엔티티를 아우터 조인하여 만든 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 검색되게 하였다.
getList메서드에서는 kw 값으로 Specification 객체를 생성하여 findAll 메서드 호출시 전달하도록 하였다.
QuestionRepository
Specification을 사용하기 위해서 QuestionRepository를 다음과 같이 수정하자.
package com.example.board.question;
import java.util.List;
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;
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 메서드를 선언했다.
QuestionController
(... 생략 ...)
public class QuestionController {
(... 생략 ...)
@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);
model.addAttribute("paging", paging);
model.addAttribute("kw", kw);
return "/question/question_list";
}
(... 생략 ...)
}
검색어에 해당하는 kw 파라미터를 추가했고 디폴트값으로 빈 문자열을 설정했다.
화면에서 입력한 검색어를 화면에 유지하기 위해 model.addAttribute("kw", kw)로 kw 값을 저장했다.
화면에서 kw 값이 파라미터로 들어오면 해당 값으로 질문 목록이 검색되어 조회될 것이다.
검색 화면
검색 창
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="${kw}">
<button class="btn btn-outline-secondary" type="button" id="btn_search">찾기</button>
</div>
</div>
</div>
<table class="table">
(... 생략 ...)
</table>
<!-- 페이징처리 시작 -->
(... 생략 ...)
<!-- 페이징처리 끝 -->
<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>
<table> 태그 상단 우측에 검색어를 입력할 수 있는 텍스트창을 생성하였다.
맨 밑에 있던 "질문 등록하기" 버튼은 검색 창의 좌측으로 이동했다.
자바 스크립트에서 이 텍스트창에 입력된 값을 읽기 위해 다음처럼 텍스트창 id 속성에 "search_kw"라는 값을 추가했다.
<input type="text" id="search_kw" class="form-control" th:value="${kw}">
또한 그리고 page와 kw를 동시에 GET으로 요청할 수 있는 searchForm을 추가했다.
GET 방식으로 요청해야 하므로 method 속성에 "get"을 설정했다.
kw와 page는 이전에 요청했던 값을 기억하고 있어야 하므로 value에 값을 유지할수 있도록 했다.
그리고 action 속성은 "폼이 전송되는 URL"이므로 질문 목록 URL인 /question/list를 지정했다.
GET 방식을 사용하는 이유
만약 GET이 아닌 POST 방식으로 검색과 페이징을 처리한다면 웹 브라우저에서 "새로고침" 또는 "뒤로가기"를 했을 때 "만료된 페이지입니다."라는 오류를 종종 만나게 될 것이다. 왜냐하면 POST 방식은 동일한 POST 요청이 발생할 경우 중복 요청을 방지하기 위해 "만료된 페이지입니다." 라는 오류를 발생시키기 때문이다.
페이징
그리고 기존 페이징을 처리하는 부분도 ?page=1 처럼 직접 URL을 링크하는 방식에서 값을 읽어 폼에 설정할 수 있도록 다음처럼 변경해야 한다. 왜냐하면 검색어가 있을 경우 검색어와 페이지 번호를 함께 전송해야 하기 때문이다.
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>
<!-- 페이징처리 끝 -->
(... 생략 ...)
모든 페이지 링크를 href 속성에 직접 입력하는 대신 data-page 속성으로 값을 읽을 수 있도록 변경했다.
검색 스크립트
question_list.html
(... 생략 ...)
<!-- 페이징처리 끝 -->
<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>
만약 다음과 같이 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을 설정하여 첫 페이지로 요청하도록 했다.
위와 같이 해당 문장만 있는 게시물이 조회된다면 성공이다.
@Query
Specification 대신 직접 쿼리를 작성하여 수행하는 방법에 대해서 알아보자.
QuestionRepository
(... 생략 ...)
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface QuestionRepository extends JpaRepository<Question, Integer> {
(... 생략 ...)
@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를 작성할 때에는 반드시 테이블 기준이 아닌 엔티티 기준으로 작성해야 한다. 즉, site_user와 같은 테이블명 대신 SiteUser처럼 엔티티명을 사용해야 하고 조인문에서 보듯이 q.author_id=u1.id와 같은 컬럼명 대신 q.author=u1처럼 엔티티의 속성명을 사용해야 한다.
@Query에 파라미터로 전달할 kw 문자열은 메서드의 매개변수에 @Param("kw")처럼 @Param 애너테이션을 사용해야 한다. 검색어를 의미하는 kw 문자열은 @Query 안에서 :kw로 참조된다.
작성한 findAllByKeyword 메서드를 사용하기 위해 QuestionService를 다음과 같이 수정하자.
(... 생략 ...)
public class QuestionService {
(... 생략 ...)
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));
return this.questionRepository.findAllByKeyword(kw, pageable);
}
(... 생략 ...)
}
서비스에 기존 작성했던 Specification을 제거해도 동일하게 동작할 것이다.
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!