시리즈!
[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 |