▼ What ?
'회원가입' 과 '로그인/로그아웃' 까지 복습하고 정리해봤는데, 양이 많아서 글쓴이를 표시하는 기능과 글을 수정하고 삭제하는 기능에 대해선 따로 정리해보려고 한다.
▼ 글쓴이 표시
Question & Answer 엔티티에 글쓴이 속성 추가
Question 엔티티 수정
- Question 엔티티에 'author' 속성을 추가하자.
➜ 여러 개의 질문(many)이 한 명의 작성자(one)에게 작성될 수 있으므로 '@ManyToOne' 을 적용해줘야 한다.
...
import jakarta.persistence.ManyToOne;
import com.gdsc.webboard.user.SiteUser;
...
public class Question {
...
@ManyToOne
private SiteUser author;
}
Answer 엔티티 수정
- Question 엔티티에 추가했듯이 author 속성을 추가해주면 된다.
...
import com.gdsc.webboard.user.SiteUser;
...
public class Answer {
...
@ManyToOne
private SiteUser author;
}
🔻 H2 console 테이블에 author 속성이 추가된 것을 확인 가능하다.
답변에 작성자 등록하기
- 답변을 작성하는 Answer 컨트롤러를 수정해주자.
➜ 현재 로그인한 유저에 대한 정보를 불러와야 하기 때문에, 스프링 시큐리티가 제공하는 Principal 객체를 사용해야 한다 !
...
import java.security.Principal;
...
public class AnswerController {
...
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable("id") Integer id,
@Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
...
}
}
- principal.getName()
➜ 현재 로그인한 유저의 사용자명(사용자ID)을 반환해주는 메서드를 사용할 수 있게 되었다.
➜ 그렇게 얻은 사용자명을 이용해서 User 서비스를 통해 SiteUser 엔티티 클래스의 객체를 조회할 수 있는 'getUser' 메서드를 'UserService' 클래스에 추가해주면 된다.
...
import java.util.Optional;
import com.gdsc.webboard.DataNotFoundException;
...
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");
}
}
}
🔻 UserRepository에 이미 선언해둔 'findByusername' 을 이용하여 쉽게 구현 가능 !
- 이제 답변 저장시에 작성자를 저장할 수 있도록 Answer 서비스를 수정 !
➜ 답변을 생성하는 create 메서드에서 파라미터로 SiteUser 엔티티 클래스 객체를 추가로 전달받아서 답변 저장시에 author 속성도 저장되게 하면 된다.
...
import com.mysite.sbb.user.SiteUser;
...
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); // 글쓴이 Setter 메서드 추가
this.answerRepository.save(answer);
return answer;
}
}
- 이렇게 Answer 서비스의 답변을 등록하는 create 메서드 수정까지 마쳤으니, 해당 메서드에 SiteUser 객체를 전달하도록 Answer 컨트롤러를 수정해주면 된다.
➜ Principal 객체를 통해 사용자명을 얻고, 그 사용자명을 통해 얻은 SiteUser 객체를 답변을 등록하는 AnswerService의 create 메서드에 전달하여 답변을 author 속성과 함께 저장하도록 했다.
...
import com.gdsc.webboard.user.SiteUser;
import com.gdsc.webboard.user.UserService;
...
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 siteUser = this.userService.getUser(principal.getName());
if (bindingResult.hasErrors()) {
model.addAttribute("question", question);
return "question_detail";
}
this.answerService.create(question, answerForm.getContent(), siteUser); // SiteUser 객체 전달
return String.format("redirect:/question/detail/%s", id);
}
}
질문에 작성자 등록하기
- 답변에 작성자를 등록하는 방법과 동일하다.
- 작성자 정보를 저장하기 위해 QuestionService 수정.
...
import com.gdsc.webboard.user.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); // 작성자에 대한 setter 메서드 추가
this.questionRepository.save(q);
}
}
- QuestionController 수정.
...
import java.security.Principal;
import com.gdsc.webboard.user.SiteUser;
import com.gdsc.webboard.user.UserService;
...
public class QuestionController {
private final QuestionService questionService;
private final UserService userService; // UserService 객체 주입
...
@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm,
BindingResult bindingResult, Principal principal) {
if (bindingResult.hasErrors()) {
return "question_form";
}
// 사용자명으로 SiteUser 객체 조회
SiteUser siteUser = this.userService.getUser(principal.getName());
// SiteUser 객체 전달
this.questionService.create(questionForm.getSubject(), questionForm.getContent(), siteUser);
return "redirect:/question/list";
}
}
로그인이 필요한, Principal 객체를 사용하는 메서드
- 로그아웃 상태에서 질문이나 답변을 등록하면 다음과 같은 서버오류(500)가 발생한다.

🔻 Principal 객체는 로그인을 해야만 생성되는 객체이기 때문이다.
➜ Principal 객체를 사용하는 메서드에 '@PreAuthorize("istAuthenticate()")' 애너테이션을 사용해야 한다.
- @PreAuthorize("istAuthenticate()")
➜ 이 애너테이션이 붙은 메서드는 로그인이 필요한 메서드임을 알린다.
➜ 따라서, 해당 메서드가 로그아웃 상태에서 호출되면 자동으로 로그인 페이지로 이동된다 !
( @PreAuthorize ➜ 해당 메서드를 실행하기 전에 권한을 검사하는 애너테이션이다. )
- 로그인이 필요한 메서드들에 모두 '@PreAuthorize("istAuthenticate()")' 애너테이션을 적용하면 된다.
...
import org.springframework.security.access.prepost.PreAuthorize;
...
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) {
...
}
}
...
import org.springframework.security.access.prepost.PreAuthorize;
...
public class AnswerController {
...
@PreAuthorize("isAuthenticated()")
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable("id") Integer id, @Valid AnswerForm answerForm,
BindingResult bindingResult, Principal principal) {
...
}
}
- 그리고 '@PreAuthorize' 애너테이션이 동작할 수 있도록 스프링 시큐리티 설정도 수정해줘야 한다.
- '@EnableMethodSecurity' 애너테이션의 'prePostEnabled = true' 요소.
➜ 로그인 여부를 검사하기 위해 사용했던 '@PreAuthorize' 애너테이션을 사용하기 위해 반드시 필요하다 !
- '@EnableMethodSecurity' 애너테이션의 'prePostEnabled = true' 요소.
...
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
...
}
- 이제 질문 등록은 로그아웃 상태에선 할 수 없게 되었지만, 답변을 등록할 때엔 로그아웃 상태에서도 글을 작성하는 것까진 가능하다.
➜ 그런데 문제가 <저장하기> 버튼을 클리하면 자동으로 로그인 화면으로 이동하게 되는데 작성한 답변이 사라지는 문제가 있다.
➜ 그래서 그냥 로그아웃 상태에선 답변 작성조차도 못하게 막는 것이 좋을 것 !
➜ question_detail 템플릿을 수정해주면 된다.
[ /web-board/src/main/resources/templates/question_detail.html ]
<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>
🔻 로그인 상태가 아닌 경우(sec:authorize="isAnonymous()"), textarea 태그에 'disabled' 속성을 적용해 입력을 못하게 만들었다.
- H2 console에서 'AUTHOR_ID' 속성(Attribute, Column)에 글쓴이가 저장된 것을 확인할 수 있다.

스프링 시큐리티 기능
- 스프링 시큐리티는 로그아웃 상태에서 "질문 등록" 버튼을 클리하면 "로그인" 화면으로 이동하고, 이후 로그인을 하게 되면 원래 하려고 했던 "질문 등록" 화면으로 이동한다.
글쓴이 표시 (Front-end)
- 질문 목록 템플릿에 글쓴이 표시 !
[ /web-board/src/main/resources/templates/question_list.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>
</div>
...
- 질문 상세 템플릿에 글쓴이 표시 !
[ /web-board/src/main/resources/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>
</div>
<!-- 답변 반복 끝 -->
▼ 수정 · 삭제
수정 일시
- 질문이나 답변이 언제 수정되었는지 확인할 수 있도록 Question과 Answer 엔티티에 수정 일시를 의미하는 'modifyDate' 속성을 추가하자.
...
public class Question {
...
privaet LocalDateTime modifyDate;
}

질문 수정
- 질문 상세 템플릿에 질문 "수정" 버튼을 추가하자.
[ /web-board/src/main/resources/templates/question_detail.html ]
- #authentication.getPrincipal().getUsername() == question.author.username
➜ 로그인한 유저와 글쓴이가 동일한 경우에만 버튼이 나타나도록 !
( #authentication.getPrincipal() : Principal 객체를 리턴하는 타임리프의 유틸리티. )
- #authentication.getPrincipal().getUsername() == question.author.username
...
<!-- 질문 -->
<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>
(... 생략 ...)
- 질문 상세 템플릿에 'GET' 방식의 "/question/modify/질문ID" 형태의 URL 링크 추가까지 해줬으니 이제 질문 컨트롤러를 수정해야 한다.
➜ 해당 URL을 'GetMapping' 해주는 'questionModify' 메서드를 생성해자.
- return "question_form";
➜ 질문 등록시 사용했던 "question_form" 템플릿을 질문 수정할 때도 그대로 사용하는데, 이렇게 할 경우엔 "저장하기" 버튼을 누르게 되면 해당 질문이 수정되는 것이 아니라 새로운 질문이 등록되게 된다.
➜ 이러한 문제를 질문 등록 템플릿에서 'form' 태그의 action을 활용하여 해결할 수 있다.
- return "question_form";
...
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
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";
}
}
- 질문 등록 템플릿을 수정해주자 !
➜ form 태그의 action 속성 없이 폼을 전송하게 되면 폼의 action은 현재의 URL(브라우저에 표시되는 URL주소)을 기준으로 전송된다.
➜ 즉, '질문 등록시' 전송되는 URL과 '질문 수정시' 전송되는 URL이 달라지게 된다.
[ /web-board/src/main/resources/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:action' 속성 삭제 -->
<form th:object="${questionForm}" method="post">
<!-- CSRF 값 수동 추가 -->
<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 값을 수동으로 추가할 경우에 위와 같이 CSRF 값을 설정하기 위한 hidden 형태의 input 엘리먼트를 수동으로 추가해야 한다 !
- 질문 데이터를 수정할 수 있는 'modify' 메서드를 Question 서비스에 추가해주자.
...
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);
}
}
- 이제 질문 수정 화면에서 질문의 제목이나 내용을 변경하고 "저장하기" 버튼을 눌렀을 때 호출되는 POST 요청을 처리하기 위해 Question 컨트롤러에 'questionModify' 메서드를 추가하자.
...
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);
}
}
🔻 이제 로그인한 유저와 글쓴이가 같으면 질문 상세 화면에 <수정> 버튼이 나타난다.
질문 삭제
- 질문 삭제 버튼이 나타나도록 질문 상세 템플릿에 코드를 추가해주자.
...
<!-- 질문 -->
<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로 직접 설정하지 않는 이유는 <삭제> 버튼을 클릭했을 때 "정말로 삭제하시겠습니까" 와 같은 재확인 절차가 필요하기 떄문이다 !
- 앞서 말한 확인창을 호출하기 위해선 다음과 같은 자바스크립트 코드가 필요하다.
➜ HTML 구조에서 '</body>' 태그 바로 위에 삽입하는 것을 지향하자 !
( 화면 렌더링이 완료된 후에 자바스크립트가 실행되기 때문이다. )
➜ 그러기 위해선 'layout.html' 을 상속하는 템플릿들에서 'content 블록' 을 구현하게 했던 것처럼, layout 템플릿을 아래처럼 수정해줘야 한다.
➜ 이렇게 하면, 이 'layout.html' 을 상속하는 템플릿은 자바스크립트의 삽입 위치를 신경쓸 필요없이 스크립트 블록을 사용하여 자바스크립트를 작성할 수 있게 되는 것이다.
[ /web-board/src/main/resources/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>
<!-- 자바스크립트 코드 추가 -->
<th:block layout:fragment="script"></th:block>
<!-- 자바스크립트 End -->
</body>
</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>
🔻 <삭제> 버튼을 클릭하고 <확인>을 선택하면 'data-uri' 속성에 저장된 "/question/delete/질문ID" 형태의 URL을 호출하게 된다.
- 이렇게 템플릿 파일까지 작성해줬으니, 다음으로 질문을 삭제하는 'delete' 메서드를 Question 서비스에 추가해주자.
➜ Question 객체를 인자로 받아 Question 리포지터리를 통해 질문 데이터를 삭제하도록 한다.
...
public class QuestionService {
private final QuestionRepository questionRepository;
...
public void delete(Question question) {
this.questionRepository.delete(question);
}
}
- 질문을 삭제하는 기능을 추가했으니, <삭제> 버튼을 클릭했을 때 호출되는 "/question/delete/질문ID" 형태의 URL을 처리하도록 하는 'questionDelete' 메서드를 Question 컨트롤러에 추가해주자.
- question.getAuthor().getUsername().equals(principal.getName())
➜ (URL로 전달받은 'id' 값으로 Question 데이터를 조회한 후) 로그인한 유저와 질문 작성자가 동일한 경우 위에서 작성한 서비스의 'delete' 메서드를 호출해 질문을 삭제하도록 한다. - return "redirect:/";
➜ 질문 제이터 삭제 후엔 질문 목록 화면으로 돌아갈 수 있도록 루트 페이지 "/" 로 리다이렉트해준다.
- question.getAuthor().getUsername().equals(principal.getName())
...
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:/";
}
}
🔻 이제, 질문을 작성한 유저와 로그인한 유저가 동일할 경우 상세조회 화면에 <삭제> 버튼이 나타날 것이다.
답변 수정
- 답변 수정/삭제 기능은 질문 수정/삭제 기능을 구현하는 방식과 거의 비슷하게 구현할 수 있다.
➜ 단, 답변 등록 템플릿(answer_form.html)을 추가적으로 생성해줘야 한다.
- 답변 수정 버튼 추가 !
[ /web-board/src/main/resources/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>
<!-- 답변 반복 끝 -->
...
- GET 방식으로 요청되는 URL을 처리하기 위해 컨트롤러를 수정하기 전에, Answer 컨트롤러에서 필요로 하는 '답변 조회 기능' 과 '답변 수정 기능' 부터 Answer 서비스에서 구현해주자 !
- getAnswer : 답변 ID로 답변 조회
- modify : 답변의 내용(content)으로 수정하고 수정 일시도 저장.
- getAnswer : 답변 ID로 답변 조회
...
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);
}
}
- <수정> 버튼을 클릭했을 때 GET 방식으로 요청되는 '/answer/modify/답변ID' 형태의 URL을 처리할 수 있도록 Answer 컨트롤러에 'answerModify' 메서드를 추가하자.
...
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); // URL의 답변 id를 통해 조회한 답변 데이터 대입
if (!answer.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
}
answerForm.setContent(answer.getContent());
return "answer_form";
}
}
🔻 답변 수정시 기존의 내용이 필요하므로 AnswerForm 객체(answerForm)에 조회한 값(answer.getContent())를 저장(setContent)해야 한다.
- 이제 답변 등록 템플릿을 작성해주자.
➜ 답변을 수정할 때도 새로운 답변으로 등록되지 않도록, 답변 등록 템플릿의 폼 태그에도 'action' 속성은 사용하지 않는다 !
[ /web-board/src/main/resources/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>
- 이제 폼 템플릿을 통해 요청되는 POST 방식의 "/answer/modify/답변ID" 형태의 URL을 처리하는 메서드를 Answer 컨트롤러에 추가해주자.
...
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());
}
}
🔻 이제 답변 등록 사용자와 로그인 사용자가 동일할 때만 <수정> 버튼이 나타날 것이다.
답변 삭제
- 답변 삭제 버튼 추가 !
[ /web-board/src/main/resources/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>
<!-- 답변 반복 끝 -->
...
- Answer 서비스에 답변을 삭제하는 'delete' 메서드 추가 !
...
public class AnswerService {
...
public void delete(Answer answer) {
this.answerRepository.delete(answer);
}
}
- Answer 컨트롤러에 <삭제> 버튼 클릭시 요쳥되는 GET 방식의 "/answer/delete/답변ID" 형태의 URL을 처리하는 'answerDelete' 메서드가 추가 !
...
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());
}
}
🔻 이제 답변 <삭제> 버튼도 잘 동작할 것이다.
수정일시 표시
- 질문과 답변에 이미 표시되고 있는 작성일시 바로 왼쪽에 '수정 일시' 를 추가하자 !
...
<!-- 질문 -->
<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>
<!-- 답변 반복 끝 -->
...
▼ What ?
'회원가입' 과 '로그인/로그아웃' 까지 복습하고 정리해봤는데, 양이 많아서 글쓴이를 표시하는 기능과 글을 수정하고 삭제하는 기능에 대해선 따로 정리해보려고 한다.
▼ 글쓴이 표시
Question & Answer 엔티티에 글쓴이 속성 추가
Question 엔티티 수정
- Question 엔티티에 'author' 속성을 추가하자.
➜ 여러 개의 질문(many)이 한 명의 작성자(one)에게 작성될 수 있으므로 '@ManyToOne' 을 적용해줘야 한다.
...
import jakarta.persistence.ManyToOne;
import com.gdsc.webboard.user.SiteUser;
...
public class Question {
...
@ManyToOne
private SiteUser author;
}
Answer 엔티티 수정
- Question 엔티티에 추가했듯이 author 속성을 추가해주면 된다.
...
import com.gdsc.webboard.user.SiteUser;
...
public class Answer {
...
@ManyToOne
private SiteUser author;
}
🔻 H2 console 테이블에 author 속성이 추가된 것을 확인 가능하다.
답변에 작성자 등록하기
- 답변을 작성하는 Answer 컨트롤러를 수정해주자.
➜ 현재 로그인한 유저에 대한 정보를 불러와야 하기 때문에, 스프링 시큐리티가 제공하는 Principal 객체를 사용해야 한다 !
...
import java.security.Principal;
...
public class AnswerController {
...
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable("id") Integer id,
@Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
...
}
}
- principal.getName()
➜ 현재 로그인한 유저의 사용자명(사용자ID)을 반환해주는 메서드를 사용할 수 있게 되었다.
➜ 그렇게 얻은 사용자명을 이용해서 User 서비스를 통해 SiteUser 엔티티 클래스의 객체를 조회할 수 있는 'getUser' 메서드를 'UserService' 클래스에 추가해주면 된다.
...
import java.util.Optional;
import com.gdsc.webboard.DataNotFoundException;
...
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");
}
}
}
🔻 UserRepository에 이미 선언해둔 'findByusername' 을 이용하여 쉽게 구현 가능 !
- 이제 답변 저장시에 작성자를 저장할 수 있도록 Answer 서비스를 수정 !
➜ 답변을 생성하는 create 메서드에서 파라미터로 SiteUser 엔티티 클래스 객체를 추가로 전달받아서 답변 저장시에 author 속성도 저장되게 하면 된다.
...
import com.mysite.sbb.user.SiteUser;
...
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); // 글쓴이 Setter 메서드 추가
this.answerRepository.save(answer);
return answer;
}
}
- 이렇게 Answer 서비스의 답변을 등록하는 create 메서드 수정까지 마쳤으니, 해당 메서드에 SiteUser 객체를 전달하도록 Answer 컨트롤러를 수정해주면 된다.
➜ Principal 객체를 통해 사용자명을 얻고, 그 사용자명을 통해 얻은 SiteUser 객체를 답변을 등록하는 AnswerService의 create 메서드에 전달하여 답변을 author 속성과 함께 저장하도록 했다.
...
import com.gdsc.webboard.user.SiteUser;
import com.gdsc.webboard.user.UserService;
...
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 siteUser = this.userService.getUser(principal.getName());
if (bindingResult.hasErrors()) {
model.addAttribute("question", question);
return "question_detail";
}
this.answerService.create(question, answerForm.getContent(), siteUser); // SiteUser 객체 전달
return String.format("redirect:/question/detail/%s", id);
}
}
질문에 작성자 등록하기
- 답변에 작성자를 등록하는 방법과 동일하다.
- 작성자 정보를 저장하기 위해 QuestionService 수정.
...
import com.gdsc.webboard.user.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); // 작성자에 대한 setter 메서드 추가
this.questionRepository.save(q);
}
}
- QuestionController 수정.
...
import java.security.Principal;
import com.gdsc.webboard.user.SiteUser;
import com.gdsc.webboard.user.UserService;
...
public class QuestionController {
private final QuestionService questionService;
private final UserService userService; // UserService 객체 주입
...
@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm,
BindingResult bindingResult, Principal principal) {
if (bindingResult.hasErrors()) {
return "question_form";
}
// 사용자명으로 SiteUser 객체 조회
SiteUser siteUser = this.userService.getUser(principal.getName());
// SiteUser 객체 전달
this.questionService.create(questionForm.getSubject(), questionForm.getContent(), siteUser);
return "redirect:/question/list";
}
}
로그인이 필요한, Principal 객체를 사용하는 메서드
- 로그아웃 상태에서 질문이나 답변을 등록하면 다음과 같은 서버오류(500)가 발생한다.

🔻 Principal 객체는 로그인을 해야만 생성되는 객체이기 때문이다.
➜ Principal 객체를 사용하는 메서드에 '@PreAuthorize("istAuthenticate()")' 애너테이션을 사용해야 한다.
- @PreAuthorize("istAuthenticate()")
➜ 이 애너테이션이 붙은 메서드는 로그인이 필요한 메서드임을 알린다.
➜ 따라서, 해당 메서드가 로그아웃 상태에서 호출되면 자동으로 로그인 페이지로 이동된다 !
( @PreAuthorize ➜ 해당 메서드를 실행하기 전에 권한을 검사하는 애너테이션이다. )
- 로그인이 필요한 메서드들에 모두 '@PreAuthorize("istAuthenticate()")' 애너테이션을 적용하면 된다.
...
import org.springframework.security.access.prepost.PreAuthorize;
...
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) {
...
}
}
...
import org.springframework.security.access.prepost.PreAuthorize;
...
public class AnswerController {
...
@PreAuthorize("isAuthenticated()")
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable("id") Integer id, @Valid AnswerForm answerForm,
BindingResult bindingResult, Principal principal) {
...
}
}
- 그리고 '@PreAuthorize' 애너테이션이 동작할 수 있도록 스프링 시큐리티 설정도 수정해줘야 한다.
- '@EnableMethodSecurity' 애너테이션의 'prePostEnabled = true' 요소.
➜ 로그인 여부를 검사하기 위해 사용했던 '@PreAuthorize' 애너테이션을 사용하기 위해 반드시 필요하다 !
- '@EnableMethodSecurity' 애너테이션의 'prePostEnabled = true' 요소.
...
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
...
}
- 이제 질문 등록은 로그아웃 상태에선 할 수 없게 되었지만, 답변을 등록할 때엔 로그아웃 상태에서도 글을 작성하는 것까진 가능하다.
➜ 그런데 문제가 <저장하기> 버튼을 클리하면 자동으로 로그인 화면으로 이동하게 되는데 작성한 답변이 사라지는 문제가 있다.
➜ 그래서 그냥 로그아웃 상태에선 답변 작성조차도 못하게 막는 것이 좋을 것 !
➜ question_detail 템플릿을 수정해주면 된다.
[ /web-board/src/main/resources/templates/question_detail.html ]
<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>
🔻 로그인 상태가 아닌 경우(sec:authorize="isAnonymous()"), textarea 태그에 'disabled' 속성을 적용해 입력을 못하게 만들었다.
- H2 console에서 'AUTHOR_ID' 속성(Attribute, Column)에 글쓴이가 저장된 것을 확인할 수 있다.

스프링 시큐리티 기능
- 스프링 시큐리티는 로그아웃 상태에서 "질문 등록" 버튼을 클리하면 "로그인" 화면으로 이동하고, 이후 로그인을 하게 되면 원래 하려고 했던 "질문 등록" 화면으로 이동한다.
글쓴이 표시 (Front-end)
- 질문 목록 템플릿에 글쓴이 표시 !
[ /web-board/src/main/resources/templates/question_list.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>
</div>
...
- 질문 상세 템플릿에 글쓴이 표시 !
[ /web-board/src/main/resources/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>
</div>
<!-- 답변 반복 끝 -->
▼ 수정 · 삭제
수정 일시
- 질문이나 답변이 언제 수정되었는지 확인할 수 있도록 Question과 Answer 엔티티에 수정 일시를 의미하는 'modifyDate' 속성을 추가하자.
...
public class Question {
...
privaet LocalDateTime modifyDate;
}

질문 수정
- 질문 상세 템플릿에 질문 "수정" 버튼을 추가하자.
[ /web-board/src/main/resources/templates/question_detail.html ]
- #authentication.getPrincipal().getUsername() == question.author.username
➜ 로그인한 유저와 글쓴이가 동일한 경우에만 버튼이 나타나도록 !
( #authentication.getPrincipal() : Principal 객체를 리턴하는 타임리프의 유틸리티. )
- #authentication.getPrincipal().getUsername() == question.author.username
...
<!-- 질문 -->
<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>
(... 생략 ...)
- 질문 상세 템플릿에 'GET' 방식의 "/question/modify/질문ID" 형태의 URL 링크 추가까지 해줬으니 이제 질문 컨트롤러를 수정해야 한다.
➜ 해당 URL을 'GetMapping' 해주는 'questionModify' 메서드를 생성해자.
- return "question_form";
➜ 질문 등록시 사용했던 "question_form" 템플릿을 질문 수정할 때도 그대로 사용하는데, 이렇게 할 경우엔 "저장하기" 버튼을 누르게 되면 해당 질문이 수정되는 것이 아니라 새로운 질문이 등록되게 된다.
➜ 이러한 문제를 질문 등록 템플릿에서 'form' 태그의 action을 활용하여 해결할 수 있다.
- return "question_form";
...
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
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";
}
}
- 질문 등록 템플릿을 수정해주자 !
➜ form 태그의 action 속성 없이 폼을 전송하게 되면 폼의 action은 현재의 URL(브라우저에 표시되는 URL주소)을 기준으로 전송된다.
➜ 즉, '질문 등록시' 전송되는 URL과 '질문 수정시' 전송되는 URL이 달라지게 된다.
[ /web-board/src/main/resources/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:action' 속성 삭제 -->
<form th:object="${questionForm}" method="post">
<!-- CSRF 값 수동 추가 -->
<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 값을 수동으로 추가할 경우에 위와 같이 CSRF 값을 설정하기 위한 hidden 형태의 input 엘리먼트를 수동으로 추가해야 한다 !
- 질문 데이터를 수정할 수 있는 'modify' 메서드를 Question 서비스에 추가해주자.
...
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);
}
}
- 이제 질문 수정 화면에서 질문의 제목이나 내용을 변경하고 "저장하기" 버튼을 눌렀을 때 호출되는 POST 요청을 처리하기 위해 Question 컨트롤러에 'questionModify' 메서드를 추가하자.
...
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);
}
}
🔻 이제 로그인한 유저와 글쓴이가 같으면 질문 상세 화면에 <수정> 버튼이 나타난다.
질문 삭제
- 질문 삭제 버튼이 나타나도록 질문 상세 템플릿에 코드를 추가해주자.
...
<!-- 질문 -->
<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로 직접 설정하지 않는 이유는 <삭제> 버튼을 클릭했을 때 "정말로 삭제하시겠습니까" 와 같은 재확인 절차가 필요하기 떄문이다 !
- 앞서 말한 확인창을 호출하기 위해선 다음과 같은 자바스크립트 코드가 필요하다.
➜ HTML 구조에서 '</body>' 태그 바로 위에 삽입하는 것을 지향하자 !
( 화면 렌더링이 완료된 후에 자바스크립트가 실행되기 때문이다. )
➜ 그러기 위해선 'layout.html' 을 상속하는 템플릿들에서 'content 블록' 을 구현하게 했던 것처럼, layout 템플릿을 아래처럼 수정해줘야 한다.
➜ 이렇게 하면, 이 'layout.html' 을 상속하는 템플릿은 자바스크립트의 삽입 위치를 신경쓸 필요없이 스크립트 블록을 사용하여 자바스크립트를 작성할 수 있게 되는 것이다.
[ /web-board/src/main/resources/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>
<!-- 자바스크립트 코드 추가 -->
<th:block layout:fragment="script"></th:block>
<!-- 자바스크립트 End -->
</body>
</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>
🔻 <삭제> 버튼을 클릭하고 <확인>을 선택하면 'data-uri' 속성에 저장된 "/question/delete/질문ID" 형태의 URL을 호출하게 된다.
- 이렇게 템플릿 파일까지 작성해줬으니, 다음으로 질문을 삭제하는 'delete' 메서드를 Question 서비스에 추가해주자.
➜ Question 객체를 인자로 받아 Question 리포지터리를 통해 질문 데이터를 삭제하도록 한다.
...
public class QuestionService {
private final QuestionRepository questionRepository;
...
public void delete(Question question) {
this.questionRepository.delete(question);
}
}
- 질문을 삭제하는 기능을 추가했으니, <삭제> 버튼을 클릭했을 때 호출되는 "/question/delete/질문ID" 형태의 URL을 처리하도록 하는 'questionDelete' 메서드를 Question 컨트롤러에 추가해주자.
- question.getAuthor().getUsername().equals(principal.getName())
➜ (URL로 전달받은 'id' 값으로 Question 데이터를 조회한 후) 로그인한 유저와 질문 작성자가 동일한 경우 위에서 작성한 서비스의 'delete' 메서드를 호출해 질문을 삭제하도록 한다. - return "redirect:/";
➜ 질문 제이터 삭제 후엔 질문 목록 화면으로 돌아갈 수 있도록 루트 페이지 "/" 로 리다이렉트해준다.
- question.getAuthor().getUsername().equals(principal.getName())
...
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:/";
}
}
🔻 이제, 질문을 작성한 유저와 로그인한 유저가 동일할 경우 상세조회 화면에 <삭제> 버튼이 나타날 것이다.
답변 수정
- 답변 수정/삭제 기능은 질문 수정/삭제 기능을 구현하는 방식과 거의 비슷하게 구현할 수 있다.
➜ 단, 답변 등록 템플릿(answer_form.html)을 추가적으로 생성해줘야 한다.
- 답변 수정 버튼 추가 !
[ /web-board/src/main/resources/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>
<!-- 답변 반복 끝 -->
...
- GET 방식으로 요청되는 URL을 처리하기 위해 컨트롤러를 수정하기 전에, Answer 컨트롤러에서 필요로 하는 '답변 조회 기능' 과 '답변 수정 기능' 부터 Answer 서비스에서 구현해주자 !
- getAnswer : 답변 ID로 답변 조회
- modify : 답변의 내용(content)으로 수정하고 수정 일시도 저장.
- getAnswer : 답변 ID로 답변 조회
...
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);
}
}
- <수정> 버튼을 클릭했을 때 GET 방식으로 요청되는 '/answer/modify/답변ID' 형태의 URL을 처리할 수 있도록 Answer 컨트롤러에 'answerModify' 메서드를 추가하자.
...
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); // URL의 답변 id를 통해 조회한 답변 데이터 대입
if (!answer.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
}
answerForm.setContent(answer.getContent());
return "answer_form";
}
}
🔻 답변 수정시 기존의 내용이 필요하므로 AnswerForm 객체(answerForm)에 조회한 값(answer.getContent())를 저장(setContent)해야 한다.
- 이제 답변 등록 템플릿을 작성해주자.
➜ 답변을 수정할 때도 새로운 답변으로 등록되지 않도록, 답변 등록 템플릿의 폼 태그에도 'action' 속성은 사용하지 않는다 !
[ /web-board/src/main/resources/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>
- 이제 폼 템플릿을 통해 요청되는 POST 방식의 "/answer/modify/답변ID" 형태의 URL을 처리하는 메서드를 Answer 컨트롤러에 추가해주자.
...
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());
}
}
🔻 이제 답변 등록 사용자와 로그인 사용자가 동일할 때만 <수정> 버튼이 나타날 것이다.
답변 삭제
- 답변 삭제 버튼 추가 !
[ /web-board/src/main/resources/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>
<!-- 답변 반복 끝 -->
...
- Answer 서비스에 답변을 삭제하는 'delete' 메서드 추가 !
...
public class AnswerService {
...
public void delete(Answer answer) {
this.answerRepository.delete(answer);
}
}
- Answer 컨트롤러에 <삭제> 버튼 클릭시 요쳥되는 GET 방식의 "/answer/delete/답변ID" 형태의 URL을 처리하는 'answerDelete' 메서드가 추가 !
...
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());
}
}
🔻 이제 답변 <삭제> 버튼도 잘 동작할 것이다.
수정일시 표시
- 질문과 답변에 이미 표시되고 있는 작성일시 바로 왼쪽에 '수정 일시' 를 추가하자 !
...
<!-- 질문 -->
<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>
<!-- 답변 반복 끝 -->
...