야미의 개발

JPA 쿼리 성능 개선하기 - @BatchSize 어노테이션 본문

스프링/웹 개발

JPA 쿼리 성능 개선하기 - @BatchSize 어노테이션

채야미 2025. 8. 31. 15:47

JPA에서 연관관계를 다룰 때 가장 흔히 발생하는 성능 문제 중 하나는 N+1 입니다.

한편 N+1은 lazy 로딩으로 설정했을때 Fetch join을 사용해서 거의 다 해결이 되기때문에, 여태까지 잘 사용해왔습니다.

 

현재 개발하고 있는 서비스를 기준으로 한번 

이 어노테이션이 있을 때와 없을 때의 차이를 비교해 보겠습니다.

 

JOIN FETCH의 한계

1. Cartesian Product 문제

JOIN FETCH를 사용하면 일대다 관계에서 데이터 중복이 발생합니다.

@Query("""
    SELECT bp FROM BeanProductEntity bp
    JOIN FETCH bp.options
""")
fun findAllWithOptions(): List<BeanProductEntity>

실행 결과:

원두1 - 옵션1
원두1 - 옵션2  // 원두1이 중복
원두1 - 옵션3  // 원두1이 중복
원두2 - 옵션4
원두2 - 옵션5  // 원두2가 중복

원두가 3개의 옵션을 가지면 결과 행이 3배로 늘어납니다.

저는 그래서 이런 조회를 할때는 In 절을 사용해서 쿼리를 두 번으로 날릴 수 있도록 했는데요 @BatchSize 어노테이션이 알아서 해주는걸 이제야 발견했습니다.

 

2. 다중 컬렉션 JOIN FETCH 불가

// 이런 쿼리는 불가능합니다
@Query("""
    SELECT bp FROM BeanProductEntity bp
    JOIN FETCH bp.options
    JOIN FETCH bp.reviews  // MultipleBagFetchException 발생
""")

Hibernate는 두 개 이상의 컬렉션을 동시에 JOIN FETCH하는 것을 허용하지 않습니다.

3. 페이징 처리의 어려움

@Query("""
    SELECT bp FROM BeanProductEntity bp
    JOIN FETCH bp.options
""")
fun findAllWithOptions(pageable: Pageable): Page<BeanProductEntity>

이 경우 Hibernate가 메모리에서 페이징을 처리하게 되어 성능 문제가 발생하네요

 

@BatchSize의 이점

1. 카테시안 곱 해결

@Entity
class BeanProductEntity(
    @OneToMany(mappedBy = "beanProduct", fetch = FetchType.LAZY)
    @BatchSize(size = 100)
    val options: List<BeanProductOptionEntity> = emptyList()
)

 

실행되는 쿼리:

-- 1. 원두만 조회 (중복 없음)
SELECT * FROM bean_product WHERE ...

-- 2. 필요할 때 옵션을 배치로 조회
SELECT * FROM bean_product_option 
WHERE bean_product_id IN (1, 2, 3, 4, 5, ...)

결과 데이터에 중복이 없어 메모리 효율이 좋다고 합니다.

2. 다중 컬렉션 처리 가능

@Entity
class BeanProductEntity(
    @OneToMany(mappedBy = "beanProduct", fetch = FetchType.LAZY)
    @BatchSize(size = 100)
    val options: List<BeanProductOptionEntity> = emptyList(),
    
    @OneToMany(mappedBy = "beanProduct", fetch = FetchType.LAZY)
    @BatchSize(size = 100)
    val reviews: List<ReviewEntity> = emptyList()
)

각 컬렉션이 독립적으로 배치 로딩되므로 제약이 없습니다.

3. 페이징과의 호환성

@Query("""
    SELECT bp FROM BeanProductEntity bp
    ORDER BY bp.createdAt DESC
""")
fun findAllBeans(pageable: Pageable): Page<BeanProductEntity>

원두만 먼저 페이징 처리하고, 연관 데이터는 나중에 배치로 로딩하므로 페이징이 정상적으로 작동합니다.

 

실제 성능 비교

JOIN FETCH 사용 시

// 원두 100개, 각각 평균 3개의 옵션을 가질 때
@Query("SELECT bp FROM BeanProductEntity bp JOIN FETCH bp.options")
fun findAllWithJoinFetch(): List<BeanProductEntity>

결과:

  • 쿼리 수: 1개
  • 반환 행 수: 300개 (100 × 3)
  • 메모리 사용량: 높음 (중복 데이터)
  • 페이징: 불가능

@BatchSize 사용 시

@OneToMany(mappedBy = "beanProduct", fetch = FetchType.LAZY)
@BatchSize(size = 100)
val options: List<BeanProductOptionEntity> = emptyList()

결과:

  • 쿼리 수: 2개 (원두 1개 + 옵션 1개)
  • 반환 행 수: 400개 (100 + 300, 중복 없음)
  • 메모리 사용량: 낮음 (중복 제거)
  • 페이징: 지원됨

 

언제 @BatchSize를 선택해야할까?

@BatchSize가 유리한 경우

  1. 일대다 관계가 많은 경우: 카테시안 곱으로 인한 데이터 중복을 피할 수 있음
  2. 페이징이 필요한 경우: 원본 엔티티에 대한 페이징이 정확히 작동
  3. 다중 컬렉션을 다뤄야 하는 경우: JOIN FETCH의 제약을 우회
  4. 메모리 효율성이 중요한 경우: 중복 데이터 없이 필요한 만큼만 로딩

JOIN FETCH가 유리한 경우

  1. 일대일 또는 다대일 관계: 카테시안 곱이 발생하지 않음
  2. 연관 데이터 사용이 확실한 경우: 쿼리 수를 최소화할 수 있음
  3. 소량의 데이터: 중복이 문제가 되지 않는 수준

 

라고 합니다.

 

저는 매번 이 어노테이션을 설정하는게 번거로워서 
전역 설정을 통해서 배치 설정을 하였습니다.

 

application.yaml

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 50

 

적당한 값은 직접 메모리 사용량이나 쿼리수를 확인해보면서 조정하면 될 거 같습니다.

Comments