사이먼's 코딩노트

[SpringBoot] 로그아웃 / 작성자 추가 본문

Java/SpringBoot

[SpringBoot] 로그아웃 / 작성자 추가

simonpark817 2024. 5. 14. 16:30

[로그아웃 구현하기]

  • 로그인 기능까지 구현이 완료되어 로그인 화면을 통해 이미 가입되어 있는 사용자 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이 아닌 경우만 작성자를 표시하도록 하였다.
  • 위 템플릿을 수정하고 로컬 서버를 다시 실행하면 아래와 같이 질문 상세 페이지에 작성자가 추가된 모습을 볼 수 있다.

질문 상세 페이지에 작성자 추가

 

반응형