(16-1) 기능구현 - 수정과 삭제 (질문)

작성된 질문을 수정하고 삭제하는 기능을 구현하자


시작하기 전에

개요 : 질문과 답변을 할 수 있는 게시판 서비스를 스프링부트를 통해 만들어 본다.

학습사이트 : https://wikidocs.net/book/7601

예제 코드 : https://github.com/pahkey/sbb


수정일 속성 추가

언제 수정되었는지 확인할 수 있도록 각 엔티티에 수정일 속성 (modifyDate) 을 추가한다.


  • Question.java / Answer.java 수정
(... 생략 ...)
public class ....{
    (... 생략 ...)
    private LocalDateTime modifyDate;
}


  • 테이블 확인

1

각 엔티티에 modify date 칼럼이 추가되었다.



질문 수정

질문을 수정하는 기능을 추가한다.

일단 질문을 수정하려면 질문수정 버튼이 있어야 한다.


질문 수정 버튼


  • question_detail.html 수정
<!-- 경로 : sbb/src/main/resources/templates/question_detail.html -->

<html layout:decorate="~{layout}">

				(...)
                
                	<!-- 글쓴이, 작성시간 표시 -->
	                <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>
    
	(...)    

</html>

로그인한 사용자와 글쓴이가 동일한 경우에만 노출되도록 아래 두개를 적용하였다.

sec:authorize="isAuthenticated()"

#authentication.getPrincipal().getUsername() == question.author.username


  • QuestionController.java 수정
// 경로 : sbb/src/main/java/com/mysite/sbb/question/QuestionController.java
package com.mysite.sbb.question;

(... 생략 ...)
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
(... 생략 ...)

@RequiredArgsConstructor
@Controller
public class QuestionController {
	
	(...)
	
		// 수정 메서드 GET
    @PreAuthorize("isAuthenticated()") // 로그인 필요
    @GetMapping("/question/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 리턴
        questionForm.setSubject(question.getSubject());
        questionForm.setContent(question.getContent());
        return "question_form";
    }
    
}

Get 형식의 요청을 처리하기 위한 questionModify 메서드 추가.


  • 여기서 문제점

question_form 템플릿을 질문 수정에서도 사용하는데, 이럴 경우 질문을 수정하고 저장하기 버튼을 누르면 질문이 수정되는 것이 아니라 새로운 질문이 등록된다.

따라서 이를 해결하기 위해 question_form 템플릿을 수정해주어야 한다


  • question_form.html 수정
<!-- 경로 : sbb/src/main/resources/templates/question_form.html -->
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container">
    <h5 class="my-3 border-bottom pb-2">질문등록</h5>
    
    <!-- CSRF -->
    <form th:object="${questionForm}" method="post">
    	<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
    
    	<!-- 템플릿 적용 -->
        <div th:replace="form_errors :: formErrorsFragment"></div>
        <div class="mb-3">
            <label for="subject" class="form-label">제목</label>
            <input type="text" th:field="*{subject}" id="subject" class="form-control">
        </div>
        <div class="mb-3">
            <label for="content" class="form-label">내용</label>
            <textarea th:field="*{content}" id="content" class="form-control" rows="10"></textarea>
        </div>
        <input type="submit" value="저장하기" class="btn btn-primary my-2">
    </form>
</div>
</html>

form태그의 action 속성을 삭제하면 CSRF값이 자동으로 생성되지 않기 때문에 CSRF값을 수동으로 설정하기 위한 hidden 형태의 input 엘리먼트를 추가한다. (스프링 시큐리티 규칙)

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

즉, 질문 등록시에 브라우저에 표시되는 URL은 /question/create이기 때문에

POST로 폼 전송시 action 속성에 /question/create가 설정이 되고,

질문 수정시에 브라우저에 표시되는 URL은 /question/modify/2 형태의 URL이기 때문에

POST로 폼 전송시 action 속성에 /question/modify/2형태의 URL이 설정되는 것이다.

이제 문제점이 해결되었다!


  • QuestionService.java 수정
// 경로 : sbb/src/main/java/com/mysite/sbb/question/QuestionService.java

package com.mysite.sbb.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);
    }
    
}

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


  • QuestionController.java 수정
// 경로 : sbb/src/main/java/com/mysite/sbb/question/QuestionController.java
package com.mysite.sbb.question;

(...)

@RequiredArgsConstructor
@Controller
public class QuestionController {
	
	(...)
    
		// 수정 메서드 POST
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/question/modify/{id}")
    public String questionModify(@Valid QuestionForm questionForm,
    							 BindingResult bindingResult, 
    							 Principal principal, 
    							 @PathVariable("id") Integer id) {
        if (bindingResult.hasErrors()) {
            return "question_form";
        }
        Question question = this.questionService.getQuestion(id);
        if (!question.getAuthor().getUsername().equals(principal.getName())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정권한이 없습니다.");
        }
        this.questionService.modify(question,
        							questionForm.getSubject(),
        							questionForm.getContent());
        return String.format("redirect:/question/detail/%s", id);
    }
    
}

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

questionForm의 데이터를 검증하고 로그인한 사용자와 수정하려는 질문의 작성자가 동일한지도 검증한다. 검증이 통과되면 QuestionService에서 작성한 modify 메서드를 호출하여 질문 데이터를 수정한다. 그리고 수정이 완료되면 질문 상세 화면을 다시 호출한다.


수정일시 표시

마지막으로 수정일시를 확인할 수 있도록 템플릿을 수정해보자


  • question_detail.html 수정
<!-- 경로 : sbb/src/main/resources/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="card-text" style="white-space: pre-line;"
             th:text="${question.content}"></div>
            <div class="d-flex justify-content-end">
            
            	<!-- 수정 일시 표시 -->
	            <div th:if="${question.modifyDate != null}"
							 class="badge bg-light text-dark p-2 text-start mx-3">
	                <div class="mb-2">modified at</div>
	                <div th:text="${#temporals.format(question.modifyDate, 'yyyy-MM-dd HH:mm')}"></div>
	            </div>
                <div class="badge bg-light text-dark p-2 text-start">
                
                	<!-- 글쓴이, 작성시간 표시 -->
	       (...)
</html>


질문 수정 확인


  • 수정 버튼 확인

2


  • 수정 확인

3

수정 완료!



질문 삭제

질문을 삭제하는 기능을 구현한다.

수정과 마찬가지로 질문 삭제 버튼이 있어야 한다.


질문 삭제 버튼

  • question_detail.html 수정
<!-- 경로 : sbb/src/main/resources/templates/question_detail.html -->

<html layout:decorate="~{layout}">

			(...)
            
            <!-- 수정, 삭제 버튼 -->
            <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>
        	
      (...)

</html>

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

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

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

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>

class = delete인 컴포넌트를 클릭하면 확인창을 띄워주는 코드이다.


자바스크립트 블록

자바스크립트 코드는 HTML 구조에서 </body> 태그 바로 위에 삽입하는 것이 일반적이다.

그 이유는 페이지가 렌더링이 완료된 후에 자바스크립트가 실행되기 때문이다.

아직 버튼이 렌더링도 안됐는데 자바스크립트 코드가 실행되면 매우 어색할 것이다.


  • layout.html 수정
<!-- 경로 : sbb/src/main/resources/templates/layout.html -->

<!doctype html>
<html lang="ko">
<head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,
     initial-scale=1, shrink-to-fit=no">
    <!-- Bootstrap CSS -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
    <!-- sbb CSS -->
    <link rel="stylesheet" type="text/css" th:href="@{/style.css}">
    <title>Hello, sbb!</title>
</head>
<body>

<!-- 네비게이션 바 -->
<nav th:replace="navbar :: navbarFragment"></nav>

<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->

<!-- Bootstrap JS -->
<script th:src="@{/bootstrap.min.js}"></script>

<!-- 자바스크립트 start -->
<th:block layout:fragment="script"></th:block>
<!-- 자바스크립트 end -->

</body>
</html>

layout.html 템플릿의 하단부에 자바스크립트 블록을 추가해 주었다.

이제 layout.html을 상속하는 템플릿들은 자바스크립트의 삽입 위치를 신경 쓸 필요없이 스크립트 블록을 사용하여 자바 스크립트를 작성하면 된다.


  • question_detail.html 수정
<!-- 경로 : sbb/src/main/resources/templates/question_detail.html -->

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">

(...)

</div>

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

하단에 삭제 확인 창을 띄우는 스크립트 코드를 삽입.


  • SBB 테스트

4

삭제버튼을 누르면 알림창이 뜬다.


삭제 기능 구현

삭제 버튼과 알림창을 만들었으니, 실제로 삭제가 되게끔 기능을 구현해 보자.


  • QuestionService.java 수정
// 경로 : sbb/src/main/java/com/mysite/sbb/question/QuestionService.java

package com.mysite.sbb.question;

(...)

@RequiredArgsConstructor
@Service
public class QuestionService {

    (...)
    
    // 질문 삭제 메서드
    public void delete(Question question) {
        this.questionRepository.delete(question);
    }    
    
}

Question 객체를 받아 question 리포지터리를 사용하여 질문 데이터를 삭제하는 메서드 추가


  • QuestionController.java 수정
// 경로 : sbb/src/main/java/com/mysite/sbb/question/QuestionController.java
package com.mysite.sbb.question;

(...)

@RequiredArgsConstructor
@Controller
public class QuestionController {
	
	(...)
    
    // 삭제 메서드
    @PreAuthorize("isAuthenticated()")
    @GetMapping("/question/delete/{id}")
    public String questionDelete(Principal principal,
    							 @PathVariable("id") Integer id) {
        Question question = this.questionService.getQuestion(id);
        
        // 사용자 검사
        if (!question.getAuthor().getUsername().equals(principal.getName())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "삭제권한이 없습니다.");
        }
        this.questionService.delete(question);
        return "redirect:/";
    }
    
}

URL로 전달받은 id값을 사용하여 데이터를 조회한 후, 사용자와 작성자가 동일하다면 QuestionService의 delete 메서드를 사용하여 질문을 삭제한다.


  • SBB 테스트

5

성공적으로 삭제된 모습






Leave a comment