25 min to read
[스프링부트 시리즈28] 수정,삭제기능 추가하기
수정과 삭제 기능 추가하기
질문 또는 답변을 작성한 후 이 글들을 수정하거나 삭제할 수 있어야 한다. 이번 절에서는 앞서 작성한 질문 또는 답변을 수정하거나 삭제하는 기능을 추가해 보자. 그동안 배운 내용을 바탕으로 비슷한 기능을 반복해 구현하므로 조금 지루할 수 있다. 하지만 스프링 부트(Spring Boot) 프로그램의 전형적인 패턴에 익숙해질 수 있는 좋은 기회라고 생각하고 따라해 보자.
수정일시 추가하기
SBB에 질문 또는 답변을 수정하거나 삭제하는 기능을 추가하기 전에 질문이나 답변이 언제 수정되었는지 확인할 수 있도록 Question
엔티티와 Answer
엔티티에 수정 일시를 의미하는 modifyDate
속성을 추가해 보자.
1) Question.java
와 Answer.java
에 각각 다음과 같이 한 줄의 코드를 추가하면 간단히 해결된다.
[파일명:/question/Question.java]
(... 생략 ...)
public class Question {
(... 생략 ...)
private LocalDateTime modifyDate;
}
[파일명:/answer/Answer.java]
(... 생략 ...)
public class Answer {
(... 생략 ...)
private LocalDateTime modifyDate;
}
2) 이와 같이 수정한 뒤, 다시 H2 콘솔에 접속해 보자. 다음과 같이 Answer와 Question 테이블에 각각 modify_date
열이 추가된 것을 확인할 수 있다.
질문수정기능 생성하기
질문 수정 버튼 만들기
사용자가 질문 상세 화면에서 [수정] 버튼을 클릭하면 수정할 수 있는 화면으로 진입할 수 있도록 다음과 같이 질문 상세 화면에 질문 수정 버튼을 추가해 보자.
[파일명: /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" 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
앞서 작성한 [수정] 버튼에 GET 방식의 @{/question/modify/${question.id}}
링크가 추가되었으므로 이 링크가 동작할 수 있도록 질문 컨트롤러를 다음과 같이 수정해 보자.
[파일명:/question/QuestionController.java]
(... 생략 ...)
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
(... 생략 ...)
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)의 값을 담아서 템플릿으로 전달했다. 이 과정이 없다면 질문 수정 화면에 ‘제목’, ‘내용’의 값이 채워지지 않아 비워져 보일 것이다. 그런데 여기서 한 가지 짚고 넘어가야 할 것이 있다. 질문을 수정할 수 있는 새로운 템플릿을 만들지 않고 질문을 등록했을 때 사용한 question_form.html
템플릿을 사용한다는 점이다.
질문 등록 템플릿 수정하기
질문을 수정하기 위한 템플릿을 새로 작성해도 문제는 없지만 제목과 내용을 기입하는 화면의 모양이 동일하므로 여기서는 굳이 새로 만들지 않고 같은 템플릿을 사용하려고 한다. 그런데 question_form.html
은 질문 등록을 위해 만든 템플릿이어서 조금 수정해야 질문 등록과 수정 기능을 함께 사용할 수 있다. 템플릿을 수정하면서 더 자세히 살펴보자.
다음과 같이 질문 등록 템플릿인 question_form.html
을 수정해 보자.
[파일명:/templates/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>
먼저 기존에 있던 <form>
태그의 th:action
속성을 삭제해야 한다. 단, th:action
속성을 삭제하면 CSRF값이 자동으로 생성되지 않아서 CSRF값을 설정하기 위해 hidden 형태로 input 요소를 이와 같이 작성하여 추가해야 한다.
CSRF값을 수동으로라도 추가해야 되는 이유는 스프링 시큐리티(Spring Security)를 사용할 때 CSRF 값이 반드시 필요하기 때문이다.
<form>
태그의 action 속성 없이 폼을 전송(submit)하면 action 속성이 없더라도 자동으로 현재 URL(여기서는 웹 브라우저에 표시되는 URL 주소)을 기준으로 전송되는 규칙이 있다. 즉, 질문 등록 시에 브라우저에 표시되는 URL은 /question/create
이어서 action 속성이 지정되지 않더라도 POST로 폼 전송할 때 action 속성으로 /question/create
가 자동 설정되고, 질문 수정 시에 브라우저에 표시되는 URL은 /question/modify/2
와 같은 URL이기 때문에 POST로 폼 전송할 때 action 속성에 /question/modify/2
와 같은 URL이 설정되는 것이다.
질문 서비스 수정하기
수정된 질문이 서비스를 통해 처리될 수 있도록 QuestionService를 다음과 같이 수정해 보자.
[파일명:/question/QuestionService.java]
(... 생략 ...)
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);
}
}
이와 같이 질문 제목과 내용을 수정할 수 있는 modify 메서드를 추가했다.
질문 컨트롤러 수정하기 2
다시 질문 컨트롤러로 돌아와 질문을 수정하는 화면에서 질문 제목이나 내용을 변경하고 [저장하기] 버튼을 누르면 호출되는 POST 요청을 처리하기 위해 다음과 같은 메서드를 추가해 보자.
[파일명:/question/QuestionController.java]
(... 생략 ...)
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/(숫자)
)으로 리다이렉트한다.
수정 기능 확인하기
로컬 서버를 재시작한 뒤, 브라우저에서 질문 상세 페이지를 확인해 보자.
1) 로그인한 사용자와 글쓴이가 같으면 질문 상세 화면에 [수정] 버튼이 보일 것이다.
2) [수정] 버튼을 클릭하여 수정 페이지로 이동하면 /question/modifiy/(질문 ID)
URL로 넘어가고, 제목과 내용을 수정할 수 있다. 제목 또는 내용을 수정한 후, [저장하기] 버튼을 클릭해 기능이 잘 동작하는지 확인해 보자.
질문 삭제 기능 생성하기
질문 삭제 버튼 만들기 이번에는 질문을 삭제하는 기능을 추가해 보자. 질문 수정과 마찬가지로 질문 상세 화면에 [삭제] 버튼을 추가하여 삭제할 수 있게 하려고 한다.
[파일명: /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="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
를 사용하여 그 값을 얻어 실행할 수 있다.
삭제를 위한 자바스크립트 작성하기
자바스크립트는 HTML, CSS와 함께 사용하며 웹 페이지에 동적인 기능을 추가할 때 사용하는 스크립트 언어이다. 여기서는 이러한 자바스크립트를 활용해 [삭제] 버튼을 클릭했을 때 ‘정말로 삭제하시겠습니까?’와 같은 메시지를 담은 확인 창을 호출하려고 한다.
1) 그러기 위해 다음과 같은 자바스크립트 코드가 필요하다.
<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이 호출될 것이다.
2) 앞서 살펴본 자바스크립트 코드는 질문 상세 템플릿에 추가하면 된다. 그 전에 템플릿에 자바스크립트를 포함하는 방법을 먼저 알아보자. 자바스크립트는 HTML 구조에서 다음과 같이 </body>
태그 바로 위에 삽입하는 것을 추천한다.
<html>
<head>
(... 생략 ...)
</head>
<body>
(... 생략 ...)
<!-- 이곳에 추가 -->
</body>
</html>
왜냐하면 화면 출력이 완료된 후에 자바스크립트가 실행되는 것이 좋기 때문이다. 화면 출력이 완료되지 않은 상태에서 자바스크립트를 실행하면 오류가 발생할 수도 있고 화면 로딩이 지연될 수도 있다. 따라서 각 템플릿에서 자바스크립트를 </body>
태그 바로 위에 삽입하고, 상속할 수 있도록 다음과 같이 layout.html을 수정해 보자.
[파일명:/templates/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
을 상속하는 템플릿은 자바스크립트의 삽입 위치를 신경 쓰지 않아도 되고, 필요할 경우에 스크립트 블록을 구현하여 자바스크립트를 작성할 수 있다.
3) 이제 question_detail.html
하단에 스크립트 블록을 다음과 같이 추가하여 자바스크립트가 실행될 수 있도록 해보자.
[파일명:/templates/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>
스크립트 블록에 질문을 삭제할 수 있는 자바스크립트를 작성했다.
질문 서비스와 컨트롤러 수정하기
1) 먼저 질문 삭제 기능을 QuestionService
에 추가해 보자.
[파일명:/question/QuestionService.java]
(... 생략 ...)
public class QuestionService {
(... 생략 ...)
public void delete(Question question) {
this.questionRepository.delete(question);
}
}
이와 같이 질문 데이터를 삭제하는 delete
메서드를 추가했다.
2) 질문 컨트롤러에서는 [삭제] 버튼을 클릭했을 때 @{/question/delete/${question.id}}
URL을 처리할 수 있도록 QuestionController
에 다음과 같은 메서드를 추가하자.
[파일명:/question/QuestionController.java] ```java (… 생략 …) 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` 데이터를 조회한 후, 로그인한 사용자와 질문 작성자가 동일할 경우 앞서 작성한 서비스를 이용하여 질문을 삭제하게 했다. 그리고 질문을 삭제한 후에는 질문 목록 화면(/)으로 돌아갈 수 있도록 했다.
3) 로컬 서버를 재시작한 후, 질문 상세 페이지를 확인해 보자. 질문을 작성한 사용자와 로그인한 사용자가 동일하다면 다음과 같이 질문 상세 화면에 [삭제] 버튼이 노출될 것이다.
4) [삭제]
버튼을 클릭하면 다음과 같이 메시지가 등장한다. [확인]
버튼을 클릭하면 다시 질문 목록 페이지로 돌아오고 해당 질문이 삭제된 것을 확인할 수 있다. 삭제 기능이 정상적으로 동작한 것이다!
답변 수정 기능 추가하기 ***** 이번에는 답변 수정 기능을 구현해 보자. 질문 수정 기능과 비슷한 과정으로 진행할 것이다. 다만, 답변 수정 기능을 구현하기 위한 템플릿이 따로 없으므로 답변 수정 시 사용할 템플릿이 추가로 필요하다. 새로 템플릿을 추가하는 내용 외에는 답변 수정 기능은 질문 수정과 크게 차이 나지 않으므로 간단히 설명하고 넘어가겠다.
버튼 추가하고 서비스와 컨트롤러 수정하기 1) 질문 상세 템플릿에서 답변 목록이 출력되는 부분에 답변 수정 버튼을 추가해 보자.
[파일명:/templates/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>
</div>
</div>
</div>
<!-- 답변 반복 끝 -->
(... 생략 ...)
로그인한 사용자와 답변 작성자가 동일한 경우 답변의 [수정] 버튼이 노출되도록 했다. [답변] 버튼을 누르면 '/answer/modify/{답변 ID}'
형태의 URL이 GET 방식으로 요청될 것이다.
2) 답변을 수정하려면 답변을 먼저 조회해야 하므로 AnswerService
에 답변을 조회하는 기능을 추가하고, 답변을 수정할 수 있는 기능도 다음과 같이 추가해 보자.
[파일명:/answer/AnswerService.java]
(... 생략 ...)
import java.util.Optional;
import com.mysite.sbb.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("answer not found");
}
}
public void modify(Answer answer, String content) {
answer.setContent(content);
answer.setModifyDate(LocalDateTime.now());
this.answerRepository.save(answer);
}
}
이와 같이 해당 답변을 조회하는 getAnswer
메서드와 답변 내용을 수정하는 modify
메서드를 추가했다.
3) 답변 영역의 [수정] 버튼 클릭 시 GET 방식으로 요청되는 /answer/modify/답변ID
URL을 처리하기 위해 다음과 같이 AnswerController
를 수정해 보자.
[파일명:/answer/AnswerController.java]
(... 생략 ...)
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.server.ResponseStatusException;
(... 생략 ...)
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
템플릿에서 사용할 수 있도록 했다. 아직 answer_form.html
이 존재하지 않으므로 다음 실습에서 해당 템플릿을 만들어 보자.
답변 수정 시 기존의 답변 내용이 필요하므로
AnswerForm
객체에 조회한 값을 저장하여 리턴해야 한다.
답변 수정 템플릿 생성하기
답변을 수정하기 위해 템플릿을 만들어 보자. templates
에 answer_form.html
파일을 생성한 뒤, 다음과 같은 내용을 입력해 보자.
[파일명:/templates/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>
답변 작성 시 사용하는 <form>
태그에도 역시 action
속성을 사용하지 않았다. 앞서 설명했듯이 action
속성을 생략하면 현재 호출된 URL로 폼이 전송된다. th:action
속성이 없으므로 csrf
항목을 직접 추가했다.
답변 컨트롤러 재수정하기
1) 이제 폼을 통해 POST 방식으로 요청되는 /answer/modify/답변 ID
URL을 처리하기 위해 다음과 같이 AnswerController
로 돌아가 코드를 추가해 보자.
[파일명:/sbb/src/main/java/com/mysite/sbb/answer/AnswerController.java]
(... 생략 ...)
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
메서드를 추가했다. 그리고 답변 수정을 완료한 후에는 질문 상세 페이지로 리다이렉트하도록 했다.
2) 답변 수정도 질문 수정과 마찬가지로 답변 등록 사용자와 로그인 사용자가 동일할 때만 [수정] 버튼이 나타난다. 로컬 서버를 재시작한 후, 질문 상세 페이지에서 확인해 보자.
3) [수정] 버튼을 클릭해 답변 내용을 수정한 후, [저장하기] 버튼을 클릭해 답변 수정 기능이 잘 동작하는지 확인해 보자.
답변 삭제 기능 추가하기
수정 기능을 추가하였으니 이번에는 답변을 삭제하는 기능을 추가해 보자. 답변 삭제 기능도 질문 삭제 기능과 동일하므로 빠르게 알아보자.
1) 질문 상세 템플릿에 답변을 삭제할 수 있는 버튼을 다음과 같이 추가하자.
[파일명:/templates/question_detail.html]
(... 생략 ...)
<!-- 답변 반복 시작 -->
<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이 실행된다.
여기서는 이미 같은 역할을 담당하는 자바스크립트가 작성되어 있으므로 별도로 스크립트를 추가하지 않는다.
2) 답변을 삭제하기 위해 AnswerService
에 다음과 같이 코드를 추가해 보자.
[파일명:/answer/AnswerService.java]
(... 생략 ...)
public class AnswerService {
(... 생략 ...)
public void delete(Answer answer) {
this.answerRepository.delete(answer);
}
}
3) 이제 답변의 [삭제] 버튼을 누르면 GET 방식으로 요청되는 /answer/delete/답변 ID
URL을 처리하기 위해 다음과 같이 AnswerController
를 수정해 보자.
[파일명:/answer/AnswerController.java]
(... 생략 ...)
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
메서드를 추가했다. 답변을 삭제한 후에는 해당 답변이 있던 질문 상세 화면으로 이동할 수 있도록 만들었다.
4) 로컬 서버를 재시작한 후, 질문 상세 페이지를 확인해 보자. 다음과 같이 질문 상세 화면에서 답변을 작성한 사용자와 로그인한 사용자가 같으면 [삭제] 버튼이 나타날 것이다.
5) [삭제] 버튼을 클릭하면 질문을 삭제할 때와 마찬가지로 다음과 같이 메시지가 등장한다. 여기서는 [확인] 버튼을 클릭하면 다시 질문 상세 페이지로 돌아오고 해당 답변이 삭제된 것을 확인할 수 있다.
수정 일시 표시하기
마지막으로 질문 상세 화면에 수정 일시가 나타나도록 기능을 추가해 보자.
1) 이미 표시된 질문과 답변의 작성 일시 바로 왼쪽에 수정 일시를 다음과 같이 추가해 보자.
[파일명:/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" 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>
<!-- 답변 반복 끝 -->
(... 생략 ...)
2) 로컬 서버를 재시작하고 로그인한 뒤, 질문 내용과 답변 내용을 모두 수정해 보자. 질문이나 답변에 수정 일시가 존재하면(즉, null이 아니면) 다음과 같이 수정 일시가 작성 일시 바로 왼쪽에 표시된다. 우리는 여기까지 질문과 답변의 수정 및 삭제 기능을 완성해 보았다.