농담곰담곰이의곰담농

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

블로그의 정보

농담곰담곰이의곰담농

브이담곰

활동하기