Follow Work/SpringbootBoard

[StringBoot] 수정과 삭제 (23)

ReCode.B 2022. 8. 17. 14:19
728x90

참고 링크 : https://wikidocs.net/book/7601

오라클DB와 intelliJ로 작업하였습니다


이번장에서는 작성한 질문과 답변을 수정하고 삭제할 수 있는 기능을 추가해 보자.

1.수정일시
2.질문수정
3.질문삭제
4.답변수정
5.답변삭제
6.수정일시표시하기


수정 일시

먼저 질문이나 답변이 언제 수정되었는지 확인할 수 있도록 Question과 Answer 엔티티에 수정 일시를 의미하는 modifyDate 속성을 추가하자.

package com.gosari.repick_project.question;


import com.gosari.repick_project.answer.Answer;
import com.gosari.repick_project.user.SiteUser;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.List;

@Setter
@Getter
@Entity
public class Question {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Integer id;

    private String subject;

    private String content;

    @Column(insertable = false, updatable = false, columnDefinition = "date default LOCALTIMESTAMP")
    private Date createDate;

    @OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE)
    private List<Answer>answerList; //질문엔티티에 답변엔티티참조 (질문1:답변N)

    @ManyToOne //여러개의 질문이 한명의 사용자에게 작성될수있으므로 @ManyToOne
    private SiteUser author; /*author 속성추가*/

    /*수정일시*/
    @Column(insertable = true, updatable = true, columnDefinition = "date default LOCALTIMESTAMP")
    private Date modifyDate;
}
package com.gosari.repick_project.answer;

import com.gosari.repick_project.question.Question;
import com.gosari.repick_project.user.SiteUser;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.Date;

@Setter
@Getter
@Entity
public class Answer {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Integer id;

    private String content;

    @Column(insertable = false, updatable = false, columnDefinition = "date default LOCALTIMESTAMP")
    private Date createDate;

    @ManyToOne
    private Question question; //답변엔티티에 질문엔티티참조 (질문1:답변N)

    @ManyToOne //여러개의 질문이 한명의 사용자에게 작성될수있으므로 @ManyToOne
    private SiteUser author; /*author 속성추가*/

    /*수정일시*/
    @Column(insertable = true, updatable = true, columnDefinition = "date default LOCALTIMESTAMP")
    private Date modifyDate;
}

insertable = true, updatable = true 일시가 바뀌게 true로 속성값을 바꾸어 설정했다.

 @Column(insertable = true, updatable = true, columnDefinition = "date default LOCALTIMESTAMP")
    private Date modifyDate;

 

이와같이 수정하면 다음처럼 테이블에 modify_date 컬럼이 추가된다.


질문 수정

작성한 질문을 수정하려면 질문 상세 화면에서 "수정" 버튼을 클릭하여 수정 화면으로 진입해야 한다.

 

질문 수정 버튼

질문 상세 화면에 다음과 같이 질문 수정 버튼을 추가하자.

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
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="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="${#dates.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>

    </div>
    </div>

        <!--질문수정버튼-->
        <div class="my-3">
            <a 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>
<!--답변의 갯수표시-->
      <h5 class="border-bottom my-3 py-2"
      th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
    <!--#list.size(이터러블객체)-타임리프가 제공하는 유틸리티로 객체의 길이를 반환-->
<!--답변 반복 시작-->
      <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="${#dates.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
    </div>
    </div>
    </div>
    </div>
<!--답변 반복 끝-->
    <!--답변 작성-->
    <form th:action="@{|/answer/create/${question.id}|}"
          th:object="${answerForm}" method="post" class="my-3">

        <div th:replace="form_errors :: formErrorsFragment"></div>
        <!--th:replace - 공통템플릿을 템플릿내에 삽입가능-->
        <!--div엘리먼트를 form_errors.html파일의 th:fragment속성명이 formErrorsFragment인 엘리먼트로 교체-->


        <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>

 

<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>

 sec:authorize="isAuthenticated()" 속성은 현재 사용자의 로그인 상태를 체크하는 속성이다.

  • sec:authorize="isAnonymous()" - 현재 로그아웃 상태
  • sec:authorize="isAuthenticated()" - 현재 로그인 상태

 

수정 버튼은 로그인한 사용자와 글쓴이가 동일한 경우에만 노출되도록  

#authentication.getPrincipal().getUsername() == question.author.username을 적용하였다.

만약 로그인한 사용자와 글쓴이가 다르다면 수정 버튼은 보이지 않을 것이다.

#authentication.getPrincipal()은 Principal 객체를 리턴하는 타임리프의 유틸리티이다.

principal 객체를 사용하면 로그인한 사용자의 사용자명을 알수 있다.

 

QuestionController

그리고 위의 수정 버튼에 GET 방식의 @{|/question/modify/${question.id}|} 링크가 추가되었으므로 질문 컨트롤러를 다음과 같이 수정하자.

package com.gosari.repick_project.question;

import com.gosari.repick_project.answer.AnswerForm;
import com.gosari.repick_project.user.SiteUser;
import com.gosari.repick_project.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;


import javax.validation.Valid;
import java.security.Principal;
import java.util.List;


@RequestMapping("/question") //URL프리픽스(prefix)
@RequiredArgsConstructor
@Controller
public class QuestionController {

    private final QuestionService questionService;
    private final UserService userService;

    /*질문리스트*/
    @RequestMapping("/list")
    public String list(Model model,
                       @RequestParam(value="page", defaultValue="0") int page){
        Page<Question> paging = this.questionService.getList(page);
        model.addAttribute("paging", paging); /*페이징*/
        return "question_list";
    }

    /*질문상세*/
    @RequestMapping(value = "/detail/{id}")
    public String detail(Model model, @PathVariable("id") Integer id,
                         AnswerForm answerForm) { /*AnswerForm추가*/
        Question question = this.questionService.getQuestion(id);
        model.addAttribute("question", question);
        return "question_detail";
    }

    /*질문등록하기*/
    @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) {

        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";
    }

    /*질문 수정하기 GET*/
    @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 객체에 값을 담아서 템플릿으로 전달했다.

(이 과정이 없다면 화면에 "제목", "내용"의 값이 채워지지 않아 비워져 보인다.)

그리고 여기서 눈여겨 보아야 할 부분은 질문 등록시 사용했던 "question_form" 템플릿을 질문 수정에서도 사용한다는 점이다. 질문 등록 템플릿을 그대로 사용할 경우 질문을 수정하고 "저장하기" 버튼을 누르면 질문이 수정되는 것이 아니라 새로운 질문이 등록된다. 이 문제는 템플릿 폼 태그의 action을 잘 활용하면 유연하게 대처할수 있다. 어떻게 대처할 수 있는지 템플릿을 수정하면서 살펴보자.

 

숫자 2처럼 변하는 id 값을 얻을 때에는 위와 같이 @PathVariable 애너테이션을 사용해야 한다.

 

question_form.html

질문 수정시에도 질문 등록과 동일한 템플릿을 사용할 수 있다.

하지만 약간의 트릭이 필요하다. 다음과 같이 질문등록 템플릿을 수정하자.

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
      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>
        <!--th:replace - 공통템플릿을 템플릿내에 삽입가능-->
        <!--div엘리먼트를 form_errors.html파일의 th:fragment속성명이 formErrorsFragment인 엘리먼트로 교체-->

        <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 속성을 삭제하자. 그리고 th:action 속성을 삭제하면 CSRF 값이 자동으로 생성되지 않기 때문에 위와 같이 CSRF 값을 설정하기 위한 hidden 형태의 input 엘리먼트를 수동으로 추가한다.

CSRF 값을 수동으로 추가하기 위해서는 위와 같이 해야한다. 이것은 스프링 시큐리티의 규칙이다.

폼 태그의 action 속성 없이 폼을 전송(submit)하면 폼의 action은 현재의 URL(브라우저에 표시되는 URL주소)을 기준으로 전송이 된다. 즉, 질문 등록시에 브라우저에 표시되는 URL은 /question/create이기 때문에 POST로 폼 전송시 action 속성에 /question/create가 설정이 되고, 질문 수정시에 브라우저에 표시되는 URL은 /question/modify/2 형태의 URL이기 때문에 POST로 폼 전송시 action 속성에 /question/modify/2 형태의 URL이 설정되는 것이다.

폼 태그의 th:action 속성을 삭제하더라도 질문 등록 및 수정 기능이 정상 동작한다.

 

QuestionService

그리고 질문 데이터를 수정할 수 있도록 QuestionService를 다음과 같이 수정하자.

package com.gosari.repick_project.question;
import com.gosari.repick_project.user.SiteUser;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import com.gosari.repick_project.exception.DataNotFoundException;

@RequiredArgsConstructor //QuestionRepository 생성자 생성해줌
@Service
public class QuestionService {
    private final QuestionRepository questionRepository;

    public List<Question> getList(){
        return this.questionRepository.findAll();
    }

    /*Question 데이터 조사*/
    public Question getQuestion(Integer id){
        Optional<Question> question = this.questionRepository.findById(id);
        if(question.isPresent()){
            return question.get();
        }else{
            throw new DataNotFoundException("question not found");
        }
    }

    /*페이징*/
    public Page<Question> getList(int page){
        List<Sort.Order> sorts = new ArrayList<>();
        sorts.add(Sort.Order.desc("createDate"));
        Pageable pageable = PageRequest.of(page, 10, Sort.by(sorts));
        return this.questionRepository.findAll(pageable);
    }

    /*질문데이터를 저장하는 create메서드*/
    public void create(String subject, String content, SiteUser user){
        Question q = new Question();
        q.setSubject(subject);
        q.setContent(content);
        q.setCreateDate(new Date()); //.now()가 아닌 new Date()로 해주면 된다
        q.setAuthor(user);
        this.questionRepository.save(q);
    }

    /*질문데이터 수정*/
    public void modify(Question question, String subject, String content) {
        question.setSubject(subject);
        question.setContent(content);
        question.setModifyDate(new Date());
        this.questionRepository.save(question);
    }

}

질문 데이터를 수정할수 있는 modify 메서드를 추가했다.

 

QuestionController

그리고 질문 수정화면에서 질문의 제목이나 내용을 변경하고 "저장하기" 버튼을 누르면 호출되는 POST 요청을 처리하기 위해 QuestionController에 다음과 같은 메서드를 추가하자.

package com.gosari.repick_project.question;

import com.gosari.repick_project.answer.AnswerForm;
import com.gosari.repick_project.user.SiteUser;
import com.gosari.repick_project.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;


import javax.validation.Valid;
import java.security.Principal;
import java.util.List;


@RequestMapping("/question") //URL프리픽스(prefix)
@RequiredArgsConstructor
@Controller
public class QuestionController {

    private final QuestionService questionService;
    private final UserService userService;

    /*질문리스트*/
    @RequestMapping("/list")
    public String list(Model model,
                       @RequestParam(value="page", defaultValue="0") int page){
        Page<Question> paging = this.questionService.getList(page);
        model.addAttribute("paging", paging); /*페이징*/
        return "question_list";
    }

    /*질문상세*/
    @RequestMapping(value = "/detail/{id}")
    public String detail(Model model, @PathVariable("id") Integer id,
                         AnswerForm answerForm) { /*AnswerForm추가*/
        Question question = this.questionService.getQuestion(id);
        model.addAttribute("question", question);
        return "question_detail";
    }

    /*질문등록하기*/
    @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) {

        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";
    }

    /*질문 수정하기 GET*/
    @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";
    }

    /*질문 수정하기 POST*/
    @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);
    }

}

 BindingResult 매개변수 @Valid 애너테이션으로 인해 검증이 수행된 결과를 의미하는 객체이다.

 

POST 형식의 /question/modify/{id} 요청을 처리하기 위해 questionModify 메서드를 추가했다.

questionForm의 데이터를 검증하고 로그인한 사용자와 수정하려는 질문의 작성자가 동일한지도 검증한다.

검증이 통과되면 QuestionService에서 작성한 modify 메서드를 호출하여 질문 데이터를 수정한다.

그리고 수정이 완료되면 질문 상세 화면을 다시 호출한다.

 

질문 수정 확인

이제 로그인 사용자와 글쓴이가 같으면 질문 상세 화면에 <수정> 버튼이 보일 것이다.

수정 기능이 잘 동작하는지 확인해 보자.


질문 삭제

이번에는 질문을 삭제하는 기능을 추가해 보자. 작성한 질문을 삭제하려면 질문 수정과 마찬가지로 질문 상세 화면에서 "삭제" 버튼을 생성하여 삭제해야 한다.

 

질문 삭제 버튼

작성한 글을 삭제할 수 있는 버튼을 다음처럼 추가하자.

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
      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="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="${#dates.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>
                <!--질문삭제버튼-->
                <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>
    <!--답변의 갯수표시-->
    <h5 class="border-bottom my-3 py-2"
        th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
    <!--#list.size(이터러블객체)-타임리프가 제공하는 유틸리티로 객체의 길이를 반환-->
    <!--답변 반복 시작-->
    <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="${#dates.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
                </div>
            </div>
        </div>
    </div>
    <!--답변 반복 끝-->
    <!--답변 작성-->
    <form th:action="@{|/answer/create/${question.id}|}"
          th:object="${answerForm}" method="post" class="my-3">

        <div th:replace="form_errors :: formErrorsFragment"></div>
        <!--th:replace - 공통템플릿을 템플릿내에 삽입가능-->
        <!--div엘리먼트를 form_errors.html파일의 th:fragment속성명이 formErrorsFragment인 엘리먼트로 교체-->


        <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>

 

<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>

<삭제> 버튼은 <수정> 버튼과는 달리 href 속성값을 javascript:void(0)로 설정했다.

그리고 삭제를 실행할 URL을 얻기 위해 th:data-uri 속성을 추가하고, 

<삭제> 버튼이 눌리는 이벤트를 확인할 수 있도록 class 속성에 "delete" 항목을 추가해 주었다.

data-uri 속성자바스크립트에서 클릭 이벤트 발생시 this.dataset.uri와 같이 사용하여 그 값을 얻을 수 있다.

href에 삭제 URL을 직접 사용하지 않고 이러한 방식을 사용하는 이유는 삭제 버튼을 클릭했을때

"정말로 삭제하시겠습니까?" 와 같은 확인 절차가 필요하기 때문이다.

 

 

자바스크립트

삭제 버튼을 눌렀을때 확인창을 호출하기 위해서는 다음과 같은 자바스크립트 코드가 필요하다.

아래 코드를 아직 추가하지 말자. 지금은 눈으로만 보자.

<script type='text/javascript'>
const delete_elements = document.getElementsByClassName("delete");
Array.from(delete_elements).forEach(function(element) {
    element.addEventListener('click', function() {
        if(confirm("정말로 삭제하시겠습니까?")) {
            location.href = this.dataset.uri;
        };
    });
});
</script>

이 자바스크립트의 의미는 delete라는 클래스를 포함하는 컴포넌트(예:버튼이나 링크)를 클릭하면 "정말로 삭제하시겠습니까?" 라는 질문을 하고 "확인"을 선택했을때 해당 컴포넌트의 data-uri 값으로 URL 호출을 하라는 의미이다. "확인" 대신 "취소"를 선택하면 아무런 일도 발생하지 않을 것이다.

※ delete 클래스는 답변 삭제에도 사용된다.

따라서 이와 같은 스크립트를 추가하면 "삭제" 버튼을 클릭하고 "확인"을 선택하면 data-uri 속성에 해당하는

@{|/question/delete/${question.id}|}이 호출될 것이다.

 

자바스크립트 블록

자바스크립트는 HTML 구조에서 다음과 같이 </body> 태그 바로 위에 삽입하는 것을 추천한다.

<html>
<head>
(... 생략 ...)
</head>
<body>
(... 생략 ...)
<!-- 이곳에 추가 -->
</body>
</html>

왜냐하면 화면 렌더링이 완료된 후에 자바스크립트가 실행되기 때문이다. 화면 렌더링이 완료되지 않은 상태에서 자바스크립트를 실행하면 오류가 발생할수도 있고 화면 로딩이 지연되는 문제가 발생할 수도 있다.

 

따라서 템플릿에서 자바스크립트를 </body> 태그 바로 위에 삽입하려면 다음처럼 layout.html을 수정해야 한다.

<!DOCTYPE html>
<html lang="ko" xmlns:layout="">
<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" href="style.css" th:href="@{/bootstrap.min.css}">
    <!--sbb CSS-->
    <link rel="stylesheet" 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을 상속하는 템플릿은 자바스크립트의 삽입 위치를 신경쓸 필요없이 스크립트 블록을 사용하여 자바스크립트를 작성하면 된다.

 

 

question_detail.html 하단에 스크립트 블록을 다음처럼 추가하자.

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
      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="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="${#dates.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>
                <!--질문삭제버튼-->
                <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>
    <!--답변의 갯수표시-->
    <h5 class="border-bottom my-3 py-2"
        th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
    <!--#list.size(이터러블객체)-타임리프가 제공하는 유틸리티로 객체의 길이를 반환-->
    <!--답변 반복 시작-->
    <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="${#dates.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
                </div>
            </div>
        </div>
    </div>
    <!--답변 반복 끝-->
    <!--답변 작성-->
    <form th:action="@{|/answer/create/${question.id}|}"
          th:object="${answerForm}" method="post" class="my-3">

        <div th:replace="form_errors :: formErrorsFragment"></div>
        <!--th:replace - 공통템플릿을 템플릿내에 삽입가능-->
        <!--div엘리먼트를 form_errors.html파일의 th:fragment속성명이 formErrorsFragment인 엘리먼트로 교체-->


        <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>


<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>
<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>

스크립트 블록에 질문을 삭제할 수 있는 자바스크립트를 작성하였다.

 

QuestionService

그리고 질문을 삭제하는 기능을 QuestionService에 추가하자.

package com.gosari.repick_project.question;
import com.gosari.repick_project.user.SiteUser;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import com.gosari.repick_project.exception.DataNotFoundException;

@RequiredArgsConstructor //QuestionRepository 생성자 생성해줌
@Service
public class QuestionService {
    private final QuestionRepository questionRepository;

    public List<Question> getList(){
        return this.questionRepository.findAll();
    }

    /*Question 데이터 조사*/
    public Question getQuestion(Integer id){
        Optional<Question> question = this.questionRepository.findById(id);
        if(question.isPresent()){
            return question.get();
        }else{
            throw new DataNotFoundException("question not found");
        }
    }

    /*페이징*/
    public Page<Question> getList(int page){
        List<Sort.Order> sorts = new ArrayList<>();
        sorts.add(Sort.Order.desc("createDate"));
        Pageable pageable = PageRequest.of(page, 10, Sort.by(sorts));
        return this.questionRepository.findAll(pageable);
    }

    /*질문데이터를 저장하는 create메서드*/
    public void create(String subject, String content, SiteUser user){
        Question q = new Question();
        q.setSubject(subject);
        q.setContent(content);
        q.setCreateDate(new Date()); //.now()가 아닌 new Date()로 해주면 된다
        q.setAuthor(user);
        this.questionRepository.save(q);
    }

    /*질문데이터 수정*/
    public void modify(Question question, String subject, String content) {
        question.setSubject(subject);
        question.setContent(content);
        question.setModifyDate(new Date());

        this.questionRepository.save(question);
    }

    /*질문데이터 삭제*/
    public void delete(Question question){
        this.questionRepository.delete(question);
    }

}

Question 객체를 입력으로 받아 Question 리포지터리를 사용하여 질문 데이터를 삭제하는 delete 메서드를 추가했다.

 

QuestionController

그리고 @{|/question/delete/${question.id}|} URL을 처리하기 위한 기능을 QuestionController에 다음과 같이 추가하자.

package com.gosari.repick_project.question;

import com.gosari.repick_project.answer.AnswerForm;
import com.gosari.repick_project.user.SiteUser;
import com.gosari.repick_project.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;


import javax.validation.Valid;
import java.security.Principal;
import java.util.List;


@RequestMapping("/question") //URL프리픽스(prefix)
@RequiredArgsConstructor
@Controller
public class QuestionController {

    private final QuestionService questionService;
    private final UserService userService;

    /*질문리스트*/
    @RequestMapping("/list")
    public String list(Model model,
                       @RequestParam(value="page", defaultValue="0") int page){
        Page<Question> paging = this.questionService.getList(page);
        model.addAttribute("paging", paging); /*페이징*/
        return "question_list";
    }

    /*질문상세*/
    @RequestMapping(value = "/detail/{id}")
    public String detail(Model model, @PathVariable("id") Integer id,
                         AnswerForm answerForm) { /*AnswerForm추가*/
        Question question = this.questionService.getQuestion(id);
        model.addAttribute("question", question);
        return "question_detail";
    }

    /*질문등록하기*/
    @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) {

        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";
    }

    /*질문 수정하기 GET*/
    @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";
    }

    /*질문 수정하기 POST*/
    @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);
    }

    /*질문 삭제하기*/
    @PreAuthorize("isAuthenticated()")
    @GetMapping("/delete/{id}")
    public String questionModify(Principal principal,
                                 @PathVariable("id") Integer id) {

        Question question = this.questionService.getQuestion(id);

        /*로그인한 사용자와 질문의 작성자가 동일하지않은 경우*/
        if(!question.getAuthor().getUsername().equals(principal.getName())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제권한이 없습니다.");
        }

        this.questionService.delete(question);
        return "redirect:/";
    }

}

URL로 전달받은 id값을 사용하여 Question 데이터를 조회한후 로그인한 사용자와 질문 작성자가 동일할 경우 위에서 작성한 서비스의 delete 메서드로 질문을 삭제한다. 질문 데이터 삭제후에는 질문 목록 화면으로 돌아갈 수 있도록 루트 페이지로 리다이렉트한다.

 

질문 삭제 확인

질문을 작성한 사용자와 로그인한 사용자가 동일할 경우 다음처럼 상세조회 화면에 "삭제" 버튼이 노출될 것이다.

"삭제" 버튼을 클릭하여 삭제가 정상적으로 동작하는지 확인해 보자.


답변 수정

이번에는 답변 수정 기능을 구현해 보자. 질문 수정과 거의 비슷한 방법으로 진행할 것이다.

다만 답변 수정은 답변 등록 템플릿이 따로 없으므로 답변 수정에 사용할 템플릿이 추가로 필요하다.

 

답변 수정 버튼

답변 목록이 출력되는 부분에 답변 수정 버튼을 추가하자.

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
      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="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="${#dates.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>
                <!--질문삭제버튼-->
                <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>
    <!--답변의 갯수표시-->
    <h5 class="border-bottom my-3 py-2"
        th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
    <!--#list.size(이터러블객체)-타임리프가 제공하는 유틸리티로 객체의 길이를 반환-->
    <!--답변 반복 시작-->
    <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="${#dates.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>
    <!--답변 반복 끝-->
    <!--답변 작성-->
    <form th:action="@{|/answer/create/${question.id}|}"
          th:object="${answerForm}" method="post" class="my-3">

        <div th:replace="form_errors :: formErrorsFragment"></div>
        <!--th:replace - 공통템플릿을 템플릿내에 삽입가능-->
        <!--div엘리먼트를 form_errors.html파일의 th:fragment속성명이 formErrorsFragment인 엘리먼트로 교체-->


        <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>


<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>

로그인한 사용자와 답변 작성자가 동일한 경우 답변의 "수정" 버튼이 노출되도록 했다.

답변 버튼을 누르면 /answer/modify/답변ID 형태의 URL이 GET 방식으로 요청될 것이다.

 

AnswerService

AnswerController를 수정하기 전에 AnswerController에서 필요한 답변조회와 답변수정 기능을 구현하자.

package com.gosari.repick_project.answer;

import com.gosari.repick_project.exception.DataNotFoundException;
import com.gosari.repick_project.question.Question;
import com.gosari.repick_project.user.SiteUser;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.Optional;

@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(new Date());
        answer.setQuestion(question);
        answer.setAuthor(author);

        this.answerRepository.save(answer);

        return answer;
    }

    /*답변조회*/
    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(new Date());
        this.answerRepository.save(answer);
    }
}

id 값으로 데이터를 조회하기 위해서는 리포지터리의 findById 메서드를 사용해야 한다.

하지만 findById의 리턴 타입은 Question이 아닌 Optional임에 주의하자.

Optional은 null 처리를 유연하게 처리하기 위해 사용하는 클래스로

위와 같이 isPresent로 null이 아닌지를 확인한 후에 get으로 실제 Question 객체 값을 얻어야 한다.

 

답변 아이디로 답변을 조회하는 getAnswer 메서드와 답변의 내용으로 답변을 수정하는 modify 메서드를 추가했다.

 

AnswerController

그리고 버튼 클릭시 요청되는 GET방식의 /answer/modify/답변ID 형태의 URL을 처리하기 위해 다음과 같이 AnswerController를 수정하자.

package com.gosari.repick_project.answer;

import com.gosari.repick_project.question.Question;
import com.gosari.repick_project.question.QuestionService;
import com.gosari.repick_project.user.SiteUser;
import com.gosari.repick_project.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

import javax.validation.Valid;
import java.security.Principal;

@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {

    private final QuestionService questionService;
    private final AnswerService answerService;
    private final UserService userService;

    /*답변생성*/
    @PreAuthorize("isAuthenticated()")
    @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);
    }

    /*답변수정*/
    @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 메서드를 추가했다. URL의 답변 아이디를 통해 조회한 답변 데이터의 "내용"을 AnswerForm 객체에 대입하여 answer_form.html 템플릿에서 사용할수 있도록 했다.

answer_form.html은 답변을 수정하기 위한 템플릿으로 신규로 작성해야 한다.

답변 수정시 기존의 내용이 필요하므로 AnswerForm 객체에 조회한 값을 저장해야 한다.

 

answer_form.html

그리고 답변 수정을 위한 answer_form.html 템플릿을 다음과 같이 신규로 작성하자.

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
      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>

 

답변 작성시 사용하는 폼 태그에도 역시 action 속성을 사용하지 않았다.

앞서 설명했듯이 action 속성을 생략하면 현재 호출된 URL로 폼이 전송된다. 

th:action 속성이 없으므로 csrf 항목도 수동으로 추가했다.

 

th:action 속성을 삭제하면 CSRF 값이 자동으로 생성되지 않기 때문에

위와 같이 CSRF 값을 설정하기 위한 hidden 형태의 input 엘리먼트를 수동으로 추가한다

* CSRF 값을 수동으로 추가하기 위해서는 위와 같이 해야한다. 이것은 스프링 시큐리티의 규칙이다.

폼 태그의 action 속성 없이 폼을 전송(submit)하면 폼의 action은 현재의 URL(브라우저에 표시되는 URL주소)을 기준으로 전송이 된다. 

 

AnswerController

이제 폼을 통해 요청되는 POST방식의 /answer/modify/답변ID 형태의 URL을 처리하기 위해

다음과 같이 AnswerController를 수정하자.

package com.gosari.repick_project.answer;

import com.gosari.repick_project.question.Question;
import com.gosari.repick_project.question.QuestionService;
import com.gosari.repick_project.user.SiteUser;
import com.gosari.repick_project.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

import javax.validation.Valid;
import java.security.Principal;

@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {

    private final QuestionService questionService;
    private final AnswerService answerService;
    private final UserService userService;

    /*답변생성*/
    @PreAuthorize("isAuthenticated()")
    @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);
    }

    /*답변수정 GET*/
    @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";
    }
    /*답변수정 POST*/
    @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 메서드를 추가했다.

답변 수정을 완료한 후에는 질문 상세 페이지로 돌아가기 위해 
answer.getQuestion.getId()로 질문의 아이디를 가져왔다.

답변 수정 확인

답변 수정도 질문 수정과 마찬가지로 답변 등록 사용자와 로그인 사용자가 동일할 때만 <수정> 버튼이 나타난다.

답변 수정 기능이 잘 동작하는지 확인해 보자.


답변 삭제

이번에는 답변을 삭제하는 기능을 추가해 보자. 답변 삭제도 질문 삭제와 동일한 방법이므로 빠르게 알아보자.

 

답변 삭제 버튼

질문 상세 화면에서 답변을 삭제할 수 있는 버튼을 다음과 같이 추가하자.

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
      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="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="${#dates.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>
                <!--질문삭제버튼-->
                <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>
    <!--답변의 갯수표시-->
    <h5 class="border-bottom my-3 py-2"
        th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
    <!--#list.size(이터러블객체)-타임리프가 제공하는 유틸리티로 객체의 길이를 반환-->
    <!--답변 반복 시작-->
    <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="${#dates.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
                </div>
            </div>

            <div class="my-3">
                <!--답변수정버튼-->
                <a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-sm btn-outline-secondary"
                   sec:authorize="isAuthenticated()"
                   th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                   th:text="수정"></a>
                <!--답변삭제버튼-->
                <a href="javascript:void(0);" th:data-uri="@{|/answer/delete/${answer.id}|}"
                   class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
                   th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                   th:text="삭제"></a>
            </div>

        </div>
    </div>
    <!--답변 반복 끝-->
    <!--답변 작성-->
    <form th:action="@{|/answer/create/${question.id}|}"
          th:object="${answerForm}" method="post" class="my-3">

        <div th:replace="form_errors :: formErrorsFragment"></div>
        <!--th:replace - 공통템플릿을 템플릿내에 삽입가능-->
        <!--div엘리먼트를 form_errors.html파일의 th:fragment속성명이 formErrorsFragment인 엘리먼트로 교체-->


        <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>


<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>

<수정> 버튼 옆에 <삭제> 버튼을 추가했다. 질문의 <삭제> 버튼과 마찬가지로 <삭제> 버튼에 delete 클래스를 적용했으므로 <삭제> 버튼을 누르면 data-uri 속성에 설정한 url이 실행될 것이다.

 

AnswerService

AnswerService에 다음처럼 답변을 삭제하는 기능을 추가하자.

package com.gosari.repick_project.answer;

import com.gosari.repick_project.exception.DataNotFoundException;
import com.gosari.repick_project.question.Question;
import com.gosari.repick_project.user.SiteUser;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.Optional;

@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(new Date());
        answer.setQuestion(question);
        answer.setAuthor(author);

        this.answerRepository.save(answer);

        return answer;
    }

    /*답변조회*/
    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(new Date());
        this.answerRepository.save(answer);
    }

    /*답변삭제*/
    public void delete(Answer answer){
        this.answerRepository.delete(answer);
    }
}

입력으로 받은 Answer 객체를 사용하여 답변을 삭제하는 delete 메서드를 추가했다.

 

AnswerController

이제 답변 삭제 버튼을 누르면 요청되는 GET방식의 /answer/delete/답변ID 형태의 URL을 처리하기 위해 다음과 같이 AnswerController를 수정하자.

package com.gosari.repick_project.answer;

import com.gosari.repick_project.question.Question;
import com.gosari.repick_project.question.QuestionService;
import com.gosari.repick_project.user.SiteUser;
import com.gosari.repick_project.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

import javax.validation.Valid;
import java.security.Principal;

@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {

    private final QuestionService questionService;
    private final AnswerService answerService;
    private final UserService userService;

    /*답변생성*/
    @PreAuthorize("isAuthenticated()")
    @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);
    }

    /*답변수정 GET*/
    @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";
    }

    /*답변수정 POST*/
    @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());
    }

    /*답변삭제*/
    @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 메서드를 추가했다.

답변을 삭제한 후에는 해당 답변이 있던 질문상세 화면으로 리다이렉트 한다.

 

답변 삭제 확인

질문 상세 화면에서 답변을 작성한 사용자와 로그인한 사용자가 같으면 <삭제> 버튼이 나타날 것이다.

잘 동작하는지 확인해 보자.


수정일시 표시하기

마지막으로 질문 상세 화면에서 수정일시를 확인할 수 있도록 템플릿을 수정해 보자.

질문과 답변에는 이미 작성일시를 표시하고 있다. 작성일시 바로 왼쪽에 수정일시를 추가하자.

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml"
      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="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="${#dates.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="${#dates.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>
                <!--질문삭제버튼-->
                <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>
    <!--답변의 갯수표시-->
    <h5 class="border-bottom my-3 py-2"
        th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
    <!--#list.size(이터러블객체)-타임리프가 제공하는 유틸리티로 객체의 길이를 반환-->
    <!--답변 반복 시작-->
    <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="${#dates.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="${#dates.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
                </div>
            </div>

            <div class="my-3">
                <!--답변수정버튼-->
                <a th:href="@{|/answer/modify/${answer.id}|}" class="btn btn-sm btn-outline-secondary"
                   sec:authorize="isAuthenticated()"
                   th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                   th:text="수정"></a>
                <!--답변삭제버튼-->
                <a href="javascript:void(0);" th:data-uri="@{|/answer/delete/${answer.id}|}"
                   class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
                   th:if="${answer.author != null and #authentication.getPrincipal().getUsername() == answer.author.username}"
                   th:text="삭제"></a>
            </div>

        </div>
    </div>
    <!--답변 반복 끝-->
    <!--답변 작성-->
    <form th:action="@{|/answer/create/${question.id}|}"
          th:object="${answerForm}" method="post" class="my-3">

        <div th:replace="form_errors :: formErrorsFragment"></div>
        <!--th:replace - 공통템플릿을 템플릿내에 삽입가능-->
        <!--div엘리먼트를 form_errors.html파일의 th:fragment속성명이 formErrorsFragment인 엘리먼트로 교체-->


        <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>


<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>
<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="${#dates.format(answer.modifyDate, 'yyyy-MM-dd HH:mm')}"></div></div>

 

질문이나 답변에 수정일시가 있는 경우(null이 아닌경우) 수정일시를 작성일시 바로 좌측에 표시하도록 했다.

이제 질문이나 답변을 수정하면 다음처럼 수정일시가 표시될 것이다.

728x90