Spring Boot - 댓글 기능 구현하기
3-15 SBB 추가 기능
이 책에서 구현할 SBB의 기능은 아쉽지만 여기까지이다. 함께 더 많은 기능을 추가하고 싶지만 이 책은 SBB의 완성이 아니라 SBB를 성장시키며 얻게 되는 경험을 전달하는 것을…
wikidocs.net
위키독스 - 점프 투 스프링부트 추가 기능 구현에 대해 설명한다.
게시글에 대한 댓글 기본 작성과 수정, 삭제 기능을 추가했다.
댓글 도메인
- com.example.sbb.comment 생성
- VS Code의 경우에는 sbb 폴더 우클릭 → New Folder → comment 생성
댓글 모델
comment
// comment.java
package com.example.sbb.comment;
import java.time.LocalDateTime;
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 com.example.sbb.answer.Answer;
import com.example.sbb.question.Question;
import com.example.sbb.user.SiteUser;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@ManyToOne
private SiteUser author;
@Column(columnDefinition = "TEXT")
private String content;
private LocalDateTime createDate;
private LocalDateTime modifyDate;
@ManyToOne
private Question question;
@ManyToOne
private Answer answer;
//수정 및 삭제 후 상세 페이지로 redirect를 위한 메서드
public Integer getQuestionId() {
Integer result = null;
if (this.question != null) {
result = this.question.getId();
} else if (this.answer != null) {
result = this.answer.getQuestion().getId();
}
return result;
}
}
- 한 명의 유저가 여러 댓글을 달 수 있다(ManyToOne)
- 한 개의 질문에 여러 개의 댓글을 달 수 있다(ManyToOne)
- 한 개의 답변에 여러 개의 댓글을 달 수 있다(ManyToOne)
댓글 수정, 삭제 후 질문 상세 페이지로 redirect 하기 위해서는 질문 id를 알아내는 메서드 작성이 필요하다.
getQuestionId() 메서드를 미리 작성해두었다.
Question 모델
//question/Question.java
package com.mysite.sbb.question;
(... 생략 ...)
import com.mysite.sbb.comment.Comment;
(... 생략 ...)
@Getter
@Setter
@Entity
public class Question {
(... 생략 ...)
@OneToMany(mappedBy = "question")
private List<Comment> commentList;
}
Answer 모델
package com.mysite.sbb.answer;
(... 생략 ...)
import com.mysite.sbb.comment.Comment;
(... 생략 ...)
@Entity
@Getter
@Setter
public class Answer {
(... 생략 ...)
@OneToMany(mappedBy = "answer")
private List<Comment> commentList;
}
- Question, Answer 엔티티를 연결해준다
질문 댓글
질문 댓글 링크
<!--templates/question_detail.html-->
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
<div class="card-body">
(... 생략 ...)
<div class="my-3" sec:authorize="isAuthenticated()"
th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}">
<a th:href="@{|/question/modify/${question.id}|}" class="btn btn-sm btn-outline-secondary">수정</a>
<a href="javascript:void(0);" class="delete btn btn-sm btn-outline-secondary"
th:data-uri="@{|/question/delete/${question.id}|}">삭제</a>
</div>
<!-- 질문 댓글 Start -->
<div class="mt-3" th:if="${not #lists.isEmpty(question.commentList)}">
<div th:each="comment,index : ${question.commentList}" class="comment py-2 text-muted">
<span style="white-space: pre-line;" th:text="${comment.content}"></span>
<span th:if="${comment.modifyDate != null}"
th:text="| - ${comment.author.username}, ${#temporals.format(comment.createDate, 'yyyy-MM-dd HH:mm')} (수정: ${#temporals.format(comment.modifyDate, 'yyyy-MM-dd HH:mm')})|"></span>
<span th:if="${comment.modifyDate == null}"
th:text="| - ${comment.author.username}, ${#temporals.format(comment.createDate, 'yyyy-MM-dd HH:mm')}|"></span>
<a sec:authorize="isAuthenticated()"
th:if="${#authentication.getPrincipal().getUsername() == comment.author.username}"
th:href="@{|/comment/modify/${comment.id}|}" class="small">수정</a>,
<a sec:authorize="isAuthenticated()"
th:if="${#authentication.getPrincipal().getUsername() == comment.author.username}"
href="javascript:void(0);" class="small delete" th:data-uri="@{|/comment/delete/${comment.id}|}">삭제</a>
</div>
</div>
<div>
<a th:href="@{|/comment/create/question/${question.id}|}" class="small"><small>댓글 추가 ..</small></a>
</div>
<!-- 질문 댓글 End -->
</div>
</div>
<h5 class="border-bottom my-3 py-2" th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
(... 생략 ...)
// static/style.css
.comment {
border-top: dotted 1px #ddd;
font-size:0.7em;
}
질문에 대한 댓글을 나타내기 위해 질문 상세 페이지 -> 질문 하단에 작성한 댓글이 표시되도록 처리했다.
댓글 추가도 하단의 버튼을 통해 입력 폼으로 넘어가도록 처리.
CommentRepository
package com.mysite.sbb.comment;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CommentRepository extends JpaRepository<Comment, Integer> {
}
CommentService
// comment/CommentService.java
package com.example.sbb.comment;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.example.sbb.DataNotFoundException;
import com.example.sbb.question.Question;
import com.example.sbb.user.SiteUser;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class CommentService {
private final CommentRepository commentRepository;
public Comment create(Question question, String content, SiteUser author) {
Comment c = new Comment();
c.setContent(content);
c.setCreateDate(LocalDateTime.now());
c.setQuestion(question);
c.setAuthor(author);
c = this.commentRepository.save(c);
return c;
}
public Comment getComment(Integer id) {
Optional<Comment> comment = this.commentRepository.findById(id);
if (comment.isPresent()) {
return comment.get();
} else {
throw new DataNotFoundException("코멘트를 찾을 수 없습니다.");
}
// return this.commentRepository.findById(id);
}
public Comment modify(Comment cmt, String content) {
cmt.setContent(content);
cmt.setModifyDate(LocalDateTime.now());
cmt = this.commentRepository.save(cmt);
return cmt;
}
public void delete(Comment c) {
this.commentRepository.delete(c);
}
}
- Private 변수로 선언한 기존 변수를 final(상수)로 변경
- 생성, 조회, 수정, 삭제 서비스를 여기서 작성한다.
CommentForm
package com.mysite.sbb.comment;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class CommentForm {
@NotEmpty(message = "내용은 필수항목입니다.")
private String content;
}
CommentController
package com.example.sbb.comment;
import java.security.Principal;
import java.util.Optional;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.ui.Model;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import com.example.sbb.question.Question;
import com.example.sbb.question.QuestionService;
import com.example.sbb.user.SiteUser;
import com.example.sbb.user.UserService;
@Controller
@RequestMapping("/comment")
@RequiredArgsConstructor
public class CommentController {
private final CommentService commentService;
private final QuestionService questionService;
private final UserService userService;
@PreAuthorize("isAuthenticated()")
@GetMapping(value = "/create/question/{id}")
public String createQuestionComment(CommentForm commentForm) {
return "comment_form";
}
// 질문에 대한 코멘트
@PreAuthorize("isAuthenticated()")
@PostMapping(value = "/create/question/{id}")
public String createQuestionComment(Model model, @PathVariable("id") Integer id, @Valid CommentForm commentForm,
BindingResult bindingResult, Principal principal) {
Question question = this.questionService.getQuestion(id);
SiteUser user = this.userService.getUser(principal.getName());
if (bindingResult.hasErrors()) {
model.addAttribute("question", question);
return "question_detail";
}
Comment comment = this.commentService.create(question, commentForm.getContent(), user);
return String.format("redirect:/question/detail/%s#comment_%s", comment.getQuestion().getId(), comment.getId());
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/modify/{id}")
public String modifyComment(CommentForm commentForm, @PathVariable("id") Integer id, Principal principal) {
Comment comment = this.commentService.getComment(id);
if (!comment.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정 권한이 없습니다.");
}
commentForm.setContent(comment.getContent());
return "comment_form";
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/modify/{id}")
public String modifyComment(@Valid CommentForm commentForm, BindingResult bindingResult, Principal principal,
@PathVariable("id") Integer id) {
if (bindingResult.hasErrors()) {
return "comment_form";
}
Comment comment = this.commentService.getComment(id);
if (!comment.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정 권한이 없습니다.");
}
this.commentService.modify(comment, commentForm.getContent());
return String.format("redirect:/question/detail/%s", comment.getQuestion().getId(), comment.getId());
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/delete/{id}")
public String deleteComment(Principal principal, @PathVariable("id") Integer id) {
Comment comment = this.commentService.getComment(id);
if (!comment.getAuthor().getUsername().equals(principal.getName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제 권한이 없습니다.");
}
this.commentService.delete(comment);
return String.format("redirect:/question/detail/%s", comment.getQuestion().getId());
}
}
- 기존 AnswerController와 같은 처리 방식 사용.
- Optional 처리되어있던 부분은 서비스에 처리 혹은 상수로 변경.
comment_form.html
<!--templates/comment_form.html-->
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
<h5 class="my-3 border-bottom pb-2">댓글 등록</h5>
<form th:object="${commentForm}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<nav th:replace="~{form_errors :: formErrorsFragment}"></nav>
<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>
- 기존 코드 사용 시 th:replace= 부분이 콘솔에서 오류를 출력하는 경우가 있음. 예제로 사용되었던 코드가 구버전의 코드이므로, 위와 같이 처리
결과
반응형
'개발 공부 > Spring Boot' 카테고리의 다른 글
Spring Boot - 답변 페이징 (0) | 2023.05.30 |
---|---|
Spring Boot - VS Code로 개발환경 설정하기 (1) | 2023.05.05 |