겉바속촉

[점프투스프링부트] 3-10. 수정과 삭제 1 본문

IT 일기 (상반기)/SPRING 기초

[점프투스프링부트] 3-10. 수정과 삭제 1

겉바속촉 2023. 3. 14. 15:40
728x90
반응형

 

점프투스프링부트 3-10

 

 

 

목표 : 작성한 질문을 수정하고 삭제할 수 있는 기능 만들기

 

 

 

 

 

1. 수정 일시

 

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

 

[파일명:/sbb/src/main/java/com/mysite/sbb/question/Question.java]

(... 생략 ...)
public class Question {
    (... 생략 ...)
    private LocalDateTime modifyDate;
}  

[파일명:/sbb/src/main/java/com/mysite/sbb/answer/Answer.java]

(... 생략 ...)
public class Answer {
    (... 생략 ...)
    private LocalDateTime modifyDate;
}

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

 

 

 

 

2. 질문 수정

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

 

2-1. 질문 수정 버튼

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

 

[파일명: /sbb/src/main/resources/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을 적용하였다.

 

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

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

 

 

2-2. QuestionController

 

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

 

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

(... 생략 ...)
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
(... 생략 ...)
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 메서드를 추가했다.

 

만약 현재 로그인한 사용자질문의 작성자가 동일하지 않을 경우에는 "수정권한이 없습니다." 오류가 발생하도록 했다.

그리고 수정할 질문의 제목과 내용을 화면에 보여주기 위해 questionForm 객체에 값을 담아서 템플릿으로 전달했다.

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

 

그리고 질문 등록시 사용했던 "question_form" 템플릿을 질문 수정에서도 사용한다는 점이다.

질문 등록 템플릿을 그대로 사용할 경우 질문을 수정하고 "저장하기" 버튼을 누르면 질문이 수정되는 것이 아니라 새로운 질문이 등록된다.

 

이 문제는 템플릿 폼 태그의 action을 잘 활용하면 대처할수 있다. 

 

 

2-3. 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>
    <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}" 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 속성을 삭제하더라도 질문 등록 및 수정 기능이 정상 동작한다.

 

 

2-4. QuestionService

 

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

 

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionService.java]

(... 생략 ...)
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 메서드를 추가했다.

 

2-5. QuestionController

 

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

 

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

(... 생략 ...)
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 형식의 /question/modify/{id} 요청을 처리하기 위해 questionModify 메서드를 추가했다.

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

 

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

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

 

2-6. 질문 수정 확인

 

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

 

 

 

 

 

 

3. 질문 삭제

이번에는 질문을 삭제하는 기능을 추가해 보자.

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

 

3-1. 질문 삭제 버튼

 

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

 

[파일명: /sbb/src/main/resources/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="my-3">
            <a th:href="@{|/question/modify/${question.id}|}" class="btn btn-sm btn-outline-secondary"
                sec:authorize="isAuthenticated()"
                th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
                th:text="수정"></a>
            <a href="javascript:void(0);" th:data-uri="@{|/question/delete/${question.id}|}"
                class="delete btn btn-sm btn-outline-secondary" sec:authorize="isAuthenticated()"
                th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
                th:text="삭제"></a>
        </div>
    </div>
</div>
(... 생략 ...)

 

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

그리고 삭제를 실행할 URL을 얻기 위해 th:data-uri 속성을 추가하고, <삭제> 버튼이 눌리는 이벤트를 확인할 수 있도록 class 속성에 "delete" 항목을 추가해 주었다.

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

 

href에 삭제 URL을 직접 사용하지 않고 이러한 방식을 사용하는 이유는 삭제 버튼을 클릭했을때 "정말로 삭제하시겠습니까?" 와 같은 확인 절차가 필요하기 때문이다.

 

 

3-2. 자바스크립트

 

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

 

<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 호출을 하라는 의미이다.

"확인" 대신 "취소"를 선택하면 아무런 일도 발생하지 않을 것이다.

 

따라서 이와 같은 스크립트를 추가하면

"삭제" 버튼을 클릭하고 "확인"을 선택하면  data-uri 속성에 해당하는 @{|/question/delete/${question.id}|}이 호출될 것이다.

 

 

3-3. 자바스크립트 블록

 

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

<html>
<head>
(... 생략 ...)
</head>
<body>
(... 생략 ...)
<!-- 이곳에 추가 -->
</body>
</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 을 상속하는 템플릿들에서 content 블록을 구현하게 했던것과 마찬가지로

script 블록을 구현할수 있도록 했다. 

 

</body> 태그 바로 위에 <th:block layout:fragment="script"></th:block> 블록을 추가했다.

 

이렇게 하면 이제 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>

 

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

 

 

3-4.  QuestionService

 

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

 

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionService.java]

(... 생략 ...)
public class QuestionService {

    (... 생략 ...)

    public void delete(Question question) {
        this.questionRepository.delete(question);
    }
}

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

 

 

3-5.  QuestionController

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

 

[파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

(... 생략 ...)
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:/";
    }
}

 

URL로 전달받은 id값을 사용하여 Question 데이터를 조회한후

로그인한 사용자와 질문 작성자가 동일할 경우

위에서 작성한 서비스의 delete 메서드로 질문을 삭제한다.

 

질문 데이터 삭제후에는 질문 목록 화면으로 돌아갈 수 있도록 루트 페이지로 리다이렉트한다.

 

 

3-6.  질문 삭제 확인

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

 

안녕하세요 글 확인

 

삭제 버튼 클릭 후 팝업창 확인

 

redirect된 후 question 목록에서 삭제한 글 없어졌는지 확인

 

 

 

 

728x90
반응형