REST API에서 페이징 처리가 필수인 이유와 실무 구현 방법
by 브이담곰🤔 페이징을 알게 된 계기
오늘 REST API에 대해 배우면서 상품 목록을 조회하는 API를 만들어보았다. 그런데 문득 이런 생각이 들었다.
"만약 쇼핑몰에 상품이 10만 개, 100만 개가 있다면 어떻게 될까?"
모든 상품 정보를 한 번에 클라이언트에 보내준다면... 🤯
- 데이터베이스에서 100만 개 데이터를 모두 조회
- 서버 메모리에 100만 개 객체 로딩
- 네트워크로 거대한 JSON 전송
- 브라우저가 100만 개 데이터 처리
분명히 서버도 클라이언트도 감당할 수 없을 것 같았다. 그래서 페이징 응답에 대해 더 자세히 알아보게 되었다.
💥 페이징이 없다면 생기는 문제들
1. 서버 사이드 문제
// ❌ 이렇게 하면 큰일난다!
@GetMapping("/api/products")
public List<ProductDTO> getAllProducts() {
return productService.findAll(); // 100만 개 상품을 모두 조회!
}
발생하는 문제들:
- 메모리 부족: 100만 개 객체가 힙 메모리를 점유
- 응답 시간 지연: 데이터 조회 + 직렬화 + 네트워크 전송 시간 급증
- 데이터베이스 부하: 한 번에 모든 데이터를 읽어야 함
- 서버 비용 증가: 불필요한 리소스 소모
2. 클라이언트 사이드 문제
- 브라우저 렌더링 지연: 100만 개 DOM 요소 생성?
- 메모리 사용량 폭증: 모바일에서는 더욱 심각
- 사용자 경험 저하: 페이지가 멈춘 것처럼 보임
- 불필요한 데이터: 사용자는 처음 20개 정도만 봄
3. 네트워크 문제
- 대역폭 낭비: 수 MB~GB 급 응답 크기
- 모바일 데이터 소모: 사용자 요금 폭탄
- 응답 시간 증가: 네트워크 전송 시간 기하급수적 증가
✅ 페이징의 해결책
Before: 100만 개 한 번에
Client ← [상품1, 상품2, ..., 상품1000000] ← Server
💀 100MB 응답, 30초 대기
After: 20개씩 나누어서
Client ← [상품1~20] + 페이징정보 ← Server
✨ 50KB 응답, 0.1초 완료
핵심 아이디어: "사용자는 보통 처음 1~2페이지만 본다!"
🏗️ 실무에서 사용하는 페이징 구조
1. 기본적인 페이징 파라미터
GET /api/products?page=0&size=20&sort=createdAt,desc
파라미터 설명 기본값
page | 페이지 번호 (0부터 시작) | 0 |
size | 페이지 크기 | 20 |
sort | 정렬 기준 | createdAt,desc |
2. Spring Data JPA로 구현하기
Repository Layer
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
// JpaRepository가 기본 페이징 기능 제공
Page<Product> findAll(Pageable pageable);
// 조건부 페이징도 쉽게 구현
Page<Product> findByCategory(String category, Pageable pageable);
Page<Product> findByNameContaining(String keyword, Pageable pageable);
}
Service Layer
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
public PageResponse<ProductDTO> getProducts(int page, int size) {
// Pageable 객체 생성
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.DESC, "createdAt"));
// 페이징 조회
Page<Product> productPage = productRepository.findAll(pageable);
// DTO 변환
List<ProductDTO> productDTOs = productPage.getContent()
.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
// 페이징 응답 생성
return PageResponse.<ProductDTO>builder()
.content(productDTOs)
.totalElements(productPage.getTotalElements())
.totalPages(productPage.getTotalPages())
.currentPage(productPage.getNumber())
.size(productPage.getSize())
.hasNext(productPage.hasNext())
.hasPrevious(productPage.hasPrevious())
.build();
}
}
Controller Layer
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping
public ResponseEntity<ApiResponse<PageResponse<ProductDTO>>> getProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
PageResponse<ProductDTO> pageResponse = productService.getProducts(page, size);
return ResponseEntity.ok(ApiResponse.<PageResponse<ProductDTO>>builder()
.success(true)
.message("상품 목록 조회 성공")
.data(pageResponse)
.build());
}
}
3. 커스텀 PageResponse 클래스
@Getter
@Builder
public class PageResponse<T> {
// 실제 데이터
private List<T> content;
// 페이징 정보
private long totalElements; // 전체 데이터 개수
private int totalPages; // 전체 페이지 수
private int currentPage; // 현재 페이지 (0부터 시작)
private int size; // 페이지 크기
// 편의 정보
private boolean hasNext; // 다음 페이지 존재 여부
private boolean hasPrevious; // 이전 페이지 존재 여부
private boolean first; // 첫 번째 페이지 여부
private boolean last; // 마지막 페이지 여부
// 실제 표시되는 데이터 개수
private int numberOfElements;
}
📱 프론트엔드가 받는 JSON 응답
성공적인 페이징 응답
{
"success": true,
"message": "상품 목록 조회 성공",
"data": {
"content": [
{
"id": 1,
"name": "맥북 프로 16인치",
"price": 3500000,
"category": "노트북",
"imageUrl": "/images/macbook-pro-16.jpg",
"createdAt": "2024-06-11T10:30:00"
},
{
"id": 2,
"name": "아이폰 15 Pro",
"price": 1500000,
"category": "스마트폰",
"imageUrl": "/images/iphone-15-pro.jpg",
"createdAt": "2024-06-11T09:15:00"
}
],
"totalElements": 1547,
"totalPages": 78,
"currentPage": 0,
"size": 20,
"hasNext": true,
"hasPrevious": false,
"first": true,
"last": false,
"numberOfElements": 20
}
}
프론트엔드에서 활용하기
async function loadProducts(page = 0) {
try {
const response = await fetch(`/api/products?page=${page}&size=20`);
const result = await response.json();
if (result.success) {
const { content, totalElements, currentPage, totalPages, hasNext, hasPrevious } = result.data;
// 상품 목록 렌더링
renderProducts(content);
// 페이지 정보 표시
document.getElementById('page-info').textContent =
`${currentPage + 1} / ${totalPages} 페이지 (총 ${totalElements.toLocaleString()}개 상품)`;
// 페이징 버튼 상태 관리
document.getElementById('prev-btn').disabled = !hasPrevious;
document.getElementById('next-btn').disabled = !hasNext;
} else {
console.error('상품 로딩 실패:', result.message);
}
} catch (error) {
console.error('네트워크 에러:', error);
}
}
// 이전/다음 페이지 이동
let currentPage = 0;
document.getElementById('prev-btn').onclick = () => {
if (currentPage > 0) {
currentPage--;
loadProducts(currentPage);
}
};
document.getElementById('next-btn').onclick = () => {
currentPage++;
loadProducts(currentPage);
};
🔍 고급 페이징: 검색과 필터링
실무에서는 단순 페이징보다는 검색 + 필터링 + 페이징이 함께 사용된다.
검색 요청 DTO
@Getter
@Setter
public class ProductSearchRequest {
// 페이징 파라미터
private int page = 0;
private int size = 20;
// 정렬 파라미터
private String sortBy = "createdAt";
private String sortDirection = "DESC";
// 검색 및 필터 파라미터
private String keyword; // 상품명 검색
private String category; // 카테고리 필터
private Integer minPrice; // 최소 가격
private Integer maxPrice; // 최대 가격
private Boolean inStock; // 재고 있는 상품만
}
복합 조건 쿼리
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query("SELECT p FROM Product p WHERE " +
"(:keyword IS NULL OR LOWER(p.name) LIKE LOWER(CONCAT('%', :keyword, '%'))) AND " +
"(:category IS NULL OR p.category = :category) AND " +
"(:minPrice IS NULL OR p.price >= :minPrice) AND " +
"(:maxPrice IS NULL OR p.price <= :maxPrice) AND " +
"(:inStock IS NULL OR (:inStock = true AND p.stockQuantity
'Spring' 카테고리의 다른 글
Thymeleaf 문법 (5) | 2025.06.11 |
---|---|
@PathVariable vs @RequestParam 완벽 가이드 (0) | 2025.06.11 |
[Back-End] Bash에서 AWS EC2 접속 오류 해결 (0) | 2024.05.10 |
[Back-End] 우분투에 mongodb 안깔리는 오류 해결 (0) | 2024.05.10 |
[Front-End] (# 1) 웹 사이트 만들기 기본 틀 (0) | 2024.05.10 |
블로그의 정보
농담곰담곰이의곰담농
브이담곰