1. 글쓴이 항목 추가하기
- 질문 또는 답변을 작성할 때 사용자 정보도 DB에 저장
- 게시판의 질문 목록과 답변 상세 페이지에 '글쓴이' 항목 추가
1) 엔티티의 속성 추가
질문 엔티티에 author 속성 추가
Question.java
package com.example.demo.question;
import java.time.LocalDateTime;
import java.util.List;
import com.example.demo.answer.Answer;
import com.example.demo.user.SiteUser;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.ManyToOne;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
public class Question {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Integer id;
@Column(length=200)
private String subject;
@Column(columnDefinition="TEXT")
private String content;
private LocalDateTime createDate;
@OneToMany(mappedBy="question", cascade=CascadeType.REMOVE)
private List<Answer> answerList;
@ManyToOne
private SiteUser author;
}
- 사용자 한 명이 질문을 여러개 작성할 수 있으므로 @ManyToOne 적요 ㅇ
답변 엔티티의 author 속성 추가
Answer.java
package com.example.demo.answer;
import java.time.LocalDateTime;
import com.example.demo.question.Question;
import com.example.demo.user.SiteUser;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne; //다대일 관계
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
public class Answer {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Integer id;
@Column(columnDefinition="TEXT")
private String content;
private LocalDateTime createDate;
@ManyToOne
private Question question;
@ManyToOne
private SiteUser author;
}
2) 글쓴이 저장하기
- 새로운 데이터를 저장하려면 서버와 DB를 관리하는 컨트롤러와 서비스(또는 리포지터리)에도 관련 내용을 업데이트해야 함
답변 컨트롤러와 서비스 업데이트
AnswerController.java
package com.example.demo.answer;
...
import java.security.Principal;
@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {
...
@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable("id")Integer id,
@Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
...
}
...
}
}
- 답변을 저장할 때, 사용자 정보도 저장
- 현재 로그인한 사용자의 정보를 알려면 스프링 시큐리티가 제공하는 Principal 객체를 사용
- createAnser 메서드에 Principal 객체를 매개변수로 지정
- principal.getName()을 호출하면 현재 로그인한 사용자의 사용자명(사용자 ID)를 알 수 있음
UserService.java
package com.example.demo.user;
import java.util.Optional;
import com.example.demo.DataNotFoundException;
...
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
...
//SiteUser 조회
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");
}
}
}
- SiteUser를 조회할 수 있는 getUser 메서드를 UserService에 추가
- userRepository의 findByUsername 메서드 사용
- 사용자명에 해당하는 데이터가 없을 경우에는 DataNotFoundException이 발생하도록 함
AnswerService.java
package com.example.demo.answer;
...
import com.example.demo.user.SiteUser;
@RequiredArgsConstructor
@Service
public class AnswerService {
private final AnswerRepository answerRepository;
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);
this.answerRepository.save(answer);
return answer;
}
}
- create 메서드에 SiteUser 객체를 추가로 전달받아 작성자도 함께 저장하도록 수정
AnswerController.java
package com.example.demo.answer;
...
import com.example.demo.user.SiteUser;
import com.example.demo.user.UserService;
...
@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
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 = this.userService.getUser(principal.getName());
if(bindingResult.hasErrors()) {
model.addAttribute("question",question);
return "question_detail";
}
this.answerService.create(question,answerForm.getContent(),siteUser);
return String.format("redirect:/question/detail/%s", id);
}
}
- principal 객체를 통해 사용자명을 얻은 후, 사용자명을 통해 SiteUser 객체를 얻어 답변을 등록할 때 사용
질문 컨트롤러와 서비스 업데이트
QuestionService.java
package com.example.demo.question;
...
import com.example.demo.user.SiteUser;
...
@RequiredArgsConstructor
@Service
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);
this.questionRepository.save(q);
}
}
- create 메서드에 SiteUser를 추가하여 Question 데이터를 생성하도록 수정
QuestionController.java
package com.example.demo.question;
...
import java.security.Principal;
import com.example.demo.user.SiteUser;
import com.example.demo.user.UserService;
...
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController{
private final QuestionService questionService;
private final UserService userService;
...
@PostMapping("/create")
public String questionCreate(@Valid QuestionForm questionForm, BindingResult bindingResult, Principal principal) {
if(bindingResult.hasErrors()) {
return "question_form";
}
SiteUser siteUser = this.userService.getUser(principal.getName());
this.questionService.create(questionForm.getSubject(), questionForm.getContent(),siteUser);
return "redirect:/question/list";
}
}
- Principal 객체를 통해 사용자명을 구한 후, SiteUser를 조회하여 질문 저장시 함께 저장할 수 있도록 함
3) 로그인 페이지로 이동시키기
- 로그아웃 상태에서 질문/답변을 등록시 500 오류(서버 오류) 발생
- principal 객체가 null이라서 발생
- principal 객체는 로그인을 해야만 생성되는 객체인데 현재는 로그아웃 상태이므로 principal 객체에 값이 없어서 발생
- principal 객체를 사용하는 메서드에 @PreAuthorize("isAuthenticated()")을 사용하면 해결
- @PreAuthorize("isAuthenticated()")가 붙은 메서드는 로그인한 경우에만 실행
- @PreAuthorize("isAuthenticated()")이 적용된 메서드가 로그아웃 상태에서 호출되면 로그인 페이지로 강제 이동
QuestionController.java
...
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) {
...
}
}
AnswerController.java
...
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) {
...
}
}
SecurityConfig.java
- @PreAuthorize이 동작할 수 있도록 스프링 시큐리티의 설정 수정
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
...
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
...
}
4) 답변 작성 막아두기
- 사용자가 로그아웃 상태인 경우 답변 작성 막기
templates/question_detail.html
...
<!-- 답변 작성 -->
<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>
...
- 로그인 상태가 아닌 경우 textarea 태그에 disabled 속성을 적용하여 사용자가 화면에서 입력하지 못하게 함
5) 화면에 글쓴이 나타내기
질문 목록에 글쓴이 표시
templates/question_list.html
...
<tr class="text-center">
<th>번호</th>
<th style="width:50%">제목</th>
<th>글쓴이</th>
<th>작성일시</th>
</tr>
...
<tr class="text-center" th:each="question, loop : ${paging}">
<td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
<td class="text-start">
<a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
<span class="text-danger small ms-2" th:if="${#lists.size(question.answerList) > 0}"
th:text="${#lists.size(question.answerList)}">
</span>
</td>
<!-- author 속성이 null이 아닌 경우에만 글쓴이 표시 -->
<td><span th:if="${question.author != null}" th:text="${question.author.username}"></span></td>
<td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
</tr>
...
질문 상세에 글쓴이 표시
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>
</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 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. 수정과 삭제 기능 추가하기
1) 수정 일시 추가하기
- Question 엔티티와 Answer 엔티티에 수정 일시를 의미하는 modifyDate 속성 추가
Question.java
...
@Getter
@Setter
@Entity
public class Question {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Integer id;
...
private LocalDateTime modifyDate;
}
Answer.java
...
@Getter
@Setter
@Entity
public class Answer {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private Integer id;
...
private LocalDateTime modifyDate;
}
2) 질문 수정 기능 생성하기
질문 수정 버튼 생성
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을 적용
질문 컨트롤러 수정(1)
QuestionController.java
package com.example.demo.question;
...
import org.springframework.security.access.prepost.PreAuthorize;
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";
}
}
- questionModify 메서드 추가
- 현재 로그인한 사용자와 질문의 작성자가 동일하지 않을 경우에는 '수정 권한이 없습니다'라는 오류 발생
- 수정할 질문의 제목(subject)과 내용(object)의 값을 담아서 템플릿으로 전달
질문 등록 템플릿 수정
templates/question_form.html
...
<form th:object="${questionForm}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
</form>
...
- 기존에 있던 <form> 태그의 th:action 속성 삭제
- th:action 속성을 삭제하면 CSRF 값이 자동으로 생성되지 않아서 CSRF 값을 설정하기 위해 hidden 형태로 input 요소 추가
- <form> 태그의 action 속성 없이 폼을 전송하면 action 속성이 없더라도 현재 URL을 기준으로 전송됨
- 질문 등록시에 브라우저에 표시되는 URL은 /question/create이어서 action 속성이 지정되지 않더라도 POST로 폼 전송할 때 action 속성으로 /question/create가 자동 설정되고, 질문 수정 시에 브라우저에 표시되는 URL은 /question/modify/2와같은 URL이므로 POST로 폼 전송할 때 action 속성에 /question/modify/2와 같은 URL 설정
질문 서비스 수정
questionService.java
package com.example.demo.question;
...
@RequiredArgsConstructor
@Service
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);
}
}
질문 컨트롤러 수정(2)
QuestionController.java
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
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 요청을 처리하기 위해 questionModify 메서드 추가
- questionForm의 데이터를 검증하고 로그인한 사용자와 수정하려는 질문의 작성자가 동일한지도 검증
- 검증이 통과되면 QuestionService에서 작성한 modify 메서드를 호출하여 질문데이터를 수정
- 수정이 완료되면 질문 상세 화면(/question/detail/(숫자))으로 리다이렉트
3) 질문 삭제 기능 생성하기
질문 삭제 버튼 생성
templates/question_detail.html
...
<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>
- href 속성값을 javascript:void(0)로 설정하고 삭제를 실행할 URL을 얻기 위해 th:data-uri 속성을 추가
- [삭제] 버튼을 클릭하는 이벤트를 확인하기 위해 class 속성에 delete 항목 추가
삭제를 위한 자바스크립트 작성
- [삭제] 버튼을 클릭했을 때 '정말로 삭제하시겠습니까?'와 같은 메시지 확인창 호출
<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을 호출하라는 의미
- 질문 상세 템플릿에 </body> 태그 바로 위에 삽입
templates/question_detail.html
...
<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>
...
- 각 템플릿에서 자바스크립트를 </body> 태그 바로 위에 삽입하고, 상속할 수 있도록 layout.html 수정
templates/layout.html
...
<!-- 자바스크립트 Start -->
<th:block layout:fragment="script"></th:block>
<!-- 자바스크립트 End -->
</body>
...
질문 서비스와 컨트롤러 수정
QuestionService.java
@RequiredArgsConstructor
@Service
public class QuestionService {
...
//질문 삭제 처리
public void delete(Question question) {
this.questionRepository.delete(question);
}
}
- 질문 데이터를 삭제하는 delete 메서드 추가
QuestionController.java
@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
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:/";
}
}
- [삭제] 버튼을 클릭했을 때 @{|/question/delete/${question.id}|} URL을 처리
- URL로 전달받은 id값을 사용하여 Question 데이터를 조회한 후, 로그인한 사용자와 질문 작성자가 동일할 경우 QuestionService를 이용하여 질문 삭제
- 질문을 삭제한 후에는 질문 목록 화면(/)으로 리다이렉트
4) 답변 수정 및 삭제 기능 추가
- 질문 수정 /삭제와 동일한 과정으로 진행
'SpringBoot' 카테고리의 다른 글
[Do it] 3장 스프링 부트 고급 기능 익히기(6) (0) | 2024.01.25 |
---|---|
[Do it] 3장 스프링 부트 고급 기능 익히기(5) (0) | 2024.01.24 |
[Do it] 3장 스프링 부트 고급 기능 익히기(3) (1) | 2024.01.23 |
[Do it] 3장 스프링 부트 고급 기능 익히기(2) (0) | 2024.01.23 |
[Do it] 3장 스프링 부트 고급 기능 익히기(1) (0) | 2024.01.22 |