사이먼's 코딩노트
[SpringBoot] 로그아웃 / 작성자 추가 본문
[로그아웃 구현하기]
- 로그인 기능까지 구현이 완료되어 로그인 화면을 통해 이미 가입되어 있는 사용자 ID와 비밀번호를 입력했을 때, 로그인이 정상적으로 수행되고 메인 화면인 localhost:8090/question/list 페이지로 이동하게 된다.
- 하지만 로그인이 된 후에도 내비게이션 바에는 여전히 '로그인' 이라는 이름으로 링크가 표시되는 것을 확인할 수 있다.
- 상식적으로 로그인한 상태라면 해당 링크는 '로그아웃' 링크로 바뀌는 것이 맞기 때문에 해당 기능을 추가해봅시다.
- 먼저, 스프링 시큐리티의 타임리프 확장 기능을 사용하여 사용자의 로그인 상태를 확인해야 한다.
- 아래는 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()" href="javascript:;"
onClick="this.nextElementSibling.submit();"
>로그아웃</a>
<form sec:authorize="isAuthenticated()" th:action="@{/user/logout}" method="POST" hidden></form>
</li>
- 로그인, 로그아웃 버튼이 구현된 li > a 태그에서 추가된 sec:authorize="isAnonymous()" 속성은 로그인 되지 않은 경우에 해당 요소가 표시된다는 뜻이다. 다시 말해 로그인이 되지 않았을 때는 '로그인' 링크가 보인다는 것이다.
- 만대로 sec:authorize="isAuthenticated()" 속성은 로그인된 경우에 해당 요소가 표시된다는 뜻이다. 다시 말해 로그인이 되어있는 경우에는 '로그아웃' 링크가 보인다는 것이다.
- 여기서 sec:authorize 속성은 사용자의 로그인 여부에 따라 요소를 출력하거나 출력하지 않게 한다.
[SecurityConfig 수정]
- 위에서 내비게이션 바 템플릿을 통해 '로그아웃' 링크를 /user/logout으로 설정했다.
- 아래는 로그아웃 설정을 위해 SecurityConfig.java 클래스에서 수정된 코드이다.
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin((formLogin) -> formLogin
.loginPage("/user/login")
.defaultSuccessUrl("/")
)
.logout(
logout -> logout
.logoutUrl("/user/logout")
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
)
;
return http.build();
}
- 로그아웃 기능을 위해 로그아웃 URL을 /user/logout으로 설정하고 로그아웃이 성공하면 Root URL로 이동하도록 하였다.
- .invalidateHttpSession(true)를 통해 로그아웃 시 생성된 사용자 세션도 삭제하도록 처리했다.
- 기존에 작성했던 로그인이 되지 않았을 때 막아놓은 페이지들은 모두 코드를 삭제하고 아래에서 다른 방식을 통해 적용할 예정이다.
- 코드 작성을 모두 마쳤다면 로컬 서버를 다시 실행하고 로그인을 수행했을 때, 내비게이션 바에서 '로그아웃' 이라는 링크가 생기는 것을 확인할 수 있다.
[로그인 여부에 따른 기능 제한]
- 위에서 언급한 것과 같이 이번에는 로그인 여부에 따라 페이지를 막아두는 작업을 진행해봅시다.
- 페이지를 막아두는 것이란 예를 들어 로그인이 되지 않았을 경우 사용자는 답변 등록을 할 수 없거나, 질문 등록을 할 수 없게 하는 것을 의미한다.
- 해당 기능을 추가하기 위해선 QuestionController.java와 AnswerController.java 클래스에서 등록과 관련된 create() 메서드의 코드 수정이 필요하다.
@PreAuthorize("isAuthenticated()")
- 두 컨트롤러의 questionCreate(), createAnswer() 메서드 위에 @PreAuthorize("isAuthenticated()") 어노테이션을 추가하였다.
- @PreAuthorize("isAuthenticated()") 어노테이션이 붙은 메서드는 로그인한 경우에만 실행하게 된다. 즉, 해당 어노테이션을 메서드에 붙이면 create() 메서드는 로그인한 사용자만 호출할 수 있게 된다.
- 만약 create() 메서드가 로그아웃 상태에서 호출되면 로그인 페이지로 강제 이동하게 된다.
- @PreAuthorize("isAuthenticated()") 어노테이션이 동작하려면 스프링 시큐리티가 설정된 SecurityConfig.java 클래스에서도 코드 수정이 필요하다.
@EnableMethodSecurity(prePostEnabled = true)
- SecurityConfig.java 클래스에 위와 같은 어노테이션을 붙이게 되면 인증과 관련된 동작이 제대로 실행된다.
- prePostEnabled = true는 QuestionController와 AnswerController에서 로그인 여부를 판별할 때 사용한 @PreAuthorize 어노테이션을 사용하기 위해 반드시 필요한 설정이다.
- 마지막으로 템플릿에서도 로그아웃 상태에서 답변을 작성하지 못하도록 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 placeholder="로그인 후 이용해주세요." th:field="*{content}" rows="3" class="form-control"></textarea>
<textarea sec:authorize="isAuthenticated()" required maxlength="20000" placeholder="내용(20,000자 이하)" th:field="*{content}" rows="7" class="form-control"></textarea>
<input sec:authorize="isAuthenticated()" type="submit" value="답변등록" class="btn btn-primary my-2">
</form>
- 위 코드는 question_detail.html 템플릿에서 '답변등록' 버튼이 있는 부분에 작성된 코드이다.
- sec:authorize="isAnonymous()"를 통해 로그인이 되지 않았을 경우에는 답변 입력창의 기능을 disabled하고 '로그인 후 이요해주세요.' 라는 텍스트로 사용자에게 안내와 함께 '답변등록' 버튼이 화면에서 표시되지 않는다.
- 로그인이 되었다면 sec:authorize="isAuthenticated()"를 통해 기존에 구현하였던 입력창에서 답변을 입력하고 '답변등록' 버튼을 클릭할 수 있다.
[작성자 추가]
- 질문 또는 답변을 작성할 때 글쓴이가 누구인지 작성자 정보를 DB에 저장하고, 화면에 나타나도록 해봅시다.
- DB에 작성자 정보를 저장하기 위해선 질문 또는 답변을 작성한 사용자는 반드시 로그인이 되어있어야 한다.
- question, answer 테이블에 작성자를 저장하기 위해선 먼저 각 엔티티에 속성을 추가해야한다.
- 아래는 Question.java와 Answer.java 클래스에서 공통으로 추가된 코드이다.
@ManyToOne
private SiteUser author;
- 여기서 author 변수에 @ManyToOne 어노테이션을 적용한 이유는 사용자 한 명이 질문이나 답변을 여러개 작성할 수 있기 떄문이다.
[Controller 수정]
- 질문이나 답변을 저장할 때, 작성자 정보도 저장할 수 있도록 각 컨트롤러를 아래와 같이 수정해봅시다.
- 코드의 순서는 QuestionController.java 다음 AnswerController.java 이다.
@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());
Question q = this.questionService.create(questionForm.getSubject(), questionForm.getContent(), siteUser);
return "redirect:/question/list";
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/create/{id}")
public String createAnswer(
Model model,
@PathVariable("id") Integer id,
@Valid AnswerForm answerForm,
BindingResult bindingResult,
Principal principal) {
Question q = this.questionService.getQuestion(id);
SiteUser siteUser = this.userService.getUser(principal.getName());
if(bindingResult.hasErrors()) {
model.addAttribute("question", q);
return "question_detail";
}
Answer answer = this.answerService.create(q, answerForm.getContent(), siteUser);
return "redirect:/question/detail/%d".formatted(id);
}
- 현재 로그인한 사용자의 정보를 알려면 스프링 시큐리티가 제공하는 Principal 객체를 사용해야 한다.
- principal.getName() 메서드를 호출하면 현재 로그인한 사용자의 ID를 알수 있다.
[Service 수정]
- Principal 객체를 사용하면 이제 로그인한 사용자명을 알 수 있으므로 사용자명으로 SiteUser 객체를 조회할 수 있다.
- 아래는 SiteUser를 조회할 수 있는 getUser() 메서드를 UserService.java 클래스에 추가한 모습이며, 그 아래에 차례로 작성자명을 저장할 수 있도록 QuestionService.java와 AnswerService.java의 create() 메서드에 수정된 코드를 포함한다.
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");
}
}
- getUser() 메서드는 userRepository.java의 findByUsername() 메서드를 이용하여 SELECT * FROM site_user WHERE username='xxx' 의 방식으로 사용자의 이름을 조회하도록 하였다.
- 사용자명에 해당하는 데이터가 없을 경우에는 DataNotFoundException() 예외처리가 발생하도록 하였다.
public Question create(String subject, String content, SiteUser author) {
Question q = new Question();
q.setSubject(subject);
q.setContent(content);
q.setAuthor(author);
q.setCreateDate(LocalDateTime.now());
this.questionRepository.save(q);
return q;
}
public Answer create(Question q, String content, SiteUser author) {
Answer answer = new Answer();
answer.setContent(content);
answer.setQuestion(q);
answer.setCreateDate(LocalDateTime.now());
answer.setAuthor(author);
this.answerRepository.save(answer);
return answer;
}
- 각 서비스 클래스의 create() 메서드에 SiteUser 객체를 매개변수로 추가 전달받아 작성자도 함께 저장하도록 코드를 수정하였다.
[question_list.html 템플릿 수정]
- 이제는 질문 목록을 보여주는 localhost:8090/question/list 페이지 화면에 작성자를 추가해봅시다.
- 아래는 수정된 question_list.html 템플릿의 코드이며, 전체 코드가 아닌 테이블에 관한 일부 코드이다.
<table class="table">
<colgroup>
<col width="100">
<col>
<col width="150">
<col width="200">
</colgroup>
<thead class="table-info">
<tr>
<th>번호</th>
<th>제목</th>
<th>작성자</th>
<th>작성일시</th>
</tr>
</thead>
<!--페이징 시작-->
<tbody>
<tr th:each="question, loop : ${paging}">
<td th:text="${paging.totalElements - (paging.number * paging.size) - loop.index}"></td>
<td>
<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>
- 테이블의 헤더쪽에 <th>작성자</th>를 추가하여 질문 목록 테이블의 항목에 '작성자'를 추가하였다.
- colgroup 태그 안에는 각 항목별로 너비를 일정 수치만큼 지정하였고, 나머지 너비는 제목 항목이 모두 가져가도록 하였다.
- 페이징 시작 부분의 <tbody> 태그 안에서는 작성일시가 표시되는 부분 위에 작성자가 표시되도록 코드를 추가하였다.
- 작성자 정보없이 저장된 기존의 질문들은 author 속성에 해당하는 데이터가 없으므로 th:if 조건문을 통해 author 속성의 값이 null이 아닌 경우만 작성자를 표시하도록 하였다.
- 위 템플릿을 수정하고 로컬 서버를 다시 실행하면 아래와 같이 질문 목록에 작성자가 추가된 모습을 볼 수 있다.
[question_detail.html 템플릿 수정]
- 질문 목록과 함께 작성자를 보여줘야 하는 화면이 한 군데가 더 있다.
- 질문 상세 템플릿인 question_detail.html에 아래와 같은 코드를 추가하여 질문 상세 화면인 localhost:8090/question/detail/{id} 페이지에서도 작성자 항목이 표시되도록 해봅시다.
- 아래 코드는 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>
<!-- 답변의 갯수 표시 -->
<h5 class="border-bottom my-3 py-2"
th:text="|${#lists.size(question.answerList)}개의 답변이 있습니다.|"></h5>
<!-- 답변 반복 시작 -->
<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>
<!-- 답변 반복 끝 -->
- 작성일시가 표시되는 부분 위에 작성자가 보이도록 코드 수정을 하였다.
- 이 때도 마찬가지로 th:if 조건문을 통해 작성자 정보없이 저장된 기존의 질문과 답변들은 author 속성에 해당하는 데이터가 없으므로 author 속성의 값이 null이 아닌 경우만 작성자를 표시하도록 하였다.
- 위 템플릿을 수정하고 로컬 서버를 다시 실행하면 아래와 같이 질문 상세 페이지에 작성자가 추가된 모습을 볼 수 있다.
반응형
'Java > SpringBoot' 카테고리의 다른 글
[SpringBoot] 답변 수정 및 삭제 (0) | 2024.05.15 |
---|---|
[SpringBoot] 질문 수정 및 삭제 (0) | 2024.05.15 |
[SpringBoot] 로그인 구현 (0) | 2024.05.13 |
[SpringBoot] 회원가입 구현 (0) | 2024.05.13 |
[SpringBoot] 게시물 번호 정렬 / 답변 개수 표시 (0) | 2024.05.13 |