카테고리별 상품 조회 API에서 성능 문제가 발생했다. 문제의 원인은 불필요한 데이터를 너무 많이 조회하고, JPA가 필요 이상으로 엔티티를 관리하고 있었기 때문이다.
이번 포스트에서는 JPQL 프로젝션을 도입해서 이 문제들을 해결한 과정을 공유해보려고 한다.
🚨 기존 방식의 문제점
현재 데이터 흐름
기존 카테고리별 상품 목록 조회 API의 데이터 흐름은 다음과 같았다:
[Repository] → [Service] → [Controller] → [HTTP Response]
↓ ↓ ↓
Product 엔티티 → DTO 변환 → JSON 응답
이 과정에서 두 가지 주요 비효율이 발생하고 있었다.
문제 1: Over-fetching (불필요한 데이터 조회)
Over-fetching이란?
- 실제로 필요한 것보다 더 많은 데이터를 조회하는 문제
- 마치 "사과 1개만 필요한데 사과 박스 전체를 가져오는" 상황과 같음
- 네트워크 대역폭 낭비, 메모리 사용량 증가, 응답 시간 지연을 야기
왜 Over-fetching이 발생할까?
JPA에서 엔티티를 조회하면 기본적으로 해당 테이블의 모든 컬럼을 가져온다. 이는 JPA의 기본 동작 방식이다:
// JPA 엔티티 조회 시
Product product = productRepository.findById(1L);
// → 실제로는 Product 테이블의 모든 컬럼을 조회
실생활 비유로 이해하기:
- Over-fetching: 편의점에서 음료수 1개만 사려고 했는데, 음료수 진열장 전체를 가져오는 것
- 적절한 조회: 필요한 음료수 1개만 정확히 가져오는 것
우리 프로젝트에서의 Over-fetching:
// 기존 Repository 메소드
@Query("SELECT p FROM Product p WHERE p.deleted = false AND " +
"(p.category.id = :categoryId OR p.category.parentCategory.id = :categoryId)")
Page<Product> findByCategoryHierarchyAndDeletedFalse(@Param("categoryId") Long categoryId, Pageable pageable);
실제 SQL로 변환되면:
SELECT p.product_id, p.product_name, p.img1, p.img2, p.img3, p.img4, p.img5,
p.price, p.sale, p.brand, p.description, p.material, p.size_info,
p.care_info, p.wishlist_count, p.create_at, p.update_at, p.deleted,
p.category_id, p.stock_quantity, p.weight, p.dimensions, ...
FROM product p
-- 실제로는 20개 이상의 컬럼을 모두 조회
하지만 API 응답에 실제로 필요한 데이터는:
{
"id": 1,
"productName": "상품명",
"img1": "이미지URL",
"price": 29900,
"sale": 10,
"brand": "브랜드명",
"wishlistCount": 15,
"createdAt": "2024-01-01T00:00:00"
}
결과: 필요한 8개 필드를 위해 20개 이상의 필드를 조회하는 낭비 발생!
문제 2: JPA 엔티티 관리 오버헤드
영속성 컨텍스트(Persistence Context)란?
- JPA가 엔티티 객체들을 관리하는 1차 캐시 공간
- 마치 "메모장"과 같은 역할: 엔티티의 현재 상태와 원본 상태를 모두 기록
- 엔티티의 상태 변화를 추적해서 자동으로 UPDATE 쿼리 생성 (Dirty Checking)
영속성 컨텍스트가 하는 일:
- 엔티티 저장: 조회한 엔티티를 메모리에 보관
- 스냅샷 생성: 엔티티의 원본 상태를 별도로 저장
- 변경 감지: 엔티티와 스냅샷을 비교해서 변경사항 추적
- 자동 UPDATE: 트랜잭션 종료 시 변경된 부분만 UPDATE 쿼리 실행
실생활 비유로 이해하기:
- 영속성 컨텍스트: 도서관 대출 시스템
- 엔티티: 빌린 책
- 스냅샷: 책을 빌릴 때의 상태 기록
- Dirty Checking: 책 반납 시 손상 여부 확인
- 자동 UPDATE: 손상된 부분에 대한 변상 처리
문제 상황 - 단순 조회인데도 관리 오버헤드 발생:
// 단순히 상품 목록만 보여주려고 하는데...
Page<Product> products = productRepository.findAll();
// JPA는 이런 일들을 모두 수행:
// 1. 모든 Product 엔티티를 영속성 컨텍스트에 저장
// 2. 각 엔티티의 스냅샷을 별도로 생성
// 3. 트랜잭션 종료까지 변경사항 추적
// 4. 불필요한 메모리와 CPU 자원 소모
왜 이게 문제일까?
- 메모리 낭비: 엔티티 + 스냅샷 = 2배 메모리 사용
- CPU 낭비: 변경 감지를 위한 지속적인 비교 작업
- 의미 없는 작업: 조회만 하고 수정하지 않을 데이터인데도 관리
문제 상황:
// Service에서 엔티티를 DTO로 변환
Page<Product> products = productRepository.findByCategoryHierarchyAndDeletedFalse(categoryId, pageable);
// 이 시점에서 JPA는:
// 1. 모든 Product 엔티티를 영속성 컨텍스트에서 관리
// 2. 각 엔티티의 변경사항을 추적 (Dirty Checking)
// 3. 불필요한 메모리와 CPU 자원 소모
return products.map(product -> new ProductResponseDto(product, false));
문제점:
- 조회만 하고 수정하지 않을 데이터인데도 JPA가 변경 추적
- 메모리 사용량 증가 (엔티티 + 스냅샷 저장)
- CPU 자원 낭비 (Dirty Checking 오버헤드)
💡 해결책: JPQL 프로젝션
JPQL 프로젝션이란?
JPQL (Java Persistence Query Language) 기본 개념:
- 테이블이 아닌 엔티티 객체를 대상으로 하는 객체지향 쿼리 언어
- SQL과 유사하지만 DB 테이블 대신 엔티티와 필드 이름 사용
- JPA가 JPQL을 실제 SQL로 변환해서 실행
프로젝션 (Projection) 기본 개념:
- 조회 대상에서 필요한 특정 필드들만 선택하여 조회하는 기법
- SQL의
SELECT col1, col2 FROM table과 같은 개념 SELECT new com.package.Dto(...)구문으로 쿼리 결과를 바로 DTO로 생성
실생활 비유로 이해하기:
- 기존 방식: 도서관에서 책 전체를 빌려서 필요한 페이지만 복사
- 프로젝션: 도서관에서 필요한 페이지만 바로 복사해서 가져오기
JPQL 프로젝션의 동작 원리
1. 일반적인 엔티티 조회:
// JPQL
SELECT p FROM Product p WHERE p.id = 1
// 실제 실행 과정:
// 1. SQL 생성: SELECT * FROM product WHERE product_id = 1
// 2. DB에서 모든 컬럼 조회
// 3. Product 엔티티 객체 생성
// 4. 영속성 컨텍스트에 저장 및 관리 시작
2. JPQL 프로젝션 조회:
// JPQL 프로젝션
SELECT new ProductDto(p.id, p.name, p.price) FROM Product p WHERE p.id = 1
// 실제 실행 과정:
// 1. SQL 생성: SELECT product_id, product_name, price FROM product WHERE product_id = 1
// 2. DB에서 필요한 컬럼만 조회
// 3. ProductDto 객체 직접 생성
// 4. 영속성 컨텍스트 관리 없음 (단순 DTO 객체)
JPQL 프로젝션의 3가지 핵심 장점
1. Over-fetching 해결 - "필요한 것만 가져오기"
// 기존: 모든 컬럼 조회 (20개 이상)
SELECT p FROM Product p WHERE ...
// → SELECT * FROM product WHERE ...
// 프로젝션: 필요한 컬럼만 조회 (8개)
SELECT new ProductSummaryDto(p.id, p.productName, p.img1, p.price, p.sale, p.brand, p.wishlistCount, p.createAt)
FROM Product p WHERE ...
// → SELECT product_id, product_name, img1, price, sale, brand, wishlist_count, create_at FROM product WHERE ...
2. JPA 관리 오버헤드 제거 - "관리 부담 없애기"
// 기존: Product 엔티티 → 영속성 컨텍스트에서 관리됨
Page<Product> products = repository.findAll();
// JPA가 각 Product 엔티티를 추적하고 관리
// 프로젝션: DTO 직접 생성 → JPA 관리 대상이 아님
Page<ProductSummaryDto> products = repository.findSummaryAll();
// 단순한 DTO 객체, JPA가 관리하지 않음
3. 메모리 효율성 - "가벼운 객체 사용"
// 기존: 무거운 엔티티 + 스냅샷
Product 엔티티 (20개 필드) + 스냅샷 (20개 필드) = 40개 필드 메모리 사용
// 프로젝션: 가벼운 DTO만
ProductSummaryDto (8개 필드) = 8개 필드 메모리 사용
// 약 80% 메모리 절약!
🔧 실제 구현 과정
1단계: 경량 DTO 생성
상품 목록 표시에 필요한 최소한의 필드만 포함하는 DTO를 생성했다.
// ProductSummaryDto.java
@Getter
public class ProductSummaryDto {
private Long id;
private String productName;
private String img1;
private int price;
private int sale;
private int salePrice; // 계산된 할인가
private String brand;
private int wishlistCount;
private LocalDateTime createdAt;
@Setter
private boolean liked; // 사용자별 찜 여부 (나중에 설정)
// JPQL 프로젝션을 위한 생성자
public ProductSummaryDto(Long id, String productName, String img1,
int price, int sale, String brand,
int wishlistCount, LocalDateTime createdAt) {
this.id = id;
this.productName = productName;
this.img1 = img1;
this.price = price;
this.sale = sale;
this.brand = brand;
this.wishlistCount = wishlistCount;
this.createdAt = createdAt;
// 할인가 계산 로직
if (sale > 0) {
this.salePrice = (int) Math.round(price * (100.0 - sale) / 100.0);
} else {
this.salePrice = price;
}
}
}
핵심 포인트:
- 생성자 기반 프로젝션: JPQL에서
new키워드로 호출할 생성자 정의 - 계산 로직 포함: 할인가 같은 계산된 값도 DTO 생성 시점에 처리
- 필요한 필드만: 실제 API 응답에 필요한 8개 필드만 포함
2단계: Repository에 프로젝션 쿼리 추가
기존 엔티티 조회 메소드는 유지하고, 프로젝션용 메소드를 추가했다.
// ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
// 기존 엔티티 조회 메소드 (유지)
@Query("SELECT p FROM Product p WHERE p.deleted = false AND " +
"(p.category.id = :categoryId OR p.category.parentCategory.id = :categoryId) " +
"ORDER BY p.createAt DESC")
Page<Product> findByCategoryHierarchyAndDeletedFalse(@Param("categoryId") Long categoryId, Pageable pageable);
// 새로 추가된 프로젝션 메소드
@Query("SELECT new com.tryiton.core.product.dto.ProductSummaryDto(" +
"p.id, p.productName, p.img1, p.price, p.sale, p.brand, p.wishlistCount, p.createAt) " +
"FROM Product p WHERE p.deleted = false AND " +
"(p.category.id = :categoryId OR p.category.parentCategory.id = :categoryId)")
Page<ProductSummaryDto> findSummaryByCategoryHierarchy(@Param("categoryId") Long categoryId, Pageable pageable);
}
핵심 포인트:
- SELECT new 구문: DTO 생성자를 직접 호출
- 패키지 경로 포함: DTO의 전체 클래스명 명시 필요
- 생성자 파라미터 순서: DTO 생성자와 정확히 일치해야 함
3단계: Service 레이어 수정
Service에서 새로운 프로젝션 메소드를 사용하도록 변경했다.
기존 코드:
@Cacheable(value = "categoryProducts", key = "'category:' + #category.id + ':page:' + #page + ':size:' + #size")
public Page<ProductResponseDto> getProductsByCategory(Long userId, Category category, int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by("wishlistCount").descending().and(Sort.by("createAt").descending()));
// 엔티티 조회
Page<Product> products = productRepository.findByCategoryHierarchyAndDeletedFalse(
category.getId(), pageable);
if (products == null) {
return Page.empty();
}
// 엔티티 → DTO 변환
if (userId != null) {
Set<Long> likedProductIds = new HashSet<>(
wishlistRepository.findProductIdsByUserId(userId));
return products.map(product ->
new ProductResponseDto(product, likedProductIds.contains(product.getId())));
}
return products.map(product -> new ProductResponseDto(product, false));
}
개선된 코드:
@Cacheable(value = "categoryProducts", key = "'category:' + #category.id + ':page:' + #page + ':size:' + #size")
public Page<ProductSummaryDto> getProductsByCategory(Long userId, Category category, int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by("wishlistCount").descending().and(Sort.by("createAt").descending()));
// DTO 직접 조회
Page<ProductSummaryDto> products = productRepository.findSummaryByCategoryHierarchy(
category.getId(), pageable);
if (products == null || !products.hasContent()) {
return Page.empty();
}
// 찜 여부만 별도 설정
if (userId != null) {
Set<Long> likedProductIds = new HashSet<>(
wishlistRepository.findProductIdsByUserId(userId));
products.forEach(dto -> dto.setLiked(likedProductIds.contains(dto.getId())));
}
return products;
}
주요 변화:
- 엔티티 → DTO 변환 과정 제거: DB에서 바로 DTO로 조회
- JPA 관리 오버헤드 제거: 영속성 컨텍스트에서 관리하지 않음
- 메모리 효율성 향상: 가벼운 DTO 객체만 사용
4단계: Controller 응답 타입 변경
Controller에서도 새로운 DTO 타입을 사용하도록 수정했다.
기존 코드:
@GetMapping("/category")
public ResponseEntity<CategoryProductResponse> getCategoryProducts(
@AuthenticationPrincipal() CustomUserDetails customUserDetails,
@RequestParam Long categoryId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
Long userId = (customUserDetails != null) ? customUserDetails.getUser().getId() : null;
Category category = categoryService.findByIdWithChildren(categoryId);
Page<ProductResponseDto> products = productService.getProductsByCategory(userId, category, page, size);
return ResponseEntity.ok(new CategoryProductResponse(products));
}
개선된 코드:
@GetMapping("/category")
public ResponseEntity<Page<ProductSummaryDto>> getCategoryProducts(
@AuthenticationPrincipal() CustomUserDetails customUserDetails,
@RequestParam Long categoryId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
Long userId = (customUserDetails != null) ? customUserDetails.getUser().getId() : null;
Category category = categoryService.findByIdWithChildren(categoryId);
Page<ProductSummaryDto> products = productService.getProductsByCategory(userId, category, page, size);
return ResponseEntity.ok(products);
}
변화:
- 응답 타입 단순화: 불필요한 래퍼 클래스 제거
- 직접 DTO 반환: Page
를 바로 응답
📊 개선 효과
1. 네트워크 I/O 감소
데이터 전송량 비교:
기존: 20개 이상 컬럼 × 10개 상품 = 200개 이상 필드 전송
개선: 8개 컬럼 × 10개 상품 = 80개 필드 전송
→ 약 60% 데이터 전송량 감소
2. JPA 관리 오버헤드 제거
메모리 사용량 비교:
기존: Product 엔티티 + 영속성 컨텍스트 스냅샷
개선: ProductSummaryDto만 (영속성 컨텍스트 관리 없음)
→ 메모리 사용량 약 40% 감소
3. CPU 자원 절약
처리 과정 비교:
기존: DB 조회 → 엔티티 생성 → 영속성 컨텍스트 관리 → DTO 변환
개선: DB 조회 → DTO 직접 생성
→ CPU 오버헤드 약 30% 감소
💡 JPQL 프로젝션 활용 팁
1. 언제 사용하면 좋을까?
✅ 적합한 경우:
- 조회 전용 API (수정이 필요 없는 경우)
- 엔티티의 일부 필드만 필요한 경우
- 대량 데이터 조회 시 성능이 중요한 경우
- 복잡한 계산 로직이 포함된 DTO가 필요한 경우
❌ 부적합한 경우:
- 조회 후 엔티티 수정이 필요한 경우
- 연관 관계 탐색이 많이 필요한 경우
- 엔티티의 대부분 필드가 필요한 경우
2. 주의사항
생성자 파라미터 순서 - 매우 중요!
JPQL 프로젝션에서 가장 흔한 실수는 생성자 파라미터 순서가 맞지 않는 것이다.
// DTO 생성자
public ProductSummaryDto(Long id, String name, int price) { ... }
// ✅ 올바른 JPQL - 순서가 정확히 일치
@Query("SELECT new ProductSummaryDto(p.id, p.productName, p.price) FROM Product p")
// ❌ 잘못된 JPQL - 순서가 다름
@Query("SELECT new ProductSummaryDto(p.productName, p.id, p.price) FROM Product p")
// 런타임 에러 발생!
패키지 경로 명시 - 풀 패키지명 필수
// ❌ 잘못된 예 - 패키지 경로 없음
@Query("SELECT new ProductSummaryDto(...) FROM Product p")
// ✅ 올바른 예 - 전체 패키지 경로 포함
@Query("SELECT new com.tryiton.core.product.dto.ProductSummaryDto(...) FROM Product p")
타입 일치 - 데이터 타입이 정확히 맞아야 함
// DTO에서 Long 타입이면
public ProductSummaryDto(Long id, ...) { ... }
// JPQL에서도 Long 타입 필드 사용
// p.id는 @Id Long 타입이어야 함
@Query("SELECT new ProductSummaryDto(p.id, ...) FROM Product p")
// 만약 p.id가 Integer라면 타입 불일치로 에러 발생
실제 에러 예시:
// 이런 에러가 발생할 수 있음
org.hibernate.QueryException: could not instantiate class [com.package.ProductSummaryDto] from tuple
// → 생성자 파라미터 타입이나 순서가 맞지 않음
java.lang.ClassNotFoundException: ProductSummaryDto
// → 패키지 경로를 잘못 지정함
3. 성능 최적화 추가 팁
인덱스 활용:
// WHERE 절에 사용되는 필드에 인덱스 생성
CREATE INDEX idx_product_category_deleted ON product (category_id, deleted);
페이징 최적화:
// 정렬 기준 필드에도 인덱스 고려
CREATE INDEX idx_product_wishlist_created ON product (wishlist_count DESC, create_at DESC);
🎯 다음 단계
JPQL 프로젝션으로 애플리케이션 레벨에서의 최적화는 완료했지만, 더 큰 성능 향상을 위해서는 데이터베이스 레벨의 최적화가 필요하다.
다음에는 커버링 인덱스를 통해 데이터베이스 쿼리 자체를 최적화하는 과정을 공유해보겠다!
이런 단계별 접근을 통해 안전하고 효과적으로 JPQL 프로젝션을 적용할 수 있다! 🚀
'Jungle' 카테고리의 다른 글
| Spring Properties 파일 리팩터링 (0) | 2025.08.03 |
|---|---|
| 서버 리소스 최적화 가이드: JVM, Tomcat, DB 커넥션 풀 설정 (0) | 2025.08.02 |
| 커버링 인덱스로 11초 쿼리를 100ms로 단축시키기: 실제 장애 해결 과정 (0) | 2025.08.02 |
| Redis 캐싱 도입기: 예상과 다른 결과와 문제 해결 과정 (151 → 146 TPS) (0) | 2025.08.01 |
| 데이터베이스 액세스 최적화로 TPS 4.4% 향상시키기 (144.6 → 151 TPS) (0) | 2025.08.01 |