본문 바로가기

SpringBoot

[Do it] 3장 스프링 부트 고급 기능 익히기(6)

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.orOR 검색(여러 조건 중 하나라도 만족하는 경우 해당 항목을 반환)이 되게 함 

 

 

질문 리포지터리 수정하기 

 

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);
    }

...
}