728x90
반응형
SMALL
만약 답변이 여러 개인 상태에서 수정하거나 삭제하면 리다이렉트로 화면이 재실행되기 때문에
무조건 페이지 상단으로 이동하게 된다. 그래서 자신이 작성한 답변을 확인하려면 스크롤을 다시 내려야 하는 경우가 있다.

비동기 방식을 이용할 수도 있지만, HTML에는 URL을 호출할 때 원하는 위치로 이동해주는 앵커(anchor)태그가 있다. 즉, <a>태그를 활용하여 답변 등록, 수정, 추천 시 원하는 위치로 이동할 수 있다.

 

답변 앵커 추가하기

/templates/question_detail.html

...
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
	<a th:id="|answer_${answer.id}|"></a>
	<div class="card-body">
...

위처럼 <a>태그를 추가하자. 여기서 id 속성은 유일한 값이어야 하므로 답변의 id값을 사용했다.

 

리다이렉트 수정하기

이제 답변 등록, 수정 시 위에서 지정한 a태그로 이동하게끔 코드를 수정해야한다.

먼저 원래 컨트롤러에서 답변 등록, 수정 후 리다이렉트하는 코드를 보면

return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());

위와 같다. 이러한 코드들을

return String.format("redirect:/question/detail/%s#answer_%s", answer.getQuestion().getId(), answer.getId());

이렇게 앵커를 추가해야한다. 이 때 수정해야 하는 곳은 총 3곳으로 답변 등록, 수정, 추천 부분이다.

추후 컨트롤러 수정 단계에서 수정해보자.

 

답변 서비스 수정하기

답변 컨트롤러에서 답변이 등록된 위치로 이동하려면 답변의 객체가 필요하다.

/answer/AnswerService.java

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

메서드의 리턴타입을 Answer로 수정하고 answer을 리턴하도록 했다.

 

답변 컨트롤러 수정하기

/answer/AnswerController.java

@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";
		}
		Answer answer = this.answerService.create(question, answerForm.getContent(), siteUser);
		
		return String.format("redirect:/question/detail/%s#answer_%s", 
				answer.getQuestion().getId(), answer.getId());
	}
    
    ...
    @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_%s", 
				answer.getQuestion().getId(), answer.getId());
	}
    
    
    ...
    
    @PreAuthorize("isAuthenticated()")
	@GetMapping("/vote/{id}")
	public String questionVote(Principal principal, @PathVariable("id") Integer id) {
		Answer answer = this.answerService.getAnswer(id);
		SiteUser siteUser = this.userService.getUser(principal.getName());
		this.answerService.vote(answer, siteUser);
		return String.format("redirect:/question/detail/%s#answer_%s", 
				answer.getQuestion().getId(), answer.getId());
	}

answerService.create메서드를 위에서 리턴타입이 Answer로 수정했기 때문에 answer객체로 받아주고

앞서 말했듯이 return 값의 URL을 수정해준다.

 

그리고 답변 수정하는 부분과 답변 추천 기능의 return값을 수정해준다.

 

결과 확인하기

댓글을 미리 여러개 작성해놓았다. 마지막 댓글을 수저해보자.
댓글을 수정하고 저장해보자.
리다이렉트가 되어도 상단이 아닌 해당 답변위치로 이동했다. 위의 url을 보면 #answer_14를 볼 수 있다.

728x90
반응형
LIST
728x90
반응형
SMALL

엔티티에 속성 추가하기

질문이나 답변을 추천한 사요자를 저장해야하므로 엔티티에 속성을 추가해야한다.

/qeustion/Question.java

...
//관계주입(추천)
@ManyToMany
Set<SiteUser> voterSet;

 

/answer/Answer.java

//관계주입(추천)
@ManyToMany
Set<SiteUser> voterSet;

 

서버를 재시작하고 콘솔을 확인해보자.

 

질문 추천 기능 생성하기

추천버튼을 추가하자

/templates/question_detail.html

...
<!--추천, 수정 및 삭제 버튼-->
<div class="my-3">
	<!--추천 버튼-->
	<a href="javascript:void(0);" class="recommend btn btn-sm btn-outline-secondary"
	th:data-uri="@{|/question/vote/${question.id}|}">추천
		<span class="badge rounded-pill bg-success"
		th:text="${#lists.size(question.voter)}"></span>
	</a>

lists.size메서드를 통해 추천 수를 볼 수 있다.

추천 버튼을 클릭하면 javascript:void(0)으로 되어 있어서 아무런 동작을 하지 않는다.

대신 자바스크립트를 통해 data-uri로 이동하게 하고, 메시지 확인창으로 한 번 나타나도록 할 것이다.

 

/templates/question_detail.html

...
const recommend_elements = document.getElementsByClassName("recommend");
Array.from(recommend_elements).forEach(function(element){
	element.addEventListener('click', function(){
		if(confirm("정말로 추천하시겠습니까?")){
			location.href = this.dataset.uri;
		}
	})
})
</script>

 

기능을 서비스에 추가하자.

/question/QuestionService.java

...
public void vote(Question question, SiteUser siteUser) {
	question.getVoter().add(siteUser);
	this.questionRepostiory.save(question);
}

 

추천을 눌럿을 때 GET방식으로 작동하는 URL을 처리하도록 컨트롤러를 수정하자.

/question/QuestionController.java

@PreAuthorize("isAuthenticated()")
	@GetMapping("/vote/{id}")
	public String questionVote(Principal principal, @PathVariable("id") Integer id) {
		Question question = this.questionService.getQuestion(id);
		SiteUser siteUser = this.userService.getUser(principal.getName());
		this.questionService.vote(question, siteUser);
		return String.format("redirect:/question/detail/%s", id);
	}

 

 

 

 

답변 추천 기능 생성하기

위와 똑같으니 코드만 보자

/templates/question_detail.html

....
<!-- 추천, 수정 및 삭제 버튼 -->
			<div class="my-3">
				<!--추천 버튼-->
				<a href="javascript:void(0);" class="recommend btn btn-sm btn-outline-secondary"
				th:data-uri="@{|/answer/vote/${answer.id}|}">추천
					<span class="badge rounded-pill bg-success"
					th:text="${#lists.size(answer.voter)}"></span>
				</a>

 

/answer/AnswerService.java

...
public void vote(Answer answer, SiteUser siteUser) {
		answer.getVoter().add(siteUser);
		this.answerRepository.save(answer);
	}

 

/answer/AnswerController.java

...
	@PreAuthorize("isAuthenticated()")
	@GetMapping("/vote/{id}")
	public String questionVote(Principal principal, @PathVariable("id") Integer id) {
		Answer answer = this.answerService.getAnswer(id);
		SiteUser siteUser = this.userService.getUser(principal.getName());
		this.answerService.vote(answer, siteUser);
		return String.format("redirect:/question/detail/%s", answer.getQuestion().getId());
	}

728x90
반응형
LIST
728x90
반응형
SMALL

질문과 답변 수정 일시 표시하기

/templates/question_detail.html

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

서버를 재시작하고 확인해보자.

 

수정한 질문과 답변에만 수정일시가 나타난다.

728x90
반응형
LIST
728x90
반응형
SMALL

답변 삭제 기능 추가하기

상세 템플릿에 삭제 버튼을 추가

/templates/question_detail.html

...
<!-- 수정 및 삭제 버튼 -->
<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>
<!-- 수정 및 삭제 버튼 끝 -->	
...

 

서비스 수정하기

/answer/AnswerService.java

public void delete(Answer answer) {
	this.answerRepository.delete(answer);
}

 

컨트롤러 수정하기

GET방식으로 요청되는 URL 처리하기 위해 메서드를 추가하자

/answer/AnswerController.java

@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());
}

 

로컬 서버를 재시작한 후 삭제를 해보자.

728x90
반응형
LIST
728x90
반응형
SMALL

답변 수정 기능 추가하기

 

[Spring Boot] 29. 게시판 질문 수정 기능 추가하기

수정 및 삭제 기능 추가수정 일시 추가하기게시판의 질문과 답변을 수정하거나 삭제 기능을 추가하기 전에 언제 수정되었는지 확인할 수 있도록 속성을 추가하자./question/Question.javapackage com.mysit

exuzii.tistory.com

 

 

[Spring Boot] 30. 게시판 질문 삭제 기능 추가하기

[Spring Boot] 29. 게시판 질문 수정 기능 추가하기수정 및 삭제 기능 추가수정 일시 추가하기게시판의 질문과 답변을 수정하거나 삭제 기능을 추가하기 전에 언제 수정되었는지 확인할 수 있도록

exuzii.tistory.com

 

위 질문 수정 삭제 기능과 매우 유사하다.

 

버튼 추가하기

/templates/question_detail.html

...
		<!-- 수정 버튼 -->
			<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>
<!-- 답변 반복 끝  -->

로그인한 사용자와 답변 작성자와 동일할 경우에만 수정 버튼이 활성화됟록 했다.

수정 버튼을 클릭하면 /answer/modify/답변id URL이 GET방식으로 요청된다.

 

서비스 수정하기

답변을 수정하기 위해서는 답변을 먼저 조회해야한다.

/answer/AnswerService.java

package com.mysite.sbb.answer;

import java.time.LocalDateTime;
import java.util.Optional;

import org.springframework.stereotype.Service;

import com.mysite.sbb.DataNotFoundException;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.user.SiteUser;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class AnswerService {
	private final AnswerRepository answerRepository;
	
	public void 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);
	}
	
	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(LocalDateTime.now());
		this.answerRepository.save(answer);
	}
}

조회하기 위한  getAnswer와 modify  메서드를 추가했다.

 

컨트롤러 수정하기

다음으로 URL 처리하는 컨트롤러를 수정하자.

/answer/AnswerController.java

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

 

답변 수정 템플릿 생성하기

/templates/answer_form.html

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

여기서도 form태그에 action속성을 사용하지 않았다.

생략하면 현재 호출된 URL로 폼이 전송된다.

 

답변 컨트롤러 재수정하기

POST방식으로 요청되는 URL을 처리하는 메서드를 추가한다.

/answer/AnswerController.java

	@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());
	}

답변 수정을 완료한 후에는 상세 페이지로 리다이렉트 한다.

 

확인하기

로컬서버 재시작한 후 확인해보자.

수정 버튼 나타남.
내용 수정
내용 수정 완료

728x90
반응형
LIST
728x90
반응형
SMALL
 

[Spring Boot] 29. 게시판 질문 수정 기능 추가하기

수정 및 삭제 기능 추가수정 일시 추가하기게시판의 질문과 답변을 수정하거나 삭제 기능을 추가하기 전에 언제 수정되었는지 확인할 수 있도록 속성을 추가하자./question/Question.javapackage com.mysit

exuzii.tistory.com

수정 기능은 위 게시글 참고!

 

질문 삭제 기능 생성하기

질문 삭제 버튼 만들기

질문 수정과 마찬가지로 상세 화면에 삭제 버튼을 추가하자.

/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>
<!--수정 및 삭제 버튼 끝-->

로그인한 사용자가 자신의 질문에서 삭제 버튼을 클릭하면 자바스크립트 코드가 실행되도록 했다.

또 삭제를 실행할 URL을 얻기 위해 data-uri속성을 사용했다.

class속성에는 삭제 이벤트 확인을 위해 delete 클래스를 추가했다.

이러한 이유는 바로 삭제하지 않고 "삭제하시겠습니까?"메시지를 통해 확일절차를 넣기 위해서이다.

 

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

/templates/layout.html

...
	<!-- 자바 스크립트 Start -->
	<th:block layout:fragment="script"></th:block>
	<!-- 자바 스크립트 End -->
</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>
</html>

 

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

/question/QuestionService.java

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

 

컨트롤러에서는 삭제 버튼을 클릭했을 때 @{|/question/delete/${question.id}|} URL을 처리하기 위해 메서드를 추가하자.

/question/QuestionController.java

@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:/";
	}

 

삭제 기능 확인하기

서버를 재시작한 후 로그인해보자.

로그인하면 자신의 글에서 삭제 버튼이 나타남.
삭제 버튼을 클릭하면 확인 메시지가 뜸.
확인을 누르면 질문이 삭제되고 메인으로 이동.

728x90
반응형
LIST
728x90
반응형
SMALL

수정 및 삭제 기능 추가

수정 일시 추가하기

게시판의 질문과 답변을 수정하거나 삭제 기능을 추가하기 전에 언제 수정되었는지 확인할 수 있도록 속성을 추가하자.

/question/Question.java

package com.mysite.sbb.question;

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

import com.mysite.sbb.answer.Answer;
import com.mysite.sbb.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.ManyToOne;
import jakarta.persistence.OneToMany;
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;
	
	private LocalDateTime modifyDate;
}

 

/answer/Answer.java

package com.mysite.sbb.answer;

import java.time.LocalDateTime;

import com.mysite.sbb.question.Question;
import com.mysite.sbb.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;
	
	private LocalDateTime modifyDate;
}
Question과 Answer모두 동일하게 수정일시를 추가한 후, 다시 H2콘솔에 접속해보면 열이 추가된 것을 확인할 수 있다.

 

질문 수정 기능 생성하기

질문 수정 버튼 만들기

/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>
</div>
<!--수정 버튼 끝-->
...

수정 버튼이 로그인한 사용자가 글쓴이가 동일한 경우에만 보여야 하므로 th:if로 조건을 주었다.

 

질문 컨트롤러 수정하기(1)

위의 <a>태그를 통해 GET방식으로  @{|/question/modify/${question.id}|} url이 추가되었으므로 이 링크가 정상 작동하도록 수정해야 한다.

 /question/QuestionController.java

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

유효성 검사를 위해 QuestionForm을 선언해두었다.

먼저 해당하는 question객체를 가져오고

그에 해당하는 제목과 내용으로 QuestionForm을 세팅해준다. 이 내용들을 가지고 qeustion_form화면으로 이동한다.

 

질문 등록 템플릿 수정하기

질문을 등록하는 화면이나 수정하는 화면은 내용 기입하는 곳이 동일하므로 같은 템플릿을 사용한다.

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

 

먼저 기존에 있던 form태그의 action속성을 삭제한다. 그러면 CSRF값이 자동생성되지 않아 input태그의 hidden타입으로 직접 추가해주어야 한다. 그러지 않으면 접근 거부가 나타난다.

여기서 action속성없이 submit하게되면 자동으로 여기 화면으로 접근하게된 경로를 갖고 있으므로

/question/cretae URL로 GET방식으로 접근했다면 submit할 시 자동으로 POST방식으로 /question/cretae가 action속성으로 잡힌다. 수정시에는 /question/modify/2와 같은 URL로 접근했기 때문에 POST방식으로 자동으로 /question/modify/2로 설정된다.

 

질문 서비스 수정하기

/qeustion/QuestionService.java

public void modify(Question question, String subject, String content) {
		question.setSubject(subject);
		question.setContent(content);
		question.setModifyDate(LocalDateTime.now());
		this.questionRepostiory.save(question);
	}

수정된 질문이 처리되도록 서비스를 수정했다.

 

질문 컨트롤러 수정하기(2)

내용 변경 후 저장 버튼을 클릭하게 되면 action이 /question/modify/{id}로 자동 생성되고 POST방식으로 전달할 것이다.

이를 처리하기 위해 컨트롤러를 수정하자.

/question/QuestionController.java

@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);
	}

유효성 검사를 통해 만약 오류가 있다면 다시 question_form 으로 돌아가고

작성자와 동일한지도 검사한다.

이상이 없다면 위의 Service에서 만들었던 modify메서드를 호출하여 question객체와 수정된 제목과 내용을 넘겨 수정하게 한다.

이후에는 상세화면으로 돌아가게끔 했다.

 

수정 기능 확인하기

로그인 했을 경우 내가 작성한 글에서만 수정 버튼 활성화.
내용 변경 후 저장하기 버튼 클릭
내용이 변경된 것을 확인

728x90
반응형
LIST
728x90
반응형
SMALL

질문이나 답변을 작성할 때 사용자 정보도 DB에 같이 저장해보자.

엔티티에 속성 추가하기

질문 엔티티에 속성 추가하기

먼저 기존의 질문과 답변 엔티티에 글쓴이에 해당하는 속성을 추가하자.

/question/Question.java

...
//관계주입
@ManyToOne
private SiteUser author;
...

ManyToOne으로 관계주입을 했는데 이는 사용하 한 명이 여러 질문을 생성할 수 있다는 의미이다.

 

답변 엔티티에 속성 추가하기

위와 같은 방법으로 답변에도 속성을 추가하자.

/answer/Answer.java

...
//관계주입
@ManyToOne
private SiteUser author;
...

 

테이블 확인하기

서버를 재접속하고 H2 서버에 접속하여 QUESTION과 ANSWER 테이블을 확인해보자.

 

질문과 답변 테이블에 작성자가 추가되었다.
author_id의 기본값들이 null로 들어감.

 

author_id의 기본값들이 null로 들어감.

 

글쓴이 저장하기

이제 각 엔티티에 속성이 추가됐으므로 질문이나 답변 데이터를 저장할 때 글쓴이도 함께 저장할 수 있다.

그러기 위해서 컨트롤러와 서비스를 수정해야한다.

 

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

/answer/AnswerController.java

package com.mysite.sbb.answer;

import java.security.Principal;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RequestMapping("/answer")
@RequiredArgsConstructor
@Controller
public class AnswerController {
	private final QuestionService questionService;
	private final AnswerService answerService;
	
	@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);
		
		if (bindingResult.hasErrors()) {
			model.addAttribute("question",question);
			return "question_detail";
		}
		this.answerService.create(question, answerForm.getContent());
		
		return String.format("redirect:/question/detail/%s", id);
	}
}

createAnswer 메서드의 매개변수에 시큐리티가 제공하는 Principal principal을 추가했다. 

이 객체는 현재 로그인한 사용자의 정보를 제공한다.

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

 

principal 객체를 사용하면 사용자명을 알 수 있으므로 그 사용자명으로 SiteUser객체를 조회할 수 있다.

UserService.java

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

여기서 userRepository의 findByUsername메서드를 통해서 쉽게 사용자를 찾을 수 있다.

만약 없을 경우 예외를 발생시킨다.

 

답변 내용을 저장할 때 글쓴이도 저장할 수 있다.

/answer/AnswerService.java

package com.mysite.sbb.answer;

import java.time.LocalDateTime;
import org.springframework.stereotype.Service;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.user.SiteUser;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class AnswerService {
	private final AnswerRepository answerRepository;
	
	public void 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);
	}
}

매개변수로 SiteUser 객체를 받고 answer.setAuthor() 메서드로 함께 저장하도록 수정했다.

 

다시 컨트롤러로 돌아가서 메서드를 완성하자.

/answer/AnswerController.java

package com.mysite.sbb.answer;

import java.security.Principal;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import com.mysite.sbb.question.Question;
import com.mysite.sbb.question.QuestionService;
import com.mysite.sbb.user.SiteUser;
import com.mysite.sbb.user.UserService;

import groovyjarjarantlr4.v4.parse.ANTLRParser.finallyClause_return;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@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);
	}
}

먼저 userService를 사용하기 위해 상단에 선언을 했다.

createAnswer메서드에서 매개변수에 선언해놓은 Principle객체를 이용해서 SiteUser를 선언했고,

답변을 생성하기 위한 this.answerService.create메서드에 siteUser 객체를 추가하여 답변을 등록하도록 했다.

 

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

앞서 답변처럼 똑같이 하면된다.

먼저 글쓴이를 저장하기 위해 QuestionService를 수정한다.

/question/QuestionService.java

...
public void create(String subject, String content, SiteUser siteUser) {
		Question q = new Question();
		q.setSubject(subject);
		q.setContent(content);
		q.setCreateDate(LocalDateTime.now());
		q.setAuthor(siteUser);
		this.questionRepostiory.save(q);
	}
...

create메서드의 수정이 필요하다.

매개변수에 SiteUser객체를 선언하고 q.setAuthor()를 통해 질문에 글쓴이도 포함하여 데이터를 생성할 수 있도록 했다.

 

/qeustion/QuestionController.java

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

글쓴이를 생성하기 위해 상단에 userService를 추가했고

questionCreate메서드의 매개변수에 Principle객체를 선언했다.

그리고 SiteUser객체를 생성 후 this.questionService.create()메서드의 매개변수에 siteUser를 추가했다.

 

로컬 서버를 재시작 후 로그인 한 다음 질문과 답변을 등록해보자.

테스트 코드에 오류가 있으므로 수정한다.

SbbApplicationTests.java

@Test
	void testJpa() {
		for(int i=1; i<=300; i++) {
			String subject = String.format("테스트 데이터입니다:[%03d]", i);
			String content = "내용 없음";
			this.questionService.create(subject, content, null);
		}
	}

create메서드의 매개변수에 사용자를 넣어야하므로 그냥 null로 채워준다.

 

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

로그아웃 상태에서 질문이나 답변을 등록하려고 하면 에러가 난다.

principal 객체가 널이라서 500에러 발생.

 

따라서 create메서드는 로그인한 상태에서만 호출할 수 있어야한다.

만약 로그아웃 상태에서 create메서드를 호출하면 로그인 페이지로 강제 이동하게끔 해야한다.

/question/QuestionController.java

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

두 개의 메서드에 @PreAuthorize("isAuthenticated()")어노테이션을 추가했다.

 

똑같이 답변컨트롤러도 수정하자.

/answer/AnswerController.java

...
	@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어노테이션이 동작할 수 있도록 스프링 시큐리티 설정을 수정해야한다.

SecurityConfig.java

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

 

이렇게 모두 수정 후 로그아웃 상태에서 질문이나 답변을 등록하면 자동으로 로그인 화면으로 이동한다.

 

답변 작성 막아두기

현재 로그인하지 않은 상태에서 '질문 등록하기' 버튼을 클릭하면 바로 로그인 페이지로 이동하지만,

답변은 답변을 작성한 후 '답변등록'버튼을 클릭해야 로그인 페이지로 이동해버리는 바람에, 애써 작성한 내용이 모두 사라지는 상황이 발생한다.

따라서 로그아웃 상태일 때는 아예 답변 작성을 하지 못하도록 해야한다.

 

/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}" rows="10" class="form-control"></textarea>
		<textarea sec:authorize="isAuthenticated()"
			th:field="*{content}" rows="10" class="form-control"></textarea>
			
		<input type="submit" value="답변등록" class="btn btn-primary my-2">
	</form>
	<!-- 답변 작성 끝-->
...

위의 2개의 textarea 코드를 보면 사용자의 로그인 상태에 따라 입력 유무를 판단하게 했다.

 

이처럼 로그인 하지 않은 경우에는 textarea가 비활성화된다.

 

화면에 글쓴이 나타내기

질문 목록에 글쓴이 표시하기

/templates/question_list.html

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
	<thead class="table-dark">
		<tr class="text-center">
			<th>번호</th>
			<th style="width: 50%;">제목</th>
			<th>글쓴이</th>
			<th>작성일시</th>
		</tr>
	</thead>
	<tbody>
		<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>
			<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>
	</tbody>
</table>

<!-- 페이징 처리 시작 -->
<div th:if="${!paging.isEmpty()}">
	<ul class="pagination justify-content-center">
		<li class="page-item" th:classappend="${!paging.hasPrevious} ? 'disabled'">
			<a class="page-link" th:href="@{|?page=${paging.number-1}|}">
				<span>이전</span>
			</a>
		</li>
		<li th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}"
		th:if="${page >= paging.number-5 and page <= paging.number+5}"
		th:classappend="${page == paging.number}? 'active'" class="page-item">
			<a th:text="${page}" class="page-link" th:href="@{|?page=${page}|}"></a>
		</li>
		<li class="page-item" th:classappend="${!paging.hasNext} ? 'disabled'">
			<a class="page-link" th:href="@{|?page=${paging.number+1}|}">
				<span>다음</span>
			</a>
		</li>
	</ul>
</div>
<!-- 페이징 처리 끝 -->

<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>

테이블에 글쓴이를 추가했다.

 

질문에 글쓴이를 추가한 상태

 

질문 상세에 글쓴이 표시하기

/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>
	<!-- 질문 끝-->
...

작성일자 바로 위에 사용자 id가 나타나도록 했다.

 

답변 부분에도 글쓴이를 추가해보자.

...
	<!-- 답변 반복 시작 -->
	<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>
	<!-- 답변 반복 끝  -->
...

위와 똑같이 답변의 글쓴이를 추가했다.

완성된 화면.

728x90
반응형
LIST
728x90
반응형
SMALL

로그인 기능 구현하기

회원가입 단계에서 SITE_USER 테이블에 정보를 저장했다.

여기에 저장된 데이터로 로그인을 하려면 복잡한 단계가 필요하지만 스프링 시큐리티를 활용하면 쉽게 진행할 수 있다.

 

로그인 URL 등록하기

SecurityConfig.java

package com.mysite.sbb;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
	@Bean
	SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
		http
			.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
					.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
			.csrf((csrf) -> csrf
					.ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
			.headers((headers)-> headers
					.addHeaderWriter(new XFrameOptionsHeaderWriter(
							XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
			.formLogin((formLogin)-> formLogin
					.loginPage("/user/login")
					.defaultSuccessUrl("/"))
			;
		return http.build();
	}
	
	@Bean
	PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
}

.formLogin 메서드를 추가했다. 이 메서드는 스프링 시큐리티의 로그인 설정을 담당하는 부분으로,

설정 내용은 로그인 페이지의 URL은 /user/login이고 성공시 / 루트로 이동함을 의미한다.

 

User 컨트롤러에 URL 매핑

UserController.java

...
@GetMapping("/login")
	public String login() {
		return "login_form";
	}
...

 

로그인 템플릿 작성하기

/templates/login_form.html

<html layout:decorate="~{layout}">
	<div layout:fragment="content" class="container my-3">
		<form th:action="@{/user/login}" method="post">
			<div th:if="${param.error}">
				<div class="alert alert-danger">
					사용자 ID 또는 비밀번호를 확인해 주세요.
				</div>
			</div>
			<div class="mb-3">
				<label for="username" class="form-label">사용자 ID</label>
				<input type="text" name="username" id="username" class="form-control">
			</div>
			<div class="mb-3">
				<label for="password" class="form-label">비밀번호</label>
				<input type="password" name="password" id="password" class="form-control">
			</div>
			<button type="submit" class="btn btn-primary">로그인</button>
		</form>
	</div>
</html>

스프링 시큐리티의 로그인이 실패할 경우에 시큐리티 기능으로 로그인 페이지로 리다이렉트된다.

이 때 매개변수로 error가 함께 전달된다.

따라서 error가 전달될 경우 오휴 메시지를 출력한다.

 

 

http://localhost:8080/user/login 확인을 해보자.

 

하지만 우리가 등록했던 데이터로 아직 로그인할 수 없다. 왜냐면 스프링 시큐리티에 어떤 기준으로 로그인을 해야 하는지 설정하지 않았기 때문이다.

 

스프링 시큐리티를 통해 로그인을 수행하는 방법은 SecurityConfig.java와 같은 시큐리티 설정 파일에 사용자ID와 비밀번호를 등록하여 인증하는 방법이 있는데, 우리는 DB에서 회원 정보를 조회하여 로그인하는 방법을 이용할 것이다.

 

이제 DB에서 조회하는 서비스를 만들고, 서비스를 시큐리티에 등록해보자.

 

User 리포지터리 수정하기

사용자 ID를 조회하는 기능을 추가하자.

/user/UserRepository.java

package com.mysite.sbb.user;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<SiteUser, Long> {
	Optional<SiteUser> findByUsername(String username);
}

 

UserRole 파일 생성하기

스프링 시큐리티는 인증뿐만 아니라 권한도 관리한다.

사용자가 로그인을 하면 ADMIN 또는 USER같은 권한을 줘야 한다.

/user/UserRole.java

package com.mysite.sbb.user;

import lombok.Getter;

@Getter
public enum UserRole {
	ADMIN("ROLE_ADMIN"),
	USER("ROLE_USER");
	
	UserRole(String value) {
		this.value = value;
	}
	
	private String value;
}

 

UserSecurityService 서비스 생성하기

/user/UserSecurityService.java

package com.mysite.sbb.user;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class UserSecurityService implements UserDetailsService{

	private final UserRepository userRepository;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		Optional<SiteUser> _siteUser = this.userRepository.findByUsername(username);
		
		if (_siteUser.isEmpty()) {
			throw new UsernameNotFoundException("사용자를 찾을 수 없습니다.");
		}
		SiteUser siteUser = _siteUser.get();
		List<GrantedAuthority> authorities = new ArrayList<>();
		if ("admin".equals(username)) {
			authorities.add(new SimpleGrantedAuthority(UserRole.ADMIN.getValue()));
		} else {
			authorities.add(new SimpleGrantedAuthority(UserRole.USER.getValue()));
		}
		
		return new User(siteUser.getUsername(), siteUser.getPassword(), authorities);
	}
	

}

스프링 시큐리티가 로그인 시 사용할 UserSecurityService는 스프링 시큐리티가 제공하는 UserDetailsService 인터페이스를 구현해야한다.

상속받은 메서드 loadUserByUsername 메서드는 사용자명으로 스프링 시큐리티의 사용자 객체를 조회하여 리턴하는 메서드이다.

사용자명으로 SiteUser 객체를 조회하고, 만약 데이터가 없으면 예외를 발생시킨다.

만약 사용자명이 admin이면 ADMIN권한을 부여하고 이외의 사용자명이면 USER권한을 부여한다.

마지막으로 User객체를 반환하는데, 이 객체는 스프링 시큐리티에서 사용하며 User 생성자에 사용자명, 비밀번호, 권한 리스트를 전달한다.

 

스프링 시큐리티 설정 수정하기

로그인 기능을 완성하기 위해 파일을 수정하자.

SecurityConfig.java

...
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
	return authenticationConfiguration.getAuthenticationManager();
}
...

위처럼 AuthenticationManager 빈을 생성했다. 이 빈은 스프링 시큐리티의 인증을 처리한다.

AuthenticationManager는 사용자 인증 시 앞에서 작성한 UserSecurityService와 PasswordEncoder를 내부적으로 사용해 인증과 권한 부여를 한다.

 

로그인 화면 수정하기

/templates/navbar.html

...
<li class="nav-item">
	<a class="nav-link" th:href="@{/user/login}">로그인</a>
</li>
...

네비게이션 바에서 로그인을 클릭 시 화면이동이 되게끔 url을 변경했다.

 

DB에 없는 사용자나 잘못된 비밀번호를 입력하면 오류 메시지가 발생한다.

 

현재 로그인을 해도 상단바에 아직도 '로그인'이라고 나와있다.

 

로그인 하면 '로그아웃', 로그아웃 상태에서는 '로그인'으로 바뀌어야 한다.

 

아래와 같은 스프링 시큐리티의 타임리프 확장 기능을 사용해보자.

sec:authorize="isAnonymous()" //로그인 되지 않은 경우에 '로그인' 표시
sec:authorize="isAuthenticated()" //로그인된 경우에 '로그아웃' 표시 

 

/templates/navbar.html

...
<li class="nav-item">
	<a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
	<a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
</li>
...

화면을 다시 실행해보면 로그아웃으로 변경된걸 볼 수 있다.

 

로그아웃 기능 구현하기

위 html에서 로그아웃의 링크를 '/user/logout'으로 지정했다. 

하지만 로그아웃을 누르면 기능 구현이 안되어 있어 404 오류가 발생한다.

 

로그아웃 설정 추가하기

SecurityConfig.java

...
	@Bean
	SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
		http
			.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
					.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
			.csrf((csrf) -> csrf
					.ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
			.headers((headers)-> headers
					.addHeaderWriter(new XFrameOptionsHeaderWriter(
							XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
			.formLogin((formLogin)-> formLogin
					.loginPage("/user/login")
					.defaultSuccessUrl("/"))
			.logout((logout) -> logout
					.logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
					.logoutSuccessUrl("/")
					.invalidateHttpSession(true))
			;
		return http.build();
	}
...

로그아웃 URL을 '/user/logout'으로 설정하고 로그아웃을 성공했을 경우 '/' 루트 페이지로 이동하도록 했다.

또한 .invaliddateHttpSession(true)를 통해 로그아웃 시 생성된 사용자 세션도 삭제하게 했다.

 

로그아웃하면 메인으로 이동하고 로그인으로 변경된 것을 볼 수 있다.

728x90
반응형
LIST
728x90
반응형
SMALL

회원가입 기능 구성하기

게시판이 얼추 만들어졌으니 회원 가입 기능을 완성해보자

 

회원 엔티티 생성하기

sbb아래 user 패키지 생성

/user/SiteUser.java

package com.mysite.sbb.user;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
public class SiteUser {
	@Id
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	private Long id;
	
	@Column(unique = true)
	private String username;
	
	private String password;
	
	@Column(unique = true)
	private String email;

}

회원의 필요한 속성은 사용자id, 비밀번호, 이메일이 필요하다.

또 username, email 에는 @Column(unique = true) 를 지정했는데 유일한 값만 저장이 가능하다는 것을 의미한다.

 

로컬 서버를 재시작한 후 H2 콘솔에 접속해보자.

SITE_USER 테이블이 잘 생성된 것을 볼 수 있다.

 

User 리포지터리와 서비스 생성하기

/user/UserRepository.java

package com.mysite.sbb.user;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<SiteUser, Long> {

}

@@리포지터리는 인터페이스이다.

SiteUser의 기본키의 타입이 Long이므로 JpaRepository<SiteUser, Long>로 설정해주었다.

 

/user/UserService.java

package com.mysite.sbb.user;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class UserService {
	private final UserRepository userRepository;
	
	public SiteUser create(String username, String email, String password) {
		SiteUser user = new SiteUser();
		user.setUsername(username);
		user.setEmail(email);
		BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
		user.setPassword(passwordEncoder.encode(password));
		this.userRepository.save(user);
		
		return user;
	}
}

UserService에는 회원 데이터를 생성하는 create 메서드를 추가했다.

여기서 비밀번호는 암호화를 하기 위해 스프링 시큐리티의 BCryptPasswordEncoder 클래스를 사용했다.

 

하지만 이렇게 객체를 new로 생성하게 되면 암호화 방식이 변경될 때 BCryptPasswordEncoder 를 사용한 부분을 모두 수정해야하는 번거로움이 있기 때문에 빈을 설정해서 사용하는 것이 좋다.

 

가장 쉬운 방법은 @Configuration이 적용됐던 SecurityConfig.java 파일에 @Bean 메서드를 새로 추가하는 것이다.

 

/sbb/SecurityConfig.java

package com.mysite.sbb;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
	@Bean
	SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
		http
			.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
					.requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
			.csrf((csrf) -> csrf
					.ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
			.headers((headers)-> headers
					.addHeaderWriter(new XFrameOptionsHeaderWriter(
							XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
			;
		return http.build();
	}
	
	@Bean
	PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
}

 

이렇게 설정해놓으면 UserService도 수정해야한다.

 

/user/UserService.java

package com.mysite.sbb.user;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class UserService {
	private final UserRepository userRepository;
	private final PasswordEncoder passwordEncoder;
	
	public SiteUser create(String username, String email, String password) {
		SiteUser user = new SiteUser();
		user.setUsername(username);
		user.setEmail(email);
		user.setPassword(passwordEncoder.encode(password));
		this.userRepository.save(user);
		
		return user;
	}
}

new로 생성하지 않고 빈으로 등록한 PasswordEncoder 객체를 주입받아 사용했다.

 

회원가입 폼 생성하기

/user/UserCreateForm.java

package com.mysite.sbb.user;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;

@Setter
@Getter
public class UserCreateForm {
	@Size(min = 3, max = 25)
	@NotEmpty(message = "사용자 ID는 필수 항목입니다.")
	private String username;
	
	@NotEmpty(message = "비밀번호는 필수 항목입니다.")
	private String password1;

	@NotEmpty(message = "비밀번호 확인은 필수 항목입니다.")
	private String password2;
	
	@NotEmpty(message = "이메일은 필수 항목입니다.")
	@Email
	private String email;
}

@Size는 데이터 길이가 3~25 사이여야 한다는 조건, @Email 은 속성 값이 이메일 형식과 일치하는지 확인.

 

회원가입 컨트롤러 생성하기

/user/UserController.java

package com.mysite.sbb.user;

import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {
	
	private final UserService userService;
	
	@GetMapping("/signup")
	public String signup(UserCreateForm userCreateForm) {
		return "signup_form";
	}
	
	@PostMapping("/signup")
	public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
		if (bindingResult.hasErrors()) {
			return "signup_form";
		}
		
		if (!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
			bindingResult.rejectValue("password2", "passwordInCorrect", "2개의 비밀번호가 일치하지 않습니다.");
			return "signup_form";
		}
		
		userService.create(userCreateForm.getUsername(), userCreateForm.getEmail(), userCreateForm.getPassword1());
		
		return "redirect:/";
	}
}

Get으로 요청 시 템플릿을 제공하고, Post로 요청 시 회원가입을 진행하도록 했다.

또 비밀번호1과 2가 동일한지 검증하는 조건문이 추가됐고, 만약 일치하지 않는다면 오류가 발생하게 했다.

오류가 없다면 userService.create로 사용자 데이터를 저장한다.

 

 

회원 가입 화면 구성하기

회원가입 템플릿 생성하기

/templates/signup_form.html

<html layout:decorate="~{layout}">
	<div layout:fragment="content" class="container my-3">
		<div class="my-3 border-bottom">
			<div>
				<h4>회원 가입</h4>
			</div>
		</div>
		<form th:action="@{/user/signup}" th:object="${userCreateForm}" method="post">
			<div th:replace="~{form_errors :: formErrorsFragment}"></div>
			<div class="mb-3">
				<label for="username" class="form-label">사용자 ID</label>
				<input type="text" th:field="*{username}" class="form-control">
			</div>
			<div class="mb-3">
				<label for="password1" class="form-label">비밀번호</label>
				<input type="password" th:field="*{password1}" class="form-control">
			</div>
			<div class="mb-3">
				<label for="password2" class="form-label">비밀번호 확인</label>
				<input type="password" th:field="*{password2}" class="form-control">
			</div>
			<div class="mb-3">
				<label for="email" class="form-label">이메일</label>
				<input type="email" th:field="*{email}" class="form-control">
			</div>
			<button type="submit" class="btn btn-primary">회원 가입</button>
		</form>
	</div>
</html>

 

네비게이션 바에 회원 가입 링크 추가하기

/templates/navbar.html

<nav th:fragment="navbarFragment" class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
	<div class="container-fluid">
		<a class="navbar-brand" href="/">SBB</a>
		<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
		data-bs-target="#navbarSupportedContent"
		aria-controls="navbarSupportedContent" aria-expanded="false"
		aria-label="Toggle navigation">
		<span class="navbar-toggler-icon"></span>
		</button>
		<div class="collapse navbar-collapse" id="navbarSupportedContent">
			<ul class="navbar-nav me-auto mb-2 mb-lg-0">
				<li class="nav-item">
					<a class="nav-link" href="#">로그인</a>
				</li>
				<li class="nav-item">
					<a class="nav-link" th:href="@{/user/signup}">회원 가입</a>
				</li>
			</ul>
		</div>
	</div>
</nav>

로그인 아래에 회원가입 메뉴바를 추가했다.

 

회원 가입 기능 확인하기

로컬 서버를 재시작한 후 확인해보자.

메뉴에 회원 가입이 추가되었다.

 

회원가입 화면
비밀번호 두개를 서로 다르게 입력하면 위의 오류를 확인할 수 있다.

올바르게 회원가입을 진행하면 메인 페이지로 리다이렉트 된다.

 

데이터가 잘 들어갔는지 확인해보자.

비밀번호는 암호화되어 들어간다.

 

중복 회원 가입 방지하기

회원가입 시 이미 등록한 사용자 아이디 또는 이메일 주소로 가입해보면 오류가 발생할 것이다.

 

이렇게 오류 화면을 그대로 보여주는 것을 옳지 않다.

이미 메일주소가 있다는 것을 알리는 메시지가 나오도록 수정해보자.

 

/users/UserController.java

@PostMapping("/signup")
	public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
		if (bindingResult.hasErrors()) {
			return "signup_form";
		}
		
		if (!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
			bindingResult.rejectValue("password2", "passwordInCorrect", "2개의 비밀번호가 일치하지 않습니다.");
			return "signup_form";
		}
		
		try {
			userService.create(userCreateForm.getUsername(), userCreateForm.getEmail(), userCreateForm.getPassword1());
		} catch (DataIntegrityViolationException e) {
			e.printStackTrace();
			bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
			return "signup_form";
		} catch (Exception e) {
			e.printStackTrace();
			bindingResult.reject("signupFailed", e.getMessage());
			return "signup_form";
		}
		
		return "redirect:/";
	}

수정 후 다시 시도한 화면

 

728x90
반응형
LIST

+ Recent posts