해당 게시글은 점프 투 스프링부트 교재를 통한 개인 학습 용도이며 기초 세팅은 생략하였습니다.
자바 8, 스프링부트 2.7.7버전 입니다.
수정 일시
Question과 Answer 엔티티에 modifyDate필드를 추가해 DB에 컬럼을 추가시키자
private LocalDateTime modifyDate;
질문 수정
작성한 질문을 수정하려면 질문 상세 화면에서 "수정" 버튼을 클릭하여 수정 화면으로 진입해야 한다.
질문 수정 버튼
question_detail에 질문 수정버튼을 추가하자.
(... 생략 ...)
<!-- 질문 -->
<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()은 Principal 객체를 리턴하는 타임리프의 유틸리티이다.
QuestionController
(...생략...)
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/question_form";
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/modify/{id}")
public String questionModify(@Valid QuestionForm questionForm, BindingResult bindingResult,
Principal principal, @PathVariable("id") Integer id) {
if(bindingResult.hasErrors()) {
return "/question/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);
}
로그인한 사용자와 질문의 작성자가 동일하지 않을 경우 오류가 발생하도록 처리했다.
보통 수정을 진행하면 해당 글의 수정 전의 내용이 그대로 적혀있는 경우가 대부분이다.
수정할 질문의 제목과 내용을 화면에 보여주기 위해 questionForm 객체에 현재 question의 값을 담아 전달했다.
POST형식의 메서드에서는 questionForm의 데이터를 검증하고 수정하려는 사용자가 작성자와 동일한지 검증한다.
검증이 통과되면 서비스의 modify 메서드를 호출하여 데이터를 수정할 수 있게 설계했고
수정이 완료되면 질문 상세화면을 다시 호출하게 만들었다.
하지만 현재 QuestionService에 modify 메서드가 없기 때문에 modify에 오류가 생겼을 것이다.
quick fixes available 기능을 이용하여 modify에 마우스를 올려 서비스에 간편하게 메서드를 생성할 수 있다.
QuestionService
생성된 modify 메서드에 살을 채워넣자.
(... 생략 ...)
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);
}
}
question_form.html
질문 수정시에도 질문 등록과 동일한 템플릿을 사용할 수 있도록 수정하자
<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>
폼 태그의 th:action 속성을 삭제하면 CSRF 값이 자동으로 생성되지 않기 때문에 위와 같이 CSRF 값을 설정하기 위한 hidden 형태의 input 엘리먼트를 수동으로 추가하자.
폼 태그의 action 속성 없이 폼을 전송(submit)하면 폼의 action은 현재의 URL(브라우저에 표시되는 URL주소)을 기준으로 전송이 된다. 질문 등록시에 브라우저에 표시되는 URL은 /question/create이기 때문에 POST로 폼 전송시 action 속성에 /question/create가 설정이 되고, 질문 수정시에 브라우저에 표시되는 URL은 /question/modify/2 형태의 URL이기 때문에 POST로 폼 전송시 action 속성에 /question/modify/2 형태의 URL이 설정되는 것이다.
질문 삭제
질문 삭제 버튼
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" 항목을 추가해 주었다.
data-uri 속성은 자바스크립트에서 클릭 이벤트 발생시 this.dataset.uri와 같이 사용하여 그 값을 얻을 수 있다.
href에 삭제 URL을 직접 사용하지 않고 이러한 방식을 사용하는 이유는 삭제 버튼을 클릭했을때 "정말로 삭제하시겠습니까?" 와 같은 확인 절차가 필요하기 때문이다.
자바스크립트
삭제 버튼을 눌렀을때 확인창을 호출하기 위해서는 다음과 같은 자바스크립트 코드가 필요하다.
<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}|}이 호출될 것이다.
자바스크립트 블록
자바스크립트는 HTML 구조에서 다음과 같이 </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>
</body> 태그 바로 위에 <th:block layout:fragment="script"></th:block> 블록을 추가했다.
question_detail.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>
QuestionService
질문을 삭제하는 기능을 QuestionService에 추가하자.
(... 생략 ...)
public class QuestionService {
(... 생략 ...)
public void delete(Question question) {
this.questionRepository.delete(question);
}
}
QuestionController
그리고 @{|/question/delete/${question.id}|} URL을 처리하기 위한 기능을 QuestionController에 다음과 같이 추가하자.
(... 생략 ...)
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 데이터를 조회한후 로그인한 사용자와 질문 작성자가 동일할 경우 위에서 작성한 서비스의 delete 메서드로 질문을 삭제한다.
답변
답변 수정 및 삭제는 답변 등록 템플릿이 따로 없으므로 답변 수정에 사용할 템플릿이 추가로 필요하다.
버튼 및 수정일시 표시하기
답변 목록이 출력되는 부분에 버튼을 추가하자.
question_detail.html
(...생략...)
<!-- 답변 반복 시작 -->
<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>
<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>
<!-- 답변 반복 끝 -->
(...생략...)
그리고, 작성일자를 나타냈던 것 처럼, 해당 코드를 수정일자도 원하는 위치에 넣도록 하자.
question_detail.html
<div th:if="${question.modifyDate != null}" class="badge bg-light text-dark p-2 text-start mx-3">
<div class="mb-2">수정일자</div>
<div th:text="${#temporals.format(question.modifyDate, 'yyyy-MM-dd HH:mm')}"></div>
</div>
답변 쪽의 수정일자는 question을 answer로 바꾸면 된다.
AnswerController
(...생략...)
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/answer_form";
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/modify/{id}")
public String answerModify(@Valid AnswerForm answerForm, BindingResult bindingResult,
@PathVariable("id") Integer id, Principal principal) {
if (bindingResult.hasErrors()) {
return "/answer/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());
}
@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());
}
}
답변 수정을 완료한 후에는 질문 상세페이지로 돌아가기 위해 answer.getQuestion.getId()로 질문의 ID값을 가져왔다.
AnswerService
QuestionService 작성 때와 동일하게 quick fixes available 기능을 이용하여 메서드 및 파라미터값을 자동 생성시킨 뒤에 작성해보자.
package com.example.board.answer;
import java.util.Optional;
import com.example.board.practice.DataNotFoundException;
(...생략...)
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("답변이 없습니다.");
}
}
public void modify(Answer answer, String content) {
answer.setContent(content);
answer.setModifyDate(LocalDateTime.now());
this.answerRepository.save(answer);
}
public void delete(Answer answer) {
this.answerRepository.delete(answer);
}
}
answer_form.html
그리고 답변 수정을 위한 answer_form.html 템플릿을 다음과 같이 신규로 작성하자.
<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>
수정, 삭제 및 수정일자가 잘 들어왔는지 확인해보자.
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!