질문 엔티티에 속성 추가하기
public class Question {
...
@ManyToOne
private SiteUser author;
}
author 속성에는 @ManyToOne 애너테이션을 적용했는데, 이는 사용자 한 명이 질문을 여러 개 작성할 수 있기 때문이다.
답변 엔티티에 속성 추가하기
public class Answer {
...
@ManyToOne
private SiteUser author;
}
테이블 확인하기
H2 콘솔에 접속하여 Question, Answer 테이블을 확인해 보면 question, answer 테이블에 author_id 열이 생성된 것을 확인할 수 있다. 이 열에는 글쓴이의 ID 값이 저장된다.
답변 컨트롤러와 서비스 업데이트하기
public class AnswerController {
...
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable("id") Integer id,
@Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
Question question = this.questionService.getQuestion(id);
if (bindingResult.hasErrors()) {
model.addAttribute("question", question);
return "question_detail";
}
this.answerService.create(question, answerForm.getContent());
return String.format("redirect:/question/detail/%s", id);
}
}
현재 로그인한 사용자의 정보를 알려면 스프링 시큐리티가 제공하는 Principal 객체를 사용해야 한다. principal.getName()을 호출하면 현재 로그인한 사용자의 사용자명(사용자ID)을 알 수 있다.
public class UserService {
...
public SiteUser getUser(String username) {
Optional<SiteUser> siteUser = this.userRepository.findByusername(username);
if (siteUser.isPresent()) {
return siteUser.get();
} else {
throw new DataNotFoundException("siteuser not found");
}
}
}
getUser 메서드는 userRepository의 findByusername 메서드를 사용하여 쉽게 만들 수 있다. 사용자명에 해당하는 데이터가 없을 경우에는 DataNoFoundException이 발생하도록 했다.
public class AnswerService {
...
public Answer create(Question question, String content, SiteUser author) {
Answer answer = new Answer();
answer.setContent(content);
answer.setCreateDate(LocalDateTime.now());
answer.setQuestion(question);
answer.setAuthor(author);
this.answerRepository.save(answer);
return answer;
}
}
create 메서드에 SiteUser 객체를 추가로 전달받아 작성자도 함께 저장하도록 수정했다.
public class AnswerController {
private final QuestionService questionService;
private final AnswerService answerService;
private final UserService userService;
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable("id") Integer id,
@Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
Question question = this.questionService.getQuestion(id);
SiteUser siteUser = this.userService.getUser(principal.getName());
if (bindingResult.hasErrors()) {
model.addAttribute("question", question);
return "question_detail";
}
this.answerService.create(question, answerForm.getContent(), siteUser);
return String.format("redirect:/question/detail/%s", id);
}
}
principal 객체를 통해 사용자명을 얻은 후, 사용자명을 통해 SiteUser 객체를 얻어 답변을 등록할 때 사용했다.
질문 컨트롤러와 서비스 업데이트하기
public class QuestionService {
...
public void create(String subject, String content, SiteUser user) {
Question q = new Question();
q.setSubject(subject);
q.setContent(content);
q.setCreateDate(LocalDateTime.now());
q.setAuthor(user);
this.questionRepository.save(q);
}
}
public class QuestionController {
private final QuestionService questionService;
private final UserService userService;
...
@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm,
BindingResult bindingResult, Principal principal) {
if (bindingResult.hasErrors()) {
return "question_form";
}
SiteUser siteUser = this.userService.getUser(principal.getName());
this.questionService.create(questionForm.getSubject(), questionForm.getContent(), siteUser);
return "redirect:/question/list";
}
}
Principal 객체를 통해 사용자명을 구한 후, SiteUser를 조회하여 질문 저장 시 함께 저장할 수 있도록 했다.
이는 principal 객체가 널(null)이라서 발생한 오류이다. principal 객체는 로그인을 해야만 생성되는 객체인데 현재는 로그아웃 상태이므로 principal 객체에 값이 없어 오류가 발생하는 것이다.
위 문제를 해결하려면 principal 객체를 사용하는 메서드에 @PreAuthorize("isAuthenticated()") 애너테이션을 사용해야 한다. @PreAuthorize("isAuthenticated()") 애너테이션이 붙은 메서드는 로그인한 경우에만 실행된다. 즉, 이 애너테이션을 메서드에 붙이면 해당 메서드는 로그인한 사용자만 호출할 수 있다. @PreAuthorize("isAuthenticated()") 애너테이션이 적용된 메서드가 로그아웃 상태에서 호출되면 로그인 페이지로 강제 이동된다.
public class QuestionController {
...
@PreAuthorize("isAuthenticated()")
@GetMapping("/create")
public String questionCreate(QuestionForm questionForm) {
return "question_form";
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm,
BindingResult bindingResult, Principal principal) {
...
}
로그인이 필요한 메서드(질문 등록과 관련된 메서드)들에 @PreAuthorize("isAuthenticated()") 애너테이션을 적용했다.
public class AnswerController {
...
@PreAuthorize("isAuthenticated()")
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable("id") Integer id,
@Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
...
}
}
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
...
}
SecurityConfig에 적용한 @EnableMethodSecurity 애너테이션의 prePostEnabled = true는 QuestionController와 AnswerController에서 로그인 여부를 판별할 때 사용한 @PreAuthorize 애너테이션을 사용하기 위해 반드시 필요한 설정이다.
로그아웃 상태에서 [질문 등록] 버튼을 누르면 로그인 페이지로 이동한다. 로그인을 완료하면 이전에 요청한 질문 등록 페이지가 등장한다. 이는 로그인 후에 원래 가려고 했던 페이지로 리다이렉트시키는 스프링 시큐리티의 기능 덕분에 가능한 것이다.
현재 질문 등록 페이지에서는 사용자가 로그아웃 상태라면 아예 글을 작성할 수 없다. 하지만 답변 등록 페이지에서는 로그아웃 상태에서도 글은 작성할 수 있어서 답변을 작성한 후 [답변 등록] 버튼을 눌러야만 로그인 화면으로 이동된다. 이렇게 되면 애써 사용자가 작성한 답변이 사라지는 문제가 있다. 이 문제를 해결하려면 사용자가 로그아웃 상태인 경우 아예 답변 작성을 못하게 막는 것이 좋은 방법일 것이다.
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
...
<!-- 답변 작성 -->
<form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" method="post" class="my-3">
<div th:replace="~{form_errors :: formErrorsFragment}"></div>
<textarea sec:authorize="isAnonymous()" disabled th:field="*{content}" class="form-control" rows="10"></textarea>
<textarea sec:authorize="isAuthenticated()" th:field="*{content}" class="form-control" rows="10"></textarea>
<input type="submit" value="답변등록" class="btn btn-primary my-2">
</form>
</div>
</html>
로그인 상태가 아닌 경우 textarea 태그에 disabled 속성을 적용하여 사용자가 화면에서 아예 입력하지 못하게 만들었다. 여기서 sec:authorize="isAnonymous()", sec:authorize="isAuthenticated()"는 현재 사용자의 로그인 상태를 체크하는 속성으로, sec:authorize="isAnonymous()"는 현재 로그아웃 상태임을 의미하고, sec:authorize="isAuthenticated()"는 현재 로그인 상태임을 의미한다.
질문 목록에 글쓴이 표시하기
...
<tr class="text-center">
<th>번호</th>
<th style="width:50%">제목</th>
<th>글쓴이</th>
<th>작성일시</th>
</tr>
...
...
<tr class="text-center" th:each="question, loop : ${paging}">
<td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
<td class="text-start">
<a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
<span class="text-danger small ms-2" th:if="${#lists.size(question.answerList) > 0}"
th:text="${#lists.size(question.answerList)}">
</span>
</td>
<td><span th:if="${question.author != null}" th:text="${question.author.username}"></span></td>
<td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
</tr>
...
<td> ... </td> 요소를 삽입하여 질문의 글쓴이를 표시했다. 글쓴이 정보 없이 저장된 기존의 질문들은 author 속성에 해당하는 데이터가 없으므로 author 속성의 값이 null이 아닌 경우만 글쓴이를 표시하도록 했다. 그리고 여기서도 표시되는 항목을 모두 가운데 정렬하도록 tr 요소에 text-center 클래스를 추가하고, 제목 항목의 값들만 왼쪽 정렬하도록 text-start 클래스를 추가했다.

질문 상세에 글쓴이 표시하기
...
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
<div class="d-flex justify-content-end">
<div class="badge bg-light text-dark p-2 text-start">
<div class="mb-2">
<span th:if="${question.author != null}" th:text="${question.author.username}"></span>
</div>
<div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
</div>
</div>
...
...
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${answer.content}"></div>
<div class="d-flex justify-content-end">
<div class="badge bg-light text-dark p-2 text-start">
<div class="mb-2">
<span th:if="${answer.author != null}" th:text="${answer.author.username}"></span>
</div>
<div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
</div>
</div>
<!-- 답변 반복 끝 -->
...

public class Question {
...
private LocalDateTime modifyDate;
}
public class Answer {
...
private LocalDateTime modifyDate;
}
질문 수정 버튼 만들기
...
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
<div class="d-flex justify-content-end">
<div class="badge bg-light text-dark p-2 text-start">
<div class="mb-2">
<span th:if="${question.author != null}" th:text="${question.author.username}"></span>
</div>
<div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
<div class="my-3">
<a th:href="@{|/question/modify/${question.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
th:text="수정"></a>
</div>
</div>
</div>
...
[수정] 버튼이 로그인한 사용자와 글쓴이가 동일할 경우에만 노출되도록 #authentication.getPrincipal().getUsername() == question.author.username을 적용했다. #authentication.getPrincipal()은 타임리프에서 스프링 시큐리티와 함께 사용하는 표현식으로, 이를 통해 현재 사용자가 인증되었다면 사용자 이름(사용자 ID)을 알 수 있다. 만약 로그인한 사용자와 글쓴이가 다르다면 이 [수정] 버튼은 보이지 않을 것이다.
질문 컨트롤러 수정하기 1
public class QuestionController {
...
@PreAuthorize("isAuthenticated()")
@GetMapping("/modify/{id}")
public String questionModify(QuestionForm questionForm, @PathVariable("id") Integer id, Principal principal) {
Question question = this.questionService.getQuestion(id);
if(!question.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
}
questionForm.setSubject(question.getSubject());
questionForm.setContent(question.getContent());
return "question_form";
}
}
이와 같이 questionModify 메서드를 추가했다. 만약 현재 로그인한 사용자와 질문의 작성자가 동일하지 않을 경우에는 '수정 권한이 없습니다.'라는 오류가 발생하도록 했다. 그리고 수정할 질문의 제목과 내용을 화면에 보여 주기 위해 questionForm 객체에 id값으로 조회한 질문의 제목(subject)과 내용(object)의 값을 담아서 템플릿으로 전달했다. 이 과정이 없다면 질문 수정 화면에 '제목', '내용'의 값이 채워지지 않아 비워져 보일 것이다.
질문 등록 템플릿 수정하기
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
<h5 class="my-3 border-bottom pb-2">질문등록</h5>
<form th:object="${questionForm}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div th:replace="~{form_errors :: formErrorsFragment}"></div>
<div class="mb-3">
<label for="subject" class="form-label">제목</label>
<input type="text" th:field="*{subject}" class="form-control">
</div>
<div class="mb-3">
<label for="content" class="form-label">내용</label>
<textarea th:field="*{content}" class="form-control" rows="10"></textarea>
</div>
<input type="submit" value="저장하기" class="btn btn-primary my-2">
</form>
</div>
</html>
기존에 있던 <form> 태그의 th:action 속성을 삭제해야 한다. 단, th:action 속성을 삭제하면 CSRF값이 자동으로 생성되지 않아서 CSRF값을 설정하기 위해 hidden 형태로 input 요소를 이와 같이 작성하여 추가해야 한다.
CSRF값을 수동으로라도 추가해야 되는 이유는 스프링 시큐리티를 사용할 때 CSRF 값이 반드시 필요하기 때문이다.
<form> 태그의 action 속성 없이 폼을 전송(submit)하면 action 속성이 없더라도 자동으로 현재 URL을 기준으로 전송되는 규칙이 있다. 즉, 질문 등록 시에 브라우저에 표시되는 URL은 /question/create이어서 action 속성이 지정되지 않더라도 POST로 폼 전송할 때 action 속성으로 /question/create가 자동 설정되고, 질문 수정 시에 브라우저에 표시되는 URL은 /question/modify/2와 같은 URL이기 때문에 POST로 폼 전송할 때 action 속성에 /question/modify/2와 같은 URL이 설정되는 것이다.
질문 서비스 수정하기
public class QuestionService {
...
public void modify(Question question, String subject, String content) {
question.setSubject(subject);
question.setContent(content);
question.setModifyDate(LocalDateTime.now());
this.questionRepository.save(question);
}
}
질문 컨트롤러 수정하기 2
(... 생략 ...)
public class QuestionController {
(... 생략 ...)
@PreAuthorize("isAuthenticated()")
@PostMapping("/modify/{id}")
public String questionModify(@Valid QuestionForm questionForm, BindingResult bindingResult,
Principal principal, @PathVariable("id") Integer id) {
if (bindingResult.hasErrors()) {
return "question_form";
}
Question question = this.questionService.getQuestion(id);
if (!question.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
}
this.questionService.modify(question, questionForm.getSubject(), questionForm.getContent());
return String.format("redirect:/question/detail/%s", id);
}
}
POST 형식의 /question/modify/{id} 요청을 처리하기 위해 이와 같이 questionModify 메서드를 추가했다. questionModify 메서드는 questionForm의 데이터를 검증하고 로그인한 사용자와 수정하려는 질문의 작성자가 동일한지도 검증한다. 검증이 통과되면 QuestionService에서 작성한 modify 메서드를 호출하여 질문 데이터를 수정한다. 그리고 수정이 완료되면 질문 상세 화면(/question/detail/(숫자))으로 리다이렉트한다.
수정 기능 확인하기

질문 삭제 버튼 만들기
...
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
<div class="card-body">
(... 생략 ...)
<div class="my-3">
<a th:href="@{|/question/modify/${question.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
th:text="수정"></a>
<a href="javascript:void(0);" th:data-uri="@{|/question/delete/${question.id}|}"
class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
th:text="삭제"></a>
</div>
</div>
</div>
...
로그인한 사용자가 자신이 작성한 질문을 삭제할 수 있도록 [삭제] 버튼을 클릭하면 자바스크립트 코드가 실행되도록 구현했다. [삭제] 버튼은 [수정] 버튼과는 달리 href 속성값을 javascript:void(0)로 설정하고 삭제를 실행할 URL을 얻기 위해 th:data-uri 속성을 추가한 뒤, [삭제] 버튼을 클릭하는 이벤트를 확인하기 위해 class 속성에 delete 항목을 추가했다.
href에 삭제를 위한 URL을 직접 사용하지 않고 이러한 방식을 사용한 이유는 [삭제] 버튼을 클릭했을 때 ‘정말로 삭제하시겠습니까?’와 같은 메시지와 함께 별도의 확인 절차를 중간에 끼워 넣기 위해서이다. 만약 href에 삭제를 위한 URL을 직접 사용한다면 삭제를 확인하는 과정을 거치지 않고 질문이 삭제되어 버릴 것이다.
data-uri 속성에 설정한 값은 클릭 이벤트 발생 시 별도의 자바스크립트 코드에서 this.dataset.uri를 사용하여 그 값을 얻어 실행할 수 있다.
삭제를 위한 자바스크립트 작성하기
<script type='text/javascript'>
const delete_elements = document.getElementsByClassName("delete");
Array.from(delete_elements).forEach(function(element) {
element.addEventListener('click', function() {
if(confirm("정말로 삭제하시겠습니까?")) {
location.href = this.dataset.uri;
};
});
});
</script>
이 자바스크립트 코드의 의미는 delete라는 클래스를 포함하는 컴포넌트(예를 들어 버튼이나 링크 등)를 클릭하면 ‘정말로 삭제하시겠습니까?’라고 질문하고 [확인]을 클릭했을 때 해당 컴포넌트에 속성으로 지정된 data-uri값으로 URL을 호출하라는 의미이다. [확인] 대신 [취소]를 선택하면 아무런 일도 발생하지 않을 것이다. 따라서 이와 같은 스크립트를 추가하면 [삭제] 버튼을 클릭하고 [확인]을 선택하면 data-uri 속성에 해당하는 @{|/question/delete/${question.id}|} URL이 호출될 것이다.
</body> 태그 바로 위에 삽입하는 것을 추천한다.<html>
<head>
...
</head>
<body>
...
<!-- 이곳에 추가 -->
</body>
</html>
왜냐하면 화면 출력이 완료된 후에 자바스크립트가 실행되는 것이 좋기 때문이다. 화면 출력이 완료되지 않은 상태에서 자바스크립트를 실행하면 오류가 발생할 수도 있고 화면 로딩이 지연될 수도 있다. 따라서 각 템플릿에서 자바스크립트를 </body> 태그 바로 위에 삽입하고, 상속할 수 있도록 layout.html을 수정해 보자.
<!doctype html>
<html lang="ko">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
<!-- sbb CSS -->
<link rel="stylesheet" type="text/css" th:href="@{/style.css}">
<title>Hello, sbb!</title>
</head>
<body>
<!-- 네비게이션바 -->
<nav th:replace="~{navbar :: navbarFragment}"></nav>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
<!-- Bootstrap JS -->
<script th:src="@{/bootstrap.min.js}"></script>
<!-- 자바스크립트 Start -->
<th:block layout:fragment="script"></th:block>
<!-- 자바스크립트 End -->
</body>
</html>
layout.html을 상속하는 템플릿들에서 content 블록을 구현하게 했던 것과 마찬가지 방법으로 script 블록을 구현할 수 있도록 </body> 태그 바로 위에 <th:block layout:fragment="script"></th:block> 블록을 추가했다. 이렇게 하면 이제 layout.html을 상속하는 템플릿은 자바스크립트의 삽입 위치를 신경 쓰지 않아도 되고, 필요할 경우에 스크립트 블록을 구현하여 자바스크립트를 작성할 수 있다.
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
...
</div>
<script layout:fragment="script" type='text/javascript'>
const delete_elements = document.getElementsByClassName("delete");
Array.from(delete_elements).forEach(function(element) {
element.addEventListener('click', function() {
if(confirm("정말로 삭제하시겠습니까?")) {
location.href = this.dataset.uri;
};
});
});
</script>
</html>
질문 서비스와 컨트롤러 수정하기
public class QuestionService {
...
public void delete(Question question) {
this.questionRepository.delete(question);
}
}
질문 데이터를 삭제하는 delete 메서드를 추가했다.
public class QuestionController {
...
@PreAuthorize("isAuthenticated()")
@GetMapping("/delete/{id}")
public String questionDelete(Principal principal, @PathVariable("id") Integer id) {
Question question = this.questionService.getQuestion(id);
if (!question.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제권한이 없습니다.");
}
this.questionService.delete(question);
return "redirect:/";
}
}
사용자가 [삭제] 버튼을 클릭했다면 URL로 전달받은 id값을 사용하여 Question 데이터를 조회한 후, 로그인한 사용자와 질문 작성자가 동일할 경우 앞서 작성한 서비스를 이용하여 질문을 삭제하게 했다. 그리고 질문을 삭제한 후에는 질문 목록 화면(/)으로 돌아갈 수 있도록 했다.

버튼 추가하고 서비스와 컨트롤러 수정하기
...
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${answer.content}"></div>
<div class="d-flex justify-content-end">
<div class="badge bg-light text-dark p-2 text-start">
<div class="mb-2">
<span th:if="${answer.author != null}" th:text="${answer.author.username}"></span>
</div>
<div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
<div class="my-3">
<a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
th:text="수정"></a>
</div>
</div>
</div>
<!-- 답변 반복 끝 -->
...
로그인한 사용자와 답변 작성자가 동일한 경우 답변의 [수정] 버튼이 노출되도록 했다. [답변] 버튼을 누르면 '/answer/modify/{답변 ID}' 형태의 URL이 GET 방식으로 요청될 것이다.
public class AnswerService {
...
public Answer getAnswer(Integer id) {
Optional<Answer> answer = this.answerRepository.findById(id);
if (answer.isPresent()) {
return answer.get();
} else {
throw new DataNotFoundException("answer not found");
}
}
public void modify(Answer answer, String content) {
answer.setContent(content);
answer.setModifyDate(LocalDateTime.now());
this.answerRepository.save(answer);
}
}
해당 답변을 조회하는 getAnswer 메서드와 답변 내용을 수정하는 modify 메서드를 추가했다.
public class AnswerController {
...
@PreAuthorize("isAuthenticated()")
@GetMapping("/modify/{id}")
public String answerModify(AnswerForm answerForm, @PathVariable("id") Integer id, Principal principal) {
Answer answer = this.answerService.getAnswer(id);
if (!answer.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
}
answerForm.setContent(answer.getContent());
return "answer_form";
}
}
answerModify 메서드를 추가했다. DB에서 답변 ID를 통해 조회한 답변 데이터의 내용(content)을 AnswerForm 객체에 대입하여 answer_form.html 템플릿에서 사용할 수 있도록 했다.
답변 수정 시 기존의 답변 내용이 필요하므로 AnswerForm 객체에 조회한 값을 저장하여 리턴해야 한다.
답변 수정 템플릿 생성하기
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
<h5 class="my-3 border-bottom pb-2">답변 수정</h5>
<form th:object="${answerForm}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div th:replace="~{form_errors :: formErrorsFragment}"></div>
<div class="mb-3">
<label for="content" class="form-label">내용</label>
<textarea th:field="*{content}" class="form-control" rows="10"></textarea>
</div>
<input type="submit" value="저장하기" class="btn btn-primary my-2">
</form>
</div>
</html>
답변 작성 시 사용하는 <form> 태그에도 역시 action 속성을 사용하지 않았다. action 속성을 생략하면 현재 호출된 URL로 폼이 전송된다. th:action 속성이 없으므로 csrf 항목을 직접 추가했다.
답변 컨트롤러 재수정하기
public class AnswerController {
...
@PreAuthorize("isAuthenticated()")
@PostMapping("/modify/{id}")
public String answerModify(@Valid AnswerForm answerForm, BindingResult bindingResult,
@PathVariable("id") Integer id, Principal principal) {
if (bindingResult.hasErrors()) {
return "answer_form";
}
Answer answer = this.answerService.getAnswer(id);
if (!answer.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
}
this.answerService.modify(answer, answerForm.getContent());
return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
}
}
POST 방식의 답변 수정을 처리하기 위해 answerModify 메서드를 추가했다. 그리고 답변 수정을 완료한 후에는 질문 상세 페이지로 리다이렉트하도록 했다.

...
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
<div class="card-body">
(... 생략 ...)
<div class="my-3">
<a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
th:text="수정"></a>
<a href="javascript:void(0);" th:data-uri="@{|/answer/delete/${answer.id}|}"
class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
th:text="삭제"></a>
</div>
</div>
</div>
<!-- 답변 반복 끝 -->
...
[수정] 버튼 옆에 [삭제] 버튼이 노출되도록 [삭제] 버튼을 생성하는 코드를 추가했다. 질문의 [삭제] 버튼과 마찬가지로 답변의 [삭제] 버튼에 delete 클래스를 적용했으므로 [삭제] 버튼을 누르면 앞서 작성한 자바스크립트에 의해 data-uri 속성에 설정한 url이 실행된다.
public class AnswerService {
...
public void delete(Answer answer) {
this.answerRepository.delete(answer);
}
}
public class AnswerController {
...
@PreAuthorize("isAuthenticated()")
@GetMapping("/delete/{id}")
public String answerDelete(Principal principal, @PathVariable("id") Integer id) {
Answer answer = this.answerService.getAnswer(id);
if (!answer.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제권한이 없습니다.");
}
this.answerService.delete(answer);
return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
}
}
답변을 삭제하는 answerDelete 메서드를 추가했다. 답변을 삭제한 후에는 해당 답변이 있던 질문 상세 화면으로 이동할 수 있도록 만들었다.

...
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
<div class="d-flex justify-content-end">
<div th:if="${question.modifyDate != null}" class="badge bg-light text-dark p-2 text-start mx-3">
<div class="mb-2">modified at</div>
<div th:text="${#temporals.format(question.modifyDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
<div class="badge bg-light text-dark p-2 text-start">
<div class="mb-2">
<span th:if="${question.author != null}" th:text="${question.author.username}"></span>
</div>
<div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
...
</div>
</div>
...
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
<div class="card-body">
<div class="card-text" style="white-space: pre-line;" th:text="${answer.content}"></div>
<div class="d-flex justify-content-end">
<div th:if="${answer.modifyDate != null}" class="badge bg-light text-dark p-2 text-start mx-3">
<div class="mb-2">modified at</div>
<div th:text="${#temporals.format(answer.modifyDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
<div class="badge bg-light text-dark p-2 text-start">
<div class="mb-2">
<span th:if="${answer.author != null}" th:text="${answer.author.username}"></span>
</div>
<div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
...
</div>
</div>
<!-- 답변 반복 끝 -->
...

public class Question {
...
@ManyToMany
Set<SiteUser> voter;
}
@ManyToMany 애너테이션과 함께 Set<SiteUser> voter를 작성해 voter 속성을 다대다 관계로 설정하여 질문 엔티티에 추가했다. 이때 다른 속성과 달리 Set 자료형으로 작성한 이유는 voter 속성값이 서로 중복되지 않도록 하기 위해서이다. List 자료형과 달리 여기서는 Set 자료형이 voter 속성을 관리하는데 효율적이다.
public class Answer {
...
@ManyToMany
Set<SiteUser> voter;
}
author 속성을 추가할 때와 달리 QUESTION_VOTER, ANSWER_VOTER라는 테이블이 생성된 것을 확인할 수 있다. 이렇게 @ManyToMany 애너테이션을 사용해 다대다 관계로 속성을 생성하면 새로운 테이블을 만들어 관련 데이터를 관리한다. 여기서 생성된 테이블의 인덱스 항목을 펼쳐 보면 서로 연관된 엔티티의 고유 번호(즉, ID)가 기본키로 설정되어 다대다 관계임을 알 수 있다.
...
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
<div class="card-body">
(... 생략 ...)
<div class="my-3">
<a href="javascript:void(0);" class="recommend btn btn-sm btn-outline-secondary"
th:data-uri="@{|/question/vote/${question.id}|}">
추천
<span class="badge rounded-pill bg-success" th:text="${#lists.size(question.voter)}"></span>
</a>
<a th:href="@{|/question/modify/${question.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
th:text="수정"></a>
<a href="javascript:void(0);" th:data-uri="@{|/question/delete/${question.id}|}"
class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
th:text="삭제"></a>
</div>
</div>
</div>
...
[추천] 버튼을 [수정] 버튼 왼쪽에 추가하기 위한 코드를 작성했다. lists.size 메서드에 question.voter를 사용하여 추천 수도 함께 보이도록 했다. [추천] 버튼을 클릭하면 href의 속성이 javascript:void(0)으로 되어 있어서 아무런 동작도 하지 않는다. 하지만 class 속성에 recommend를 적용해 자바스크립트로 data-uri에 정의된 URL이 호출되도록 할 것이다. 따라서 [삭제] 버튼과 마찬가지로 [추천] 버튼을 눌렀을 때 메시지가 적힌 팝업 창을 통해 추천을 진행할 것이다.
class="recommend btn btn-sm btn-outline-secondary" 에서 recommend는 추천 버튼을 클릭하는 이벤트를 얻기 위한 클래스이다.
...
<script layout:fragment="script" type='text/javascript'>
const delete_elements = document.getElementsByClassName("delete");
Array.from(delete_elements).forEach(function(element) {
element.addEventListener('click', function() {
if(confirm("정말로 삭제하시겠습니까?")) {
location.href = this.dataset.uri;
};
});
});
const recommend_elements = document.getElementsByClassName("recommend");
Array.from(recommend_elements).forEach(function(element) {
element.addEventListener('click', function() {
if(confirm("정말로 추천하시겠습니까?")) {
location.href = this.dataset.uri;
};
});
});
</script>
</html>
[추천] 버튼에 recommend 클래스가 적용되어 있으므로 [추천] 버튼을 클릭하면 '정말로 추천하시겠습니까?'라는 메시지가 담긴 팝업 창이 나타나고, [확인]을 선택하면 data-uri 속성에 정의한 URL인 @{|/question/vote/${question.id}|}이 호출될 것이다.
public class QuestionService {
...
public void vote(Question question, SiteUser siteUser) {
question.getVoter().add(siteUser);
this.questionRepository.save(question);
}
}
로그인한 사용자를 질문 엔티티에 추천인으로 저장하기 위해 vote 메서드를 추가했다.
public class QuestionController {
...
@PreAuthorize("isAuthenticated()")
@GetMapping("/vote/{id}")
public String questionVote(Principal principal, @PathVariable("id") Integer id) {
Question question = this.questionService.getQuestion(id);
SiteUser siteUser = this.userService.getUser(principal.getName());
this.questionService.vote(question, siteUser);
return String.format("redirect:/question/detail/%s", id);
}
}
questionVote 메서드를 추가했다. 다른 기능과 마찬가지로 추천 기능도 로그인한 사람만 사용할 수 있도록 @PreAuthorize("isAuthenticated( )") 애너테이션을 적용했다. 그리고 앞서 작성한 QuestionService의 vote 메서드를 호출하여 사용자(siteUser)를 추천인(voter)으로 저장했다. 오류가 없다면 추천인을 저장한 후 질문 상세 화면으로 리다이렉트한다.

...
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
<div class="card-body">
...
<div class="my-3">
<a href="javascript:void(0);" class="recommend btn btn-sm btn-outline-secondary"
th:data-uri="@{|/answer/vote/${answer.id}|}">
추천
<span class="badge rounded-pill bg-success" th:text="${#lists.size(answer.voter)}"></span>
</a>
<a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-sm btn-outline-secondary"
sec:authorize="isAuthenticated()"
th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
th:text="수정"></a>
<a href="javascript:void(0);" th:data-uri="@{|/answer/delete/${answer.id}|}"
class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
th:text="삭제"></a>
</div>
</div>
</div>
<!-- 답변 반복 끝 -->
...
질문 추천 기능을 만들 때와 마찬가지로 답변 영역의 상단에 답변을 추천할 수 있는 버튼을 생성했다. 이 역시 추천 버튼에 class="recommend"가 적용되어 있으므로 추천 버튼을 클릭하면 '정말로 추천하시겠습니까?'라는 메시지가 적힌 팝업 창이 나타나고 [확인]을 선택하면 data-uri 속성에 정의한 URL이 호출될 것이다.
public class AnswerService {
...
public void vote(Answer answer, SiteUser siteUser) {
answer.getVoter().add(siteUser);
this.answerRepository.save(answer);
}
}
public class AnswerController {
...
@PreAuthorize("isAuthenticated()")
@GetMapping("/vote/{id}")
public String answerVote(Principal principal, @PathVariable("id") Integer id) {
Answer answer = this.answerService.getAnswer(id);
SiteUser siteUser = this.userService.getUser(principal.getName());
this.answerService.vote(answer, siteUser);
return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
}
}
answerVote 메서드를 추가했다. 추천은 로그인한 사람만 가능해야 하므로 @PreAuthorize("isAuthenticated( )") 애너테이션을 적용했다. 그리고 앞서 작성한 AnswerService의 vote 메서드를 호출하여 추천인을 저장한다. 오류가 없다면 추천인을 저장한 후 질문 상세 화면으로 리다이렉트한다.

이번에는 SBB의 문제점을 해결하려고 한다. 발견된 문제점은 답변을 작성하거나 수정하면 페이지 상단으로 스크롤이 이동해서 자신이 작성한 답변을 확인하려면 다시 스크롤을 내려서 확인해야 한다는 점이다. 이 문제는 답변을 추천한 경우에도 동일하게 발생한다. Ajax와 같은 비동기 통신 기술로 이 문제를 해결할 수도 있지만 여기서는 보다 쉬운 방법을 사용하려고 한다. HTML에는 URL 호출 시 원하는 위치로 이동해 주는 앵커(anchor) 태그 즉, <a> 태그가 있는데, 이를 활용하면 답변 등록, 답변 수정, 답변 추천 시 앵커 태그를 이용하여 원하는 위치로 이동할 수 있다.
...
<!-- 답변의 갯수 표시 -->
<h5 class="border-bottom my-3 py-2"
th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
<a th:id="|answer_${answer.id}|"></a>
<div class="card-body">
...
답변이 반복되어 표시되도록 하는 th:each 문장 바로 다음에 <a th:id="|answer_${answer.id}|"></a>와 같이 앵커 태그를 추가했다. 앵커 태그의 id 속성은 유일한 값이어야 하므로 답변의 id값을 사용했다.
앵커 태그의 id 속성이 유일하지 않고 중복된 값이 존재한다면 맨 처음 한 개를 제외한 나머지 앵커는 제대로 동작하지 않는다.
이제 답변을 등록하거나 수정할 때 앞서 지정한 앵커 태그를 사용해 원하는 화면 위치로 이동할 수 있도록 코드를 수정하려고 한다.
return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
이와 같은 코드에 앵커를 포함하여 다음과 같이 수정할 수 있다.
return String.format("redirect:/question/detail/%s#answer_%s",
answer.getQuestion().getId(), answer.getId());
리다이렉트되는 질문 상세 페이지 URL에 #answer_%s를 이와 같이 삽입하여 앵커를 추가한 것이다. 이때 수정해야 하는 곳은 총 3곳으로 답변 등록, 수정, 추천 부분에서 리다이렉트와 관련된 코드를 수정하면 된다.
답변 컨트롤러에서 답변이 등록된 위치로 이동하려면 반드시 답변 객체, 즉 Answer 객체가 필요하다. 그동안 AnswerService에서는 답변 등록 시 답변 객체를 리턴하지 않으므로 다음과 같이 AnswerService를 먼저 수정해 보자.
public class AnswerService {
...
public Answer create(Question question, String content, SiteUser author) {
Answer answer = new Answer();
answer.setContent(content);
answer.setCreateDate(LocalDateTime.now());
answer.setQuestion(question);
answer.setAuthor(author);
this.answerRepository.save(answer);
return answer;
}
...
}
public class AnswerController {
...
@PreAuthorize("isAuthenticated()")
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable("id") Integer id,
@Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
Question question = this.questionService.getQuestion(id);
SiteUser siteUser = this.userService.getUser(principal.getName());
if (bindingResult.hasErrors()) {
model.addAttribute("question", question);
return "question_detail";
}
Answer answer = this.answerService.create(question,
answerForm.getContent(), siteUser);
return String.format("redirect:/question/detail/%s#answer_%s",
answer.getQuestion().getId(), answer.getId());
}
...
@PreAuthorize("isAuthenticated()")
@PostMapping("/modify/{id}")
public String answerModify(@Valid AnswerForm answerForm, @PathVariable("id") Integer id,
BindingResult bindingResult, Principal principal) {
if (bindingResult.hasErrors()) {
return "answer_form";
}
Answer answer = this.answerService.getAnswer(id);
if (!answer.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
}
this.answerService.modify(answer, answerForm.getContent());
return String.format("redirect:/question/detail/%s#answer_%s",
answer.getQuestion().getId(), answer.getId());
}
...
@PreAuthorize("isAuthenticated()")
@GetMapping("/vote/{id}")
public String answerVote(Principal principal, @PathVariable("id") Integer id) {
Answer answer = this.answerService.getAnswer(id);
SiteUser siteUser = this.userService.getUser(principal.getName());
this.answerService.vote(answer, siteUser);
return String.format("redirect:/question/detail/%s#answer_%s",
answer.getQuestion().getId(), answer.getId());
}
}
답변을 작성, 수정, 추천한 후에 해당 답변으로 이동할 수 있도록 앵커 태그를 추가했다.
질문 상세 페이지에서 답변을 등록, 수정, 추천을 실행하면 자신이 작업한 답변 부분으로 돌아온다. 즉, 스크롤이 지정한 앵커로 이동함을 확인할 수 있다.

깃허브(Github), 노션(Notion)과 같이 우리가 자주 사용하는 서비스에서는 글을 작성할 때 마크다운(markdown)이라는 도구를 사용한다. 마크다운은 텍스트 기반의 마크업 언어로, HTML과 달리 쉽고 간단한 문법을 사용하며 텍스트 편집기를 통해 웹상에서 글자를 강조하거나 제목, 목록, 이미지, 링크 등을 추가할 때도 유용하게 활용할 수 있다.
SBB 서비스에도 질문이나 답변 등의 글쓰기 작성 도구로 마크다운을 적용해 보자.
<!-- 목록 표시하기 -->
* 자바
* 스프링부트
* 알고리즘
1. 하나
1. 둘
1. 셋
<!-- 강조 표시하기 -->
스프링부트는 **자바**로 만들어진 웹 프레임워크이다.
<!-- 링크 표시하기 -->
스프링 홈페이지는 https://spring.io 입니다.
<!-- 링크 표시하기 -->
스프링 홈페이지는 https://spring.io 입니다.
<!-- 소스 코드 표시하기 -->
<!-- ```
@Controller
public class HelloController {
@GetMapping("/hello")
@ResponseBody
public String hello() {
return "Hello Spring Boot Board";
}
}
``` -->
<!-- 소스 코드 표시하기 -->
> 마크다운은 Github에서 사용하는 글쓰기 도구이다.
dependencies {
...
implementation 'org.commonmark:commonmark:0.21.0'
}
commonmark는 버전 정보를 왜 입력할까?
지금까지 build.gradle 파일에 필요한 라이브러리를 등록할 때 버전을 명시하지 않았다. 하지만 commonmark는 이와 같이 0.21.0이라는 버전을 지정해야 한다. 왜냐하면 스프링 부트의 라이브러리 관리 방식 때문이다. 스프링 부트가 내부적으로 관리하는 라이브러리에 포함되면 버전 정보가 필요 없고 포함되지 않으면 버전 정보가 필요하다. 즉, commonmark는 스프링 부트가 내부적으로 관리하는 라이브러리가 아니어서 이와 같이 명시하는 것이다. 참고로, 스프링 부트가 관리하는 라이브러리의 경우 버전 정보를 명시하지 않으면 스프링 부트가 가장 궁합이 잘 맞는 버전을 자동으로 선택한다. 따라서 라이브러리들의 호환성을 생각한다면 버전 정보는 따로 입력하지 않는 편이 좋다.
package com.mysite.sbb;
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 클래스는 스프링 부트가 관리하는 빈으로 등록된다. 이렇게 빈으로 등록된 컴포넌트는 템플릿에서 사용할 수 있다.
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">
...
질문과 답변 영역에 마크다운을 각각 적용하기 위해 줄 바꿈을 표시하려고 사용한 기존의 style="white-space: pre-line;" 스타일을 삭제하고 ${@commonUtil.markdown(question.content)}와 같이 마크다운 컴포넌트를 적용했다. 이때 th:text가 아닌 th:utext를 사용한 부분에 주의하자. 만약 th:utext 대신 th:text를 사용할 경우 HTML의 태그들이 이스케이프(escape) 처리되어 화면에 그대로 보일 것이다. 마크다운으로 변환된 HTML 문서를 제대로 표시하려면 이스케이프 처리를 하지 않고 출력하는 th:utext를 사용해야 한다.


검색 대상으로는 질문 제목, 질문 내용, 질문 작성자, 답변 내용, 답변 작성자이다. 예를 들어 ‘스프링’을 검색하면 ‘스프링’이라는 문자열이 제목, 내용, 질문 작성자, 답변, 답변 작성자에 존재하는지 찾아보고 그 결과를 화면에 보여준다.
이런 조건으로 검색하려면 다음과 같은 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 인터페이스를 사용하는 것이 편리하다. 이 인터페이스는 DB 검색을 더 유연하게 다룰 수 있고, 복잡한 검색 조건도 처리할 수 있다.
검색 기능을 구현하기 위해 다음과 같이 QuestionService에 search 메서드를 추가
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문을 Specification 객체로 생성하여 리턴하는 메서드이다.
그리고 검색어(kw)가 포함되어 있는지를 like 키워드로 검색하기 위해 제목, 내용, 질문 작성자, 답변 내용, 답변 작성자 각각에 cb.like를 사용하고 최종적으로 cb.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 엔티티를 조회한 결과를 페이징하여 반환한다.
질문 서비스 수정하기
검색어를 포함하여 질문 목록을 조회하기 위해 다시 QuestionService로 돌아와 getList 메서드를 수정
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 메서드 호출 시 전달했다.
질문 컨트롤러 수정하기
QuestionService의 getList 메서드의 입력 항목이 변경되었으므로 QuestionController도 다음과 같이 수정해야 한다.
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 매개변수를 추가했고 기본값으로 빈 문자열을 설정했다.
검색어가 입력되지 않을 경우 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>
<!-- 페이징처리 시작 -->
...
<!-- 페이징처리 끝 -->
<!-- 기존 코드 삭제 -->
</div>
</html>
질문 목록 위에 검색창이 노출되도록 이와 같이 입력하여 <table> 태그 상단 오른쪽에 검색어를 입력할 수 있는 텍스트 창을 생성했다. 이와 더불어 기존에 아래에 있던 [질문 등록하기] 버튼은 검색창의 왼쪽에 배치되도록 수정했다. 그리고 자바스크립트에서 이 검색창에 입력된 값을 읽을 수 있도록 다음과 같이 검색창의 id 속성에 'search_kw'라는 값을 추가하였다.
<input type="text" id="search_kw" class="form-control" th:value="${kw}">
검색 폼 만들기
page와 kw를 동시에 GET 방식으로 요청하기 위해 searchForm을 앞서 코드를 삭제한 자리에 다음과 같이 추가
...
<!-- 페이징처리 끝 -->
<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를 지정했다.
POST 방식이 아니라 왜 GET 방식을 사용할까?
page, kw를 POST 방식으로 전달하는 방법은 추천하고 싶지 않다. 만약 POST 방식으로 검색과 페이징을 처리한다면 웹 브라우저에서 '새로 고침' 또는 '뒤로 가기'를 했을 때 '만료된 페이지입니다.'라는 오류를 만날 것이다.
왜냐하면 브라우저는 동일한 POST 요청이 발생할 경우, 예를 들어 2페이지에서 3페이지로 이동한 후 '뒤로가기'를 통해 2페이지로 이동하는 것과 같은 중복 요청을 방지하기 위해 '만료된 페이지입니다.'라는 오류를 발생시키기 때문이다. 이러한 이유로 여러 매개변수를 조합하여 게시물 목록을 조회할 때는 GET 방식을 사용하는 것을 강력히 권장한다.
페이징 수정하기
페이징을 처리하는 부분도 기존의 ?page=1과 같이 직접 URL을 링크하는 방식이 아니라 값을 읽어 폼에 설정할 수 있도록 다음과 같이 변경해야 한다. 왜냐하면 검색어가 있을 경우 검색어와 페이지 번호를 함께 전송해야 하기 때문이다.
...
<!-- 페이징처리 시작 -->
<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>
검색 기능 확인하기

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로 참조된다.
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 인터페이스를 사용하기 위해 작성했던 내용 대신 이와 같이 작성해도 동일하게 동작할 것이다. 이와 같이 SQL을 알고, @Query 애너테이션을 사용한다면 검색 기능을 좀 더 간단하게 구현할 수 있다.