일친 (IlChin) - 페이지네이션 기능 개발

no.oneho·2025년 6월 1일
0

일친 개발기

목록 보기
13/17

저번 포스팅에서 공통 반환 dto를 생성 할 때 제네릭 타입으로 선언해둬서 반환으로 원하는 모든 타입을 넣을 수 있다.

그렇다면 입맛대로 새로 만든 리스폰스 클래스도 반환타입으로 지정이 가능하다는건데
그 점에서 페이지네이션 기능이 담긴 dto를 만들어서 해당 기능이 필요할 땐 해당 클래스를 사용해 써보도록 하겠다.

먼저 dto 클래스를 하나 생성한다

SearchPageResponse.class

public record SearchPageResponse<T>(
	Long totalElements,
	Long totalPages,
	Integer currentPage,
	Integer pageSize,
	List<T> content) {}

record는 자바14쯤에서 새롭게 나온 클래스 타입인데, record 타입으로 선언하면 효율적이고 간결하게 dto 생성이 가능하다, getter, setter는 물론이고 생성자, toString같은 메서드, 그리고 불변성 보장도 해주다 보니 여러모로 유용하다

물론 lombok을 사용해도 되긴하지만 코드량을 좀 더 줄이고 간단하게 만들기위해 record로 생성했다.

다시 본론으로 와서 페이지네이션 기능은 99% 사용자의 편의성을 위해 만들어진 기능인데 이에따라 사용자와 좀 더 가까운곳에서 개발하는 프론트엔드쪽에서 활용해야한다고 생각한다.

그렇기때문에 만약 프론트엔드 개발자와 협업중이라면 서로 어떤 값들이 필요한지 문서화를 미리미리 해두는게 좋다.

여기서는 최대한 보편적인 페이지네이션 클래스로 생성하여

totalElements 에는 전체 데이터의 개수 (토탈페이지와 연관, 또는 전체 데이터개수 반환 필요할 때)

totalPages 에는 전체 페이지의 수

currentPage현재 페이지

pageSize페이지당 담길 개수

content담겨있는 정보

정도를 넣었고 객체 생성을 위해 빌더 메서드 하나만 생성하도록 하겠다.
생성자가 자동으로 들어가서 굳이 안넣어도 되지만.. 그냥 다른곳들은 빌더로 생성을 하는데 여기만 생성자로 하면 코드가 좀 일관성 없어보여서 넣는다.

@Builder
public record SearchPageResponse<T>(
        Long totalElements,
        Long totalPages,
        Integer currentPage,
        Integer pageSize,
        List<T> content) {

    public static <T> SearchPageResponse<T> of(
            Long totalElements,
            Long totalPages,
            Integer currentPage,
            Integer pageSize,
            List<T> content
    ) {
        return SearchPageResponse.<T>builder()
                .totalElements(totalElements)
                .totalPages(totalPages)
                .currentPage(currentPage)
                .pageSize(pageSize)
                .content(content)
                .build();
    }
}

그러면 일단 이런 형태가 완성이 되는데, 원래 dto마다 전부 테스트를 돌릴 생각은 없지만 해당 dto는 앞으로도 자주 사용할 코드기에 테스트코드로 한번 돌려보자

테스트 코드

class SearchPageResponseTest {

    @Test
    void 정상_데이터_생성_테스트() {
        // Given
        Long totalElements = 100L;
        Long totalPages = 10L;
        Integer currentPage = 1;
        Integer pageSize = 10;
        List<String> content = Arrays.asList("Item1", "Item2", "Item3");

        // When
        SearchPageResponse<String> response = SearchPageResponse.of(
                totalElements,
                totalPages,
                currentPage,
                pageSize,
                content
        );

        // Then
        assertNotNull(response);
        assertEquals(totalElements, response.totalElements());
        assertEquals(totalPages, response.totalPages());
        assertEquals(currentPage, response.currentPage());
        assertEquals(pageSize, response.pageSize());
        assertEquals(content, response.content());
        assertEquals(3, response.content().size());
    }

    @Test
    void 빈_콘텐츠_생성_테스트() {
        // Given
        Long totalElements = 0L;
        Long totalPages = 0L;
        Integer currentPage = 1;
        Integer pageSize = 10;
        List<String> content = List.of();

        // When
        SearchPageResponse<String> response = SearchPageResponse.of(
                totalElements,
                totalPages,
                currentPage,
                pageSize,
                content
        );

        // Then
        assertNotNull(response);
        assertEquals(totalElements, response.totalElements());
        assertEquals(totalPages, response.totalPages());
        assertEquals(currentPage, response.currentPage());
        assertEquals(pageSize, response.pageSize());
        assertTrue(response.content().isEmpty());
    }

    @Test
    void Null_생성_테스트() {
        // When
        SearchPageResponse<String> response = SearchPageResponse.of(
                null,
                null,
                null,
                null,
                null
        );

        // Then
        assertNotNull(response);
        assertNull(response.totalElements());
        assertNull(response.totalPages());
        assertNull(response.currentPage());
        assertNull(response.pageSize());
        assertNull(response.content());
    }
}

실제 사용은 현재 프로젝트엔 없지만 예전에 했던 프로젝트 코드를 보면

controller

@Auth
	@GetMapping("memo-list")
	@Operation(summary = "메모 리스트 조회", description = "작성된 메모 리스트를 조회합니다.\n"
		+ "empIdx 필드를 null로 주시면 전체 조회입니다.")
	public Response<SearchPageResponse<MemoItem.Response>> getMemoList(
		@RequestParam(name = "pageSize") Integer pageSize,
		@RequestParam(name = "pageNumber") Integer pageNumber,
		@RequestParam(name = "empIdx", required = false) Long empIdx
	) {
		return new Response<>(HttpStatus.OK.value(), memoService.getCallMemoList(pageSize, pageNumber-1, empIdx));
	}

service

public SearchPageResponse<MemoItem.Response> getCallMemoList(Integer pageSize, Integer pageNumber, Long empIdx) {

		Employee employee = employeeRepository.findByIdx(AuthHolder.getUserId())
			.orElseThrow(() -> new CustomException(EmployeeErrorCode.NOT_FOUND_EMPLOYEE));

		Pageable pageable = Pageable.ofSize(pageSize).withPage(pageNumber);
		List<Memo> memos = memoRepository.findMemoList(pageable, empIdx, employee);
		List<MemoItem.Response> memoItems = memos.stream()
			.map(MemoItem.Response::from)
			.toList();

		Long total = memoRepository.countMemoList(empIdx, employee);
		Long totalPage = (long)Math.ceil((double)total / pageSize);
		return SearchPageResponse.of(total, totalPage, pageNumber + 1, pageSize, memoItems);
	}

이런 느낌으로 사용하면 된다

profile
이렇게 짜면 요구사항이나 기획이 변경됐을 때 불편하지 않을까? 라는 생각부터 시작해 설계를 해나가는 개발자

0개의 댓글