사이먼's 코딩노트

[SpringBoot] 검색 기능 구현 본문

Java/SpringBoot

[SpringBoot] 검색 기능 구현

simonpark817 2024. 5. 17. 00:17

[검색 기능 구현하기]

  • 질문 목록 페이지인 localhost:8090/question/list에서 찾고자 하는 내용을 키워드로 검색하여 해당 검색어와 관련있는 질문을 조회할 수 있는 기능을 구현해봅시다.
  • 그러기 위해선 검색어를 입력할 수 있는 텍스트 창을 만들고 검색어를 입력하여 조회하면 검색어에 해당되는 질문들이 보여야 한다.
  • 검색 대상으로는 질문 제목, 질문 내용, 질문 작성자, 답변 내용, 답변 작성자가 되겠고 DB에서 직접 SELECT문으로 조회했을 때는 아래와 같은 쿼리문을 사용해야 한다.
SELECT *
FROM question AS Q
LEFT JOIN site_user AS QA
ON Q.author_id = QA.id
LEFT JOIN answer AS A
ON Q.id = A.question_id
LEFT JOIN site_user AS AA
ON A.author_id = AA.id
WHERE (
Q.subject LIKE '%sbb%'
OR
Q.content LIKE '%sbb%'
OR
QA.username LIKE '%sbb%'
OR
A.content LIKE '%sbb%'
OR
AA.username LIKE '%sbb%'
)
GROUP BY Q.id;
  • 해당 쿼리문은 question, answer, site_user 테이블을 대상으로 'sbb'이라는 문자열이 포함된 데이터를 검색한다.
  • question 테이블을 기준으로 answer, site_user 테이블을 LEFT JOIN하여 문자열 'sbb'를 검색한다.
  • 위 쿼리문을 그대 사용하지 않고 JPA를 사용하여 자바 코드를 구현해봅시다.

 

[Specification 인터테이스 사용]

  • 위에서 작성한 쿼리와 같이 여러 테이블에서 데이터를 검색해야 할 경우에는 JPA가 제공하는 Specification 인터페이스를 사용하는 것이 편리하며 해당 인터페이스는 DB 검색을 더 유연하게 다룰 수 있고, 복잡한 검색 조건도 처리할 수 있다.
  • 아래는 QuestionService.java 클래스에서 검색 기능을 구현하기 위해 작성된 코드이다.
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문을 Specification 객체로 생성하여 리턴하는 메서드이다.
  • 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로 LIKE 조건을 사용하고 최종적으로 cb.or로 OR 검색이 되도록 하였다.

 

[Repository 수정]

  • 위에서 작성한 Specification을 통해 질문을 조회하려면 QuestionRepository.java 클래스에서 아래와 같이 코드를 추가해야한다.
Page<Question> findAll(Specification<Question> spec, Pageable pageable);
  • 추가된 findAll() 메서드는 Specification과 Pageable 객체를 사용하여 DB에서 Question 엔티티를 조회한 결과를 페이징하여 반환한다.

 

[Service 수정]

  • 검색어를 포함하여 질문 목록을 조회하기 위해 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));

    if ( kw == null || kw.trim().length() == 0 ) {
        return questionRepository.findAll(pageable);
    }

    Specification<Question> spec = search(kw);

    return questionRepository.findAll(spec, pageable);
}
  • 검색어를 의미하는 매개변수 kw를 getList() 메서드에 추가하고 kw값으로 Specification 객체를 생성하여 findAll() 메서드 호출 시 전달했다.
  • 중간에 작성된 조건문은 검색어가 없을 경우에는 기존처럼 전체 검색을 해오도록 findAll() 메서드에 pageable 객체만 넘겨준다.

 

[Controller 수정]

  • QuestionService.java에서 getList() 메서드의 입력 항목이 변경되었으므로 QuestionController.java 클래스에서도 아래와 같이 코드 수정이 필요하다.
@GetMapping("/list")
public String list(Model model, @RequestParam(value="page", defaultValue="0") int page, @RequestParam(value = "kw", defaultValue = "") String kw) {
    // URL : localhost:8090/question/list 뒤에 ?page=0 이 붙는다.
    Page<Question> paging = this.questionService.getList(page, kw);

    model.addAttribute("paging", paging);
    model.addAttribute("kw", kw);

    return "question_list";
}
  • 검색어에 해당하는 kw 매개변수를 추가했고, defaultValue를 통해 기본값으로 빈 문자열을 설정했다.
  • 검색어가 입력되지 않을 경우 kw값이 null이 되는 것을 방지하기 위해 빈 문자열을 기본값으로 설정했다.
  • 화면에서 입력한 검색어를 화면에 그대로 유지하기 위해 model.addAttribut("kw", kw)로 kw값을 저장했다.
  • 화면에서 검색어가 입력된다면 kw값이 매개변수로 들어오고 해당 값으로 질문 목록이 검색되어 조회될 것이다.

 

[question_list.html 템플릿 수정]

  • 마지막으로 검색어를 입력할 수 있는 텍스트 창을 아래와 같이 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">
        <form>
            <div class="input-group">
                <input type="text" name="kw" class="form-control" placeholder="검색어를 입력하세요." th:value="${param.kw}">
                <button class="btn btn-outline-secondary">찾기</button>
            </div>
        </form>
    </div>
</div>
  • 질문 목록 위에 검색창이 노출되도록 <table> 태그 위쪽에 '질문 등록하기' 버튼과 함께 검색어를 입력할 수 있는 텍스트 창을 생성했다.
  • page와 kw를 동시에 GET 방식으로 요청하기 위해 검색창이 감싸진 태그를 <form> 태그로 다시 감싸줬다.
  •  th:value="${param.kw}"로 속성을 지정하게 되면 URL에 'localhost:8090/question/list?kw=테스트'와 같이 받아온다.
  • 추가로 검색어를 통해 검색된 질문의 개수가 여러 개여서 페이지네이션을 통해 이동을 했을 때도 검색된 질문 데이터가 유지되도록 아래와 같이 코드를 수정해야 한다.
<!-- 페이지네이션 시작 -->
<nav th:if="${!paging.isEmpty()}" th:with="queryStrBase = '?kw=' + ${param.kw != null ? param.kw : ''}">
    <ul class="pagination justify-content-center">
        <li class="page-item" th:classappend="${paging.number == 0} ? 'disabled'">
            <a class="page-link" th:href="@{|${queryStrBase}&page=0|}">
                <span>&laquo;</span>
            </a>
        </li>

        <li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
            <a class="page-link" th:href="@{|${queryStrBase}&page=${paging.number-1}|}">Previous</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" th:href="@{|${queryStrBase}&page=${page}|}"></a>
        </li>

        <li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
            <a class="page-link" th:href="@{|${queryStrBase}&page=${paging.number+1}|}">Next</a>
        </li>

        <li class="page-item" th:classappend="${paging.number == paging.totalPages - 1} ? 'disabled'">
            <a class="page-link" th:href="@{|${queryStrBase}&page=${paging.totalPages - 1}|}">
                <span>&raquo;</span>
            </a>
        </li>
    </ul>
</nav>
<!-- 페이지네이션 끝-->
  • 각 버튼이 위치한 a태그에서 th:href 속성에 ${queryStrBase}를 추가하게 되면 해당 에러가 발생하지 않고 정상적으로 동작되는 것을 확인할 수 있다.
  • 위에서 설명한 모든 코드를 작성하고 로컬 서버를 다시 실행하면 질문 목록 페이지에서 검색창이 추가된 모습과 원하는 검색어를 입력했을 때 조회가 성공적으로 이루어지는 것을 확인할 수 있다.

검색 기능 추가

 

검색 결과

 

  • 지금까지 구현된 모든 SBB 프로그램의 코드는 아래 깃허브 리포지터리 주소를 통해 확인하실 수 있습니다.
  • URL : https://github.com/psm817/sbb_2024_05
 

GitHub - psm817/sbb_2024_05

Contribute to psm817/sbb_2024_05 development by creating an account on GitHub.

github.com

 

반응형