시리즈!
[JPA] Spring Data JPA 페이징 (1/3) - 기본 개념과 핵심 객체
[JPA] Spring Data JPA 페이징(2/3) - 실무 패턴과 동적 쿼리
[JPA] Spring Data JPA 페이징 (3/3) - 성능 최적화와 테스트
1. 페이징 성능 최적화
1-1. COUNT 쿼리 최적화
Page를 사용할 때 COUNT 쿼리가 자동으로 실행되는데, 복잡한 조인이 포함되면 성능 문제가 발생한다.
// 문제 상황: 복잡한 조인이 COUNT 쿼리에도 포함됨
@Query("SELECT p FROM Product p " +
"LEFT JOIN FETCH p.category c " +
"LEFT JOIN FETCH p.supplier s " +
"LEFT JOIN FETCH p.images i " +
"WHERE p.isDeleted = :isDeleted")
Page<Product> findAllWithDetails(@Param("isDeleted") String isDeleted, Pageable pageable);
// 실행되는 쿼리
// 1. 데이터 조회
// SELECT p.*, c.*, s.*, i.*
// FROM product p
// LEFT JOIN category c ON p.category_id = c.id
// LEFT JOIN supplier s ON p.supplier_id = s.id
// LEFT JOIN product_image i ON p.id = i.product_id
// WHERE p.is_deleted = 'N'
// LIMIT 20 OFFSET 0;
// 2. COUNT 쿼리 (불필요한 조인 포함)
// SELECT COUNT(p)
// FROM product p
// LEFT JOIN category c ON p.category_id = c.id
// LEFT JOIN supplier s ON p.supplier_id = s.id
// LEFT JOIN product_image i ON p.id = i.product_id
// WHERE p.is_deleted = 'N';
해결 방법은 COUNT 쿼리를 별도로 지정하는 것이다.
// 해결: COUNT 쿼리 분리
@Query(
value = "SELECT p FROM Product p " +
"LEFT JOIN FETCH p.category c " +
"LEFT JOIN FETCH p.supplier s " +
"LEFT JOIN FETCH p.images i " +
"WHERE p.isDeleted = :isDeleted",
countQuery = "SELECT COUNT(p) FROM Product p " +
"WHERE p.isDeleted = :isDeleted" // 조인 제거
)
Page<Product> findAllWithDetails(@Param("isDeleted") String isDeleted, Pageable pageable);
// COUNT 쿼리가 단순해짐
// SELECT COUNT(p.id)
// FROM product p
// WHERE p.is_deleted = 'N';
// 조인이 없어서 훨씬 빠름
성능 차이는 다음과 같다.
// 데이터 10만 건 기준 성능 측정
// 최적화 전: COUNT 쿼리 500ms (복잡한 조인 포함)
// 최적화 후: COUNT 쿼리 50ms (단순 COUNT만)
// 10배 성능 향상
1-2. 커버링 인덱스 활용
필요한 컬럼만 조회하여 인덱스만으로 쿼리를 처리할 수 있도록 최적화한다.
// 비효율적: 모든 컬럼 조회
@Query("SELECT p FROM Product p WHERE p.category.id = :categoryId")
Page<Product> findByCategoryId(@Param("categoryId") Long categoryId, Pageable pageable);
// 실행되는 쿼리
// SELECT p.id, p.name, p.price, p.description, p.stock, p.created_at, ... (모든 컬럼)
// FROM product p
// WHERE p.category_id = ?
// LIMIT 20 OFFSET 0;
// 인덱스 이후 테이블 접근 필요
// 효율적: 필요한 컬럼만 조회 (DTO 프로젝션)
@Query("SELECT new com.server.smzshop.product.dto.ProductSimpleDto(" +
"p.id, p.name, p.price) " +
"FROM Product p " +
"WHERE p.category.id = :categoryId")
Page<ProductSimpleDto> findSimpleByCategoryId(
@Param("categoryId") Long categoryId,
Pageable pageable
);
// 실행되는 쿼리
// SELECT p.id, p.name, p.price
// FROM product p
// WHERE p.category_id = ?
// LIMIT 20 OFFSET 0;
// 인덱스 생성
// CREATE INDEX idx_product_category_covering
// ON product(category_id, id, name, price);
// 이 인덱스만으로 쿼리 완료 (테이블 접근 불필요)
커버링 인덱스를 사용하면 성능이 크게 향상된다.
// 성능 비교 (데이터 10만 건)
// 전체 컬럼 조회: 100ms (인덱스 + 테이블 접근)
// 커버링 인덱스 활용: 20ms (인덱스만 접근)
// 5배 성능 향상
1-3. N+1 문제 해결
페이징 조회 시 연관 엔티티를 함께 조회할 때 N+1 문제가 발생할 수 있다.
// N+1 문제 발생
@Query("SELECT p FROM Product p WHERE p.isDeleted = :isDeleted")
Page<Product> findAll(@Param("isDeleted") String isDeleted, Pageable pageable);
// Service
public Page<ProductResponse> getProducts(Pageable pageable) {
Page<Product> productPage = productRepository.findAll("N", pageable);
return productPage.map(product -> ProductResponse.builder()
.id(product.getId())
.name(product.getName())
.categoryName(product.getCategory().getName()) // N+1 발생!
.build()
);
}
// 실행되는 쿼리
// 1. Product 20건 조회
// SELECT * FROM product WHERE is_deleted = 'N' LIMIT 20;
// 2. 각 Product마다 Category 조회 (20번)
// SELECT * FROM category WHERE id = ?;
// SELECT * FROM category WHERE id = ?;
// ...
// 총 21개 쿼리 실행
해결 방법은 여러 가지가 있다.
// 해결 방법 1: JOIN FETCH 사용
@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);
// 실행되는 쿼리 (1개로 줄어듦)
// SELECT p.*, c.*
// FROM product p
// LEFT JOIN category c ON p.category_id = c.id
// WHERE p.is_deleted = 'N'
// LIMIT 20;
// 해결 방법 2: @EntityGraph 사용
@EntityGraph(attributePaths = {"category"})
@Query("SELECT p FROM Product p WHERE p.isDeleted = :isDeleted")
Page<Product> findAllWithCategory(@Param("isDeleted") String isDeleted, Pageable pageable);
// 해결 방법 3: @BatchSize 사용 (OneToMany 관계에 적합)
@Entity
public class Product {
@ManyToOne(fetch = FetchType.LAZY)
private Category category;
@BatchSize(size = 100)
@OneToMany(mappedBy = "product")
private List<ProductImage> images;
}
// images를 조회할 때 IN 쿼리로 100개씩 묶어서 조회
// SELECT * FROM product_image WHERE product_id IN (?, ?, ..., ?); // 100개씩
1-4. No Offset 페이징
OFFSET 방식의 성능 문제를 근본적으로 해결하는 방법이다.
// OFFSET 방식의 문제
// 1000페이지 조회: OFFSET 20000 (page=1000, size=20)
// SELECT * FROM product ORDER BY id LIMIT 20 OFFSET 20000;
// DB가 20000건을 읽고 건너뛴 후 20건 반환
// 페이지가 뒤로 갈수록 기하급수적으로 느려짐
// No Offset 방식
@Query("SELECT p FROM Product p " +
"WHERE p.id > :lastId " +
"AND p.isDeleted = :isDeleted " +
"ORDER BY p.id ASC")
List<Product> findNextPage(
@Param("lastId") Long lastId,
@Param("isDeleted") String isDeleted,
Pageable pageable
);
// 실행되는 쿼리
// SELECT * FROM product
// WHERE id > 980 AND is_deleted = 'N'
// ORDER BY id ASC
// LIMIT 20;
// OFFSET 없이 WHERE로 필터링
// 인덱스를 효율적으로 사용
No Offset 페이징의 성능 비교는 다음과 같다.
// 성능 비교 (데이터 100만 건)
// OFFSET 방식
// 1페이지: OFFSET 0 → 10ms
// 100페이지: OFFSET 2000 → 50ms
// 1000페이지: OFFSET 20000 → 500ms
// 10000페이지: OFFSET 200000 → 5000ms (급격히 느려짐)
// No Offset 방식
// 1페이지: WHERE id > 0 → 10ms
// 100페이지: WHERE id > 98000 → 10ms
// 1000페이지: WHERE id > 980000 → 10ms
// 10000페이지: WHERE id > 9980000 → 10ms (일정한 성능)
2. 페이징 테스트 작성
2-1. Repository 테스트
Repository 계층은 실제 데이터베이스와 연동하여 테스트한다.
@SpringBootTest
@Transactional // 테스트 후 자동 롤백
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Test
void 페이징_조회_테스트() {
// given: 테스트 데이터 15개 생성
List<Product> testProducts = createTestProducts(15);
testProducts.forEach(productRepository::save);
// when: 첫 페이지 조회 (0페이지, 5개씩)
PageRequest pageable = PageRequest.of(0, 5, Sort.by("id").ascending());
Page<Product> result = productRepository.findAllByIsDeleted("N", pageable);
// then: 페이징 결과 검증
assertThat(result.getTotalElements()).isEqualTo(15); // 전체 데이터 15개
assertThat(result.getContent()).hasSize(5); // 현재 페이지 5개
assertThat(result.getTotalPages()).isEqualTo(3); // 총 3페이지
assertThat(result.getNumber()).isEqualTo(0); // 0페이지
assertThat(result.isFirst()).isTrue(); // 첫 페이지
assertThat(result.isLast()).isFalse(); // 마지막 아님
assertThat(result.hasNext()).isTrue(); // 다음 페이지 있음
}
@Test
void 두번째_페이지_조회_테스트() {
// given
List<Product> testProducts = createTestProducts(15);
testProducts.forEach(productRepository::save);
// when: 1페이지 조회 (두 번째 페이지)
PageRequest pageable = PageRequest.of(1, 5, Sort.by("id").ascending());
Page<Product> result = productRepository.findAllByIsDeleted("N", pageable);
// then
assertThat(result.getNumber()).isEqualTo(1); // 1페이지
assertThat(result.getContent()).hasSize(5); // 5개
assertThat(result.isFirst()).isFalse(); // 첫 페이지 아님
assertThat(result.isLast()).isFalse(); // 마지막도 아님
assertThat(result.hasNext()).isTrue(); // 다음 있음
assertThat(result.hasPrevious()).isTrue(); // 이전 있음
}
@Test
void 마지막_페이지_조회_테스트() {
// given
List<Product> testProducts = createTestProducts(12);
testProducts.forEach(productRepository::save);
// when: 2페이지 조회 (마지막 페이지, 2개만 있음)
PageRequest pageable = PageRequest.of(2, 5);
Page<Product> result = productRepository.findAllByIsDeleted("N", pageable);
// then
assertThat(result.isLast()).isTrue(); // 마지막 페이지
assertThat(result.getNumberOfElements()).isEqualTo(2); // 2개만 있음
assertThat(result.hasNext()).isFalse(); // 다음 없음
}
@Test
void 정렬_조건_테스트() {
// given
List<Product> testProducts = createTestProducts(10);
testProducts.forEach(productRepository::save);
// when: 가격 내림차순 정렬
PageRequest pageable = PageRequest.of(0, 5,
Sort.by("price").descending()
.and(Sort.by("id").ascending())
);
Page<Product> result = productRepository.findAllByIsDeleted("N", pageable);
// then: 가격이 높은 순으로 정렬되었는지 확인
List<Product> content = result.getContent();
for (int i = 0; i < content.size() - 1; i++) {
assertThat(content.get(i).getPrice())
.isGreaterThanOrEqualTo(content.get(i + 1).getPrice());
}
}
private List<Product> createTestProducts(int count) {
List<Product> products = new ArrayList<>();
for (int i = 1; i <= count; i++) {
Product product = Product.builder()
.name("상품" + i)
.price(BigDecimal.valueOf(1000 * i))
.stock(10)
.description("상품 설명" + i)
.isDeleted("N")
.build();
products.add(product);
}
return products;
}
}
2-2. Service 테스트 (Mock)
Service 계층은 Mock을 사용하여 단위 테스트를 작성한다.
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@InjectMocks
private ProductService productService;
@Mock
private ProductRepository productRepository;
@Test
void 페이징_조회_서비스_테스트() {
// given: Mock 데이터 준비
List<Product> mockProducts = createTestProducts(3);
Pageable pageable = PageRequest.of(0, 3);
// PageImpl로 가짜 Page 객체 생성
Page<Product> mockPage = new PageImpl<>(
mockProducts, // 현재 페이지 데이터 (3개)
pageable, // 페이징 정보
10 // 전체 데이터는 10개라고 가정
);
// Repository의 동작 정의
when(productRepository.findAllByIsDeleted("N", pageable))
.thenReturn(mockPage);
// when: Service 메서드 호출
Page<ProductResponse> result = productService.getProducts(pageable);
// then: 결과 검증
assertThat(result.getContent()).hasSize(3); // 3개 조회됨
assertThat(result.getTotalElements()).isEqualTo(10); // 전체 10개
assertThat(result.getTotalPages()).isEqualTo(4); // 10개/3개 = 4페이지
assertThat(result.getNumber()).isEqualTo(0); // 0페이지
// Repository가 올바르게 호출되었는지 검증
verify(productRepository, times(1))
.findAllByIsDeleted("N", pageable);
}
@Test
void 동적_검색_서비스_테스트() {
// given
ProductSearchCondition condition = ProductSearchCondition.builder()
.name("노트북")
.minPrice(BigDecimal.valueOf(500000))
.maxPrice(BigDecimal.valueOf(2000000))
.build();
Pageable pageable = PageRequest.of(0, 10);
List<Product> mockProducts = List.of(
createMockProduct(1L, "맥북", BigDecimal.valueOf(1500000)),
createMockProduct(2L, "삼성 노트북", BigDecimal.valueOf(900000))
);
Page<Product> mockPage = new PageImpl<>(mockProducts, pageable, 2);
// Specification은 equals가 작동하지 않으므로 any() 사용
when(productRepository.findAll(any(Specification.class), eq(pageable)))
.thenReturn(mockPage);
// when
Page<ProductResponse> result = productService.searchProducts(condition, pageable);
// then
assertThat(result.getContent()).hasSize(2);
assertThat(result.getTotalElements()).isEqualTo(2);
// Specification을 사용하여 조회했는지 확인
verify(productRepository).findAll(any(Specification.class), eq(pageable));
}
@Test
void Entity를_DTO로_변환하는지_테스트() {
// given
List<Product> mockProducts = createTestProducts(5);
Pageable pageable = PageRequest.of(0, 5);
Page<Product> mockPage = new PageImpl<>(mockProducts, pageable, 5);
when(productRepository.findAllByIsDeleted("N", pageable))
.thenReturn(mockPage);
// when
Page<ProductResponse> result = productService.getProducts(pageable);
// then: DTO 변환 확인
assertThat(result.getContent()).hasSize(5);
assertThat(result.getContent().get(0)).isInstanceOf(ProductResponse.class);
assertThat(result.getContent().get(0).getId())
.isEqualTo(mockProducts.get(0).getId());
assertThat(result.getContent().get(0).getName())
.isEqualTo(mockProducts.get(0).getName());
}
private Product createMockProduct(Long id, String name, BigDecimal price) {
return Product.builder()
.id(id)
.name(name)
.price(price)
.stock(10)
.isDeleted("N")
.build();
}
private List<Product> createTestProducts(int count) {
List<Product> products = new ArrayList<>();
for (int i = 1; i <= count; i++) {
products.add(createMockProduct((long) i, "상품" + i,
BigDecimal.valueOf(1000 * i)));
}
return products;
}
}
2-3. PageImpl 생성자 이해
Mock 테스트에서 Page 객체를 생성할 때 사용하는 PageImpl의 생성자를 이해해야 한다.
// PageImpl 생성자
public PageImpl(
List<T> content, // 현재 페이지의 실제 데이터
Pageable pageable, // 페이징 정보 (페이지 번호, 크기, 정렬)
long total // 전체 데이터 개수
)
// 사용 예시
List<Product> currentPageData = Arrays.asList(product1, product2, product3);
Pageable pageable = PageRequest.of(1, 3); // 1페이지, 3개씩
long totalElements = 10; // 전체 10개
Page<Product> page = new PageImpl<>(currentPageData, pageable, totalElements);
// 결과
page.getContent(); // [product1, product2, product3]
page.getNumber(); // 1 (두 번째 페이지)
page.getSize(); // 3
page.getTotalElements(); // 10
page.getTotalPages(); // 4 (10개를 3개씩 = 4페이지)
page.isFirst(); // false
page.isLast(); // false
page.hasNext(); // true
page.hasPrevious(); // true
실제 시나리오를 예로 들면 다음과 같다.
// 시나리오: 전체 100개 상품 중 2페이지(5개씩) 조회
List<Product> page2Products = Arrays.asList(
product6, product7, product8, product9, product10 // 6~10번 상품
);
Pageable pageable = PageRequest.of(1, 5); // 1페이지 (두 번째), 5개씩
Page<Product> result = new PageImpl<>(
page2Products, // 현재 페이지 데이터 (5개)
pageable, // 페이징 정보
100 // 전체 100개
);
// 검증
assertThat(result.getNumber()).isEqualTo(1); // 1페이지
assertThat(result.getSize()).isEqualTo(5); // 5개씩
assertThat(result.getContent()).hasSize(5); // 현재 5개
assertThat(result.getTotalElements()).isEqualTo(100); // 전체 100개
assertThat(result.getTotalPages()).isEqualTo(20); // 총 20페이지
assertThat(result.isFirst()).isFalse(); // 첫 페이지 아님
assertThat(result.isLast()).isFalse(); // 마지막 아님
3. 실무 적용 가이드
3-1. 상황별 페이징 전략 선택
실무에서 상황에 따라 적절한 페이징 전략을 선택한다.
// 1. 관리자 페이지 - Page 사용
// 전체 개수, 페이지 번호가 필요한 경우
@GetMapping("/admin/products")
public ResponseEntity<Page<ProductResponse>> getProductsForAdmin(
@PageableDefault(size = 20) Pageable pageable
) {
Page<ProductResponse> products = productService.getProducts(pageable);
return ResponseEntity.ok(products);
}
// 2. 모바일 무한 스크롤 - Slice 사용
// 다음 페이지 존재 여부만 필요한 경우
@GetMapping("/mobile/products")
public ResponseEntity<Slice<ProductResponse>> getProductsForMobile(
@PageableDefault(size = 20) Pageable pageable
) {
Slice<ProductResponse> products = productService.getProductsSlice(pageable);
return ResponseEntity.ok(products);
}
// 3. 대용량 실시간 데이터 - Cursor 사용
// OFFSET 성능 문제 해결이 필요한 경우
@GetMapping("/feed")
public ResponseEntity<CursorResponse<FeedResponse>> getFeed(
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "20") int size
) {
CursorResponse<FeedResponse> feed = feedService.getFeedByCursor(cursor, size);
return ResponseEntity.ok(feed);
}
// 4. 복잡한 검색 - Specifications + Page
// 동적 쿼리가 필요한 경우
@GetMapping("/search")
public ResponseEntity<Page<ProductResponse>> searchProducts(
@ModelAttribute ProductSearchCondition condition,
@PageableDefault(size = 20) Pageable pageable
) {
Page<ProductResponse> products = productService.searchProducts(condition, pageable);
return ResponseEntity.ok(products);
}
3-2. 프로젝트 구조 예시
실무 프로젝트에서 페이징을 적용한 전체 구조는 다음과 같다.
// 프로젝트 구조
com.server.smzshop
├── product
│ ├── domain
│ │ ├── Product.java // Entity
│ │ └── ProductRepository.java // Repository
│ ├── application
│ │ ├── ProductService.java // Service
│ │ ├── ProductSpecifications.java // Specifications
│ │ └── ProductSpecificationBuilder.java // Builder
│ ├── presentation
│ │ ├── ProductController.java // Controller
│ │ ├── dto
│ │ │ ├── ProductResponse.java // Response DTO
│ │ │ ├── ProductSearchCondition.java // Search Condition
│ │ │ ├── SliceResponse.java // Slice Response
│ │ │ └── CursorResponse.java // Cursor Response
│ └── test
│ ├── ProductRepositoryTest.java // Repository Test
│ └── ProductServiceTest.java // Service Test
// Repository
public interface ProductRepository extends JpaRepository<Product, Long>,
JpaSpecificationExecutor<Product> {
Page<Product> findByIsDeleted(String isDeleted, Pageable pageable);
Slice<Product> findSliceByIsDeleted(String isDeleted, Pageable pageable);
List<Product> findTop20ByIdLessThanAndIsDeletedOrderByIdDesc(Long id, String isDeleted);
}
// Service
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository productRepository;
// Page 방식
public Page<ProductResponse> getProducts(Pageable pageable) {
Page<Product> productPage = productRepository.findByIsDeleted("N", pageable);
return productPage.map(this::toResponse);
}
// Slice 방식
public SliceResponse<ProductResponse> getProductsSlice(Pageable pageable) {
Slice<Product> productSlice = productRepository.findSliceByIsDeleted("N", pageable);
return toSliceResponse(productSlice);
}
// Cursor 방식
public CursorResponse<ProductResponse> getProductsByCursor(Long cursor, int size) {
List<Product> products = cursor == null
? productRepository.findTop20ByIsDeletedOrderByIdDesc("N")
: productRepository.findTop20ByIdLessThanAndIsDeletedOrderByIdDesc(cursor, "N");
return toCursorResponse(products, size);
}
// Specifications 방식
public Page<ProductResponse> searchProducts(
ProductSearchCondition condition,
Pageable pageable
) {
Specification<Product> spec = new ProductSpecificationBuilder()
.isNotDeleted()
.nameLike(condition.getName())
.priceBetween(condition.getMinPrice(), condition.getMaxPrice())
.build();
Page<Product> productPage = productRepository.findAll(spec, pageable);
return productPage.map(this::toResponse);
}
}
// Controller
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
@GetMapping
public ResponseEntity<Page<ProductResponse>> getProducts(
@PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC)
Pageable pageable
) {
return ResponseEntity.ok(productService.getProducts(pageable));
}
@GetMapping("/scroll")
public ResponseEntity<SliceResponse<ProductResponse>> getProductsForScroll(
@PageableDefault(size = 20) Pageable pageable
) {
return ResponseEntity.ok(productService.getProductsSlice(pageable));
}
@GetMapping("/cursor")
public ResponseEntity<CursorResponse<ProductResponse>> getProductsByCursor(
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "20") int size
) {
return ResponseEntity.ok(productService.getProductsByCursor(cursor, size));
}
@GetMapping("/search")
public ResponseEntity<Page<ProductResponse>> searchProducts(
@ModelAttribute ProductSearchCondition condition,
@PageableDefault(size = 20) Pageable pageable
) {
return ResponseEntity.ok(productService.searchProducts(condition, pageable));
}
}
이러한 구조로 실무 프로젝트를 구성하면 다양한 페이징 요구사항을 효율적으로 처리할 수 있다.
'공부일기.. > JPA' 카테고리의 다른 글
| [JPA] Spring Data JPA 페이징(2/3) - 실무 패턴과 동적 쿼리 (0) | 2025.12.11 |
|---|---|
| [JPA] Spring Data JPA 페이징 (1/3) - 기본 개념과 핵심 객체 (0) | 2025.12.08 |
| [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 |