점프투스프링부트(스프링부트+JPA)

2장 2-10, 2-11 요약 (질문 상세 페이지 코드 리뷰(질문 상세내용과 답변 기능), URL 프리픽스)

여행자0 2024. 12. 16. 17:17

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 

생략