implementation 'org.commonmark:commonmark:0.21.0'
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
import org.springframework.stereotype.Component;
@Component
public class CommonUtil {
public String Markdown(String markdown) {
Parser parser = Parser.builder().build();
Node document = parser.parse(markdown);
HtmlRenderer renderer = HtmlRenderer.builder().build();
return renderer.render(document);
}
}
@Component 어노테이션으로 CommonUtil 생성
이 클래스는 스프링 부트가 관리하는 빈으로 등록
빈으로 등록된 컴포넌트는 템플릿에서 사용 가능
CommonUtil 클래스엔 markdown 메서드를 생성
markdown 메서드는 마크다운 텍스트를 HTML 문서로 변환하여 리턴함
마크다운 문법이 적용된 일반 텍스트를 변환된 HTML로 리턴
(... 생략 ...)
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
<div class="card-body">
<div class="card-text" th:utext="${@commonUtil.markdown(question.content)}"></div>
<div class="d-flex justify-content-end">
(... 생략 ...)
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
<a th:id="|answer_${answer.id}|"></a>
<div class="card-body">
<div class="card-text" th:utext="${@commonUtil.markdown(answer.content)}"></div>
<div class="d-flex justify-content-end">
(... 생략 ...)
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(u2.get("username"), "%" + kw + "%"),
cb.like(a.get("content"), "%" + kw + "%"));
}
};
}
search 메서드
검색어를 가리키는 kw를 입텍스트력받아 쿼리의 조인문과 where문을 Spectification 객체로
생성하여 리턴하는 메서드
쿼리를 자바 코드로 재현한 것
q : Root 자료형, 기준이 되는 Question 엔티티의 객체를 의미
질문 제목과 내용을 검색하기 위해 필요
u1 : Question 엔티티와 SiteUser 엔티티를 아우터 조인 (JoinType.LEFT) 하여 만든
SiteUser 엔티티의 객체
Question 엔티티와 SiteUser 엔티티는 author 속성으로 연결되어 있어 q.join("author") 와 같이 조인해야
u1 객체는 질문 작성자를 검색하기 위해 필요
a : Question 엔티티와 Answer 엔티티를 아우터 조인하여 만든
Answer 엔티티의 객체
Question 엔티티와 Answer 엔티티는 answerList 속성으로 연결되어 있어어
q.join("answerList") 와 같이 조인해야 함
a 객체는 답변 내용을 검색할 때 필요
검색어 kw가 포함 되어있는지를 like 키워드로 검색하기 위해
제목, 내용, 질문 작성자, 답변 내용, 답변 작성자 각각에 cb.like를 사용
최종적으로 cb.or로 OR 검색이 되게 함
쿼리문과 비교를 해보면 코드 구성을 쉽게 이해할 수 있다.
OR 검색
(여러 조건 중 하나라도 만족하는 경우 해당 항목을 반환하는 검색 조건을 말함)
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);
}
findAll 메서드
Specification, Pageable 객체를 사용하여 DB에서 Question 엔티티를 조회한 걸 페이징
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(pageable);
}
검색어 매개변수 (kw) 를 getList 메서드에 추가, kw 값으로 Specification 객체를 생성하고
findAll 메서드 호출시 전달하게 했음
@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 매개변수 추가, 기본값으로 빈 문자열을 설정
검색어가 입력되지 않을 경우 kw 값이 null이 되는 것을 방지하기 위해 빈 문자열을 설정
화면에서 입력한 검색어를 그래도 유지하기 위해
model.addAttribute("kw", kw) 로 kw 값 저장
작동 순서
화면에서 검색어가 입력되면?
kw 값이 매개변수로 들어오고
해당 값으로 질문 목록이 검색되어 조회
<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>
<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
요건 지우고 아래 추가하기
<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>
GET 방식으로 요청해야 하니까 method 속성 'get' 설정
kw, page는 이전에 요청했던 값을 기억하고 있어야하니까 value 값 유지
이전에 요청했던 kw, page의 값은 컨트롤러로부터 다시 전달 받음
action 속성엔 폼이 전송되는 URL = 질문 목록 URL /question/list 지정
POST 방식이 아니라 왜 GET 방식을 사용할까?
page, kw를 POST 방식으로 전달하는 방법은 추천하고 싶지 않다. 만약 POST 방식으로 검색과 페이징을 처리한다면 웹 브라우저에서 '새로 고침' 또는 '뒤로 가기'를 했을 때 '만료된 페이지입니다.'라는 오류를 만날 것이다.왜냐하면 브라우저는 동일한 POST 요청이 발생할 경우, 예를 들어 2페이지에서 3페이지로 이동한 후 '뒤로가기'를 통해 2페이지로 이동하는 것과 같은 중복 요청을 방지하기 위해 '만료된 페이지입니다.'라는 오류를 발생시키기 때문이다. 이러한 이유로 여러 매개변수를 조합하여 게시물 목록을 조회할 때는 GET 방식을 사용하는 것을 강력히 권장한다.
<!-- 페이징처리 시작 -->
<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>
<!-- 페이징처리 끝 -->
페이징 수정
(... 생략 ...)
<!-- 페이징처리 끝 -->
<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>
페이징 처리 끝 아래 닫히는 div 밑에 자바 스크립트 넣기
@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 u2.username like %:kw% "
+ "or a.content like %:kw% ")
Page<Question> findAllByKeyword(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로 참조해야함
@Query
site_user 테이블명 x SiteUser 엔티티명 O
q.author_id=u1.id 컬럼명 X q.author=u1 엔티티 속성명 O
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);
}