들어가며
웹 애플리케이션에서 무한 스크롤이나 페이징을 통해 데이터를 나눠 불러오는 방식은 서버 성능과 사용자 경험을 모두 고려한 방식입니다. 한 번에 모든 데이터를 불러오면 페이지 로드 속도가 느려지고, 특히 데이터가 1000개 가까이 되는 경우 사용자 경험과 성능에 부정적인 영향을 줄 수 있습니다. 이번 쇼핑몰 프로젝트에서도 메인 페이지에 약 1000개의 상품 데이터를 한 번에 불러오는 것은 비효율적이라 판단하여 페이징 처리를 도입하기로 했습니다.
이번 글에서는 Spring Data의 페이지네이션 기능을 위해 사용하는 클래스&인터페이스에 대해 설명하고, 이를 어떻게 활용하는 지 설명하겠습니다.
클래스 & 인터페이스
먼저 페이지네이션을 위해 사용한 Spring Data의 인터페이스와 클래스입니다.
인터페이스
- Page : 페이지네이션 결과를 담고 있는 페이지 단위 데이터를 나타내는 클래스입니다.
- Pageable : 페이지 요청 정보를 정의하는 인터페이스로, 페이지 번호와 페이지 크기, 정렬 기준 등의 정보를 포함합니다.
클래스
- PageRequest : Pageable의 구현체로 페이지 요청 정보를 담고 있는 클래스입니다. 특정 페이지 번호와 페이지 크기를 설정해 요청할 수 있습니다.
- Sort : 정렬 기준을 정의하는 클래스입니다. 엔티티의 필드명을 기준으로 정렬할 수 있고, 오름차순과 내림차순이 가능합니다.
- Order : Sort 클래스에서 개별 정렬 조건을 나타내기 위한 내부 클래스입니다.
Pageable
Pageable은 페이지네이션을 위해 필요한 페이지 번호, 크기, 정렬 기준 등의 필드를 정의하고 있는 인터페이스입니다. Pageable에서 페이지네이션 정보를 얻기 위한 메서드까지 정의되어 있습니다. Pageable은 PageRequest 클래스를 통해 구현됩니다.
PageRequest
아래의 javadoc에서 PageRequest는 Pageable 인터페이스의 구현체라고 적혀있는 것을 확인 할 수 있습니다. 이처럼 PageRequest는 Pageable의 구현체로서 페이지네이션 실제 페이지 요청 정보를 담고 있는 클래스로 사용됩니다.
PageRequest는 정적 메서드 of를 사용해서 PageRequest의 객체를 생성한 뒤 반환합니다.
아래는 정적 메서드 of의 인자에 대한 설명입니다. 인자는 API 요청 시 Query Parameter로 전달하는 것이 일반적입니다.
- pageNumber : 클라이언트에게 반환할 페이지 번호입니다. 0부터 시작합니다.
- pageSize : 한 번의 요청에 전송할 데이터의 개수입니다. 0보다 커야합니다.
- sort : 데이터의 정렬 기준입니다. 생략하면 Sort.unsorted()가 자동으로 전달됩니다.
PageRequest가 Pageable의 구현체인 이유?
위의 javadoc에는 PageRequest가 Pageable의 구현체라고 설명하고 있습니다. AbstractPageRequest를 상속 받는 PageRequest가 Pageable의 구현체가 될 수 있는 이유는 추상 클래스인 AbstractPageRequest가 Pageable 인터페이스를 구현하고 있기 때문입니다.
Sort
Sort 클래스는 PageRequest의 세번째 인자로서 페이지네이션에서 정렬 기준을 담당합니다. 위의 사진처럼 정렬 방향과 필드를 포함해 Sort 객체를 생성할 수 있습니다. Sort 객체의 흐름을 간략하게 설명하자면 Sort 클래스 내의 static 메서드인 by를 통해 properties나 orders로 정렬 기준을 지정할 수 있습니다. 이때 첫번째 인자로 direction 값을 전달할 수 있는데, 생략한다면 오름차순으로 정렬됩니다.
1. properties와 orders (direction 생략)
properties에는 엔티티의 필드에 정의된 필드 이름을 전달하면 됩니다. orders는 properties에서 더 확장된 옵션을 제공합니다. 대소문자나 Null 값 처리 등에 대한 세부적인 옵션까지 정해서 Sort 객체를 생성할 수 있습니다. 만약 정렬 direction을 지정하지 않으면 DEFAULT_DIRECTION 값이 적용됩니다. DEFAULT_DIRECTION은 오름차순(ASC)입니다.
아래는 Sort 객체를 direction 없이 생성하는 예시입니다.
// properties 예시
Sort sortByProperties = Sort.by("name", "createdAt");
2. properties와 orders (direction 포함)
첫번째 인자로 direction 값을, 두번재 인자로 아까와 같은 properties를 전달하면 됩니다. 이때 direction 값은 ENUM 타입으로 ASC와 DESC가 있습니다.
아래는 Sort 객체를 생성할 때 direction을 포함하는 예시입니다. 참고로 Order 필드는 내부적으로 direction과 properties를 가지고 있습니다.
// properties 예시
Sort sort = Sort.by(Direction.DESC, "name", "createdAt", "age");
String... properties?
...은 가변 인자(Varargs) 문법입니다. 이 문법은 하나의 메서드에서 같은 타입의 인수를 여러개 받을 수 있을 때 사용합니다. 메서드 호출 시 인자를 여러개 전달하면 컴파일 과정에서 자동으로 배열로 전환됩니다.
Order
Order 클래스는 복잡한 정렬에만 요구되기 때문에 Order 클래스에 대해선 간단하게 짚고 넘어가겠습니다.
Order 클래스의 필드를 보면 Sort 클래스의 direction, prpoperties 값을 이미 갖고 있는 것을 볼 수 있습니다. Order 객체는 Sort 객체를 생성할 때 인자로 전달 될 수 있기 때문에 아래와 같이 사용할 수 있습니다.
// orders 예시
Sort sortByOrders = Sort.by(
Order.asc("name").ignoreCase(),
Order.desc("createdAt").nullsLast()
);
Page
Page 객체는 페이징 처리된 데이터를 담는 컨테이너 같은 역할입니다. 상품에 대한 데이터와 페이지에 대한 메타 데이터를 담고 있는 인터페이스입니다.
Page는 인터페이스인데 어떻게 Page를 반환할까?
자바 표준 라이브러리에서 Page는 인터페이스로 정의되어 있습니다. 그러면 클라이언트에게 어떻게 Page 객체를 반환하는 걸까요? Page 객체는 JPA에서 기본적으로 구현된 PageImpl가 있기 때문에 자동으로 이 구현체를 생성한 뒤 반환합니다. 이 PageImpl 클래스는 개발자가 직접 정의할 필요는 없습니다.
Page의 메타 데이터
{
"content": [
{
/* 상품 DTO */
}
],
"pageable": {
"pageNumber": 0,
"pageSize": 12,
"sort": {
"empty": false,
"unsorted": false,
"sorted": true
},
"offset": 0,
"unpaged": false,
"paged": true
},
"last": false,
"totalElements": 480,
"totalPages": 40,
"size": 12,
"number": 0,
"sort": {
"empty": false,
"unsorted": false,
"sorted": true
},
"first": true,
"numberOfElements": 1,
"empty": false
}
content
상품에 대한 DTO가 담겨있는 영역입니다. 만약 데이터만 응답에 포함하고 싶다면 getContent 메서드를 사용하면 됩니다.
pageable
클라이언트가 요청한 페이지에 대한 정보를 담고 있습니다. 현재 페이지 위치, 크기, 정렬 상태 등의 정보를 담고 있습니다.
last 이후
page 객체에 대한 메타 데이터가 들어있습니다. 이 메타 데이터는 클라이언트에게 중요한 데이터입니다. 간략하게 설명하면 last는 현재 페이지가 마지막인지 알려주고, totalElements는 전체 데이터 개수, totalPages는 전체 페이지 개수를 나타냅니다.
페이지네이션 코드 예시
1. ProductController
products로 GET 요청을 하면 페이지네이션 된 상품 데이터를 받을 수 있습니다.
@GetMapping("/products")
public ResponseEntity<Page<ProductListResponse>> getProductsList(
@RequestParam(required = false) String type,
@RequestParam(defaultValue = "0") Integer page,
@RequestParam(defaultValue = "12") Integer size,
@RequestParam(defaultValue = "createAt_desc") String sort,
) {
Page<ProductListResponse> productsListResponse =
productService.getProductsList(type, page, size, sort);
return ResponseEntity.ok(productsListResponse);
}
쿼리 파라미터에 대해 설명하겠습니다.
- type
상품의 타입입니다. required 값이 false이므로 생략 가능합니다. - page
PageRequest에 전달할 요청 page입니다. 페이지는 0부터 시작합니다. defaultValue를 0으로 뒀기 때문에 클라이언트 측에서 따로 전달하지 않는 경우 첫 페이지를 반환합니다. - size
PageRequest에 전달할 페이지당 데이터 개수입니다. 마찬가지로 defaultValue를 12로 뒀기 때문에 클라이언트에서 따로 전달하지 않는 경우 12개씩 전달합니다. - sort
정렬 기준입니다. 언더바(_)를 기준으로 왼쪽은 엔티티의 필드입니다. Sort 객체를 생성할 때 properties로 전달됩니다. 그리고 오른쪽은 direction입니다. sort 값을 생략한다면 상품 추가 최신순으로 조회할 수 있도록 구현했습니다.
2. ProductService
쿼리 파라미터에 따라 Page 객체를 만드는 서비스 코드입니다. 가독성을 위해 예외 처리는 전부 생략했습니다.
public Page<ProductListResponse> getProductsList(
String productType,
Integer categoryId,
Integer page,
Integer size,
String sort,
) {
Sort sortOrder = Sort.by("sort").descending(); // 기본 정렬
if (sort != null && !sort.isEmpty()) {
String[] sortParams = sort.split("_");
String sortBy = sortParams[0];
String direction = sortParams.length > 1 ? sortParams[1] : "desc";
sortOrder = direction.equalsIgnoreCase("desc")
? Sort.by(sortBy).descending()
: Sort.by(sortBy).ascending();
}
Pageable pageable = PageRequest.of(page, size, sortOrder);
Page<Product> productsPage;
if (productType == null) {
productsPage = productRepository.findAll(pageable);
} else {
productsPage = productRepository.findByPetKind(petKind, pageable);
}
return productsPage.map(ProductListResponse::new);
}
쿼리 파라미터 값의 유무에 따라 page 객체를 만드는 서비스 로직입니다.
3. ProductRepository
// import 문 생략
public interface ProductRepository extends JpaRepository<Product, Integer> {
Page<Product> findAll(Pageable pageable);
Page<Product> findByPetKind(PetKind petKind, Pageable pageable);
}
펫 종류에 따라 다른 JPA 쿼리 메서드를 호출해 Product 객체를 반환합니다.
.
.
.
마치며
JPA의 페이지네이션 클래스를 활용해 클라이언트 측에 응답을 구현하는 것은 단순하고 직관적이었습니다. 하지만 이를 단순히 사용하는 데 그치지 않고, 각각의 클래스와 인터페이스를 하나씩 살펴보며 그 역할과 클래스 간의 관계를 이해하는 과정은 훨씬 어려웠습니다.
이번 포스팅 주제를 정하면서 큰 도전이 될 것 같아서 고민을 많이 했는데 오랜 시간이 걸린만큼 확실하게 배웠습니다. 제 블로그의 핵심 가치인 쉬운 주제를 깊이 있게 다뤄보기에 어느정도 일치한 것 같아서 뿌듯합니다!
처음 JPA의 페이지네이션을 접한 분들도 이 포스팅 하나로 충분히 이해가 됐으면 좋겠습니다. 만약 궁금한 점이나 피드백 할 점이 있다면 적극 환영합니다!
'Spring > Spring Data' 카테고리의 다른 글
[Spring Data] JPA의 EnableJpaAuditing으로 Entity 필드 관리 (0) | 2024.11.03 |
---|