JAVA/JPA 학습기록

[JPA] Spring Data JPA 페이징 기법을 적용해보자

kth990303 2022. 8. 4. 14:02
반응형

조회 쿼리로 결과가 나올 때, 데이터가 너무 많으면 사용자가 보기에도 불편하고 로딩에도 오랜 시간이 걸린다. 그렇기 때문에 많은 사이트들에선 많은 데이터들을 페이지로 나누어서 관리한다. 스크롤을 내릴 때마다 데이터를 추가로 로드하는 무한 스크롤 방식을 사용하거나, 이전/다음 페이지로 이동하는 방식이 주로 사용된다.

 

이렇게 페이징 기법을 적용하려면 전체 데이터 개수와 요구되는 페이지넘버에 따른 인덱싱 계산을 해주어야 하는데, 실수할 여지도 많고 꽤나 번거롭다. 다행히 Spring Data JPA에서는 스스로 페이지 관련 인덱싱을 계산해준 결과를 반환해주는 기능을 제공해준다. 이번 포스팅에선 Spring Data JPA의 Pageable, Page에 대해 알아보고 MVC 패턴에서 Pageable이 어디까지 참조하면 좋을지 고찰해보는 시간을 가져보려 한다.


Spring Data JPA에서의 Pageable, Page

회원이 받은 롤링페이퍼 목록을 조회하는 기능을 만든다고 가정해보자.

수많은 롤링페이퍼들을 한번에 가져와서 전부 사용자에게 보여주기에는 위에서 언급한대로 가독성을 떨어뜨릴 수 있고, 성능 이슈도 존재할 수 있다. 따라서 우리는 페이징 기법을 적용하려 한다.

 

Page와 PageRequest, Pageable

아래 메서드에는 PageRequest, Page가 쓰인다.

Page<Rollingpaper> findByMemberId(final Long memberId, final PageRequest pageRequest);

롤링페이퍼 엔티티에는 연관관계로 Member(Addressee)가 매핑돼있으며, Pageable 인터페이스를 통해 페이지 정보를 넘겨주어 해당 정보를 반환하게 하는 메서드이다.

 

조회 쿼리에 페이징을 적용하기 위해 요청된 PageRequest에 대해 먼저 알아보자.

 

PageRequest

PageRequest에는 해당되는 데이터를 size개만큼 나누어 몇 page에 해당되는 부분을 가져오라는 요청을 할 수 있도록 정적 메서드를 제공해준다.

public static PageRequest of(int page, int size) {
    return of(page, size, Sort.unsorted());
}

public static PageRequest of(int page, int size, Sort sort) {
    return new PageRequest(page, size, sort);
}

한 페이지에 존재시킬 데이터의 개수를 size, 요청으로 가져오길 원하는 페이지 넘버를 page에 기록해주면 된다.

예를 들어 전체 데이터가 100개라고 하자. 하지만 우리는 해당 데이터를 전부 가져오는 것이 아닌, 맨 처음 페이지부터 20개씩만 가져오고 싶다. 그렇다면 아래와 같이 위 메서드를 사용하면 된다.

final Pageable pageRequest = PageRequest.of(0, 20);

PageRequest의 page는 0-index base라는 것에 주의하자. 

 

만약, 전체 데이터가 7개인데, PageRequest.of(2,3)이라면?

7~9번째 데이터를 가져와야 하는데 8~9번째 데이터는 존재하지 않으므로 7번째 데이터만 가져오게 된다.

해당되는 데이터가 아예 존재하지 않을 경우 비어있는 값을 반환하게 된다.

 

만약 해당 데이터 결과에 정렬 조건을 설정하고 싶다면 아래와 같이 작성해주면 된다.

PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "nickname"));

롤링페이퍼들을 받은사람의 닉네임 순으로 내림차순으로 정렬해준 결과를 반환해준다.

 

Pageable

PageRequest는 Pageable 인터페이스의 구현체를 상속받는다.

따라서 변경의 유연성을 위해 요청값의 타입을 PageRequest보다 상위 타입인 Pageable을 사용하도록 하였다.

final Pageable pageRequest = PageRequest.of(page, count);	// Pageable 타입 사용
final Page<Rollingpaper> rollingpapers = 
    rollingpaperRepository.findByMemberId(loginMemberId, pageRequest);

상위의 추상화된 클래스 타입을 넘겨주게 함으로써 구현체에 의존하지 않도록 했다.

 

// AS-IS
Page<Rollingpaper> findByMemberId(final Long memberId, final PageRequest pageRequest);

// TO-DO
Page<Rollingpaper> findByMemberId(final Long memberId, final Pageable pageRequest);

위에서 본 코드도 TO-DO와 같이 변경해주었다.

 

Page

이제 조회 쿼리의 결과 타입으로 주로 반환되는 Page 인터페이스에 대해 알아보자.

public interface Page<T> extends Slice<T> {

	static <T> Page<T> empty() {
		return empty(Pageable.unpaged());
	}

	static <T> Page<T> empty(Pageable pageable) {
		return new PageImpl<>(Collections.emptyList(), pageable, 0);
	}

	/**
	 * Returns the number of total pages.
	 *
	 * @return the number of total pages
	 */
	int getTotalPages();

	/**
	 * Returns the total amount of elements.
	 *
	 * @return the total amount of elements
	 */
	long getTotalElements();

	<U> Page<U> map(Function<? super T, ? extends U> converter);
}

여기서 주로 쓰이는 메서드는 전체 데이터 개수를 반환해주는 getTotalElements()전체 페이지 수를 반환해주는 getTotalPages()이다. 

 

Repository에서의 페이징 기법

JpaRepository를 사용한다면 아래와 같이 작성해주면 JpaRepository가 알아서 Page를 반환해준다.

// 페이징 기법 미적용
List<Rollingpaper> findByMemberId(final Long memberId);	

// 페이징 기법 적용
Page<Rollingpaper> findByMemberId(final Long memberId, final Pageable pageRequest);

해당 메서드의 테스트 코드를 작성해봄으로써 우리가 원하는대로 결과가 나오는지 확인해보자.

 

RollingpaperRepositoryTest

@Test
@DisplayName("롤링페이퍼들을 memberId와 PageRequest로 찾는다.")
void findByMemberIdAndPageRequest() {
    // 7개의 롤링페이퍼 저장
    // ...

    final Page<Rollingpaper> actual = 
        rollingpaperRepository.findByMemberId(member.getId(), PageRequest.of(1, 3));

    assertAll(
            () -> assertThat(actual).contains(rollingPaper4, rollingPaper5, rollingPaper6),
            () -> assertThat(actual).doesNotContain(rollingPaper1, rollingPaper2, rollingPaper3, rollingPaper7)
    );
}

내가 받은 롤링페이퍼를 총 7개를 만들어주고, 조회쿼리의 요청 PageRequest.of(1, 3)을 보내줌으로써 3개씩 분할하여 두 번째 페이지를 가져오도록 했다. 즉, 4~6번째 롤링페이퍼 데이터가 결과로 출력돼야 하고, 1~3, 7번째 롤링페이퍼는 결과로 출력되지 않아야 한다.

테스트가 통과하는 것으로 보아, 원하는대로 페이징 기법이 적용된 결과를 가져오는 것을 확인할 수 있다!

이제 해당 결과를 Service에서 사용하면 된다.

 

참고로, 쿼리를 잘 살펴보면 Spring data jpa에서 limit과 offset을 활용하여 페이징 결과를 반환하는 것을 확인할 수 있는데, 실제로 데이터가 수천만개 ~ 수억개 이상이 되면 성능이 느리다고 한다. (그보다 적은 개수를 반환하는 결과에는 크게 체감되지 않는다고 함.)

 

no offset으로 쿼리튜닝을 하면 성능을 향상시킬 수 있다고 하는데, 해당 부분은 jojoldu님의 포스팅을 참고하자.

https://jojoldu.tistory.com/528

 

1. 페이징 성능 개선하기 - No Offset 사용하기

일반적인 웹 서비스에서 페이징은 아주 흔하게 사용되는 기능입니다. 그래서 웹 백엔드 개발자분들은 기본적인 구현 방법을 다들 필수로 익히시는데요. 다만, 그렇게 기초적인 페이징 구현 방

jojoldu.tistory.com


Pageable과 MVC 패턴에 대한 주관적인 고찰

import org.springframework.data.domain.Pageable;

Pageable은 Spring Data 기술에 종속적이다. 다행히 Spring Data JPA에 종속적이진 않아 Spring Data JDBC 등의 기술들에서도 쓸 수 있지만, db 관련 기술임에는 확실하다.

 

Repository - Service - Controller 구조를 띌 때, Repository에서 Pageable, PageRequest를 쓰는 것은 당연한 것처럼 보인다. 하지만, Service, Controller에서 Pageable에 의존하는 것은 과연 옳은 구조일지도 한 번 생각해보는 시간을 가져보자.

 

Pageable을 Controller에서

컨트롤러에서 조회 메서드를 페이징 기법을 적용해서 사용하는 방법에는 두 가지가 있을 듯하다.

 

1. PageRequest를 Controller에서 아예 사용하지 않고 page, size를 Service에 넘겨주기

 @GetMapping("/me/rollingpapers/received")
public ResponseEntity<ReceivedRollingpapersResponseDto> findReceivedRollingpapers(
        @AuthenticationPrincipal @Valid final LoginMemberRequest loginMemberRequest,
        @RequestParam("page") final Integer page,
        @RequestParam("count") final int count
) {
    return ResponseEntity.ok(
            rollingpaperService.findReceivedRollingpapers(loginMemberRequest.getId(), page, count)
    );
}

2. PageRequest를 Controller에서 생성해주어 (또는 직접 받아주어) Service에 넘겨주기

 @GetMapping("/me/rollingpapers/received")
public ResponseEntity<ReceivedRollingpapersResponseDto> findReceivedRollingpapers(
        @AuthenticationPrincipal @Valid final LoginMemberRequest loginMemberRequest,
        final Pageable pageRequest
) {
    return ResponseEntity.ok(
            rollingpaperService.findReceivedRollingpapers(loginMemberRequest.getId(), pageRequest)
    );
}

여기서, PageRequest를 @RequestBody로 넘겨주기에는 Get Method이므로 지양하는 것이 좋고, @RequestParam 또는 @ModelAttribute로 넘겨주는 것이 나은데, @ModelAttribute를 생략하고 위와 같이 final Pageable pageRequest을 작성해줄 수 있다.

 

사실 두 방법 모두 괜찮다.

1번 방법을 선택했다면, Pageable이 DB 관련 기술스택에 종속적이기 때문에 요청/응답을 처리하는 Controller 관심사에 위배되기 때문일 것이고, 2번 방법을 선택했다면 Pageable PageRequest가 요청 중 하나이기 때문에 Controller에 의존하는 것 또한 괜찮다고 생각했기 때문일 것이다.

팀 컨벤션에 따라 맞게 설정하면 될 듯하다. 

 

 

Pageable을 Service에서

Repository에서 Page 타입의 변수를 바로 반환할 수도 있고, 서비스에서 요구하는 정보들을 DTO로 감싸서 반환할 수도 있다. 후자의 방안을 선택한다면 Pageable을 Repository에서만 의존할 수 있어 Spring Data JPA가 Service에 의존적이지 않게 할 수 있다는 장점이 있다.

 

-- 아래부터는 개인적인 의견을 서술한 글이다. --

 

하지만 Spring Data JPA에서 구현체에 의존하지 않은 결과 데이터 값들을 편하게 사용하라고 제공해준 인터페이스까지 서비스에서 사용하지 않는 것이 과연 옳은가? 하는 생각도 들었다. 인터페이스 같은 고수준 추상화 클래스를 서비스에서 의존하는 것은, Pageable 내부의 구현체 로직에 의존하는 것이 아닌, 결과 데이터를 사용하는 것이다. 따라서 Spring Data 기술을 아예 갈아치우지 않는 이상 변경의 유연성이 떨어지는 것은 아니라고 생각했기 때문이다.

 

물론, 기술스택을 아예 갈아치운다면 Service에서 해당 기술을 의존하기 때문에, 그에 대한 변경을 피할 수 없게 된다. 하지만 특정 기술을 사용하기로 한 순간부터 어느정도의 의존은 불가피하다고 생각되며, Spring Data의 장점인 반복 줄이기, 편리한 db 관리 로직을 아예 버리면서까지, 기술 스택 변경에 대한 유연함을 챙길 필요는 없다고 생각한다. 또한, Pageable, Page를 서비스에 사용 및 의존하지 않고 별도로 Repository에서 사용할 DTO를 따로 만들어서 반환하게 할 경우, 해당 설계에 대한 학습 장벽이 높아지고 불필요한 반복이 증가해 오히려 개발자의 피로감을 높인다는 생각도 든다.

 

이러한 이유로 Service layer에서 Pageable을 사용하고 Repository에서 Page를 반환하는 것은 괜찮다는 생각이다. 프로젝트에서도 Controller에서만 Pageable 의존성을 끊고, Service layer에서는 Pageable을 사용하도록 하고 있다.


Pageable과 MVC 패턴 고찰 쪽은 그냥 이러한 의견도 있구나~ 하고 넘어가면 괜찮을 듯하다.

정답이 없는 문제니까 팀 컨벤션으로 정하면 될 것 같다.

 

기회가 되면 QueryDSL로 쿼리튜닝도 해보고 싶다~

반응형