게시글 리스트를 보여주는 과정에서, 개수가 많아지는 경우 모든 게시글 데이터를 한번에 뿌려주는 것보다
페이지별로 나눠서 보여주는 것이 깔끔하고 데이터 절약에 좋기 때문에 게시글 리스트 페이징 처리를 구현해보려 한다.
현재 프로젝트에서는 검색 부분과 함께 페이징 처리가 되야한다는점을 생각하며 만들어 보려고 한다.
이전 글에 비즈니스로직을 설계하면서 repository와 service까지 페이징 처리를 하기 위해 다 만들어놨습니다.
이제 이것을 뷰와 컨트롤러에 적용만하면 됩니다.
현재 게시판상태입니다. 밑의 페이지버튼은 눌러도 아무런 반응이 없습니다.
이 상태에서 이번 페이지가 예를들어서 3페이지이고, 전체 페이지바의 크기가 5개이다 가정하면
previous 12345 next 이런식으로 구현을 하려고 합니다.
3번 페이지면 12345 3이 가운데 오고 현재 페이지가 5 페이지면 34567 이런식으로 구현해보도록 하겠습니다.
저는 페이징처리를 비즈니스 로직으로 보고 서비스로 등록하여 하겠습니다.
PaginationService
@Service
public class PaginationService {
private static final int BAR_LENGTH = 5;
public List<Integer> getPaginationBarNumbers(int currentPageNumber, int totalPages) {
int startNumber = Math.max(currentPageNumber - (BAR_LENGTH / 2), 0);
int endNumber = Math.min(startNumber + BAR_LENGTH, totalPages);
return IntStream.range(startNumber, endNumber).boxed().collect(toList());
}
public int currentBarLength() {
return BAR_LENGTH;
}
BAR_LENGTH =5 는 페이지 바의 길이입니다.
리스트로 현재 페이지와 총페이지를 넘겨주려고합니다.
startNumber 는 현재페이지를 받습니다.(currentPageNumber) 에서 바의길이 5에서 /2를 해섯 중앙 값을 구합니다. 두개를 빼줍니다. 그 중의 0보다 큰수를 넣어줍니다. 0을 넣은 이유는 첫 시작페이지가 0일경우 음수가 나올 수 도 있기때문에 0을 넣어줍니다.
끝 페이지는 시작페이지와 바의 길이를 더해서 토탈페이지와 비교해 작은 수를 넣어주면 됩니다.
IntStrema.range는 startNumber 부터 endNumber까지 반복을 해주는겁니다. 그래서 리스트로 리턴을 해주면 됩니다.
PaginationServiceTest
@DisplayName("비즈니스 로직 - 페이지네이션")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = PaginationService.class)
class PaginationServiceTest {
private final PaginationService sut;
public PaginationServiceTest(@Autowired PaginationService sut) {
this.sut = sut;
}
@DisplayName("현재 페이지 번호와 총 페이지 수를 주면, 페이징 바 리스트를 만들어준다.")
@MethodSource
@ParameterizedTest(name = "[{index}] 현재페이지: {0}, 총 페이지: {1} => {2}")
void givenCurrentPageNumberAndTotalPages_whenCalculating_thenReturnsPaginationBarNumbers(int currentPageNumber, int totalPages, List<Integer> expected) {
//given
//when
List<Integer> actual = sut.getPaginationBarNumbers(currentPageNumber, totalPages);
//then
assertThat(actual).isEqualTo(expected);
}
static Stream<Arguments> givenCurrentPageNumberAndTotalPages_whenCalculating_thenReturnsPaginationBarNumbers() {
return Stream.of(
arguments(0, 13, List.of(0, 1, 2, 3, 4)),
arguments(1, 13, List.of(0, 1, 2, 3, 4)),
arguments(2, 13, List.of(0, 1, 2, 3, 4)),
arguments(3, 13, List.of(1, 2, 3, 4, 5)),
arguments(4, 13, List.of(2, 3, 4, 5, 6)),
arguments(5, 13, List.of(3, 4, 5, 6, 7)),
arguments(6, 13, List.of(4, 5, 6, 7, 8)),
arguments(10, 13, List.of(8, 9, 10, 11, 12)),
arguments(11, 13, List.of(9, 10, 11, 12)),
arguments(12, 13, List.of(10, 11, 12))
);
}
@DisplayName("현재 설정되어 있는 페이지네이션 바의 길이를 알려준다.")
@Test
void givenNothing_whenCalling_thenReturnsCurrentBarLength() {
//given
//when
int barLength = sut.currentBarLength();
//then
assertThat(barLength).isEqualTo(5);
}
}
보통 테스트 메소드에 @Test 어노테이션을 붙이지만 현재는 @ParameterizedTest으로 사용하였습니다.
@ParameterizedTest 어노테이션은 파라미터를 값을 연속적으로 여러번 주입해서 동일한 메소드를 여러번 테스트하면서 입력, 출력 값을 볼 수 있는 기능입니다.
@MethodSource는 입력값을 줄 메소드입니다.
입력값을 넣어줄 메소드소스는 static으로 테스트 메소드와 동일하게 가져가면 인식을해서 계속 주입하면서 테스트를 할 수 있습니다. 이런방법이 아니라면 @MethodSource(testMethod) 이런식으로 메소드 이름을 지정해줄수 있습니다
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = PaginationService.class)
@SpringBootTest 에서는 여러 옵션을 줄 수 있습니다. 대표적으로 webEnvironment 가 있는데 기본값은 SpringBootTest.WebEnvironment.MOCK 입니다. 모킹한 웹 환경을 넣어 줍니다. 이런것들은 실제 웹환경을 넣어 주기 때문에 무거워질수있습니다. 현재 저희는 페이징 테스트만 할것이기 때문에 이런것들이 다 필요가 없습니다.그래서 NONE을 넣어 줍니다.
그리고 classes 는 설정 클래스를 지정할 수 있는데 기본 값으로는 루트 애플리케이션 테스트에서 시작하는 모든 빈스캔 대상들을 불러옵니다. 그러면서 통합테스트 역할을 하게 되는데 저는 PaginationService 만 테스트를 할 것이기때문에
PaginationService.class를 넣어줍니다.
ArticleController
@GetMapping
public String articles(@RequestParam(required = false) SearchType searchType,
@RequestParam(required = false) String searchValue,
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
ModelMap modelMap) {
Page<ArticleResponse> articles = articleService.searchArticles(searchType, searchValue, pageable).map(ArticleResponse::from);
List<Integer> paginationBarNumbers = paginationService.getPaginationBarNumbers(pageable.getPageNumber(), articles.getTotalPages());
modelMap.addAttribute("articles", articles);
modelMap.addAttribute("paginationBarNumbers", paginationBarNumbers);
return "articles/index";
}
ArticleControllerTest
@DisplayName("[view][GET] 게시글 리스트 (게시판) 페이지 - 정상 호출")
@Test
public void givenNothing_whenRequestingArticlesView_thenReturnsArticlesView() throws Exception {
// Given
given(articleService.searchArticles(eq(null), eq(null), any(Pageable.class))).willReturn(Page.empty());
given(paginationService.getPaginationBarNumbers(anyInt(), anyInt())).willReturn(List.of(0, 1, 2, 3, 4));
// When & Then
mvc.perform(get("/articles"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("articles/index"))
.andExpect(model().attributeExists("articles"))
.andExpect(model().attributeExists("paginationBarNumbers"));
then(articleService).should().searchArticles(eq(null), eq(null), any(Pageable.class));
then(paginationService).should().getPaginationBarNumbers(anyInt(), anyInt());
}
@DisplayName("[view][GET] 게시글 리스트 (게시판) 페이지 - 페이징, 정렬 기능")
@Test
void givenPagingAndSortingParams_whenSearchingArticlesPage_thenReturnsArticlesPage() throws Exception {
// Given
String sortName = "title";
String direction = "desc";
int pageNumber = 0;
int pageSize = 5;
Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Order.desc(sortName)));
List<Integer> barNumbers = List.of(1, 2, 3, 4, 5);
given(articleService.searchArticles(null, null, pageable)).willReturn(Page.empty());
given(paginationService.getPaginationBarNumbers(pageable.getPageNumber(), Page.empty().getTotalPages())).willReturn(barNumbers);
// When & Then
mvc.perform(
get("/articles")
.queryParam("page", String.valueOf(pageNumber))
.queryParam("size", String.valueOf(pageSize))
.queryParam("sort", sortName + "," + direction)
)
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("articles/index"))
.andExpect(model().attributeExists("articles"))
.andExpect(model().attribute("paginationBarNumbers", barNumbers));
then(articleService).should().searchArticles(null, null, pageable);
then(paginationService).should().getPaginationBarNumbers(pageable.getPageNumber(), Page.empty().getTotalPages());
}
@DisplayName("[view][GET] 게시글 리스트 (게시판) 페이지 - 정상 호출")
//given 에서 호출이 되는지만 확인 하는 거니깐 anyInt()를 파라미터로 넣어서 아무숫자나 넣어줍니다. 리턴값도 같습니다.
그리고 //When & Then 에 컨트롤러에서 model 의 값이 있는지 확인 해줍니다.
@DisplayName("[view][GET] 게시글 리스트 (게시판) 페이지 - 페이징, 정렬 기능")
정렬까지 있는 테스트입니다. 스프링 데이터 Page가 페이징과 정렬기능을 다 가지고 있어서 정렬을 따로 구현안해도 이미 되어 있습니다. 위의 테스트와 다르게 이번엔 값들을 다 넣어줍니다. 그래서 get으로 요청할때 쿼리 파라미터로 다 넘겨 줍니다. 그 후에 then에서 검증을 해줍니다.
그러면 테스트가 통과되는 모습입니다. 이제 뷰에 적용해보도록 하겠습니다.
Index.html
<nav id="pagination" ria-label="Page navigation">
<ul class="pagination justify-content-center">
<li class="page-item"><a class="page-link" href="#">Previous</a></li>
<li class="page-item"><a class="page-link" href="#">1</a></li>
<li class="page-item"><a class="page-link" href="#">Next</a></li>
</ul>
</nav>
id 를 pagination으로 줍니다.
Index.th.xml
attr sel="#pagination">
<attr sel="li[0]/a"
th:text="'previous'"
th:href="@{/articles(page=${articles.number - 1})}"
th:class="'page-link' + (${articles.number} <= 0 ? ' disabled' : '')"
/>
<attr sel="li[1]" th:class="page-item" th:each="pageNumber : ${paginationBarNumbers}">
<attr sel="a"
th:text="${pageNumber + 1}"
th:href="@{/articles(page=${pageNumber})}"
th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
/>
</attr>
<attr sel="li[2]/a"
th:text="'next'"
th:href="@{/articles(page=${articles.number + 1})}"
th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
/>
</attr>
#pagination 으로 아이디로 셀렉트 합니다. Index.html에서 li가 3개있어서 xml에서도 li를 3개 잡아줍니다.
previous 와 next는 놔두고 가운데 1만 반복시키기 위해 이렇게 가져옵니다.
li[0] :
li/a는 a태그를 선택합니다.
th:text로 previous를 적어주고 href의 링크는 현재페이지의 -1을 해줍니다.
만약 0보다 작거나 같으면 disabled 속성을 주어서 못누르게 해줍니다.
li[1] : th:each를 통해 반복해줍니다. barNumber가 5니깐 5번반복을해주는데
text는 인덱스 번호가 나옵니다. 인덱스는 0부터 시작, 그래서 pageNumber의 +1을해주면 사람이 생각하는 순서와 똑같이 됩니다. 링크는 인덱스 번호를 보내줍니다. 이거 또한 현재 페이지이면 disabled 속성을 주어서 못누르게 합니다.
li[2] 도 li[0]이랑 똑같습니다.
그리고 실행을 해보면 현재 1페이지에 있을때 previous와 1이 disabled로 색이 변해져있고 눌리지 않습니다.
끝페이지에서도 페이지 바가 3개만 나오고 next와 13이 비활성화 되어 있는 모습입니다.
정렬
위의 사진들의 제목,해시태그, 작성자, 작성일을 눌렀을때 정렬을 해주고 싶은 기능을 만들어 보겠습니다.
Index.html
<table class="table" id="article-table">
<thead>
<tr>
<th class="title col-6"><a>제목</a></th>
<th class="hashtag col-2"><a>해시태그</a></th>
<th class="user-id col"><a>작성자</a></th>
<th class="created-at col"><a>작성일</a></th>
</tr>
</thead>
<tbody>
위와같이 <a> 태그를 달아줍니다.
Index.th.xml
<?xml version="1.0"?>
<thlogic>
<attr sel="#header" th:replace="header :: header"></attr>
<attr sel="#footer" th:replace="footer :: footer"></attr>
<attr sel="main" th:object="${articles}">
<attr sel="#article-table">
<attr sel="thead/tr">
<attr sel="th.title/a" th:text="'제목'" th:href="@{/articles(
page=${articles.number},
sort='title' + (*{sort.getOrderFor('title')} != null ? (*{sort.getOrderFor('title').direction.name} != 'DESC' ? ',desc' : '') : '')
)}"/>
<attr sel="th.hashtag/a" th:text="'해시태그'" th:href="@{/articles(
page=${articles.number},
sort='hashtag' + (*{sort.getOrderFor('hashtag')} != null ? (*{sort.getOrderFor('hashtag').direction.name} != 'DESC' ? ',desc' : '') : '')
)}"/>
<attr sel="th.user-id/a" th:text="'작성자'" th:href="@{/articles(
page=${articles.number},
sort='userAccount.userId' + (*{sort.getOrderFor('userAccount.userId')} != null ? (*{sort.getOrderFor('userAccount.userId').direction.name} != 'DESC' ? ',desc' : '') : '')
)}"/>
<attr sel="th.created-at/a" th:text="'작성일'" th:href="@{/articles(
page=${articles.number},
sort='createdAt' + (*{sort.getOrderFor('createdAt')} != null ? (*{sort.getOrderFor('createdAt').direction.name} != 'DESC' ? ',desc' : '') : '')
)}"/>
</attr>
<attr sel="tbody" th:remove="all-but-first">
<attr sel="tr[0]" th:each="article : ${articles}">
<attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/' + ${article.id}}"/>
<attr sel="td.hashtag" th:text="${article.hashtag}"/>
<attr sel="td.user-id" th:text="${article.nickname}"/>
<attr sel="td.created-at/time" th:datetime="${article.createdAt}"
th:text="${#temporals.format(article.createdAt, 'yyyy-MM-dd')}"/>
</attr>
</attr>
</attr>
<attr sel="#pagination">
<attr sel="li[0]/a"
th:text="'previous'"
th:href="@{/articles(page=${articles.number - 1})}"
th:class="'page-link' + (${articles.number} <= 0 ? ' disabled' : '')"
/>
<attr sel="li[1]" th:class="page-item" th:each="pageNumber : ${paginationBarNumbers}">
<attr sel="a"
th:text="${pageNumber + 1}"
th:href="@{/articles(page=${pageNumber})}"
th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
/>
</attr>
<attr sel="li[2]/a"
th:text="'next'"
th:href="@{/articles(page=${articles.number + 1})}"
th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
/>
</attr>
</attr>
</thlogic>
Page 에 이미 정렬기능이 있으므로
<attr sel="th.title/a" th:text="'제목'" th:href="@{/articles(page=${articles.number},sort='title' + (*sort.getOrderFor('title')}
!= null ? (*{sort.getOrderFor('title').direction.name} != 'DESC' ? ',desc' : '') : ''))}"/>
위와 같이 쿼리 파라미터로 articles?page와 함께 sort를 title로 보내줍니다. 이 sort의 기본값은 asc 이므로
desc가 아니라면 desc를 넣어주고 desc 라면 빈값을 넣어 asc로 만들어주면됩니다. 이렇게 해시태그, 작성자,작성일 다 해주면 됩니다.
그러면 a태그를 걸어서 위와같이 나오게 됩니다. 이것을 css로 잡아줍시다.
table-header.css
#article-table > thead a {
text-decoration: none;
color: black;
}
검은 색으로 잘 나오는것을 볼 수 있고 제목으로 asc 한상태입닌다. 다시 한번 제목을 누르면
1페이지에서 desc되어 나오는것을 볼 수 있습니다.
'Project > project-board' 카테고리의 다른 글
[Project] 해시태그 검색 구현 (0) | 2022.07.20 |
---|---|
[Project] 게시판 검색기능 (0) | 2022.07.15 |
[Project] 게시글, 댓글 비즈니스 로직, 페이지 구현 (0) | 2022.07.14 |
[Project] DTO 설계 (0) | 2022.07.14 |
[Project] QueryDSL 적용 (0) | 2022.07.11 |
댓글