게시글을 보여주는 기능은 구현을 했지만 게시판 페이징을 구현하지 않아서 더 많은 게시글들을 볼수 없다. 이번에는 게시판 페이징을 구현한다.
현재 페이지의 숫자가 페이징바의 가운데에 위치하게 설정한다. 매 페이지마자 페이지네이션 숫자를 계산에서 바를 그려주는 방식으로 구현한다.
유틸리티 클래스로 만들던지 , 페이지네이션 바의 정보를 도메인 오브젝트로 설계할수도 있다.
하지만 이번에는 스프링의 서비스 빈으로 등록해서 빈으로써 사용하는 방식으로 접근하기로 한다.
그렇게 하기 위해서 먼저 서비스에서 PaginationService
를생성하고, 테스트 파일도 생성한다.
페이지네이션 바의 길이를 고정상수로 지정하고, 페이지네이션 바의 현재 숫자와 총 페이지 수를 리턴하는 메소드, 고정상수인 바의 길이를 가져오는 getter 까지만 간단하게 만들었다.
@Service
public class PaginationService {
private static final int BAR_LENGTH = 5;
public List<Integer> getPaginationBarNumbers(int currentPageNumber, int totalPages){
return null;
}
public int currentBarLength() {
return BAR_LENGTH;
}
}
그렇다면 테스트에서는 어떤 항목을 검사해야 하는가?
현재의 페이지 숫자를 알아야 가운데에 표시를 하니까, 현재 페이지 숫자와 총 페이지 수를 알려준다면, 그에 맞는 페이징 바 리스트를 만들어주는지 테스트 해볼수 있을 것이다.
@DisplayName("비즈니스 로직 - 페이지네이션")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = void.class)
class PaginationServiceTest {
private final PaginationService sut;
public PaginationServiceTest(@Autowired PaginationService paginationService) {
this.sut = paginationService;
}
...
}
SpringBootTest의 webEnvironment 옵션에서 기본값은 MOCK으로 실제 웹 환경을 넣어주는데 이 값 대신에 NONE을 입력해주면 webEnvironment를 전혀 넣지 않는다. 따라서 이방법을 사용하면 부트테스트의 무게를 줄일수 있다.
사실 여기서 더 줄일수 있는데 위와 같이 classes값을 void.class로 잡아주는 것이다. classes의 기본값은 현재 프로젝트의 메인 어플리케이션의 메인 테스트에서 시작하는 모든 빈 스캔 대상들을 configuration으로 불러오게 된다. 그렇게 하면서 통합 테스트의 역할을 해주지만, classes의 값을 임의로 선정해서 범위를 고를수 있게 된다.
void.class처럼 아무것도 읽지 않게 만들어주면 무게가 한층 더 가벼워진다. 만약에 원하는 테스트가 @Autowired는 되게 했으면 좋겠다라고 한다면 void 대신에 해당 테스트의 이름을 집어넣어주면 된다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = PaginationService.class)
이런식으로. 테스트의 실행 시간이 빨라진 것이 느껴질 것이다.
junit 테스트 기법중 파라미터 테스트로 작성해본다. 이를 위해서 @ParameterizedTest를 사용하는데, 여기에 값을 여러번 지속적으로 주입해서 동일한 대상 메소드를 여러번 테스트하면서 입출력값을 볼수 있는 테스트이다.
여러가지 주입방식이 있지만, 이번에는 @MethodSource 주입 방식으로 테스트를 진행한다.
@DisplayName("현재 페이지 번호와 총 페이지 수를 주면, 페이징 바 리스트를 만들어준다.")
@MethodSource
@ParameterizedTest
void givenCurrentPageNumberAndTotalPages_whenCalculating_thenReturnPaginationBarNumbers(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_thenReturnPaginationBarNumbers () {
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(9,13, List.of(7,8,9,10,11)),
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))
);
}
이런 방식으로 테스트의 이름과 같은 이름의 메소드를 생성하고, 내부에 본인이 원하는 값들로 테스트를 여러번 진행할수 있게 해줌으로써 내가 입출력값을 한눈에 볼수 있게 된다.
현재페이지 숫자, 총 페이지 숫자, 예상되는 페이지 리스트 를 추가해서 해당 인덱스에 속하는 페이지에 이동할때 페이징바의 숫자가 첫번째에서 점점 올라오면서 항상 가운데에 위치하게끔 설정하고 테스트를 진행하는 것이다.
총 페이지수를 13로 지정한것은 테스트 데이터가 123개의 게시글을 추가했고, 단위 페이지당 게시글 수를 10개로 지정했으므로 총 13개의 페이지가 존재하는 것이다. 하지만 currentPageNumber는 인덱스이기 때문에 0부터 시작한다는 사실을 인지할 것.
현재 설정되어있는 페이지네이션 바의 길이를 알려주는 테스트로, 이미 나는 5라고 설정을 했지만, 다른 개발자들과 협업을 진행할 때, 다른 사람들이 해당 테스트 코드를 통해 간접적으로 정보를 알 수 있다는 것이다. 다시말해 스펙의 명세를 코드를 통해 알려주기 위함이다.
@DisplayName("현재 설정되어있는 페이지네이션 바의 길이를 알려준다.")
@Test
void givenNothing_whenCalling_thenReturnsCurrentBarLength() {
// Given
// When
int barLength = sut.currentBarLength();
// Then
assertThat(barLength).isEqualTo(5);
}
이제 테스트의 골격은 맞춰졌으니, 페이지네이션 서비스를 작성한다.
두번째 테스트는 이미 통과했으니 첫번째 테스트에 관련된 서비스 코드를 작성하면 된다.
먼저 페이지네이션바의 양옆, 스타트 번호와 끝 번호를 계산한다.
스타트 번호는 currentPageNumber 에서 바의 길이의 절반을 뺀 값이 해당된다.(ex currentpageNumber가 3이라면, 스타트번호는 3 - (5/2) = 0이 되는 것) 만약에 현재 페이지 번호가 0이면 음수가 나와버리기 때문에 Math.max()를 사용해서 0과 비교했을 때 큰 수를 선택하겠다 라는 내용을 작성하면 된다.
startNumber를 정의했으니 endNumber는 쉽다. startNumber에 BAR_LENGTH를 더한값인데, 이렇게만 하면 해당 값이 전체 페이지의 수보다 많아 질수 있기 때문에, Math.min을 사용해서 totalPages와 비교했을때 작은 수를 선택하겠다 라는 내용을 작성하면 된다.
int startNumber = Math.max(currentPageNumber - (BAR_LENGTH / 2),0);
int endNumber = Math.min(startNumber + BAR_LENGTH, totalPages);
이렇게 시작번호와 끝번호를 알았으니 IntStream을 활용해서 리스트를 생성하면 된다.
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().toList();
}
테스트를 돌려보면...
아까 @ParameterizedTest 덕분에 테스트 하나하나의 결과값 모두를 확인해볼수 있다.
그런데 테스트 하나하나의 제목이 너무 길어서 정리를 해주고 싶다면 @ParameterizedTest에 제목 포멧을 지정할수 있다.
@ParameterizedTest(name = "[{index}] {0}, {1} => {2}")
[{index}] : 테스트 번호
{0}, {1} 입력값
{2} 출력값
따라서 currentPageNumber 와 totalPages를 입력해서 나온 expected 가 제목으로 나타난다.
요런 식으로.
이로써 서비스 작성 끝! 이제 요걸 컨트롤러에 가져다가 쓰면 된다!
컨트롤러 테스트와 컨트롤러에 페이지네이션을 내용을 적용시킨다.
테스트에서 @MockBean으로 PaginationService를 추가하고 페이징과 관련된 내용에 해당 서비스를 추가해야한다.
given에 paginationService를 사용해서 이런 리스트가 나올것이다라는 것을 명시. 이때 입력값을 그냥 any로 해버리면 null또한 입력값이 되어버릴수 있기 때문에, anyInt로 설정, 예상되는 리스트는 그냥 0,1,2,3,4로 작성함
then에서 해당 서비스 메소드가 호출 되었는지를 확인!
@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(result -> content().contentType(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());
}
게시글 상세 페이지에는 페이지네이션 바가 들어가지 않으니 패스!
나머지 두개의 테스트는 아직 구현되지 않았으므로 역시 패스!
ArticleControllerTest
에서 페이지네이션 관련으로 추가된 내용이 있지만, 궁극적인 테스트 목적은 리스트의 호출이지 페이징의 정상 작동 여부가 아니므로, 페이징관련 테스트를 하나 더 추가했다.
@DisplayName("[view][GET] 게시글 리스트 (게시판) 페이지 - 페이징, 정렬기능")
@Test
public 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(result -> content().contentType(MediaType.TEXT_HTML)) // 데이터 확인
.andExpect(view().name("articles/index")) // 뷰의 존재여부 검사
.andExpect(model().attributeExists("articles")) // 뷰에 모델 어트리뷰트로 넣어준 데이터존재 여부 검사
.andExpect(model().attribute("paginationBarNumbers",barNumbers));
then(articleService).should().searchArticles(eq(null),eq(null),any(Pageable.class));
then(paginationService).should().getPaginationBarNumbers(pageable.getPageNumber(),Page.empty().getTotalPages());
}
given 으로 정렬 기준과 방향을 설정하는 sortName과 direction 정의.
페이지번호와 페이징크기를 설정하는 pageNumber와 pageSize 정의
PageRequest를 통해 pageNumber,pageSize, Sort.by(Sort.Order.desc(sortName))파라미터로 만들어진 pageable 정의.
페이징 바의 번호를 예시로 만든 barNumbers 리스트 정의
paginationServie에서 만든 getPaginationBarNumbers가 이전에 정의한 값들로 넣었을 때, barNumbers를 리턴해줘야 한다!는 명시
when에서 get을 할 때 queryParam을 통해서 실제로 page,size,sort의 값이 pageable에 받아들여진다. 적용 여부를 이제 then에서 확인해주는 것이다. 'paginationBarnumbers 가 내가 예상한 barNumbers와 일치하는가?' 를 확인.
then에서 이제 getPaginationBarNumbers메소드가 호출되는지 여부를 확인한다.
현재 테스트를 실행해도 통과되지 않을 것이다. 왜냐하면 아직 컨트롤러 서비스에 paginationBarNumbers를 넣지 않았기 때문!
이제 컨트롤러에 페이지네이션 서비스를 의존성에 추가하고 매핑 내용을 수정한다.
현재 map.addAttribute를 통해서 articles 라는 어트리뷰트 이름을 가진 pageable이 존재한다.
해당 Page는 ArticleResponse에서 내보내진 것으로 article서비스에서 searchArticles메소드 호출된후 매핑된 값으로 맨앞으로 순서를 옮길수 있다.
그다음 paginationBarNumbers 어트리뷰트를 추가하기 위한 barNumbers를 paginationService에서 getPaginationBarNumbers메소드를 호출한뒤 나타나는 결과값을 리스트로 정의한다. 이때 파라미터는 pageable에서 페이지번호와 , articles에서 getTotalPages()메소드를 호출한 값이 해당될 것이다. (getPaginationBarNumbers메소드의 파라미터는 현재 페이지 번호와 총 페이지의 수, 이렇게 2가지였기 때문이다.)
그럼이제 테스트에서 존재여부를 파악하지 못한 paginationBarNumbers 어트리뷰트를 추가해주면 된다. 이때 대상은 바로 barNubmers 리스트이다.
@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 map) {
Page<ArticleResponse> articles = articleService.searchArticles(searchType,searchValue,pageable).map(ArticleResponse::from);
List<Integer> barNumbers = paginationService.getPaginationBarNumbers(pageable.getPageNumber(),articles.getTotalPages());
map.addAttribute("articles", articles);
map.addAttribute("paginationBarNumbers",barNumbers);
return "articles/index";
}
그럼 이제 다시 컨트롤러 테스트를 돌려보면?
오케이 테스트 통과!
하지만 아직 뷰에 적용시키지 않았기 때문에 어플리케이션을 실행해도 변화가 나타나지 않을것이다.
index.html 에서 페이지네이션 부분을 select해서 설정을 추가한다.
현재 페이지네이션은 이전과 다음 버튼, 그리고 그 사이에 5개의 페이지 숫자 li 가 존재하지만 숫자를 하나만 남게 뷰를 수정한다.
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>
<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>
li의 첫번째 항목의 a태그에 text와 href, 그리고 클래스의 설정을 추가한다.
text는 해당 태그에 나타날 이름, href는 클릭했을때 이동하는 경로를 작성해준다. 컨트롤러에서 매핑한 articles에서 get 파라미터를 괄호로 표현을 하고 page=${articles.number}에서 1을 빼준 값의 페이지로 이동하게 된다.
클래스는 스타일 설정으로, 만약에 현재 articles.number가 0보다 작거나 같으면 disabled 가추가되어서 클릭해도 아무런 반응이 없게 나타날수 있도록 설정했다.
두번째 항목은 서비스에서 받아온 paginationBarNumber의 리스트 크기만큼 반복으로 동작한다.
당연히 href는 해당 페이지의 pageNumber값과 일치한 링크로 설정하지만 pageNumber는 paginationBarNumbers가 인덱스 값로 구성된 리스트이기 때문에 text에서는 1을 더해줘서 사용자가 보기 편하게 작성한다.(처음값이 0이다!)
마지막으로는 다음 페이지로 이동하는 버튼으로 href는 articles.number에 1을 더한 값에 해당하는 page로 이동한다. 스타일 옵션은 현재 number의 값이 totalPages에서 1을 뺀값보다 같거나 크다면 다음페이지가 존재하지 않는다는 뜻이기 때문에 disable 옵션으로 클릭해도 반응이 없게 설정한다.
이제 게시판 페이지의 페이지 기능이 구현되었다.