▼ What ?
이번 주차엔 웹사이트의 내비게이션바, 페이징 기능, 게시물의 일련번호와 답변 개수를 표시하도록 구현해보고, 스프링 시큐리티가 무엇이며 어떤 원리로 동작하는지에 대해 알아보는 시간을 가져보았다.
▼ 네비게이션바 (Front-End)
네비게이션바 생성
- 메인페이지로 돌아갈 수 있는 기능을 추가하는 것이다.
➜ 모든 페이지에서 공통적으로 보여야 하므로 layout.html 템플릿에 추가하자 !
[ .../main/resources/templates/layout.html ]
...
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
<div class="container-fluid">
<a class="navbar-brand" href="/">WEBBOARD</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>
- " WEBBOARD " 를 클릭하면 메인페이지로 이동 가능
- 부트스트랩(Bootstrap)은 창의 크기를 줄이면 아래처럼 네비게이션 바에 있는 링크들은 작은 메뉴 버튼 " ≡ "으로 숨긴다.
🔻 하지만, 해당 메뉴버튼을 클릭해도 아무런 변화가 일어나지 않는다.
➜ 부트스트랩 자바스크립트 파일(bootstrap.min.js)이 layout.html 파일에 포함되지 않았기 때문이다 !
- 4주차에 다운받은 'bootstrap-5.2.3-dist.zip' 압축파일에 있는 bootstrap.min.js 파일을 아래의 경로로 복사하자
[ /webboard/src/main/resources/static ]
➜ 복사한 부트스트랩 자바스크립트 파일을 사용하기 위해 layout.html 파일 하단의 '<body/>' 태그 바로 위해 추가 !
🔻 메뉴 버튼 " ≡ " 을 클릭하면 위처럼 로그인 링크가 표시된다.
- 네비게이션바 템플릿도 공통 템플릿으로 분리해주자.
➜ navbar.html 템플릿을 다른 템플릿에서 중복되어 사용되는 것은 아니지만 독립된 하나의 템플릿으로 관리하는 것이 유지 보수에 유리하다 !
( layout.html 파일에 삽입했던 코드와 동일하다. )
[ /webboard/src/main/resources/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>
</ul>
</div>
</div>
</nav>
- layout.html 파일도 다음과 같이 기존에 있던 네비게이션바 관련 코드를 삭제하고 navbar.html 템플릿을 타임리프의 'th:replace' 속성으로 포함시키면 된다 !
...
<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>
▼ 페이징
테스트 케이스 생성
- 페이징을 구현하기 전에 페이징을 테스트할 수 있을 정도로 충분한 데이터를 생성하자.
➜ 대량의 테스트 데이터를 만드는 가장 간단한 방법은 스프링부트의 '테스트 프레임워크' 를 사용하는 것이다 !
[ /webboard/src/test/java/com/gdsc/webboard/WebBoardApplicationTests.java ]
package com.gdsc.webboard;
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개의 테스트를 생성하는 테스트 케이스 코드이다.
- 로컬 서버를 중지하고 testJpa 메서드를 실행해준 후에 다시 로컬 서버를 실행하고 브라우저를 확인하면 아래처럼 300개 이상의 데이터가 한 페이지에 모두 보여지고 최근 순이 아닌 등록한 순서로 나타나는 문제를 확인할 수 있다.
➜ 페이징을 구현하자 !
페이징 구현
- 페이징 구현을 위한 패키지들은 초반에 설치한 JPA 라이브러리에 들어있다.
( org.springframwor.data.domain.Page, Pageable, PageRequest )
➜ 별도의 라이브러리 추가 설치 없이 Pagable 객체를 입력으로 받아 Page<Question> 타입 객체를 리턴하는 findAll 메서드를 QuestionRepository 클래스에 생성해주자 !
[ .../webboard/question/QuestionRepository.java ]
...
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
...
public interface QuestionRepository extends JpaRepository<Question, Integer> {
...
Page<Question> findAll(Pageable pageable);
}
- QuestionRepository 클래스를 수정해주었으니 QuestionService 클래스도 아래와 같이 수정 !
➜ 질문 목록을 조회하는 getList 메서드를 int 타입의 페이지 번호를 입력받아 해당 페이지의 질문 목록을 리턴하는 메서드로 변경해주자.
...
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
...
public class QuestionService {
...
public Page<Question> getList(int page) {
Pageable pageable = PageRequest.of(page, 10);
return this.questionRepository.findAll(pageable);
}
}
- QuestionService 클래스의 getList 메서드의 입출력 구조가 변경되었으므로 QuestionController 클래스도 아래와 같이 수정해주자 !
➜ 'http://localhost:8080/question/list?page=0' 처럼 GET 방식으로 호출된 URL에서 page값을 가져오기 위해 '@RequestParam(value='page', defaultValue='0') int page' 를 매개변수로 받도록 list 메서드를 수정해준다.
[ .../webboard/question/QuestionController.java ]
...
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.data.domain.Page;
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";
}
}
🔻 'defaultValue' 를 "0" 으로 설정해줌으로 URL에 페이지 파라미터인 'page' 가 전달되지 않은 경우 첫 페이지를 불러오도록 하였다.
( 스프링부트의 페이징은 첫 페이지 번호는 1이 아닌 0이다. )
🔻'model.addAttribute("paging", paging);' 을 통해 "paging" 이름으로 템플릿에 객체 'paging' 을 넘겨주었다 !
- Page 객체의 속성 ➜ 템플릿에서 페이징을 처리할 때 사용 !
- 템플릿에 Page<Question> 객체인 'paging' 을 "questionList" 대신 "paging" 이라는 이름으로 넘겨줬으니, question_list.html 템플릿도 그에 맞게 수정해주자.
[ .../templates/question_list.html ]
<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' URL을 요청해보면, 다음과 같이 첫 페이지에 해당하는 게시물 10개만 조회되는 것을 확인 가능하다 !
- 템플릿에 페이지 이동 기능 구현
➜ "이전", "다음" 과 같은 페이지를 이동할 수 있는 링크 추가 !
[ .../templates/question_list.html ]
<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>
🔻 이전 페이지가 없는 경우에는 "이전" 링크가 비활성화(disabled)되도록 하였다. ( 다음페이지의 경우도 마찬가지 )
🔻 페이지 리스트를 루프 돌면서 해당 페이지로 이동할 수 있는 링크를 생성한다.
➜ 이때 루프 도중의 페이지가 현재 페이지와 같을 경우에는 active 클래스를 적용하여 현재 페이지를 알 수 있도록 강조 표시를 해주었다.
🔻 'pagination', 'page-item', 'page-link' 등은 페이지 리스트를 보기 좋게 표시하기 위해 사용한 부트스트랩 pagination 컴포넌트 클래스이다 !
- 위 템플릿에 사용된 주요 페이징 기능
- 페이지 표시 제한 기능
➜ 아래처럼, 이동할 수 있는 페이지가 모두 표시되지 않고, 현재 페이지 기준으로 좌우 5개씩만 보이도록 question_list.html 템플릿을 수정해주자 !
[ .../templates/question_list.html ]
...
<!-- 페이징처리 시작 -->
<div th:if="${!paging.isEmpty()}">
...
<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>
...
</div>
<!-- 페이징처리 끝 -->
<a th:href="@{/question/create}" class="btn btn-primary">질문 등록하기</a>
</div>
</html>
- 작성일시 역순으로 조회
➜ 등록한 순서가 아닌 최근 게시물이 먼저 보이도록 QuestionService 클래스를 수정해주자 !
[ .../gdsc/webboard/question/QuestionService.java ]
...
import java.util.ArrayList;
import java.util.List;
import org.springframework.data.domain.Sort;
...
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 객체를 전달해야 한다.
➜ Sort.Order 객체로 구성된 sorts 리스트에 Sort.Order 객체를 추가하고 'Sort.by(sorts)' 로 Sort 객체를 생성할 수 있다.
➜ 작성일시(createDate)를 역순(Desc)으로 조회하려면, sorts 리스트에 'Sort.Order.desc("createDate")' 를 추가해주면 된다.
( 만약 작성일시 외에 추가로 정렬조건이 필요할 경우에는 sorts 리스트에 추가하면 된다. )
▼ 게시물 일련번호 · 답변 개수 표시
모든 게시물 번호가 1부터 시작하는 문제 해결 방법
- 게시물 번호 공식 만들기
번호 = 전체 게시물 개수 - (현재 페이지 * 페이지당 게시물 개수) - 나열 인덱스
- 게시물 번호 공식을 question_list.html 템플릿에 적용
➜ 아래 코드의 1번째 td 엘리먼트에 이 공식을 그대로 적용시킨다.
[ .../templates/question_list.html ]
<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>
- 'http://localhost:8080/question/list?page=1' URL을 요청해보면 아래처럼 게시물 번호가 의도대로 출력되는 것을 확인할 수 있다.
답변 개수 표시
- "해당 질문에 달린 답변 개수" 를 표시해주기 위해 question_list.html 템플릿을 수정 !
➜ 답변 개수는 게시물 제목 바로 오른쪽에 표시하도록 하자.
[ .../templates/question_list.html ]
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<table class="table">
...
<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(이터러블 객체)는 "iterable(반복할 수 있는) 객체" 의 사이즈를 반환하는 타임리프의 유틸리티이다. )
🔻 답변이 있는 질문은 다음처럼 제목 오른쪽에 빨간색('text-danger') 숫자로, 작게('small'), 왼쪽 여백('ms-2')이 추가되어 표시된다.