쇼핑몰 프로젝트에서 상품 목록 무한 스크롤 기능을 구현했다.
단순히 스크롤만 구현하는 것이 아니라, 여러 카테고리와 신상품순, 낮은 가격순과 같은 정렬 옵션까지 처리해야 했기에 어떻게 하면 효율적으로 만들 수 있을지 고민하였고 그 과정을 정리하려 한다.

📌왜 무한 스크롤을 선택했나?
처음에는 페이징 버튼을 두고 1,2,3 페이지 이동하는 방식으로 구현할까 생각했다.
하지만 상품 목록은 사용자가 여러 상품을 빠르게 접하기 때문에 매번 다음 페이지 버튼을 클릭하는 것보다, 스크롤을 내리는 것만으로 새로운 상품이 자연스럽게 나타나는 방식이 훨씬 더 편한 UX라고 판단했다. 그래서 무한 스크롤을 도입하기로 결정했다.
📌JPA에서 Slice를 사용한 이유
Spring Data JPA는 페이징을 위해 Page와 Slice라는 두 가지 타입을 제공한다.
- Page
- 전체 데이터 개수를 알기 위해 추가로 COUNT 쿼리를 실행한다.
- 전체 페이지 수까지 알 수 있음
- Slice
- 다음 페이지가 있는지만 판단(hasNext)
- 전체 개수를 조회하지 않아서 더 가벼움
무한 스크롤에서는 전체 페이지 수는 중요하지 않고 다음 데이터가 더 있는지 여부만 알면 되기 때문에 Slice가 적합했다.
📌API 설계
내가 만든 API는 아래와 같은 구조다.
@GetMapping("/api/products")
@ResponseBody
public ResponseEntity<Slice<ProductListDto>> getProductsApi(@RequestParam(value="categoryNo", required = false) Long categoryNo,
Pageable pageable) {
Slice<ProductListDto> productSlice = productService.getProductList(categoryNo, pageable);
return ResponseEntity.ok(productSlice);
}
pageable은 Spring Data JPA에서 페이징과 정렬 정보를 담는 객체이다.
프론트엔드에서 ?page=1&size=20&sort=productPrice,asc와 같이 요청을 보내면, Spring이 알아서 Pageable 객체에 페이징과 정렬 정보를 모두 담아준다. 덕분에 다양한 정렬 옵션을 간단하게 구현할 수 있었다.
또한, categoryNo가 주어지면 해당 카테고리뿐만 아니라 모든 하위 카테고리의 상품까지 함께 조회하도록 구현하여, 사용자가 상위 카테고리를 눌러도 관련된 모든 상품을 볼 수 있도록 했다.
📌IntersectionObserver

프론트엔드에서는 IntersectionObserver API를 활용해 스크롤 이벤트를 감지했다. 페이지 맨 아래에 있는 #scroll-trigger라는 빈 div가 화면에 보이면 다음 페이지를 요청하는 방식이다.
let currentPage = initialPageNumber; //현재 불러온 페이지 번호
let isLastPage = initialIsLast; //마지막 페이지인지 여부
let isLoading = false; //중복 요청 방지 플래그
let currentSort = "productNo,desc"; // 정렬 기준
//정렬 옵션 클릭시 실행
const sortOptions = document.querySelectorAll(".sort-option");
sortOptions.forEach(option => {
option.addEventListener("click", (e) => {
sortOptions.forEach(opt => opt.classList.remove("active")); //기존 active 제거 -> 클릭한 버튼에 active 추가
e.currentTarget.classList.add("active");
const newSort = e.currentTarget.dataset.sort; //새로운 정렬 기준(data-sort)을 읽어와서 적용
if (currentSort !== newSort) {
currentSort = newSort;
currentPage = 0;
isLastPage = false;
fetchProducts(0, currentSort, true);
}
});
});
//무한 스크롤
const scrollTrigger = document.getElementById('scroll-trigger'); //scroll-trigger 요소가 뷰포트에 보이면 isIntersecting 다음 페이지 로드
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
fetchProducts(currentPage + 1, currentSort);
}
}, { threshold: 0.1 }); //요소가 10% 이상 보일 때 실행
if (!isLastPage) { //마지막 페이지가 아니면 관찰만
observer.observe(scrollTrigger);
}
- isLoading : API 요청이 진행 중일 때 추가 요청을 막음
- isLastPage : 마지막 페이지에 도달하면 Observer의 동작을 중단
📌트러블슈팅
- 정렬을 바꿨더니 데이터가 섞였다.
- 원인 : 상태 초기화를 넣지 않고 정렬을 하여 로드된 기존 상품 목록아래에 새로운 정렬된 상품들이 추가로 붙었다.
- 해결 : 정렬 버튼 클릭 시 API 요청을 보내기 전에 페이지와 관련된 상태를 모두 초기화하는 로직을 추가했다.
sortOptions.forEach(option => {
option.addEventListener("click", (e) => {
const newSort = e.currentTarget.dataset.sort;
if (currentSort !== newSort) {
currentSort = newSort;
// 핵심: 모든 상태를 처음으로 되돌린다!
currentPage = 0;
isLastPage = false;
// 기존 목록을 비우고(true) 0페이지부터 다시 요청한다.
fetchProducts(0, currentSort, true);
}
});
});
- 카테고리 상품이 많아지니 느려졌다.
- 원인 : N+1 문제로 인해 ProductEntity 전체를 조회하다 보니 그 안에 연관된 다른 엔티티들까지 불필요한 조회를 하였다.
- 해결 : Repository단에서 조회 시 new 키워드를 사용해 원하는 데이터만 뽑아 ProductListDto로 직접 생성하도록 하였다.
@Query("SELECT new com.lcpk.mtype.dto.ProductListDto(p.productNo, p.productName, p.productPrice, img.imgUrl) "
+ "FROM ProductEntity p " + "LEFT JOIN p.images img ON img.isMain = 'Y' "
+ "WHERE p.category.categoryNo IN :categoryNos") // <-- WHERE 절이 IN으로 변경됨
Slice<ProductListDto> findByCategoryNosIn(@Param("categoryNos") List<Long> categoryNos, Pageable pageable);
📌최종 결과 구현한 상품 목록 페이지
- 페이지 진입 시 초기 상품 로드
- 스크롤 내리면 다음 상품들이 추가
- 정렬 옵션을 바꾸면 새 기준으로 목록 갱신


기본 값이 productNo을 기준으로 DESC 순으로 되어있기 때문에 페이지 로딩하면 신상품순으로 먼저 상품들이 로드된다.



📌정리
이번 기능을 구현하면서, API 설계가 프론트엔드의 구현 방식과 사용자 경험에 얼마나 큰 영향을 미치는지 체감할 수 있었다. 특히 Slice 기술 선택 하나가 시스템 전체의 성능에 큰 영향을 준다는 것을 깨달았다.
'project' 카테고리의 다른 글
| 현대이지웰 풀스택 5회차 최종 프로젝트 회고 - MTYPE (0) | 2025.10.21 |
|---|---|
| [Spring boot & JPA] 계층형 카테고리 구현하기 (0) | 2025.10.14 |