[JPA] Spring Data JPA 페이징 (1/3) - 기본 개념과 핵심 객체

2025. 12. 8. 20:29·공부일기../JPA

시리즈!

[JPA] Spring Data JPA 페이징 (1/3) - 기본 개념과 핵심 객체

[JPA] Spring Data JPA 페이징(2/3) - 실무 패턴과 동적 쿼리

[JPA] Spring Data JPA 페이징 (3/3) - 성능 최적화와 테스트

 

 

1. 페이징이 필요한 이유

실무에서 데이터베이스의 모든 데이터를 한 번에 조회하는 것은 심각한 성능 문제를 야기한다. 예를 들어 상품 테이블에 10만 건의 데이터가 있을 때, 전체 조회를 시도하면 다음과 같은 문제가 발생한다.

// 안티패턴: 전체 데이터 조회
List<Product> products = productRepository.findAll();
// 문제점:
// 1. 메모리 부족으로 OutOfMemoryError 발생 가능
// 2. DB에서 데이터를 가져오는 시간이 과도하게 길어짐
// 3. 네트워크 트래픽 증가
// 4. 사용자 경험 저하 (응답 지연)

페이징을 적용하면 이러한 문제를 해결할 수 있다.

// 올바른 방식: 페이징 적용
Pageable pageable = PageRequest.of(0, 20);
Page<Product> products = productRepository.findAll(pageable);
// 장점:
// 1. 필요한 데이터만 조회 (20건)
// 2. 빠른 응답 시간
// 3. 메모리 효율적 사용
// 4. DB 부하 감소

실무에서는 데이터가 적어 보이더라도 향후 증가할 것을 고려하여 처음부터 페이징을 적용하는 것이 일반적이다.

 


2. JPA 페이징 핵심 인터페이스

2-1. Pageable 인터페이스

Pageable은 "어떻게 페이징할 것인가"를 정의하는 인터페이스다. 페이지 번호, 크기, 정렬 조건 등을 포함한다.

public interface Pageable {
    int getPageNumber();      // 조회할 페이지 번호 (0부터 시작)
    int getPageSize();        // 한 페이지당 데이터 개수
    long getOffset();         // 데이터베이스에서 건너뛸 개수 (pageNumber * pageSize)
    Sort getSort();           // 정렬 조건
    Pageable next();          // 다음 페이지 Pageable 객체
    Pageable previousOrFirst(); // 이전 페이지 Pageable 객체 (첫 페이지면 현재 페이지)
}

Pageable의 실제 구현체는 PageRequest 클래스다.

// 기본 사용법
Pageable pageable = PageRequest.of(0, 20);
// 0번째 페이지, 20개씩 조회

// 정렬 조건 추가
Pageable pageable = PageRequest.of(0, 20, Sort.by("id").descending());
// 0번째 페이지, 20개씩, ID 내림차순 정렬

// 여러 정렬 조건 조합
Pageable pageable = PageRequest.of(0, 20, 
    Sort.by("createdAt").descending()
        .and(Sort.by("id").ascending())
);
// 생성일 내림차순으로 먼저 정렬하고, 같은 경우 ID 오름차순 정렬

// Direction을 이용한 정렬
Pageable pageable = PageRequest.of(0, 20, 
    Sort.Direction.DESC, "price", "id"
);
// price와 id를 모두 내림차순으로 정렬

2-2. Page 인터페이스

Page는 페이징 조회 결과를 담는 인터페이스다. 조회된 데이터뿐만 아니라 전체 데이터 개수, 전체 페이지 수 등의 메타데이터를 포함한다.

public interface Page<T> extends Slice<T> {
    // 전체 정보 (COUNT 쿼리 실행 필요)
    long getTotalElements();    // 전체 데이터 개수
    int getTotalPages();         // 전체 페이지 수
    
    // 현재 페이지 정보
    List<T> getContent();        // 현재 페이지의 데이터 목록
    int getNumber();             // 현재 페이지 번호 (0부터 시작)
    int getSize();               // 페이지 크기 (요청한 크기)
    int getNumberOfElements();   // 현재 페이지의 실제 데이터 개수
    
    // 페이지 상태 확인
    boolean isFirst();           // 첫 페이지 여부
    boolean isLast();            // 마지막 페이지 여부
    boolean hasNext();           // 다음 페이지 존재 여부
    boolean hasPrevious();       // 이전 페이지 존재 여부
    
    // 데이터 변환
    <U> Page<U> map(Function<T, U> converter);
}

실제 사용 예시를 살펴보자.

@GetMapping("/products")
public ResponseEntity<ProductPageResponse> getProducts(
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "20") int size
) {
    Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending());
    Page<Product> productPage = productRepository.findAllByIsDeleted("N", pageable);
    
    // Page 객체에서 페이징 정보 추출
    ProductPageResponse response = ProductPageResponse.builder()
        .products(productPage.getContent())           // 현재 페이지 데이터
        .currentPage(productPage.getNumber())         // 0
        .totalPages(productPage.getTotalPages())      // 5
        .totalElements(productPage.getTotalElements()) // 95
        .pageSize(productPage.getSize())              // 20
        .isFirst(productPage.isFirst())               // true
        .isLast(productPage.isLast())                 // false
        .hasNext(productPage.hasNext())               // true
        .build();
    
    return ResponseEntity.ok(response);
}

Page 인터페이스의 중요한 특징은 전체 데이터 개수를 알기 위해 COUNT 쿼리를 자동으로 실행한다는 점이다.

-- Page를 사용할 때 실행되는 쿼리 (2개)
-- 1. 데이터 조회 쿼리
SELECT p.* 
FROM product p 
WHERE p.is_deleted = 'N' 
ORDER BY p.id DESC 
LIMIT 20 OFFSET 0;

-- 2. 전체 개수 조회 쿼리 (자동 실행)
SELECT COUNT(p.id) 
FROM product p 
WHERE p.is_deleted = 'N';

2-3. Slice 인터페이스

Slice는 Page의 경량화 버전으로, 다음 페이지 존재 여부만 확인할 수 있다. 전체 데이터 개수를 조회하지 않기 때문에 COUNT 쿼리가 실행되지 않는다.

public interface Slice<T> {
    List<T> getContent();       // 현재 페이지 데이터
    int getNumber();            // 현재 페이지 번호
    int getSize();              // 페이지 크기
    int getNumberOfElements();  // 현재 페이지 실제 데이터 개수
    
    boolean hasNext();          // 다음 페이지 존재 여부
    boolean isFirst();          // 첫 페이지 여부
    boolean isLast();           // 마지막 페이지 여부
    
    // Page와 달리 아래 메서드가 없음
    // long getTotalElements();
    // int getTotalPages();
}

Slice 사용 예시는 다음과 같다.

// Repository에서 Slice 반환
public interface ProductRepository extends JpaRepository<Product, Long> {
    Slice<Product> findSliceByIsDeleted(String isDeleted, Pageable pageable);
}

// Service에서 사용
@Service
@RequiredArgsConstructor
public class ProductService {
    private final ProductRepository productRepository;
    
    public SliceResponse<ProductResponse> getProductsForScroll(int page, int size) {
        Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending());
        Slice<Product> productSlice = productRepository.findSliceByIsDeleted("N", pageable);
        
        return SliceResponse.<ProductResponse>builder()
            .content(productSlice.getContent().stream()
                .map(ProductResponse::from)
                .collect(Collectors.toList()))
            .currentPage(productSlice.getNumber())
            .pageSize(productSlice.getSize())
            .hasNext(productSlice.hasNext())  // 전체 개수 정보는 없음
            .build();
    }
}

Slice를 사용하면 다음과 같은 쿼리가 실행된다.

-- Slice 사용 시 실행되는 쿼리 (1개만)
-- size+1개를 조회하여 다음 페이지 존재 여부 판단
SELECT p.* 
FROM product p 
WHERE p.is_deleted = 'N' 
ORDER BY p.id DESC 
LIMIT 21 OFFSET 0;  -- 20개 요청했지만 21개 조회

-- COUNT 쿼리는 실행되지 않음

Slice는 요청한 크기보다 1개 더 많이 조회한다. 만약 21개가 조회되면 다음 페이지가 있다고 판단하고, 20개만 반환한다. 이를 통해 COUNT 쿼리 없이도 다음 페이지 존재 여부를 알 수 있다.

2-4. Page vs Slice 선택 기준

두 인터페이스의 선택은 사용 사례에 따라 결정한다.

// Page 사용이 적절한 경우
// 1. 관리자 페이지 - 전체 데이터 개수와 페이지 번호가 필요한 경우
@GetMapping("/admin/products")
public ResponseEntity<Page<ProductResponse>> getProductsForAdmin(Pageable pageable) {
    Page<Product> productPage = productRepository.findAll(pageable);
    return ResponseEntity.ok(productPage.map(ProductResponse::from));
}

// 2. 검색 결과 - "총 N개의 결과" 표시가 필요한 경우
@GetMapping("/search")
public ResponseEntity<Page<ProductResponse>> searchProducts(
    @RequestParam String keyword,
    Pageable pageable
) {
    Page<Product> productPage = productRepository.findByNameContaining(keyword, pageable);
    return ResponseEntity.ok(productPage.map(ProductResponse::from));
}

// Slice 사용이 적절한 경우
// 1. 모바일 앱의 무한 스크롤
@GetMapping("/mobile/products")
public ResponseEntity<Slice<ProductResponse>> getProductsForMobile(Pageable pageable) {
    Slice<Product> productSlice = productRepository.findSliceByIsDeleted("N", pageable);
    return ResponseEntity.ok(productSlice.map(ProductResponse::from));
}

// 2. SNS 피드 - 다음 페이지만 알면 되는 경우
@GetMapping("/feed")
public ResponseEntity<Slice<FeedResponse>> getFeed(Pageable pageable) {
    Slice<Feed> feedSlice = feedRepository.findSliceByUserId(userId, pageable);
    return ResponseEntity.ok(feedSlice.map(FeedResponse::from));
}

성능 측면에서 비교하면 다음과 같다.

// 10만 건의 데이터가 있을 때 성능 비교

// Page 사용 (COUNT 쿼리 포함)
Page<Product> page = productRepository.findAll(pageable);
// SELECT * FROM product LIMIT 20 OFFSET 0;          -- 빠름
// SELECT COUNT(*) FROM product;                      -- 데이터 많으면 느림 (인덱스 스캔)
// 총 쿼리 시간: 데이터 조회 시간 + COUNT 시간

// Slice 사용 (COUNT 쿼리 없음)
Slice<Product> slice = productRepository.findSlice(pageable);
// SELECT * FROM product LIMIT 21 OFFSET 0;          -- 빠름
// COUNT 쿼리 없음
// 총 쿼리 시간: 데이터 조회 시간만

 


3. Repository에서 페이징 구현

3-1. Spring Data JPA 메서드 쿼리

Spring Data JPA는 메서드 이름만으로 페이징 쿼리를 생성할 수 있다.

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    // 기본 페이징 - findAll에 Pageable 전달
    // 별도 메서드 정의 불필요 (JpaRepository가 제공)
    // Page<Product> findAll(Pageable pageable);
    
    // 조건이 있는 페이징 - 메서드 이름으로 쿼리 생성
    Page<Product> findByIsDeleted(String isDeleted, Pageable pageable);
    // SELECT p FROM Product p WHERE p.isDeleted = ?1
    // + 페이징 및 정렬 조건 자동 추가
    
    // 여러 조건 조합
    Page<Product> findByIsDeletedAndCategoryId(
        String isDeleted, 
        Long categoryId, 
        Pageable pageable
    );
    // SELECT p FROM Product p 
    // WHERE p.isDeleted = ?1 AND p.categoryId = ?2
    
    // Slice 반환
    Slice<Product> findSliceByIsDeleted(String isDeleted, Pageable pageable);
    
    // List 반환 (페이징 정보는 없고 데이터만)
    List<Product> findTop20ByIsDeletedOrderByIdDesc(String isDeleted);
    // LIMIT 20만 적용, 페이징 메타데이터는 없음
}

실제 사용 예시를 보자.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ProductService {
    private final ProductRepository productRepository;
    
    public Page<ProductResponse> getProducts(int page, int size, String sortBy) {
        // 동적 정렬 조건 생성
        Sort sort = Sort.by(Sort.Direction.DESC, sortBy);
        Pageable pageable = PageRequest.of(page, size, sort);
        
        // Repository 호출
        Page<Product> productPage = productRepository.findByIsDeleted("N", pageable);
        
        // Entity를 DTO로 변환
        return productPage.map(product -> ProductResponse.builder()
            .id(product.getId())
            .name(product.getName())
            .price(product.getPrice())
            .build()
        );
    }
}

3-2. @Query를 이용한 JPQL 페이징

복잡한 조건이나 조인이 필요한 경우 @Query 어노테이션을 사용한다.

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    // 기본 JPQL 페이징
    @Query("SELECT p FROM Product p WHERE p.isDeleted = :isDeleted")
    Page<Product> findAllProducts(@Param("isDeleted") String isDeleted, Pageable pageable);
    
    // 조인이 포함된 페이징
    @Query("SELECT p FROM Product p " +
           "LEFT JOIN FETCH p.category c " +
           "WHERE p.isDeleted = :isDeleted")
    Page<Product> findAllWithCategory(@Param("isDeleted") String isDeleted, Pageable pageable);
    
    // DTO 프로젝션 페이징
    @Query("SELECT new com.server.smzshop.product.dto.ProductSimpleDto(" +
           "p.id, p.name, p.price) " +
           "FROM Product p " +
           "WHERE p.isDeleted = :isDeleted")
    Page<ProductSimpleDto> findAllSimple(
        @Param("isDeleted") String isDeleted, 
        Pageable pageable
    );
}

JPQL에서 페이징을 사용할 때 주의할 점이 있다. JOIN FETCH와 페이징을 함께 사용하면 성능 문제가 발생할 수 있다.

// 안티패턴: OneToMany + JOIN FETCH + 페이징
@Query("SELECT p FROM Product p " +
       "LEFT JOIN FETCH p.images " +  // OneToMany 관계
       "WHERE p.isDeleted = :isDeleted")
Page<Product> findAllWithImages(@Param("isDeleted") String isDeleted, Pageable pageable);

// 문제점:
// 1. 페이징 쿼리가 메모리에서 실행됨 (WARN 로그 발생)
// 2. 모든 데이터를 가져온 후 애플리케이션에서 페이징 처리
// 3. OutOfMemoryError 발생 가능

// 해결 방법 1: Batch Size 설정
@BatchSize(size = 100)
@OneToMany(mappedBy = "product")
private List<ProductImage> images;

// 해결 방법 2: 별도 쿼리로 분리
Page<Product> products = productRepository.findAll(pageable);
List<Long> productIds = products.getContent().stream()
    .map(Product::getId)
    .collect(Collectors.toList());
List<ProductImage> images = imageRepository.findByProductIdIn(productIds);

3-3. COUNT 쿼리 최적화

Page를 사용하면 COUNT 쿼리가 자동으로 실행되는데, 복잡한 조인이 있는 경우 COUNT 쿼리도 조인을 포함하게 되어 느려질 수 있다.

// 안티패턴: 복잡한 조인이 COUNT 쿼리에도 포함됨
@Query("SELECT p FROM Product p " +
       "LEFT JOIN p.category c " +
       "LEFT JOIN p.supplier s " +
       "WHERE p.isDeleted = :isDeleted")
Page<Product> findAllWithDetails(@Param("isDeleted") String isDeleted, Pageable pageable);

// 실행되는 COUNT 쿼리 (불필요한 조인 포함)
// SELECT COUNT(p) FROM Product p 
// LEFT JOIN p.category c 
// LEFT JOIN p.supplier s
// WHERE p.isDeleted = 'N'

// 최적화: COUNT 쿼리 별도 지정
@Query(
    value = "SELECT p FROM Product p " +
            "LEFT JOIN FETCH p.category c " +
            "LEFT JOIN FETCH p.supplier s " +
            "WHERE p.isDeleted = :isDeleted",
    countQuery = "SELECT COUNT(p) FROM Product p " +
                 "WHERE p.isDeleted = :isDeleted"  // 조인 없는 단순한 COUNT
)
Page<Product> findAllWithDetails(@Param("isDeleted") String isDeleted, Pageable pageable);

COUNT 쿼리 최적화는 성능에 큰 영향을 미친다.

// 최적화 전
// COUNT 쿼리 실행 시간: 500ms (복잡한 조인 포함)

// 최적화 후  
// COUNT 쿼리 실행 시간: 50ms (단순 COUNT만)
// 10배 성능 향상

 

'공부일기.. > JPA' 카테고리의 다른 글

[JPA] Spring Data JPA 페이징 (3/3) - 성능 최적화와 테스트  (0) 2025.12.13
[JPA] Spring Data JPA 페이징(2/3) - 실무 패턴과 동적 쿼리  (0) 2025.12.11
[jpa] BaseEntity와 JPA Auditing - 동작 원리 , 중복필드 제거 (예시코드)  (0) 2025.10.18
[JPA] JPA의 필수개념_2 (식별자 전략 (@Id, @GeneratedValue))  (1) 2025.06.26
[JPA] JPA의 필수개념_1 (JPA정의, Projection)  (0) 2025.06.26
'공부일기../JPA' 카테고리의 다른 글
  • [JPA] Spring Data JPA 페이징 (3/3) - 성능 최적화와 테스트
  • [JPA] Spring Data JPA 페이징(2/3) - 실무 패턴과 동적 쿼리
  • [jpa] BaseEntity와 JPA Auditing - 동작 원리 , 중복필드 제거 (예시코드)
  • [JPA] JPA의 필수개념_2 (식별자 전략 (@Id, @GeneratedValue))
s0-0mzzang
s0-0mzzang
공부한것을 기록합니다...
  • s0-0mzzang
    승민이의..개발일기..🐰
    s0-0mzzang
  • 전체
    오늘
    어제
    • 전체~ (108)
      • 마음가짐..! (10)
      • 공부일기.. (76)
        • weekly-log (6)
        • Spring (19)
        • Java (18)
        • DataBase (10)
        • git (2)
        • JPA (6)
        • kafka (1)
        • Backend Architecture (3)
        • Troubleshooting (삽질..ㅋ) (2)
        • Cloud (1)
        • Docker (2)
        • 알고리즘 (1)
        • 리액트 (2)
        • Infra (3)
      • 하루일기.. (22)
        • 그림일기 (8)
        • 생각일기 (14)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 깃허브
  • 공지사항

  • 인기 글

  • 태그

    ERD
    자바
    다짐
    스프링부트
    JPA
    React
    ADC 환경
    리팩토링
    항해플러스
    MySQL
    인프라 기초
    spring
    BufferedReader
    spring boot
    SpringBoot
    Paging
    TDD
    StringTokenizer
    swagger
    항해99
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
s0-0mzzang
[JPA] Spring Data JPA 페이징 (1/3) - 기본 개념과 핵심 객체
상단으로

티스토리툴바