검색 기능 추가
문자열이 제목, 내용, 질문 작성자, 답변, 답변 작성자에 존재하는지 찾아보고 그 결과를 화면에 보여 주도록 하자.
이런 조건으로 검색하려면 다음과 같은 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 '%문자열%'
서비스 수정
QuestionService.java
package login.login_spring.service;
import jakarta.persistence.criteria.*;
import login.login_spring.domain.Answer;
import login.login_spring.domain.EzenMember;
import login.login_spring.domain.Question;
import login.login_spring.repository.QuestionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class QuestionService {
private final QuestionRepository questionRepository;
//질문 등록
public void createQuestion(Question question) {
this.questionRepository.save(question);
}
//질문 전체 조회
public Page<Question> getList(int page, String kw) {
List<Sort.Order> sorts = new ArrayList<>();
sorts.add(Sort.Order.desc("createDate")); //날짜별로 내림차순 정렬
PageRequest pageable = PageRequest.of(page, 10, Sort.by(sorts));//한 페이지에 10개씩 보여줌
Specification<Question> spec = search(kw);
return questionRepository.findAll(spec, pageable);
}
//질문 하나 조회
public Optional<Question> findOneQuestion(Long id) {
return questionRepository.findById(id);
}
//질문 수정
public Long updateQuestion(Question question) {
this.questionRepository.save(question);
return question.getId();
}
//질문 삭제
public void deleteQuestion(Question question) {
this.questionRepository.deleteById(question.getId());
}
//검색 기능
//JPA가 제공하는 Specification 인터페이스 사용
//DB 검색을 유연하게 할 수 있고 복잡한 검색 조건도 처리할 수 있음
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 criteriaBuilder) {
//중복 제거
query.distinct(true);
//질문 작성자 검색
Join<Question, EzenMember> m1 = q.join("author", JoinType.LEFT);
//답변 내용 검색
Join<Question, Answer> a = q.join("answerList", JoinType.LEFT);
//답변 작성자 검색
Join<Answer, EzenMember> m2 = q.join("author", JoinType.LEFT);
return criteriaBuilder.or(
criteriaBuilder.like(q.get("subject"), "%" + kw + "%"), //제목
criteriaBuilder.like(q.get("content"), "%" + kw + "%"), //질문내용
criteriaBuilder.like(m1.get("name"), "%" + kw + "%"), //질문작성자
criteriaBuilder.like(a.get("content"), "%" + kw + "%"), //답변내용
criteriaBuilder.like(m2.get("name"), "%" + kw + "%") //답변작성자
);
}
};
}
}
리포지토리 수정
QuestionRepository.java
package login.login_spring.repository;
import login.login_spring.domain.Question;
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;
import java.util.List;
public interface QuestionRepository extends JpaRepository<Question, Long> {
//제목으로 찾기
Question findBySubject(String subject);
//제목과 내용으로 찾기
Question findBySubjectAndContent(String subject, String content);
//질문엔터티의 subject 열 값들 중에 특정 문자열을 포함하는 데이터를 조회
List<Question> findBySubjectLike(String subject);
//findAll 메서드, questionList 페이징 처리
Page<Question> findAll(Pageable pageable);
//Specification Pageable 객체를 사용하여 DB에서 Quesiton 엔터티를 조회한 결과를 페이징하여 반환
Page<Question> findAll(Specification<Question> spec, Pageable pageable);
}
컨트롤러 수정
QuestionController.java
package login.login_spring.controller;
import jakarta.validation.Valid;
import login.login_spring.domain.EzenMember;
import login.login_spring.domain.Question;
import login.login_spring.dto.AnswerForm;
import login.login_spring.dto.QuestionForm;
import login.login_spring.service.QuestionService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.Optional;
@Controller
@RequiredArgsConstructor
public class QuestionController {
private final QuestionService questionService;
//질문 등록 폼 열기
@GetMapping("/question/create")
public String questionForm(@SessionAttribute(name = SessionConst.LOGIN_MEMBER) EzenMember loginMember, Model model) {
model.addAttribute("questionForm", new QuestionForm());
model.addAttribute("loginMember", loginMember);
return "user/questionForm";
}
//질문 등록
@PostMapping("/question/create")
public String createQuestion(@Valid @ModelAttribute("questionForm") QuestionForm form, BindingResult bindingResult, @SessionAttribute(name = SessionConst.LOGIN_MEMBER) EzenMember loginMember, Model model) {
if(bindingResult.hasErrors()){
model.addAttribute("loginMember", loginMember);
return "user/questionForm";
}
Question question = new Question();
question.setSubject(form.getSubject());
question.setContent(form.getContent());
question.setCreateDate(LocalDateTime.now());
question.setAuthor(loginMember);
questionService.createQuestion(question);
model.addAttribute("loginMember", loginMember);
return "redirect:/question/list";
}
//질문 목록 보기
@GetMapping("/question/list")
public String list(@SessionAttribute(name = SessionConst.LOGIN_MEMBER) EzenMember loginMember, @RequestParam(value = "page", defaultValue = "0") int page, @RequestParam(value = "kw", defaultValue = "") String kw, Model model){
Page<Question> paging = questionService.getList(page, kw);
model.addAttribute("paging", paging);
model.addAttribute("loginMember", loginMember);
model.addAttribute("kw", kw);
return "user/questionList";
}
//질문 상세 보기
@GetMapping("/questions/detail/{id}")
public String detail(@SessionAttribute(name = SessionConst.LOGIN_MEMBER) EzenMember loginMember, @PathVariable("id") Long id, AnswerForm answerForm, Model model){
Optional<Question> question = questionService.findOneQuestion(id);
if(question.isPresent()){
Question questionGet = question.get();
model.addAttribute("question", questionGet);
model.addAttribute("loginMember", loginMember);
model.addAttribute("answerForm", answerForm); //answerForm 가져옴
return "user/questionDetail";
}
model.addAttribute("loginMember", loginMember);
return "user/questionList";
}
//질문 수정을 위해 작성된 질문 가져오기
@GetMapping("/questions/{id}/edit")
public String editQuestion(@SessionAttribute(name = SessionConst.LOGIN_MEMBER) EzenMember loginMember, QuestionForm form, @PathVariable("id") Long id, Model model) {
Optional<Question> findQuestion = questionService.findOneQuestion(id);
Question findQuestionGet = findQuestion.get();
form.setSubject(findQuestionGet.getSubject());
form.setContent(findQuestionGet.getContent());
model.addAttribute("questionform", form);
model.addAttribute("question", findQuestionGet);
model.addAttribute("loginMember", loginMember);
return "user/updateQuestionForm";
}
//질문 수정
@PostMapping("/questions/{id}/edit")
public String updateQuestion(@PathVariable("id") Long id, @SessionAttribute(name = SessionConst.LOGIN_MEMBER) EzenMember loginMember, @Valid @ModelAttribute("questionform") QuestionForm form, BindingResult bindingResult, Model model){
if(bindingResult.hasErrors()){
model.addAttribute("loginMember", loginMember);
return "user/updateQuestionForm";
}
Optional<Question> findQuestion = questionService.findOneQuestion(id);
Question question = new Question();
question.setId(id);
question.setAuthor(loginMember);
question.setSubject(form.getSubject());
question.setContent(form.getContent());
question.setCreateDate(findQuestion.get().getCreateDate());
question.setModifyDate(LocalDateTime.now());
questionService.updateQuestion(question);
model.addAttribute("loginMember", loginMember);
return String.format("redirect:/questions/detail/%s", id);
}
//질문 삭제
@GetMapping("/questions/{id}/delete")
public String deleteQuestion(@PathVariable("id") Long id){
Optional<Question> findQuestion = questionService.findOneQuestion(id);
if(findQuestion.isPresent()){
questionService.deleteQuestion(findQuestion.get());
}
return "redirect:/question/list";
}
}
질문 목록 수정
questionList.html
<html layout:decorate="~{layout}" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<div class="container content" style="width: 1000px;" layout:fragment="content">
<div class="bg-white border rounded-4 p-5">
<h1 class="pb-4">Q&A</h1>
<!-- 검색 바 추가 -->
<div class="row my-3">
<div class="col">
<a th:href="@{/question/create}" role="button" class="btn btn-sm btn-secondary">질문 등록하기</a>
</div>
<div class="col">
<div class="input-group">
<input type="text" id="search_kw" class="form-control" th:value="${kw}"> <!-- value로 인해 찾기를 눌러도 검색어가 남아 있다. -->
<button class="btn btn-outline-secondary" type="button" id="btn_search">찾기</button>
</div>
</div>
</div>
<table class="table text-center">
<colgroup>
<col style="width: 10%;">
<col style="width: 50%;">
<col style="width: 10%;">
<col style="width: 30%;">
</colgroup>
<thead>
<tr class="table-primary">
<th scope="col">번호</th>
<th scope="col">제목</th>
<th scope="col">글쓴이</th>
<th scope="col">작성일시</th>
</tr>
</thead>
<tbody>
<tr class="align-middle" th:each = "question, iterStat : ${paging}">
<!-- 전체 게시글 번호를 계산 (내림차순):
(전체 게시글 수 - 현재 페이지 번호 * 페이지당 글 개수) - (현재 글의 index) -->
<th scope="row" th:text="${(paging.totalElements - (paging.number * paging.size) - iterStat.index)}"></th>
<!-- 순차적으로 번호 표시 -->
<td>
<a th:href="@{/questions/detail/{id} (id=${question.id})}" th:text="${question.subject}" class="questionTitle"></a>
<!-- 답글이 1개 이상 달리면 제목에 답글 개수 표시 -->
<span th:if="${#lists.size(question.answerList) != 0}" th:text="' [' + ${#lists.size(question.answerList)} + ']'"></span>
<!-- 오늘 작성된 글에만 'new' 배지 표시 -->
<span th:if="${#temporals.format(question.createDate, 'yyyy-MM-dd') == #temporals.format(#temporals.createNow(), 'yyyy-MM-dd')}" class="badge rounded-pill text-bg-danger">New</span>
</td>
<td th:text="${question.author != null ? question.author.name : 'Unknown'}"></td>
<td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
</tr>
</tbody>
</table>
<!-- 페이징 처리 시작 -->
<nav 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}">Previous</a></li>
<li class="page-item" th:each="page : ${#numbers.sequence(0, paging.totalPages - 1)}" th:if="${page >= paging.number / 10 * 10 and page < (paging.number / 10 + 1) * 10}" th:classappend="${page == paging.number}?'active'"><a class="page-link" href="javascript:void(0)" th:data-page="${page}" th:text="${page + 1}"></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}">Next</a></li>
</ul>
</nav>
<!-- javascript:void(0); : 아무것도 하지 않는 자바스크립트 실행을 의미, 단지 아무 일도 일어나지 않게 하려는 목적 -->
<!-- js의 void 연산자는 항상 undefined를 반환하므로, 이것을 사용하면 클릭 시 아무것도 일어나지 않음 -->
<!-- 검색 폼 -->
<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>
</div>
<script layout:fragment="script">
<!-- document.ready가 생략됨, 웹 브라우저가 로딩되면 함수를 실행 -->
$(function() {
//이전, 다음 페이징 버튼 클릭 시
$(".page-link").on('click', function(){
$('#page').val($(this).data('page'))
$('#searchForm').submit();
})
//찾기 버튼 클릭 시
$("#btn_search").on('click', function(){
$('#kw').val($('#search_kw').val());
$('#page').val(0); //0 페이지부터 찾기
$('#searchForm').submit();
})
})
</script>
</body>
</html>
<!-- iterStat : Thymeleaf에서 인덱스와 관련된 정보를 제공 -->
<!-- #temporals.format : Thymeleaf에서 날짜 및 시간을 포맷팅할 때 사용하는 기능 -->
Get 방식을 쓰는 이유?
page, kw를 POST 방식으로 전달하는 방법은 추천하고 싶지 않다. 만약 POST 방식으로 검색과 페이징을 처리한다면 웹 브라우저에서 '새로 고침' 또는 '뒤로 가기'를 했을 때 '만료된 페이지입니다.'라는 오류를 만날 것이다.
왜냐하면 브라우저는 동일한 POST 요청이 발생할 경우, 예를 들어 2페이지에서 3페이지로 이동한 후 '뒤로 가기'를 통해 2페이지로 이동하는 것과 같은 중복 요청을 방지하기 위해 '만료된 페이지입니다.'라는 오류를 발생시키기 때문이다. 이러한 이유로 여러 매개변수를 조합하여 게시물 목록을 조회할 때는 GET 방식을 사용하는 것을 강력히 권장한다.
jQuery CDN 추가
https://releases.jquery.com/
layout.html
<!DOCTYPE html>
<html lang="en" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta charset="UTF-8">
<title>layout</title>
<link href="/css/bootstrap.css" rel="stylesheet">
<link href="/css/style.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
<header th:fragment="header" class="fixed-top">
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom border-2 border-black py-3">
<div class="container-fluid">
<a class="navbar-brand" href="/">Home</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item">
<div th:if="${loginMember == null}">
<a class="nav-link" href="/login">로그인</a>
</div>
<div th:if="${loginMember}" class="mt-1">
<p th:text="${loginMember.name} + '님 환영합니다.'" class="m-0 fw-bold"></p>
</div>
</li>
<li class="nav-item">
<div th:if="${loginMember == null}">
<a class="nav-link" href="/add">회원 가입</a>
</div>
<div th:if="${loginMember}">
<form th:action="@{/logout}" method="post" class="ms-2">
<button type="submit" class="btn btn-secondary btn-sm">로그아웃</button>
</form>
</div>
</li>
</ul>
</div>
</div>
</nav>
</header>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
<footer th:fragment="footer" class="bg-body-tertiary fixed-bottom">
<div class="container d-flex justify-content-center align-items-center py-4">
<p class="m-0">이젠 ⓒ Designed by Crystal</p>
</div>
</footer>
<!-- 자바스크립트 -->
<script src="https://code.jquery.com/jquery-3.7.1.js" integrity="sha256-eKhayi8LEQwp4NKxN+CfCh+3qOVUtJn3QNZ0TciWLP4=" crossorigin="anonymous"></script>
<script src="/js/bootstrap.bundle.js"></script>
<th:block layout:fragment="script"></th:block>
</body>
</html>
'Framework > Spring' 카테고리의 다른 글
| [Spring] 문의 페이지 테스트 (0) | 2024.11.13 |
|---|---|
| [Spring] 에러 페이지 예제 (0) | 2024.11.12 |
| [Spring] Thymeleaf - Paging (0) | 2024.11.06 |
| [Spring] Q&A 페이지 예제 (0) | 2024.11.05 |
| [Spring] 회원 가입과 로그인 예제 (1) | 2024.11.04 |