Skip to main content

03N. 검색

About 5 minJavaSpringAWScrashcoursejavajdkjdk8streamspringspringframeworkspringbootawsaws-ec2

03N. 검색 κ΄€λ ¨


3-14. 검색

점프 투 μŠ€ν”„λ§λΆ€νŠΈ - WikiDocs

pahkey/sbb3 - 3-14open in new window

μ΄λ²ˆμ—λŠ” SBB에 검색기λŠ₯을 μΆ”κ°€ν•΄ 보자.

참고둜 검색은 이 μ±…μ—μ„œ λ‹€λ£¨λŠ” κ°€μž₯ μ–΄λ €μš΄ 뢀뢄이닀. μ°¨λΆ„ν•œ 마음으둜 λ”°λΌμ˜€κΈ° λ°”λž€λ‹€.


검색 κΈ°λŠ₯

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 1=1
AND 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

μœ„μ˜ μΏΌλ¦¬μ—μ„œ λ³Έ 것과 같이 μ—¬λŸ¬ ν…Œμ΄λΈ”μ—μ„œ 데이터λ₯Ό 검색해야 ν•  κ²½μš°μ—λŠ” JPAκ°€ μ œκ³΅ν•˜λŠ” Specification μΈν„°νŽ˜μ΄μŠ€λ₯Ό μ‚¬μš©ν•˜λŠ” 것이 νŽΈλ¦¬ν•˜λ‹€. Specification을 μ–΄λ–»κ²Œ μ‚¬μš©ν•  수 μžˆλŠ”μ§€ 예제λ₯Ό ν†΅ν•΄μ„œ μ•Œμ•„λ³΄μž.

Specification

Specification은 보닀 μ •κ΅ν•œ 쿼리의 μž‘μ„±μ„ λ„μ™€μ£ΌλŠ” JPA의 도ꡬ이닀. 보닀 μžμ„Έν•œ λ‚΄μš©μ€ λ‹€μŒμ˜ λ¬Έμ„œλ₯Ό μ°Έκ³ ν•΄ 보자.

https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#specifications

λ‹€μŒκ³Ό 같이 QuestionService에 search λ©”μ„œλ“œλ₯Ό μΆ”κ°€ν•΄ 보자.

파일λͺ…: /sbb/src/main/java/com/mysite/sbb/question/QuestionService.java

// (... μƒλž΅ ...)
import com.mysite.sbb.answer.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;
// (... μƒλž΅ ...)
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λ₯Ό λ‹€μŒκ³Ό 같이 μˆ˜μ •ν•˜μž.

파일λͺ…: /sbb/src/main/java/com/mysite/sbb/question/QuestionRepository.java

package com.mysite.sbb.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 λ©”μ„œλ“œλ₯Ό μ„ μ–Έν–ˆλ‹€.

QuestionService

그리고 QuestionService의 getList λ©”μ„œλ“œλ₯Ό λ‹€μŒκ³Ό 같이 μˆ˜μ •ν•˜μž.

파일λͺ…: /sbb/src/main/java/com/mysite/sbb/question/QuestionService.java

// (... μƒλž΅ ...)
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));
        Specification<Question> spec = search(kw);
        return this.questionRepository.findAll(spec, pageable);
    }

    // (... μƒλž΅ ...)
}





Β 



Β 
Β 




검색어λ₯Ό μ˜λ―Έν•˜λŠ” λ§€κ°œλ³€μˆ˜ kwλ₯Ό getList에 μΆ”κ°€ν•˜κ³  kw κ°’μœΌλ‘œ Specification 객체λ₯Ό μƒμ„±ν•˜μ—¬ findAll λ©”μ„œλ“œ ν˜ΈμΆœμ‹œ μ „λ‹¬ν•˜μ˜€λ‹€.

QuestionController

그리고 QuestionService의 getList λ©”μ„œλ“œμ˜ μž…λ ₯ν•­λͺ©μ΄ λ³€κ²½λ˜μ—ˆμœΌλ―€λ‘œ QuestionController도 λ‹€μŒκ³Ό 같이 μˆ˜μ •ν•΄μ•Ό ν•œλ‹€.

파일λͺ…: /sbb/src/main/java/com/mysite/sbb/question/QuestionController.java

// (... μƒλž΅ ...)
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_list";
    }

    // (... μƒλž΅ ...)
}







Β 


Β 





검색어에 ν•΄λ‹Ήν•˜λŠ” kw νŒŒλΌλ―Έν„°λ₯Ό μΆ”κ°€ν–ˆκ³  λ””ν΄νŠΈκ°’μœΌλ‘œ 빈 λ¬Έμžμ—΄μ„ μ„€μ •ν–ˆλ‹€. 그리고 ν™”λ©΄μ—μ„œ μž…λ ₯ν•œ 검색어λ₯Ό 화면에 μœ μ§€ν•˜κΈ° μœ„ν•΄ model.addAttribute("kw", kw)둜 kw 값을 μ €μž₯ν–ˆλ‹€. 이제 ν™”λ©΄μ—μ„œ kw 값이 νŒŒλΌλ―Έν„°λ‘œ λ“€μ–΄μ˜€λ©΄ ν•΄λ‹Ή κ°’μœΌλ‘œ 질문 λͺ©λ‘μ΄ κ²€μƒ‰λ˜μ–΄ 쑰회될 것이닀.


검색 ν™”λ©΄

이제 화면에 검색기λŠ₯을 μΆ”κ°€ν•΄ 보자.

검색 μ°½

검색어λ₯Ό μž…λ ₯ν•  수 μžˆλŠ” ν…μŠ€νŠΈμ°½μ„ λ‹€μŒκ³Ό 같이 질문 λͺ©λ‘ ν…œν”Œλ¦Ώμ— μΆ”κ°€ν•˜μž.

파일λͺ…: /sbb/src/main/resources/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="${kw}">
                <button class="btn btn-outline-secondary" type="button" id="btn_search">μ°ΎκΈ°</button>
            </div>
        </div>
    </div>
    <table class="table">
        <!-- (... μƒλž΅ ...) -->
    </table>
    <!-- νŽ˜μ΄μ§•μ²˜λ¦¬ μ‹œμž‘ -->
    <!-- (... μƒλž΅ ...) -->
    <!-- νŽ˜μ΄μ§•μ²˜λ¦¬ 끝 -->
    <!-- <a th:href="@{/question/create}" class="btn btn-primary">질문 λ“±λ‘ν•˜κΈ°</a> -->
</div>
</html>

<table> νƒœκ·Έ 상단 μš°μΈ‘μ— 검색어λ₯Ό μž…λ ₯ν•  수 μžˆλŠ” ν…μŠ€νŠΈμ°½μ„ μƒμ„±ν•˜μ˜€λ‹€. 맨 밑에 있던 ["질문 λ“±λ‘ν•˜κΈ°"] λ²„νŠΌμ€ 검색 창의 쒌츑으둜 μ΄λ™ν–ˆλ‹€. 그리고 μžλ°” μŠ€ν¬λ¦½νŠΈμ—μ„œ 이 ν…μŠ€νŠΈμ°½μ— μž…λ ₯된 값을 읽기 μœ„ν•΄ λ‹€μŒμ²˜λŸΌ ν…μŠ€νŠΈμ°½ id 속성에 "search_kw"λΌλŠ” 값을 μΆ”κ°€ν•œ 점에 μ£Όλͺ©ν•˜μž.

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

검색 폼

그리고 page와 kwλ₯Ό λ™μ‹œμ— GET으둜 μš”μ²­ν•  수 μžˆλŠ” searchForm을 λ‹€μŒκ³Ό 같이 μΆ”κ°€ν•˜μž.

파일λͺ…: /sbb/src/main/resources/templates/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>
</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을 λ§ν¬ν•˜λŠ” λ°©μ‹μ—μ„œ 값을 읽어 폼에 μ„€μ •ν•  수 μžˆλ„λ‘ λ‹€μŒμ²˜λŸΌ λ³€κ²½ν•΄μ•Ό ν•œλ‹€. μ™œλƒν•˜λ©΄ 검색어가 μžˆμ„ 경우 검색어와 νŽ˜μ΄μ§€ 번호λ₯Ό ν•¨κ»˜ 전솑해야 ν•˜κΈ° λ•Œλ¬Έμ΄λ‹€.

파일λͺ…: /sbb/src/main/resources/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>
<!-- νŽ˜μ΄μ§•μ²˜λ¦¬ 끝 -->
<!-- (... μƒλž΅ ...) -->





Β 






Β 


Β 







λͺ¨λ“  νŽ˜μ΄μ§€ 링크λ₯Ό 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 νŒŒλΌλ―Έν„°λ₯Ό λ™μ‹œμ— μš”μ²­ν•  수 μžˆλŠ” μžλ°”μŠ€ν¬λ¦½νŠΈλ₯Ό λ‹€μŒκ³Ό 같이 μΆ”κ°€ν•˜μž.

파일λͺ…: /sbb/src/main/resources/templates/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 μ• λ„ˆν…Œμ΄μ…˜μ΄ 적용된 findAllByKeyword λ©”μ„œλ“œλ₯Ό μΆ”κ°€ν–ˆλ‹€. μœ„μ—μ„œ μ•Œμ•„λ³Έ 쿼리λ₯Ό @Query에 κ΅¬ν˜„ν•œ 것이닀. 그리고 @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을 μ‚¬μš©ν• λ•Œμ™€ λ™μΌν•˜κ²Œ λ™μž‘ν•  것이닀.


이찬희 (MarkiiimarK)
Never Stop Learning.