쇼핑몰은 홈에서 상품 리스트를 보여준다. 새 상품별, 인기순 등 조건에 따라 리스트를 보여주려고 생각하니 페이징을 해야겠다는 생각을 하게 됐다.
Spring Data JPA는 데이터베이스에서 데이터를 페이징하는 기능을 지원하기 때문에 이를 이용하기로 함! React와 함께 REST API로 구현 중이기 때문에, 클라이언트에서 URL 파라미터로 번호, 크기를 보내 페이징을 하도록 설계했다.
따라서 컨트롤러에서 URL 파라미터로 페이지 번호와 크기를 받고, 서비스 클래스에서 직접 Pageable(번호, 크기 담은 객체)을 생성해 서비스 메서드에서 URL 파라미터를 메서드로 받도록 구현했다.
- 페이지 번호 : 말 그대로 페이지 번호이다. 0부터 시작
- 크기 : 한 페이지 안에 보여줄 요소의 개수
기존 서비스 & 컨트롤러 클래스 - 최신 상품 순으로 리스트 반환 메서드
// 서비스 클래스
/**
* 최신 상품
* @return
*/
public List<ProductListDto> getNewProducts() {
List<Product> products = productRepositoryV1.findAllByOrderByCreatedAtDesc();
return products.stream()
.map(product -> modelMapper.map(product, ProductListDto.class))
.toList();
}
// 컨트롤러 클래스
/**
* 최신순
* @return
*/
@GetMapping("/products/new")
public ResponseEntity<List<ProductListDto>> NewProductList() {
List<ProductListDto> productList = productServiceV1.getNewProducts();
return new ResponseEntity<>(productList, HttpStatus.OK);
}
이 메서드들을 가공해서 페이징을 구현할 것이다.
페이징
우리에게 필요한 건 페이징 + 정렬이므로
JpaRepository를 상속한 리포지토리에 구현해 주면 된다. 해당 리포지토리는 이미 사용하고 있으므로 정렬 쿼리만 넣어주면 됨.
사실 페이징엔 PagingAndSortingRepository를 사용하긴 하는데, 어차피 이는 JpaRepository의 상위 인터페이스이기 때문에 우리는 그냥 JpaRepository 인터페이스를 사용하면 된다.
@Repository
public interface ProductRepositoryV1 extends JpaRepository<Product, Long> {
Optional<Product> findByProductName(String productName);
Optional<Product> findByProductId(Long productId);
// 추가 - 작성시각을 담은 필드를 내림차순으로, pageable을 파라미터로 받음
Page<Product> findAllByOrderByCreatedAtDesc(Pageable pageable);
}
컨트롤러를 먼저 수정했다. 요청 시 URL 파라미터로 받는다. 파라미터 명은 각각 page
, size
로 설정.
// Controller
/**
* 최신순(페이징 처리)
* @return
*/
@GetMapping("/products/new")
public ResponseEntity<List<ProductListDto>> NewProductList(
@RequestParam(defaultValue = "0") int page, // 현재 페이지
@RequestParam(defaultValue = "10") int size // 크기
) {
Page<ProductListDto> newProductPage = productServiceV1.getNewProductsPaged(page,size);
return new ResponseEntity<>(newProductPage.getContent(), HttpStatus.OK);
}
이제 클라이언트에서 "백엔드url/products/new?page={페이지}&size={사이즈}"
로 요청을 보내면 페이징 된 결과를 반환한다.
그리고 서비스 메서드의 파라미터로 페이지와 사이즈를 보낸다.
// Service
/**
* 생성일자 기준 리스트 페이징
* @param page
* @param size
* @return
*/
public Page<ProductListDto> getNewProductsPaged(int page, int size) {
Pageable pageable = PageRequest.of(page, size); // pageable 객체 생성
// 생성일자 내림차순으로 정렬 후 페이징
Page<Product> productPage = productRepository.findAllByOrderByCreatedAtDesc(pageable);
return productPage.map(ProductListDto::new); // Dto로 매핑해 반환
}
서비스 클래스의 메서드에서는 Product
엔티티를 페이지, 사이즈로 페이징해 새로운 객체를 만든다. 그리고 반환을 위해 이를 내가 설정한 DTO
로 매핑한다.
이때 productPage.map
은 Spring Data JPA에서 제공하는 Page
객체의 메서드이다. 페이지 내의 각 엔티티를 다른 형식으로 매핑해주기 때문에 ModelMapper
를 사용하지 않고도 엔티티-DTO 변환이 가능하다.
할인 목록도 마찬가지로 구현. Product
엔티티에 할인 여부를 위한 IsDiscount
Boolean 필드가 존재한다.
// Repository
@Repository
public interface ProductRepositoryV1 extends JpaRepository<Product, Long> {
Optional<Product> findByProductName(String productName);
Optional<Product> findByProductId(Long productId);
Page<Product> findAllByOrderByCreatedAtDesc(Pageable pageable); // 최신순
Page<Product> findByIsDiscountTrue(Pageable pageable); // 할인목록
// Service
/**
* 할인 상품 목록 페이징
* @param page
* @param size
* @return
*/
public Page<ProductListDto> getDiscountProductsPaged(int page, int size) {
Pageable pageable = PageRequest.of(page, size);
Page<Product> productPage = productRepository.findByIsDiscountTrue(pageable);
return productPage.map(ProductListDto::new);
}
//Controller
/**
* 할인중 목록 (페이징 처리)
* @param page
* @param size
* @return
*/
@GetMapping("/products/discount")
public ResponseEntity<List<ProductListDto>> discountProductList(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
Page<ProductListDto> newProductPage = productServiceV1.getDiscountProductsPaged(page,size);
return new ResponseEntity<>(newProductPage.getContent(), HttpStatus.OK);
}
그러니까 이런 함수들이 최신순, 인기순, 할인 여부, 추천 여부 각각 4개씩 있는 거다. 리팩토링해야 한다.
테스트
Postman으로 우선 URL 테스트.
랜덤으로 값을 넣은 더미 데이터 500개 불러오기. 한 번에 10개, 첫 번째 페이지
리팩토링
최신순, 인기순, 추천 상품, 할인 상품 네 개 다 정렬하고 페이징하려니까 조금씩만 다르고 똑같은 함수가 네 개...
그래서 최대한 반복되는 걸 하나로 빼고 조건만 다르게 넣어봤다.
서비스 클래스의 메서드들
// 페이징 처리, 상품 목록 매핑 메서드
public Page<ProductListDto> getProductsPaged(Pageable pageable, Function<Pageable, Page<Product>> pageFetcher) {
return pageFetcher.apply(pageable).map(ProductListDto::new);
}
// 최신순
public Page<ProductListDto> getNewProductsPaged(Pageable pageable) {
return getProductsPaged(pageable, productRepository::findAllByOrderByCreatedAtDesc);
}
// 인기순
public Page<ProductListDto> getBestProductsPaged(Pageable pageable) {
return getProductsPaged(pageable, productRepository::findAllByOrderByWishListCountDesc);
}
// 할인 상품
public Page<ProductListDto> getDiscountProductsPaged(Pageable pageable) {
return getProductsPaged(pageable, p -> productRepository.findByIsDiscountTrue(pageable));
}
// 추천 상품
public Page<ProductListDto> getRecommendProductsPaged(Pageable pageable) {
return getProductsPaged(pageable, p -> productRepository.findByIsRecommendTrue(pageable));
}
컨트롤러 클래스의 메서드들
/**
* 새 상품 목록 (페이징)
* @param page
* @param size
* @return
*/
@GetMapping("/products/new")
public ResponseEntity<List<ProductListDto>> newProductList(@RequestParam(defaultValue = "0") int page,@RequestParam(defaultValue = "10") int size) {
return getProductListResponse(page, size, productServiceV1::getNewProductsPaged);
}
/**
* 인기순 (페이징)
* @param page
* @param size
* @return
*/
@GetMapping("/products/best")
public ResponseEntity<List<ProductListDto>> bestProductList(
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) {
return getProductListResponse(page, size, productServiceV1::getBestProductsPaged);
}
/**
* 할인 목록 (페이징)
* @param page
* @param size
* @return
*/
@GetMapping("/products/discount")
public ResponseEntity<List<ProductListDto>> discountProductList(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
return getProductListResponse(page, size, productServiceV1::getDiscountProductsPaged);
}
/**
* 추천 상품 (페이징)
* @param page
* @param size
* @return
*/
@GetMapping("/products/recommend")
public ResponseEntity<List<ProductListDto>> recommendProductList(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) {
return getProductListResponse(page, size, productServiceV1::getRecommendProductsPaged);
}
/**
* 페이징된 상품 목록 반환 메서드
* @param page
* @param size
* @param pageFetcher
* @return
*/
private ResponseEntity<List<ProductListDto>> getProductListResponse(int page, int size, Function<Pageable, Page<ProductListDto>> pageFetcher) {
// 페이징된 상품 목록 가져옴
Page<ProductListDto> productPage = pageFetcher.apply(PageRequest.of(page, size));
return new ResponseEntity<>(productPage.getContent(), HttpStatus.OK);
}
그래도 너무 불편해서 그냥 다 합쳐버리기로 함!
컨트롤러에서 조건도 같이 url 파라미터로 받으면 될 것 같았다.
서비스 메서드
/**
* 조건별 상품 리스트 페이징
* @param pageable
* @param condition
* @return
*/
public Page<ProductListDto> getProductsByConditionPaged(Pageable pageable, String condition) {
switch (condition) {
case "new":
return productRepository.findAllByOrderByCreatedAtDesc(pageable).map(ProductListDto::new);
case "best":
return productRepository.findAllByOrderByWishListCountDesc(pageable).map(ProductListDto::new);
case "discount":
return productRepository.findByIsDiscountTrue(pageable).map(ProductListDto::new);
case "recommend":
return productRepository.findByIsRecommendTrue(pageable).map(ProductListDto::new);
default:
throw new IllegalArgumentException("Invalid condition: " + condition);
}
}
컨트롤러 메서드
/**
* 조건별 상품 리스트 페이징
* @param page
* @param size
* @param condition
* @return
*/
@GetMapping("/products")
public ResponseEntity<List<ProductListDto>> productList(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "new") String condition
) {
Page<ProductListDto> productPage = productServiceV1.getProductsByConditionPaged(PageRequest.of(page, size), condition);
return new ResponseEntity<>(productPage.getContent(), HttpStatus.OK);
}
깔끔해서 기분이 너무 좋다.
이제 클라이언트에서 "백엔드url/products/new?page={페이지}&size={사이즈}&condition={조건}"
으로 보내주면 된다!
React 코드
API 테스트랑 백-프론트 연결은 가끔 예상치 못한 결과를 보여주므로... 대충 만들어서 우선 테스트 했다. 프론트는 내 담당이 아니고, 리액트는 잘 모르므로... 진짜 그냥 파라미터만 보내고 결과만 받아오는... 그런 야매 코드..
import React, { useState, useEffect } from 'react';
import axios from 'axios';
const PAGE_SIZE = 5; // 페이지 사이즈 지정
const ProductListByFilter = () => {
const [currentPage, setCurrentPage] = useState(0); // 현재 페이지
const [allProducts, setAllProducts] = useState([]); // 전체 상품
const [condition, setCondition] = useState('new'); // 조건, 초기상태 최신순
// 컴포넌트가 렌더링될 때마다 fetchData 함수를 호출, currentPage, condition이 변경될 때마다
useEffect(() => {
fetchData();
}, [currentPage, condition]);
const fetchData = async () => {
try {
const response = await axios.get(`http://localhost:8080/api/v1/products`, {
params: {
size: PAGE_SIZE,
page: currentPage,
condition: condition
}
});
setAllProducts(response.data);
} catch (error) {
console.error('상품을 불러오는 도중 에러가 발생했습니다:', error);
}
};
// 조건 변경
const handleConditionChange = (newCondition) => {
setCondition(newCondition);
setCurrentPage(0);
};
// 페이지 변경
const handlePageChange = (direction) => {
setCurrentPage(currentPage + direction);
};
return (
<div>
<h2>{condition}상품 목록</h2>
<div>
<div>
<button onClick={() => handleConditionChange('new')}>최신순</button>
<button onClick={() => handleConditionChange('best')}>인기순</button>
<button onClick={() => handleConditionChange('discount')}>할인</button>
<button onClick={() => handleConditionChange('recommend')}>추천</button>
</div>
<button onClick={() => handlePageChange(-1)} disabled={currentPage === 0}>이전 페이지</button>
<button onClick={() => handlePageChange(1)}>다음 페이지</button>
</div>
<div>
{allProducts.length > 0 ? (
allProducts.map(product => (
<div key={product.productId}>
<br />
<p>상품명: {product.productName}</p>
<p>가격: {product.price}</p>
{product.isDiscount && <p>{product.discountRate}% 할인 중</p>}
{product.isRecommend && <p>추천상품</p>}
<p>등록일 : {product.createdAt}</p>
<br />
</div>
))
) : (
<p>할인 상품이 없습니다.</p>
)}
</div>
</div>
);
};
export { ProductListByFilter };
대충 요런 식으로.. 잘 나온다.
다음 버튼 등 여러 요소를 고려했을 때, 전체 상품 개수 또는 전체 페이지 수도 서버에서 함께 보내줘야 하지 않을까 싶다. 이 부분도 어떻게 보내주면 좋을지 생각해 봐야겠다.
GitHub 댓글