본문 바로가기

SpringBoot

[Do it] 3장 스프링 부트 고급 기능 익히기(4)

1. 글쓴이 항목 추가하기

 

- 질문 또는 답변을 작성할 때 사용자 정보도 DB에 저장 

- 게시판의 질문 목록과 답변 상세 페이지에 '글쓴이' 항목 추가 

 

1) 엔티티의 속성 추가 

 

질문 엔티티에 author 속성 추가 

 

Question.java

package com.example.demo.question;

import java.time.LocalDateTime;
import java.util.List;

import com.example.demo.answer.Answer;
import com.example.demo.user.SiteUser;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.ManyToOne;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
public class Question {
	@Id
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	private Integer id;
	
	@Column(length=200)
	private String subject;
	
	@Column(columnDefinition="TEXT")
	private String content;
	
	private LocalDateTime createDate;
	
	@OneToMany(mappedBy="question", cascade=CascadeType.REMOVE)
	private List<Answer> answerList;
	
	@ManyToOne
	private SiteUser author;
}

 

- 사용자 한 명이 질문을 여러개 작성할 수 있으므로 @ManyToOne 적요 ㅇ

 

답변 엔티티의 author 속성 추가 

 

Answer.java

package com.example.demo.answer;

import java.time.LocalDateTime;

import com.example.demo.question.Question;
import com.example.demo.user.SiteUser;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne; //다대일 관계

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
public class Answer {
	@Id
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	private Integer id;
	
	@Column(columnDefinition="TEXT")
	private String content;
	private LocalDateTime createDate;
	
	@ManyToOne
	private Question question;
	
	@ManyToOne
	private SiteUser author;
	
	
}

 

 

 

2) 글쓴이 저장하기 

 

- 새로운 데이터를 저장하려면 서버와 DB를 관리하는 컨트롤러와 서비스(또는 리포지터리)에도 관련 내용을 업데이트해야 함

 

답변 컨트롤러와 서비스 업데이트 

 

AnswerController.java

package com.example.demo.answer;

...

import java.security.Principal;

@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {
	
	...
	
	@PostMapping("/create/{id}")
	public String createAnswer(Model model, @PathVariable("id")Integer id,
		@Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
		...
		}
		...
	}
}

 

- 답변을 저장할 때, 사용자 정보도 저장

- 현재 로그인한 사용자의 정보를 알려면 스프링 시큐리티가 제공하는 Principal 객체를 사용 

- createAnser 메서드에 Principal 객체를 매개변수로 지정

- principal.getName()을 호출하면 현재 로그인한 사용자의 사용자명(사용자 ID)를 알 수 있음 

 

UserService.java

package com.example.demo.user;


import java.util.Optional;
import com.example.demo.DataNotFoundException;

...
@RequiredArgsConstructor
@Service
public class UserService {
	private final UserRepository userRepository;
	private final PasswordEncoder passwordEncoder;
	
	...
	
	//SiteUser 조회 
	public SiteUser getUser(String username) {
		Optional<SiteUser> siteUser = this.userRepository.findByUsername(username);
		if(siteUser.isPresent()) {
			return siteUser.get();
		}else {
			throw new DataNotFoundException("siteuser not found");
		}
	}
}

 

- SiteUser를 조회할 수 있는 getUser 메서드를 UserService에 추가 

- userRepository의 findByUsername 메서드 사용

- 사용자명에 해당하는 데이터가 없을 경우에는 DataNotFoundException이 발생하도록 함 

 

AnswerService.java

package com.example.demo.answer;

...
import com.example.demo.user.SiteUser;


@RequiredArgsConstructor
@Service
public class AnswerService {

	private final AnswerRepository answerRepository;
	
	public Answer create(Question question, String content, SiteUser author) {
        Answer answer = new Answer();
        answer.setContent(content);
        answer.setCreateDate(LocalDateTime.now());
        answer.setQuestion(question);
        answer.setAuthor(author);
        this.answerRepository.save(answer);
        return answer;
    }
}

 

 

- create 메서드에 SiteUser 객체를 추가로 전달받아 작성자도 함께 저장하도록 수정 

 

AnswerController.java

package com.example.demo.answer;

...

import com.example.demo.user.SiteUser;
import com.example.demo.user.UserService;

...
@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {
	
	private final QuestionService questionService;
	private final AnswerService answerService;
	private final UserService userService;
	
	@PostMapping("/create/{id}")
	public String createAnswer(Model model, @PathVariable("id")Integer id,
		@Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
		Question question = this.questionService.getQuestion(id);
		SiteUser siteUser = this.userService.getUser(principal.getName());
		if(bindingResult.hasErrors()) {
			model.addAttribute("question",question);
			return "question_detail";
		}
		this.answerService.create(question,answerForm.getContent(),siteUser);
		return String.format("redirect:/question/detail/%s", id);
	}
}

 

- principal 객체를 통해 사용자명을 얻은 후, 사용자명을 통해 SiteUser 객체를 얻어 답변을 등록할 때 사용 

 

질문 컨트롤러와 서비스 업데이트 

 

QuestionService.java

package com.example.demo.question;

...
import com.example.demo.user.SiteUser;
...
@RequiredArgsConstructor
@Service
public class QuestionService {
	
	...
	
	public void create(String subject, String content, SiteUser user) {
		Question q = new Question();
		q.setSubject(subject);
		q.setContent(content);
		q.setCreateDate(LocalDateTime.now());
		q.setAuthor(user);
		this.questionRepository.save(q);
	}
	
	
}

 

- create 메서드에 SiteUser를 추가하여 Question 데이터를 생성하도록 수정 

 

QuestionController.java

package com.example.demo.question;

...

import java.security.Principal;
import com.example.demo.user.SiteUser;
import com.example.demo.user.UserService;

...

@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController{
	
	private final QuestionService questionService;
	private final UserService userService;
	
	...
	
	@PostMapping("/create")
	public String questionCreate(@Valid QuestionForm questionForm, BindingResult bindingResult, Principal principal) {
		if(bindingResult.hasErrors()) {
			return "question_form";
		}
		SiteUser siteUser = this.userService.getUser(principal.getName());
		this.questionService.create(questionForm.getSubject(), questionForm.getContent(),siteUser);
		return "redirect:/question/list";
	}
	
}

 

- Principal 객체를 통해 사용자명을 구한 후, SiteUser를 조회하여 질문 저장시 함께 저장할 수 있도록 함 

 

 

3) 로그인 페이지로 이동시키기

 

- 로그아웃 상태에서 질문/답변을 등록시 500 오류(서버 오류) 발생

 

- principal 객체가 null이라서 발생

- principal 객체는 로그인을 해야만 생성되는 객체인데 현재는 로그아웃 상태이므로 principal 객체에 값이 없어서 발생

 

- principal 객체를 사용하는 메서드에 @PreAuthorize("isAuthenticated()")을 사용하면 해결

- @PreAuthorize("isAuthenticated()")가 붙은 메서드는 로그인한 경우에만 실행

- @PreAuthorize("isAuthenticated()")이 적용된 메서드가 로그아웃 상태에서 호출되면 로그인 페이지로 강제 이동 

 

QuestionController.java

...
import org.springframework.security.access.prepost.PreAuthorize;
...
public class QuestionController {

...

    @PreAuthorize("isAuthenticated()")
    @GetMapping("/create")
    public String questionCreate(QuestionForm questionForm) {
        return "question_form";
    }

    @PreAuthorize("isAuthenticated()")
    @PostMapping("/create")
    public String questionCreate(@Valid QuestionForm questionForm, 
            BindingResult bindingResult, Principal principal) {
       ...
    }
}

 

AnswerController.java

...
import org.springframework.security.access.prepost.PreAuthorize;
...
public class AnswerController {

...

    @PreAuthorize("isAuthenticated()")
    @PostMapping("/create/{id}")
    public String createAnswer(Model model, @PathVariable("id") Integer id, @Valid AnswerForm answerForm,
            BindingResult bindingResult, Principal principal) {
...
    }
}

 

SecurityConfig.java

- @PreAuthorize이 동작할 수 있도록 스프링 시큐리티의 설정 수정 

import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
...

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
   ...
}

 

 

4) 답변 작성 막아두기 

 

- 사용자가 로그아웃 상태인 경우 답변 작성 막기 

 

templates/question_detail.html

...
<!-- 답변 작성 -->
	<form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" method="post" class="my-3">
		<div th:replace="~{form_errors :: formErrorsFragment}"></div>
        <!-- 사용자 로그아웃 상태 -->
		<textarea sec:authorize="isAnonymous()" disabled th:field="*{content}" class="form-control" rows="10"></textarea>
        <!-- 사용자 로그인 상태 -->
        <textarea sec:authorize="isAuthenticated()" th:field="*{content}" class="form-control" rows="10"></textarea>
		<input type="submit" value="답변등록" class="btn btn-primary my-2">
	</form>
...

 

- 로그인 상태가 아닌 경우 textarea 태그에 disabled 속성을 적용하여 사용자가 화면에서 입력하지 못하게 함

 

 

 

5) 화면에 글쓴이 나타내기 

 

질문 목록에 글쓴이 표시 

 

templates/question_list.html

...
<tr class="text-center">
    <th>번호</th>
    <th style="width:50%">제목</th>
    <th>글쓴이</th>
    <th>작성일시</th>
</tr>
...

<tr class="text-center" th:each="question, loop : ${paging}">
    <td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
    <td class="text-start">
        <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
        <span class="text-danger small ms-2" th:if="${#lists.size(question.answerList) > 0}"
            th:text="${#lists.size(question.answerList)}">
        </span>
    </td>
    <!-- author 속성이 null이 아닌 경우에만 글쓴이 표시 -->
    <td><span th:if="${question.author != null}" th:text="${question.author.username}"></span></td>
    <td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
</tr>
...

 

 

질문 상세에 글쓴이 표시 

 

templates/question_detail.html

...
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
    <div class="card-body">
        <div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
        <div class="d-flex justify-content-end">
            <div class="badge bg-light text-dark p-2 text-start">
             <!-- 글쓴이  표시-->
                <div class="mb-2">
                    <span th:if="${question.author != null}" th:text="${question.author.username}"></span>
                </div>
                <div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
            </div>
        </div>
    </div>
</div>
...
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
    <div class="card-body">
        <div class="card-text" style="white-space: pre-line;" th:text="${answer.content}"></div>
        <div class="d-flex justify-content-end">
            <div class="badge bg-light text-dark p-2 text-start">
            <!-- 글쓴이  표시-->
                <div class="mb-2">
                    <span th:if="${answer.author != null}" th:text="${answer.author.username}"></span>
                </div>
                <div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
            </div>
        </div>
    </div>
</div>
<!-- 답변 반복 끝  -->

 

 

 

2. 수정과 삭제 기능 추가하기 

 

1) 수정 일시 추가하기 

 

- Question 엔티티와 Answer 엔티티에 수정 일시를 의미하는 modifyDate 속성 추가 

 

Question.java

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

 

Answer.java

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

 

 

 

2) 질문 수정 기능 생성하기 

 

질문 수정 버튼 생성

 

templates/question_detail.html

...
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
    <div class="card-body">
        <div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
        <div class="d-flex justify-content-end">
            <div class="badge bg-light text-dark p-2 text-start">
                <div class="mb-2">
                    <span th:if="${question.author != null}" th:text="${question.author.username}"></span>
                </div>
                <div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
            </div>
        </div>
        <div class="my-3">
            <a th:href="@{|/question/modify/${question.id}|}" class="btn btn-sm btn-outline-secondary"
                sec:authorize="isAuthenticated()"
                th:if="${question.author != null and #authentication.getPrincipal().getUsername() == question.author.username}"
                th:text="수정"></a>
        </div>
    </div>
</div>
...

 

- [수정] 버튼이 로그인한 사용자와 글쓴이가 동일할 경우에만 노출되도록 #authentication.getPrincipal().getUsername() == question.author.username을 적용

 

 

질문 컨트롤러 수정(1)

 

QuestionController.java

package com.example.demo.question;

...
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;

@RequestMapping("/question")
@RequiredArgsConstructor
@Controller
public class QuestionController{
	
	...
	//질문 수정
	@PreAuthorize("isAuthenticated()")
	@GetMapping("/modify/{id}")
	public String questionModify(QuestionForm questionForm, @PathVariable("id") Integer id, Principal principal)
	{
		Question question = this.questionService.getQuestion(id);
		if(!question.getAuthor().getUsername().equals(principal.getName())) {
			throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "수정 권한이 없습니다.");
		}
		questionForm.setSubject(question.getSubject());
		questionForm.setContent(question.getContent());
		return "question_form";
		
	}
	
}

 

- questionModify 메서드 추가 

- 현재 로그인한 사용자와 질문의 작성자가 동일하지 않을 경우에는 '수정 권한이 없습니다'라는 오류 발생

- 수정할 질문의 제목(subject)과 내용(object)의 값을 담아서 템플릿으로 전달 

 

 

질문 등록 템플릿 수정 

 

templates/question_form.html

...
<form th:object="${questionForm}" method="post">
		<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
</form>
...

 

- 기존에 있던 <form> 태그의 th:action 속성 삭제

- th:action 속성을 삭제하면 CSRF 값이 자동으로 생성되지 않아서 CSRF 값을 설정하기 위해 hidden 형태로 input 요소 추가 

- <form> 태그의 action 속성 없이 폼을 전송하면 action 속성이 없더라도 현재 URL을 기준으로 전송됨 

 

- 질문 등록시에 브라우저에 표시되는 URL은 /question/create이어서 action 속성이 지정되지 않더라도 POST로 폼 전송할 때 action 속성으로 /question/create가 자동 설정되고, 질문 수정 시에 브라우저에 표시되는 URL은 /question/modify/2와같은 URL이므로 POST로 폼 전송할 때 action 속성에 /question/modify/2와 같은 URL 설정

 

 

질문 서비스 수정

 

questionService.java

package com.example.demo.question;
...

@RequiredArgsConstructor
@Service
public class QuestionService {
	
	...
	
	//질문 수정 처리
	public void modify(Question question, String subject, String content) {
		question.setSubject(subject);
		question.setContent(content);
		question.setModifyDate(LocalDateTime.now());
		this.questionRepository.save(question);
	}

}

 

 

질문 컨트롤러 수정(2)

 

QuestionController.java

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

 

- 질문 수정하는 화면에서 질문 제목이나 내용을 변경하고 [저장하기] 버튼을 누르면 호출되는 POST 요청을 처리하기 위해 questionModify 메서드 추가 

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

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

- 수정이 완료되면 질문 상세 화면(/question/detail/(숫자))으로 리다이렉트

 

 

 

3) 질문 삭제 기능 생성하기 

 

질문 삭제 버튼 생성 

 

templates/question_detail.html

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

 

- href 속성값을 javascript:void(0)로 설정하고 삭제를 실행할 URL을 얻기 위해 th:data-uri 속성을 추가 

- [삭제] 버튼을 클릭하는 이벤트를 확인하기 위해 class 속성에 delete 항목 추가 

 

 

삭제를 위한 자바스크립트 작성

 

- [삭제] 버튼을 클릭했을 때 '정말로 삭제하시겠습니까?'와 같은 메시지 확인창 호출

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

 

- delete라는 클래스를 포함하는 컴포넌트를 클릭하면 '정말로 삭제하시겠습니까?' 라고 질문하고 [확인]을 클릭했을 때 해당 컴포넌트에 속성으로 지정된 data-uri 값으로 URL을 호출하라는 의미 

 

- 질문 상세 템플릿에 </body> 태그 바로 위에 삽입

 

templates/question_detail.html

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

 

- 각 템플릿에서 자바스크립트를  </body> 태그 바로 위에 삽입하고, 상속할 수 있도록 layout.html 수정

 

templates/layout.html

...
<!-- 자바스크립트 Start -->
<th:block layout:fragment="script"></th:block>
<!-- 자바스크립트 End -->
</body>
...

 

 

질문 서비스와 컨트롤러 수정

 

QuestionService.java

@RequiredArgsConstructor
@Service
public class QuestionService {
	...
	//질문 삭제 처리
	public void delete(Question question) {
		this.questionRepository.delete(question);
	}
}

 

- 질문 데이터를 삭제하는 delete 메서드 추가 

 

QuestionController.java

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

 

- [삭제] 버튼을 클릭했을 때 @{|/question/delete/${question.id}|} URL을 처리

- URL로 전달받은 id값을 사용하여 Question 데이터를 조회한 후, 로그인한 사용자와 질문 작성자가 동일할 경우 QuestionService를 이용하여 질문 삭제 

- 질문을 삭제한 후에는 질문 목록 화면(/)으로 리다이렉트

 

 

 

4) 답변 수정 및 삭제 기능 추가 

 

- 질문 수정 /삭제와 동일한 과정으로 진행