내비게이션 바 만들기
<!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 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>
</ul>
</div>
</div>
</nav>
<!-- 기본 템플릿 안에 삽입될 내용 Start -->
<th:block layout:fragment="content"></th:block>
<!-- 기본 템플릿 안에 삽입될 내용 End -->
</body>
</html>
이 코드는 부트스트랩을 활용하여 내비게이션 바를 생성하는 내용을 작성한 것이다. 이와 같이 <li> 태그를 활용하여 내비게이션 바에 메뉴를 추가할 수 있다.

내비게이션 바의 숨은 기능 알기
<!doctype html>
<html lang="ko">
<head>
...
</head>
<body>
...
<!-- 기본 템플릿 안에 삽입될 내용 End -->
<!-- Bootstrap JS -->
<script th:src="@{/bootstrap.min.js}"></script>
</body>
</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>
</ul>
</div>
</div>
</nav>
<!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>
</body>
</html>
기존의 내비게이션 바 HTML 코드들을 삭제하고 navbar.html 템플릿을 타임리프의 th:replace 속성으로 layout.html 템플릿에 포함시켰다. navbar.html 파일은 form_errors.html처럼 다른 템플릿들에서 중복해 사용하지는 않지만 독립된 하나의 템플릿으로 관리하는 것이 유지 보수에 유리하다. 공통 템플릿을 따로 관리하는 것은 코드의 재사용성을 높여 줄 뿐만 아니라 코드의 유지 보수에도 도움이 된다.
페이징(paging)이란 입력된 정보나 데이터를 여러 페이지에 나눠 표시하고, 사용자가 페이지를 이동할 수 있게 하는 기능을 말한다.
대량 테스트 데이터 만들기
대량의 테스트 데이터를 만드는 가장 간단한 방법은 스프링 부트의 테스트 프레임워크를 이용하는 것이다.
package com.mysite.sbb;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.mysite.sbb.question.QuestionService;
@SpringBootTest
class SbbApplicationTests {
@Autowired
private QuestionService questionService;
@Test
void testJpa() {
for (int i = 1; i <= 300; i++) {
String subject = String.format("테스트 데이터입니다:[%03d]", i);
String content = "내용무";
this.questionService.create(subject, content);
}
}
}
300개 이상의 데이터가 한 페이지 보여지는 것을 확인할 수 있다. 300개가 넘는 데이터를 확인하려면 계속 스크롤을 내려야 한다.
페이지 구현하기
페이징을 구현하기 위해 추가로 설치해야 하는 라이브러리는 없다. JPA 환경 구축 시 설치했던 JPA 관련 라이브러리에 이미 페이징을 위한 패키지들이 들어 있기 때문이다. 그러므로 다음 클래스들을 이용하면 페이징을 쉽게 구현할 수 있다.
public interface QuestionRepository extends JpaRepository<Question, Integer> {
Question findBySubject(String subject);
Question findBySubjectAndContent(String subject, String content);
List<Question> findBySubjectLike(String subject);
Page<Question> findAll(Pageable pageable);
}
Pageable 객체를 입력받아 Page<Question> 타입 객체를 리턴하는 findAll 메서드를 생성했다.
public class QuestionService {
...
public Page<Question> getList(int page) {
Pageable pageable = PageRequest.of(page, 10);
return this.questionRepository.findAll(pageable);
}
...
}
getList 메서드는 정수 타입의 페이지 번호를 입력받아 해당 페이지의 Page 객체를 리턴하도록 변경했다. Pageable 객체를 생성할 때 사용한 PageRequest.of(page, 10)에서 page는 조회할 페이지의 번호이고 10은 한 페이지에 보여 줄 게시물의 개수를 의미한다. 이렇게 하면 데이터 전체를 조회하지 않고 해당 페이지의 데이터만 조회하도록 쿼리가 변경된다.
public class QuestionController {
...
@GetMapping("/list")
public String list(Model model, @RequestParam(value="page", defaultValue="0") int page) {
Page<Question> paging = this.questionService.getList(page);
model.addAttribute("paging", paging);
return "question_list";
}
...
}
http://localhost:8080/question/list?page=0와 같이 GET 방식으로 요청된 URL에서 page값을 가져오기 위해 list 메서드의 매개변수로 @RequestParam(value="page", defaultValue="0") int page가 추가되었다. URL에 매개변수로 page가 전달되지 않은 경우 기본값은 0이 되도록 설정했다.
스프링 부트(Spring Boot)의 페이징 기능을 구현할 때 첫 페이지 번호는 1이 아닌 0이므로 기본값으로 0을 설정해야 한다.
GET 방식에서는 값을 전달하기 위해서 ?와 & 기호를 사용한다. 첫 번째 파라미터는 ? 기호를 사용하고 그 이후 추가되는 값은 & 기호를 사용한다.
템플릿에 Page 클래스의 객체인 paging을 model에 설정하여 전달했다. paging 객체에는 다음과 같은 속성들이 있다.
| 속성 | 설명 |
|---|---|
| paging.isEmpty | 페이지 존재 여부를 의미한다(게시물이 있으면 false, 없으면 true). |
| paging.totalElements | 전체 게시물 개수를 의미한다. |
| paging.totalPages | 전체 페이지 개수를 의미한다. |
| paging.size | 페이지당 보여 줄 게시물 개수를 의미한다. |
| paging.number | 현재 페이지 번호를 의미한다. |
| paging.hasPrevious | 이전 페이지의 존재 여부를 의미한다. |
| paging.hasNext | 다음 페이지의 존재 여부를 의미한다. |
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
...
<tbody>
<tr th:each="question, loop : ${paging}">
...
</tr>
</tbody>
</table>
<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>
http://localhost:8080/question/list?page=0 과 http://localhost:8080/question/list?page=1 과 같이 URL을 요청하면 각 페이지에 해당하는 게시물들이 조회된다.
페이지 이동 기능 추가하기
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
...
</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: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>
페이지 리스트를 보기 좋게 표시하기 위해 부트스트랩의 pagination 컴포넌트를 이용했다. 이 템플릿에 사용한 pagination, page-item, page-link 등이 pagination 컴포넌트의 클래스로, pagination은 ul 요소 안에 있는 내용을 꾸밀 수 있고, page-item은 각 페이지 번호나 '이전', '다음' 버튼을 나타내도록 하고, page-link는 '이전', '다음' 버튼에 링크를 나타낸다.
이전 페이지가 없는 경우에는 '이전' 링크가 비활성화(disabled)되도록 했다. '다음' 링크의 경우도 마찬가지 방법으로 적용했다. 그리고 th:each 속성을 사용해 전체 페이지 수만큼 반복하면서 해당 페이지로 이동할 수 있는 '이전', '다음' 링크를 생성했다. 이때 반복하던 도중 요청 페이지가 현재 페이지와 같을 경우에는 active 클래스를 적용하여 페이지 링크에 파란색 배경이 나타나도록 했다.
타임리프의 th:classappend="조건식 ? 클래스_값"은 조건식이 참인 경우 '클래스_값'을 class 속성에 추가한다.
위 템플릿에 사용한 주요 페이징 기능
| 페이징 기능 관련 주요 코드 | 설명 |
|---|---|
| th:classappend="${!paging.hasPrevious} ? 'disabled'" | 이전 페이지가 없으면 '이전' 링크를 비활성화한다. |
| th:classappend="${!paging.hasNext} ? 'disabled'" | 다음 페이지가 없으면 '다음' 링크를 비활성화한다. |
| th:href="@{ | ?page=${paging.number-1} |
| th:href="@{ | ?page=${paging.number+1} |
| th:each="page: ${#numbers.sequence(0, paging.totalPages-1)}" | 0부터 전체 페이지 수 만큼 이 요소를 반복하여 생성한다. 이때 현재 순번을 page 변수에 대입한다. |
| th:classappend="${page == paging.number} ? 'active'" | 반복 구간 내에서 해당 페이지가 현재 페이지와 같은 경우 active 클래스를 적용한다. |
#numbers.sequence(시작 번호, 끝 번호)는 시작 번호부터 끝 번호까지 정해진 범위만큼 반복을 만들어 내는 타임리프의 기능이다.
페이지 이동 기능은 구현했지만 화면에서 보듯이 이동할 수 있는 페이지가 모두 표시되는 문제가 발생했다.

페이지 이동 기능 완성하기
...
<!-- 페이징처리 시작 -->
<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>
페이지 표시 제한 기능을 구현했다. 이 코드는 현재 페이지 기준으로 좌우 5개씩 페이지 번호가 표시되도록 만든다. 즉, 반복문 내에서 표시되는 페이지가 현재 페이지를 의미하는 paging.number보다 5만큼 작거나 큰 경우에만 표시되도록 한 것이다.

최신순으로 데이터 조회하기
...
public class QuestionService {
...
public Page<Question> getList(int page) {
List<Sort.Order> sorts = new ArrayList<>();
sorts.add(Sort.Order.desc("createDate"));
Pageable pageable = PageRequest.of(page, 10, Sort.by(sorts));
return this.questionRepository.findAll(pageable);
}
...
}
게시물을 역순(최신순)으로 조회하려면 PageRequest.of 메서드의 세 번째 매개변수에 Sort 객체를 전달해야 한다. 작성 일시(createDate)를 역순(Desc)으로 조회하려면 Sort.Order.desc("createDate")와 같이 작성한다.
만약 작성 일시 외에 정렬 조건을 추가하고 싶다면 sort.add 메서드를 활용해 sorts 리스트에 추가하면 된다.

첫 번째 페이지를 조회하면 가장 최근에 등록한 순서대로 게시물이 출력되는 것을 확인할 수 있다.
게시물 번호 공식 만들기
만약 질문 게시물이 12개라면 1페이지에는 가장 최근 게시물인 12번째~3번째 게시물이, 2페이지에는 2번째~1번째 게시물이 역순으로 표시되어야 한다. 질문 게시물의 번호를 역순으로 정렬하려면 다음 공식을 적용해야 한다.
게시물 번호 = 전체 게시물 개수 - (현재 페이지 * 페이지당 게시물 개수) - 나열 인덱스
| 항목 | 설명 |
|---|---|
| 게시물 번호 | 최종 표시될 게시물의 번호 |
| 전체 게시물 개수 | 데이터베이스에 저장된 게시물 전체 개수 |
| 현재 페이지 | 페이징에서 현재 선택한 페이지 |
| 페이지당 게시물 개수 | 한 페이지당 보여 줄 게시물의 개수 |
| 나열 인덱스 | for 문 안의 게시물 순서(나열 인덱스는 현재 페이지에서 표시할 수 있는 게시물의 인덱스이므로, 예를 들어 10개를 표시하는 페이지에서는 0~9, 2개를 표시하는 페이지에서는 0~1로 반복된다.) |
게시물 번호 공식 적용하기
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
<thead class="table-dark">
...
</thead>
<tbody>
<tr th:each="question, loop : ${paging}">
<td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
<td>
<a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
</td>
<td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
</tr>
</tbody>
</table>
...
</div>
</html>
paging.getTotalElements는 전체 게시물 개수를 말한다. 필자의 경우에는 302개이다. paging.number는 현재 페이지 번호로 페이지를 변경할 때마다 달라진다. paging.size는 페이지당 게시물 개수로 여기서는 10으로 정해져 있다. 마지막으로 loop.index는 나열 인덱스로 0부터 시작한다.
다음 표는 템플릿에 사용한 공식의 상세 정보를 정리한 것이다.
| 항목 | 설명 |
|---|---|
| paging.getTotalElements | 전체 게시물 개수를 의미한다. |
| paging.number | 현재 페이지 번호를 의미한다. |
| paging.size | 페이지당 게시물 개수를 의미한다. |
| loop.index | 나열 인덱스를 의미한다(0부터 시작). |

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
<thead class="table-dark">
<tr>
<th>번호</th>
<th>제목</th>
<th>작성일시</th>
</tr>
</thead>
<tbody>
<tr th:each="question, loop : ${paging}">
<td th:text="${paging.getTotalElements - (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 th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></d>
</tr>
</tbody>
</table>
...
th:if="${#lists.size(question.answerList) > 0}"로 답변이 있는지 조사하고, th:text="${#lists.size(question.answerList)}"로 답변 개수를 표시했다.
#list.size(이터러블_객체)는 '이터러블_객체'의 사이즈를 리턴하는 타임리프의 기능이다.

스프링 부트는 회원 가입과 로그인을 도와주는 스프링 시큐리티(Spring Security)를 사용할 수 있다.
스프링 시큐리티는 스프링 기반 웹 애플리케이션의 인증과 권한을 담당하는 스프링의 하위 프레임워크이다. 여기서 인증(authenticate)은 로그인과 같은 사용자의 신원을 확인하는 프로세스를, 권한(authorize)은 인증된 사용자가 어떤 일을 할 수 있는지(어떤 접근 권한이 있는지) 관리하는 것을 의미한다.
스프링 시큐리티 설치하기
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
}
스프링 시큐리티와 이와 관련된 타임리프(Thymeleaf) 라이브러리를 사용하도록 설정했다. thymeleaf-extras-springsecurity6 패키지는 타임리프 템플릿 엔진과 스프링 시큐리티 프레임워크를 함께 사용할 때 필요한 타임리프의 확장 기능이다.
스프링 시큐리티 설정하기

스프링 시큐리티는 기본적으로 인증되지 않은 사용자가 SBB와 같은 웹 서비스를 사용할 수 없게끔 만든다. 따라서 이와 같이 인증을 위한 로그인 화면이 나타나는 것이다. 이러한 스프링 시큐리티의 기본 기능을 SBB에 그대로 적용되면 곤란하므로 설정을 통해 바로잡아야 한다. SBB는 로그인하지 않아도 게시물을 조회할 수 있어야 하기 때문이다.
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.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 인증되지 않은 모든 페이지의 요청을 허락한다는 의미이다.
// 따라서 로그인하지 않더라고 모든 페이지에 접근할 수 있도록 한다.
http
.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
.requestMatchers("/**").permitAll()) // 교재 코드가 deprecated되어 수정.
;
return http.build();
}
}
@Configuration은 이 파일이 스프링의 환경 설정 파일임을 의미하는 애너테이션이다. 여기서는 스프링 시큐리티를 설정하기 위해 사용했다. @EnableWebSecurity는 모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 애너테이션이다. 이 애너테이션을 사용하면 스프링 시큐리티를 활성화하는 역할을 한다. 내부적으로 SecurityFilterChain 클래스가 동작하여 모든 요청 URL에 이 클래스가 필터로 적용되어 URL별로 특별한 설정을 할 수 있게 된다. 스프링 시큐리티의 세부 설정은 @Bean 애너테이션을 통해 SecurityFilterChain 빈을 생성하여 설정할 수 있다.
이렇게 스프링 시큐리티 설정 파일을 구성하면 이제 질문 목록, 질문 답변 등의 기능을 이전과 동일하게 사용할 수 있다.
빈이란?
빈(bean)은 스프링에 의해 생성 또는 관리되는 객체를 의미한다. 컨트롤러, 서비스, 리포지터리 등도 모두 빈에 해당한다. 또한 @Bean 애너테이션을 통해 자바 코드 내에서 별도로 빈을 정의하고 등록할 수도 있다.
H2 콘솔 오류 수정하기
그런데 스프링 시큐리티를 적용하면 H2 콘솔 로그인 시 403 Forbidden 오류가 발생한다. 403 Forbidden은 작동 중인 서버에 클라이언트의 요청이 들어왔으나, 서버가 클라이언트의 접근을 거부했을 때 반환하는 HTTP 오류 코드이다. 이 오류는 서버 또는 서버에 있는 파일 등에 접근 권한이 없을 경우에 발생한다.
403 Forbidden 오류가 발생하는 이유를 좀 더 구체적으로 설명하면, 스프링 시큐리티의 CSRF 방어 기능에 의해 H2 콘솔 접근이 거부되기 때문이다. CSRF는 웹 보안 공격 중 하나로, 조작된 정보로 웹 사이트가 실행되도록 속이는 공격 기술이다. 스프링 시큐리티는 이러한 공격을 방지하기 위해 CSRF 토큰을 세션을 통해 발행하고, 웹 페이지에서는 폼 전송 시에 해당 토큰을 함께 전송하여 실제 웹 페이지에서 작성한 데이터가 전달되는지를 검증한다.
토큰이란 요청을 식별하고 검증하는 데 사용하는 특수한 문자열 또는 값을 의미한다. 그리고 세션이란 사용자의 상태를 유지하고 관리하는 데 사용하는 기능이다.
<input type="hidden" name="_csrf" value="jojww8mW5ussVpCmu7ON6wL37L-_YwOThANWaCuXcKTY6SYGvumT8vnwhNkBYqPFjp652TWTwd3cBTC-tjFgCUmvR5y62hUw"/>
위와 같은 input 요소가 <form> 태그 안에 자동으로 생성된 것을 확인할 수 있다.
스프링 시큐리티에 의해 이와 같은 CSRF 토큰이 자동으로 생성된다.(CSRF 토큰은 서버에서 생성되는 임의의 값으로 페이지 요청 시 항상 다른 값으로 생성된다.)
스프링 시큐리티는 이런 식으로 페이지에 CSRF 토큰을 발행하여 이 값이 다시 서버로 정확하게 들어오는지를 확인하는 과정을 거친다. 만약 CSRF 토큰이 없거나 해커가 임의의 CSRF 토큰을 강제로 만들어 전송한다면 스프링 시큐리티에 의해 차단될 것이다. 정리하자면, H2 콘솔은 스프링 프레임워크가 아니므로 CSRF 토큰을 발행하는 기능이 없어 이와 같은 403 오류가 발생한 것이다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
.requestMatchers("/**").permitAll())
.csrf((csrf) -> csrf
.ignoringRequestMatchers("/h2-console/**")) //수정
;
return http.build();
}
}
/h2-console/로 시작하는 모든 URL은 CSRF 검증을 하지 않는다는 설정을 추가했다.
이와 같은 오류가 발생하는 원인은 H2 콘솔의 화면이 프레임(frame) 구조로 작성되었기 때문이다. 즉, H2 콘솔 UI(user interface) 레이아웃이 작업 영역이 나눠져 있음을 의미한다. 스프링 시큐리티는 웹 사이트의 콘텐츠가 다른 사이트에 포함되지 않도록 하기 위해 X-Frame-Options 헤더의 기본값을 DENY로 사용하는데, 프레임 구조의 웹 사이트는 이 헤더의 값이 DENY인 경우 이와 같이 오류가 발생한다.
스프링 부트에서 X-Frame-Options 헤더는 클릭재킹 공격을 막기 위해 사용한다. 클릭재킹은 사용자의 의도와 다른 작업이 수행되도록 속이는 보안 공격 기술이다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
.requestMatchers("/**").permitAll())
.csrf((csrf) -> csrf
.ignoringRequestMatchers("/h2-console/**"))
.headers((headers) -> headers
.addHeaderWriter(new XFrameOptionsHeaderWriter(
XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
;
return http.build();
}
}
이와 같이 URL 요청 시 X-Frame-Options 헤더를 DENY 대신 SAMEORIGIN으로 설정하여 오류가 발생하지 않도록 했다. X-Frame-Options 헤더의 값으로 SAMEORIGIN을 설정하면 프레임에 포함된 웹 페이지가 동일한 사이트에서 제공할 때에만 사용이 허락된다.
스프링 시큐리티를 사용하면 웹 프로그램(애플리케이션)의 보안을 강화하고 사용자 인증 및 권한 부여를 효과적으로 관리할 수 있으며, 외부 공격으로부터 시스템을 보호하는 데 도움을 얻을 수 있다.
회원 가입 기능을 구현하려면 회원 정보와 관련된 데이터를 저장하고 이를 관리하는 엔티티와 리포지터리 등을 만들어야 하고, 폼과 컨트롤러와 같은 요소를 생성해 사용자로부터 입력받은 데이터를 웹 프로그램에서 사용할 수 있도록 만들어야 한다.
회원 엔티티 생성하기
| 속성 이름 | 설명 |
|---|---|
| username | 사용자 이름(또는 사용자 ID) |
| password | 비밀번호 |
| 이메일 |
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;
}
엔티티명을 User 대신 SiteUser로 한 이유는 스프링 시큐리티에 이미 User 클래스가 있기 때문이다. 물론 패키지가 달라 User라는 이름을 사용할 수 있지만 패키지 오용으로 인한 오류가 발생할 수 있으므로 User 대신 SiteUser로 만들었다.
그리고 username, email 속성에는 @Column(unique = true)로 지정했다. 여기서 unique = true는 유일한 값만 저장할 수 있음을 의미한다. 즉, 값을 중복되게 저장할 수 없음을 말한다. 이렇게 해야 username과 email에 동일한 값이 저장되는 것을 막을 수 있다.

SITE_USER 테이블과 데이터 열들 그리고 unique로 설정한 속성들로 인해 생긴 UK_로 시작하는 인덱스들을 확인할 수 있다.
unique=true로 지정한 속성들은 DB에 유니크 인덱스로 생성된다. 여기서 쓰인 UK는 unique key의 줄임말이다.
User 리포지터리와 서비스 생성하기
package com.mysite.sbb.user;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<SiteUser, Long> {
}
SiteUser의 기본키 타입은 Long이므로 JpaRepository<SiteUser, Long>으로 사용했다.
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;
}
}
User 서비스에는 User 리포지터리를 사용하여 회원(User) 데이터를 생성하는 create 메서드를 추가했다. 이때 User의 비밀번호는 보안을 위해 반드시 암호화하여 저장해야 한다. 그러므로 스프링 시큐리티의 BCryptPasswordEncoder 클래스를 사용하여 암호화하여 비밀번호를 저장했다.
BCryptPasswordEncoder 클래스는 비크립트 해시 함수를 사용하는데, 비크립트는 해시 함수의 하나로 주로 비밀번호와 같은 보안 정보를 안전하게 저장하고 검증할 때 사용하는 암호화 기술이다.
하지만 이렇게 BCryptPasswordEncoder 객체를 직접 new로 생성하는 방식보다는 PasswordEncoder 객체를 빈으로 등록해서 사용하는 것이 좋다. 왜냐하면 암호화 방식을 변경하면 BCryptPasswordEncoder를 사용한 모든 프로그램을 일일이 찾아다니며 수정해야 하기 때문이다.
PasswordEncoder는 BCryptPasswordEncoder의 인터페이스이다.
PasswordEncoder 빈을 만드는 가장 쉬운 방법은 @Configuration이 적용된 SecurityConfig.java 파일에 @Bean 메서드를 새로 추가하는 것이다.
@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();
}
}
@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;
}
}
BcryptPasswordEncoder 객체를 직접 생성하여 사용하지 않고 빈으로 등록한 Password Encoder 객체를 주입받아 사용할 수 있도록 수정했다.
회원 가입 폼 생성하기
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;
@Getter
@Setter
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;
}
username은 입력받는 데이터의 길이가 3~25 사이여야 한다는 검증 조건을 설정했다. @Size는 문자열의 길이가 최소 길이(min)와 최대 길이(max) 사이에 해당하는지를 검증한다. password1과 password2는 ‘비밀번호’와 ‘비밀번호 확인’에 대한 속성이다. 로그인할 때는 비밀번호가 한 번만 필요하지만 회원 가입 시에는 입력한 비밀번호가 정확한지 확인하기 위해 2개의 필드가 필요하므로 이와 같이 작성한다. 그리고 email 속성에는 @Email 애너테이션이 적용되었다. @Email은 해당 속성의 값이 이메일 형식과 일치하는지를 검증한다.
회원 가입 컨트롤러 생성하기
package com.mysite.sbb.user;
import jakarta.validation.Valid;
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 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:/";
}
}
/user/signup URL이 GET으로 요청되면 회원 가입을 위한 템플릿을 렌더링하고, POST로 요청되면 회원 가입을 진행하도록 했다.
그리고 회원 가입 시 password1과 password2가 동일한지를 검증하는 조건문을 추가했다. 만약 2개의 값이 서로 일치하지 않을 경우에는 bindingResult.rejectValue를 사용하여 입력 받은 2개의 비밀번호가 일치하지 않는다는 오류가 발생하게 했다. bindingResult.rejectValue의 매개변수는 순서대로 각각 bindingResult.rejectValue(필드명, 오류 코드, 오류 메시지)를 의미한다.
여기서 오류 코드는 임의로 passwordInCorrect로 정의했다. 하지만 대형 프로젝트에서는 번역과 관리를 위해 오류 코드를 잘 정의하여 사용해야 한다.
그리고 userService.create 메서드를 사용하여 사용자로부터 전달받은 데이터를 저장한다.
회원 가입 템플릿 생성하기
<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>
'사용자 ID', '비밀번호', '비밀번호 확인', '이메일'에 해당하는 input 요소들을 추가하여 회원 가입 화면에 각각의 필드가 나타나도록 했다. 그리고 [회원 가입] 버튼을 누르면 form 데이터가 POST 방식으로 /user/signup/ URL에 전송된다.
내비게이션 바에 회원 가입 링크 추가하기
<!-- 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>
...
<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>
회원 가입을 할 수 있는 링크를 추가했다.

비밀번호, 비밀번호 확인 항목을 다르게 입력하면 다음과 같이 검증 오류가 발생한다.
SELECT * FROM SITE_USER

SBB에서 회원 가입을 완료하면 DB에 회원 정보가 저장되는 것을 확인할 수 있다.
이미 등록된 사용자 ID 또는 이메일 주소를 DB에 저장하는 것은 회원 엔티티의 unique=true 설정으로 허용되지 않으므로 이와 같은 오류가 발생하는 것이다.
public class UserController {
...
@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:/";
}
}
사용자 ID 또는 이메일 주소가 이미 존재할 경우에는 DataIntegrityViolationException이라는 예외가 발생하므로 '이미 등록된 사용자입니다.'라는 오류 메시지가 화면에 표시하도록 했다. 그리고 그 밖에 다른 예외들은 해당 예외에 관한 구체적인 오류 메시지를 출력하도록 e.getMessage()를 사용했다. 여기서 bindingResult.reject(오류 코드, 오류 메시지)는 UserCreateForm의 검증에 의한 오류 외에 일반적인 오류를 발생시킬 때 사용한다.

로그인 URL 등록하기
// SecurityConfig.java 수정
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
.requestMatchers("/**").permitAll())
.csrf((csrf) -> csrf
.ignoringRequestMatchers("/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이고 로그인 성공 시에 이동할 페이지는 루트 URL(/)임을 의미한다.
User 컨트롤러에 URL 매핑 추가하기
public class UserController {
...
@GetMapping("/login")
public String login() {
return "login_form";
}
}
@GetMapping("/login")을 통해 /user/login URL로 들어오는 GET 요청을 이 메서드가 처리한다. 그리고 매핑한 login 메서드는 login_form.html 템플릿을 출력하도록 만든다. 실제 로그인을 진행하는 @PostMapping 방식의 메서드는 스프링 시큐리티가 대신 처리하므로 우리가 직접 코드를 작성하여 구현할 필요가 없다.
로그인 템플릿 작성하기
<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>
사용자 ID와 비밀번호로 로그인할 수 있는 템플릿을 작성했다. 스프링 시큐리티의 로그인이 실패할 경우에는 시큐리티의 기능으로 인해 로그인 페이지로 리다이렉트된다. 이때 페이지 매개변수로 error가 함께 전달된다. 따라서 로그인 페이지의 매개변수로 error가 전달될 경우 ‘사용자 ID 또는 비밀번호를 확인해 주세요.’라는 오류 메시지를 출력하도록 했다.
로그인 실패 시 매개변수로 error가 전달되는 것은 스프링 시큐리티의 규칙이다.
스프링 시큐리티는 로그인 실패 시 http://localhost:8080/user/login?error와 같이 error 매개변수를 전달한다. 이때 템플릿에서 ${param.error}로 error 매개변수가 전달되었는지 확인할 수 있다.

하지만 아직 로그인을 수행할 수는 없다. 왜냐하면 스프링 시큐리티에 무엇을 기준으로 로그인해야 하는지 아직 설정하지 않았기 때문이다.
스프링 시큐리티를 통해 로그인을 수행하는 방법에는 여러 가지가 있는데, 그중에서 가장 간단한 방법으로 SecurityConfig.java와 같은 시큐리티 설정 파일에 사용자 ID와 비밀번호를 직접 등록하여 인증을 처리하는 메모리 방식이 있다. 하지만 우리는 회원 가입을 통해 회원 정보를 DB에 저장했으므로 DB에서 회원 정보를 조회하여 로그인하는 방법을 사용할 것이다.
User 리포지터리 수정하기
// UserRepository.java 수정
public interface UserRepository extends JpaRepository<SiteUser, Long> {
Optional<SiteUser> findByusername(String username);
}
UserRole 파일 생성하기
스프링 시큐리티는 인증뿐만 아니라 권한도 관리한다. 스프링 시큐리티는 사용자 인증 후에 사용자에게 부여할 권한과 관련된 내용이 필요하다. 그러므로 사용자가 로그인한 후, ADMIN 또는 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;
}
UserRole은 enum 자료형(열거 자료형)으로 작성했다. 관리자를 의미하는 ADMIN과 사용자를 의미하는 USER라는 상수를 만들었다. 그리고 ADMIN은 ‘ROLE_ADMIN’, USER는 ‘ROLE_USER’라는 값을 부여했다. 그리고 UserRole의 ADMIN과 USER 상수는 값을 변경할 필요가 없으므로 @Setter 없이 @Getter만 사용할 수 있도록 했다.
UserSecurityService 서비스 생성하기
// 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();
// 사용자의 권한 정보를 나타내는 GrantedAuthority 객체를 생성하는 데 사용할 리스트 생성
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 인터페이스를 구현해야 한다. 스프링 시큐리티의 UserDetailsService는 loadUserByUsername 메서드를 구현하도록 강제하는 인터페이스이다. loadUserByUsername 메서드는 사용자명(username)으로 스프링 시큐리티의 사용자(User) 객체를 조회하여 리턴하는 메서드이다.
조금 더 자세히 살펴보면, loadUserByUsername 메서드는 사용자명으로 SiteUser 객체를 조회하고, 만약 사용자명에 해당하는 데이터가 없을 경우에는 UsernameNotFoundException을 발생시킨다. 그리고 사용자명이 ‘admin’인 경우에는 ADMIN 권한(ROLE_ADMIN)을 부여하고 그 이외의 경우에는 USER 권한(ROLE_USER)을 부여했다. 마지막으로 User 객체를 생성해 반환하는데, 이 객체는 스프링 시큐리티에서 사용하며 User 생성자에는 사용자명, 비밀번호, 권한 리스트가 전달된다.
참고로, 스프링 시큐리티는 loadUserByUsername 메서드에 의해 리턴된 User 객체의 비밀번호가 사용자로부터 입력받은 비밀번호와 일치하는지를 검사하는 기능을 내부에 가지고 있다.
스프링 시큐리트 설정 수정하기
// SecurityConfig.java 수정
@Configuration
@EnableWebSecurity
public class SecurityConfig {
...
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
}
AuthenticationManager는 스프링 시큐리티의 인증을 처리한다. AuthenticationManager는 사용자 인증 시 앞에서 작성한 UserSecurityService와 PasswordEncoder를 내부적으로 사용하여 인증과 권한 부여 프로세스를 처리한다.
로그인 화면 수정하기
<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" th:href="@{/user/login}">로그인</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/user/signup}">회원가입</a>
</li>
</ul>
</div>
</div>
</nav>

이미 가입되어 있는 사용자 ID와 비밀번호를 입력하면 로그인이 정상 수행되고 메인 화면인 질문 목록 페이지로 이동한다.
하지만 로그인한 후에도 내비게이션 바에는 여전히 '로그인'이란 이름으로 링크가 표시된다. 일반적으로 로그인한 상태라면 이 링크는 로그아웃을 위해 '로그아웃' 링크로 바뀌어야 한다(반대로 로그아웃 상태에서는 '로그인' 링크로 바뀌어야 한다.)
그러기 위해 다음과 같은 스프링 시큐리티의 타임리프(Tymeleaf) 확장 기능을 사용하여 사용자의 로그인 상태를 확인해야 한다.
여기서 sec:authorize 속성은 사용자의 로그인 여부에 따라 요소를 출력하거나 출력하지 않게 한다.
<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" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
<a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/user/signup}">회원가입</a>
</li>
</ul>
</div>
</div>
</nav>

@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
.requestMatchers("/**").permitAll())
.csrf((csrf) -> csrf
.ignoringRequestMatchers("/h2-console/**"))
.headers((headers) -> headers
.addHeaderWriter(new XFrameOptionsHeaderWriter(
XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
.formLogin((formLogin) -> formLogin
.loginPage("/user/login")
.defaultSuccessUrl("/"))
.logout((logout) -> logout
// 람다식을 사용해 URL을 직접 검사
.logoutRequestMatcher(request -> request.getServletPath().equals("/user/logout"))
.logoutSuccessUrl("/")
.invalidateHttpSession(true))
;
return http.build();
}
...
}
로그아웃 기능을 구현하기 위한 설정을 추가했다. 로그아웃 URL을 /user/logout으로 설정하고 로그아웃이 성공하면 루트(/) 페이지로 이동하도록 했다. 그리고 .invalidateHttpSession(true)를 통해 로그아웃 시 생성된 사용자 세션도 삭제하도록 처리했다.
http://localhost:8080에서 로그인한 후, ‘로그아웃’ 링크를 클릭하면 다시 ‘로그인’ 링크가 등장한다.