JPQL 프로젝션으로 N+1 문제와 Over-fetching 해결하기

2025. 8. 2. 17:57·Jungle

카테고리별 상품 조회 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)

영속성 컨텍스트가 하는 일:

  1. 엔티티 저장: 조회한 엔티티를 메모리에 보관
  2. 스냅샷 생성: 엔티티의 원본 상태를 별도로 저장
  3. 변경 감지: 엔티티와 스냅샷을 비교해서 변경사항 추적
  4. 자동 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 프로젝션을 적용할 수 있다! 🚀

728x90

'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
'Jungle' 카테고리의 다른 글
  • Spring Properties 파일 리팩터링
  • 서버 리소스 최적화 가이드: JVM, Tomcat, DB 커넥션 풀 설정
  • 커버링 인덱스로 11초 쿼리를 100ms로 단축시키기: 실제 장애 해결 과정
  • Redis 캐싱 도입기: 예상과 다른 결과와 문제 해결 과정 (151 → 146 TPS)
ahpicl64
ahpicl64
in the clouds
  • ahpicl64
    구름
    ahpicl64
  • 전체
    오늘
    어제
    • 분류 전체보기 (95)
      • WIL (4)
      • Jungle (36)
      • AWS (2)
      • SQL (2)
      • CS:APP (17)
      • Algorithm (10)
      • K8s (7)
      • 자료 구조 (10)
      • Spring (4)
      • React (0)
      • 운영체제 (1)
      • 기타등등 (2)
      • 이야기 (0)
  • 블로그 메뉴

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

    • github
  • 공지사항

  • 인기 글

  • 태그

    컴퓨터시스템
    Spring
    IAM
    CloudFront
    CSAPP
    어셈블리
    부하테스트
    DevOps
    트러블슈팅
    AWS
    알고리즘
    DB
    k8s
    자료구조
    Spring boot
    S3
    python
    github actions
    queue
    EC2
  • 02-21 08:19
  • hELLO· Designed By정상우.v4.10.3
ahpicl64
JPQL 프로젝션으로 N+1 문제와 Over-fetching 해결하기
상단으로

티스토리툴바