1. 마크다운 적용하기
마크다운(markdown)
- 텍스트 기반의 마크업 언어
- HTMl과 달리 쉽고 간단한 문법을 사용하며 텍스트 편집기를 통해 웹상에서 글자를 강조하거나 제목, 목록, 이미지, 링크 등을 추가할 때도 유용하게 활용 가능
1) 마크다운 설치하기
build.gradle
- 스프링 부트가 내부적으로 관리하는 라이브러리에 포함되면 버전 정보가 필요 없고 포함되지 않으면 버전 정보가 필요
- commonmark는 스프링 부트가 내부적으로 관리하는 라이브러리가 아니어서 버전 명시
2) 마크다운 컴포넌트 작성하기
- 질문이나 답변의 '내용' 부분에 마크다운 적용
CommonUtil.java
package com.example.demo;
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 메서드는 마크다운 텍스트를 HTML 문서로 변환하여 리턴
- 마크다운 문법이 적용된 일반 텍스트를 변환된 HTML로 리턴
3) 템플릿에 마크다운 적용하기
templates/question_detail.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">
....
- ${@commonUtil.markdown(question.content)}와 같이 마크다운 컴포넌트를 적용
- th:utext 대신 th:text를 사용할 경우 HTML의 태그들이 이스케이프(escape)처리되어 화면에 그대로 보일 것
- 마크다운으로 변환된 HTML 문서를 제대로 표현하려면 이스케이프 처리를 하지 않고 출력하는 th:utext를 사용
2. 검색 기능 추가하기
1) 검색 기능 구현하기
- 검색 대상은 질문 제목, 질문 내용, 질문 작성자, 답변 내용, 답변 작성자
- ex) '스프링'을 검색하면 '스프링'이라는 문자열이 제목, 내용, 질문 작성자, 답변, 답변 작성자에 존재하는지 찾아보고 그 결과를 화면에 보여 주도록 함
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 '%스프링%'
- question, answer,site_user 테이블을 대상으로 '스프링'이라는 문자열이 포함된 데이터를 검색
- question 테이블을 기준으로 answer,site_user 테이블을 아우터 조인(outer join)하여 문자열 '스프링'을 검색
- 만약 이너 조인(inner join)을 사용한다면 합집합이 아닌 교집합으로 검색되어 데이터 검색 결과가 누락될 수 있음
- 총 3개의 테이블을 대상으로 아우터 조인하여 검색하면 중복된 결과가 나올 수 있어서 select문에 distinct를 함께 적어 중복을 제거
JPA의 Specification 인터페이스 사용하기
- 여러 테이블에서 데이터를 검색해야 할 경우에는 JPA가 제공하는 Specification 인터페이스를 사용하는 것이 편리
QuestionService.java
...
import com.example.demo.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;
@RequiredArgsConstructor
@Service
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 = q.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 + "%")); // 답변 작성자
}
};
}
...
- 검색어를 가리키는 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를 사용
- 최종적으로 cb.or로 OR 검색(여러 조건 중 하나라도 만족하는 경우 해당 항목을 반환)이 되게 함
질문 리포지터리 수정하기
QuestionRepository.java
package com.example.demo.question;
....
import org.springframework.data.jpa.domain.Specification;
public interface QuestionRepository extends JpaRepository<Question,Integer> {
...
Page<Question> findAll(Specification<Question>spec, Pageable pageable);
}
- 추가한 findAll 메서드는 Specification과 Pageable 객체를 사용하여 DB에서 Question 엔티티를 조회한 결과를 페이징하여 반환
질문 서비스 수정하기
QuestionService.java
...
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);
}
...
- getList 메서드에서 검색어를 의미하는 kw 매개변수를 추가하고 kw값으로 Specification 객체를 생성하여 findAll 메서드 호출 시 전달
질문 컨트롤러 수정하기
QuestionController.java
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController{
private final QuestionService questionService;
private final UserService userService;
@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";
}
...
- 화면에서 입력한 검색어를 화면에 그대로 유지하기 위해 model.addAttribute("kw", kw)로 kw값을 저장
2) 검색 화면 구현하기
검색창 만들기
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">
검색 폼 만들기
templates/question_list.html
...
<form th:action="@{/question/list}" method="get" id="serarchForm">
<input type="hidden" id="kw" name="kw" th:value="${kw}">
<input type="hidden" id="page" name="page" th:value="${paging.number}">
</form>
- page와 kw를 동시에 GET 방식으로 요청하기 위해 searchForm 추가
- GET 방식으로 요청
- kw와 page는 이전에 요청했던 값을 기억하고 있어야 하므로 value에 값을 유지할 수 있도록 함
- 이전에 요청했던 kw와 page의 값은 컨트롤러로부터 다시 전달 받음
- action 속성에는 폼이 전송되는 URL이므로 질문 목록 URL인 /question/list를 지정
페이징 수정하기
- 페이징을 처리하는 부분도 기존의 ?page=1과 같이 직접 URL을 링크하는 방식이 아니라 값을 읽어 폼에 설정할 수 있도록 변경해야 함
- 검색어가 있을 경우 검색어와 페이지 번호를 함께 전송해야 하기 때문
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 속성으로 값을 읽을 수 있도록 함
검색 스크립트 추가 하기
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'>
<!-- data-page 속성값을 읽어 serachForm의 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을 요청-->
<!-- 검색 버튼을 클릭하는 경우는 새로운 검색에 해당되므로 page에 항상 0을 설정하여 첫 페이지로 요청하도록 함 -->
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>
3) @Query 애너테이션 사용하기
- Specification 대신 쿼리를 직접 작성하여 검색 기능을 구현
QuestionRepository.java
...
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는 반드시 테이블 기준이 아닌 엔티티 기준으로 작성해야함
-> site_user와 같은 테이블명 대신 SiteUser처럼 엔티티명을 사용해야 함
-> q.author_id = ul.id 와 같은 컬럼명 대신 q.author=ul처럼 엔티티의 속성명을 사용해야 함
- @Query에 매개변수로 전달할 kw 문자열은 메서드의 매개변수에 @Param("kw")처럼 @Param을 사용해야 함
- 검색어를 의미하는 kw 문자열은 @Query 안에서 :kw로 참조됨
QuestionService.java
- findAllByKeyword 메서드를 사용하기 위해 수정
...
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);
}
...
}
'SpringBoot' 카테고리의 다른 글
[스프링 부트 3 백엔드 개발자 되기] ch 1. 자바 백엔드 개발자가 알아두면 좋은 지식 (0) | 2024.07.10 |
---|---|
[스프링부트 3 백엔드 개발자 되기] ch 0. 개발환경 구축하기 (0) | 2024.07.10 |
[Do it] 3장 스프링 부트 고급 기능 익히기(5) (0) | 2024.01.24 |
[Do it] 3장 스프링 부트 고급 기능 익히기(4) (1) | 2024.01.24 |
[Do it] 3장 스프링 부트 고급 기능 익히기(3) (1) | 2024.01.23 |