2장 2-10, 2-11 요약 (질문 상세 페이지 코드 리뷰(질문 상세내용과 답변 기능), URL 프리픽스)
2-10 상세 페이지 만들기
질문 목록에 링크 추가하기
question.html 파일에서 게시글의 제목을 클릭하면 게시글 상세 페이지로 이동하게 하기 위해
a태그에 링크를 걸어준다.
<td>
<a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
</td>
@{ 와 } 사이에 링크를 건다.
만약 작성하려는 링크에 @{question.id} 와 같이 자바 값이 포함되어있는 값을 작성해야 한다면
@{| 와 |} 사이에 작성해야 한다.
상세 페이지 컨트롤러
package com.study.board.question;
import java.security.Principal;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
import com.study.board.answer.Answer;
import com.study.board.answer.AnswerForm;
import com.study.board.siteUser.SiteUser;
import com.study.board.siteUser.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Controller
public class QuestionController {
private final QuestionService questionService;
private final UserService userService;
//질문상세 진입
//AnswerForm 매개변수 : questionDetail.html 화면 아래 answer영역에
//필수체크 하려고 AnswerForm과 연결을 위해 th:object 작성하였다.
//즉, 해당 화면에 AnswerForm 을 넘겨주어야해서 매개변수 추가
@GetMapping(value="/question/detail/{id}")
public String getQuestionDetail(Model model
, @PathVariable("id") Integer id
, AnswerForm answerForm) {
Question question = this.questionService.getQuestion(id);
model.addAttribute("question", question);
//questionDetail 화면 하단에 달린 답변을 따로 조회하지 않아도 된다.
//왜냐하면 Question 엔티티를 작성할때, answerList로 조회할 수 있도록,
//Question 엔티티와 Answer 엔티티의 연결사항을 이미 세팅해두었기 때문이다.
//answerList 부분과 Question 객체와 연결사항이 있기 떄문에
//객체에 쩜 찍고 answerList를 바로 가져올 수 있다.
return "questionDetail";
}
}
/question/detail/{id} 의 {id} 값처럼, 변하는 id 값을 얻을 때는 @PathVariable 을 사용한다.
이때 @GetMapping(value="/question/detail/{id}") 에서 사용한 id 와
@PathVariable("id")의 매개변수 이름이 동일해야 한다.
questionDetail.html 파일의 상단에는 질문 제목, 질문 내용, 질문 작성자, 질문 작성시간을 그대로 표출만 해 줄 거고,
해당 파일 하단에는 로그인한 사용자라면 누구나 답변을 달 수 있도록 "답변 폼"을 만들 것이다.
이때 답변 폼에 사용하는 게 AnswerForm 이다.
폼 클래스란, 사용자가 게시글 작성과 같은 기능을 사용할 때, 폼에 작성해야 하는 필수 값을 xxxxForm.java 라고 작성해두고, submit 시 필수 값 등을 체크할 때 사용하는 클래스이다.
questionDetail.html 의 답변 폼에는 th:object 라는 타임리프 문법이 쓰이는데,
이 문법은 폼클래스와 연결되는 부분이다.
즉, th:object 의 대상이 AnswerForm이라고 지정해두고, AnswerForm 에 작성된 필수값을 체크하는 것이다.
그러므로, questionDetail.html 화면으로 넘어갈 때 AnswerForm 도 함께 넘겨주어야 한다 (위의 코드처럼 파라미터로 넘긴다.)
상세 페이지 조회 서비스
* 선언된 import 중 불필요한 것은 무시한다.
package com.study.board.question;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import com.study.board.DataNotFoundException;
import com.study.board.answer.Answer;
import com.study.board.siteUser.SiteUser;
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 lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class QuestionService {
private final QuestionRepository questionRepository;
//질문상세 조회
public Question getQuestion(Integer id){
Optional<Question> question = this.questionRepository.findById(id);
if(question.isPresent()) {
return question.get();
}else {
throw new DataNotFoundException("data not found");
}
}
}
findById 로 가져온 값은 Optional 객체이다.
isPresent() 로 값의 유무를 먼저 살피고, 있을 경우 get() 하여 return 한다.
값이 없다면 DataNotFoundException 으로 예외를 던져준다.
예외 클래스 작성
package com.study.board;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(value=HttpStatus.NOT_FOUND, reason="entity not found")
public class DataNotFoundException extends RuntimeException{
private static final long serialVersionUID = 1L;
public DataNotFoundException(String message) {
super(message);
}
}
DataNotFoundException 클래스는 특정 엔티티 또는 데이터를 찾을 수 없을 때 발생시키는 예외 클래스이다..
이 예외가 발생하면 스프링 부트는 설정된 HTTP 상태코드(HttpStatus)와 이유 ("entity not found")를 포함한 응답을 생성하여 클라이언트에게 반환한다.
RuntimeException 클래스를 사용하는 것은 사용자 정의 예외 클래스를 정의하는 방법 중 하나이다.
RuntimeException 은 실행 시 발생하는 예외라는 의미이다.
상세 페이지 화면 questionDetail.html
<html layout:decorate="~{common/layout}">
<div layout:fragment="content" class="container my-3">
<h1 class="border-bottom py-2" th:text="${question.subject}"></h1>
<!--질문-->
<div class="card my-3">
<div class="card-body">
<!-- 마크다운 적용 위해 주석
<div class="card-text" style="white-space:pre-line;" th:text="${question.content}"></div>
-->
<!--마크다운표시.. 줄바꿈을 위해 사용한 기존 style 삭제후, 마크다운 적용,
th:text가 아닌 th:utext 사용
th:text 사용시 html 태그들이 escape 처리되어 화면에 그대로 보이게 됨.
마크다운으로 변환된 html문서를 제대로 표시하기 위해선 utext써야함
-->
<div class="card-text" th:utext="${@commonUtil.markdown(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.user != null}" th:text="${question.user.username}"></span>
</div>
<div th:text="${#temporals.format(question.writeDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
<div class="my-3">
<a href="javascript:void(0);" th:data-uri="@{|/question/vote/${question.id}|}"
class="recommend btn btn-sm btn-outline-secondary">
추천
<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.user != null and #authentication.getPrincipal().getUsername() == question.user.username}"
th:text="수정"></a>
<!--삭제버튼 클릭시 alert 문구를 위해 href에 바로 url을 넣지 않음
javascript:void(0) 설정,
삭제를 실행할 url을 얻기 위해 th:data-uri라는 속성에 url 추가
-->
<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.user != null and #authentication.getPrincipal().getUsername() == question.user.username}"
th:text="삭제"></a>
</div>
</div>
</div>
<!--답변갯수-->
<h5 class="border-bottom my-3 py-2" th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
<!--답변반복-->
<!--div 바로 아래 a 태그 : 답변수정,추천 후 화면 리프레쉬될때 이 위치로 돌아오도록 함. redirect경로에 a태그id값 함께 보냄-->
<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" style="white-space:pre-line;" th:text="${answer.content}"></div>
-->
<div class="card-text" th:utext="${@commonUtil.markdown(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.user != null}" th:text="${answer.user.username}"></span>
</div>
<div th:text="${#temporals.format(answer.writeDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
</div>
<div class="my-3">
<a href="javascript:void(0);" th:data-uri="@{|/answer/vote/${answer.id}|}"
class="recommend btn btn-sm btn-outline-secondary">
추천
<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.user != null and #authentication.getPrincipal().getUsername() == answer.user.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.user != null and #authentication.getPrincipal().getUsername() == answer.user.username}"
th:text="삭제"></a>
</div>
</div>
</div>
<!--답변작성-->
<!-- 필수입력체크를 위해 AnswerForm.java 작성했고, th:object 선언을 해준다 -->
<form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" method="post" class="my-3">
<!--post 호출 후 에러 발생시 문구 표출란 -->
<div th:replace="~{common/formErrors :: formErrorsFragment}"></div>
<!-- sec:authorize="isAnonymous()" : 로그인안했을 때 textarea disabled -->
<textarea sec:authorize="isAnonymous()" th:field="*{content}" rows="10" class="form-control" disabled></textarea>
<!-- sec:authorize="isAuthenticated()" : 로그인했을 때 textarea -->
<textarea sec:authorize="isAuthenticated()" th:field="*{content}" rows="10" class="form-control"></textarea>
<input type="submit" value="답변등록" class="btn btn-primary my-2">
</form>
</div>
<script layout:fragment="script" type='text/javascript'>
const delete_elements = document.getElementsByClassName("delete");
Array.from(delete_elements).forEach(function(element) {
// console.log(delete_elements)
// console.log(element)
//class="delete" 에 이벤트 추가
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>
2-11 URL 프리픽스
QuestionController.java 에 다음 2개의 URL 이 매핑되어 있다.
1. @GetMapping("/question/list")
2. @GetMapping(value="/question/detail/{id}")
* URL 매핑시 value 변수는 생략할 수 있다.
이때 URL의 프리픽스가 모두 /question 으로 시작한다.
프리픽스란 URL 의 접두사 또는 시작 부분을 가리키는 말로,
QuestionController 에 속하는 URL 매핑은 항상 /question 프리픽스로 시작하도록 설정할 수 있다.
QuestionController 클래스명 위에
@RequestMapping("/question") 어노테이션을 추가하고,
메서드 단위에서는 /question을 생략하고 뒷부분을 작성하면 된다.
2-12
생략