이번 글에서는 Hibernate의 @BatchSize에 대해 알아보겠습니다.

이 글은 Hibernate 6, Java 17을 기준으로 작성하였습니다.

@BatchSize란?

@BatchSize는 Hibernate에서 지원하는 JPA 성능 최적화 옵션 중 하나입니다. 이를 이용하면 하나의 프록시에 접근할 때 접근한 프록시 뿐만 아니라 초기화 되지 않은 여러 개의 프록시를 함께 초기화할 수 있습니다. 따라서 지연 로딩을 사용할 때 발생하는 N + 1 문제를 효과적으로 해결할 수 있습니다.

예제 엔티티

먼저 이번 글에서 사용할 엔티티에 대해 간단히 설명하겠습니다. 식당 서비스를 위해 식당, 식당 주인, 식당 메뉴 테이블을 관리합니다. ER 다이어그램은 다음과 같습니다.

Untitled.png

@BatchSize의 동작 과정

@BatchSize의 동작 수준은 두 가지로 구분할 수 있습니다.

  • Class level
  • Collection level

Class level

Entity 클래스에 @BatchSize를 달면 프록시 객체를 초기화할 때 초기화 되지 않은 다른 프록시들도 함께 초기화할 수 있습니다. 이를 그림으로 알아보겠습니다.

Untitled.png

현재 영속성 컨텍스트에 프록시 객체가 3개 있다고 가정해봅시다.

Untitled.png

@BatchSize를 사용하지 않는 상황에서는 각 객체에 접근할 때마다 프록시를 초기화해야하므로 SELECT 쿼리를 N번 실행해야합니다. 그렇기 때문에 N + 1 문제가 발생하는 것이죠.

Untitled.png

하지만 @BatchSize를 이용하면 size만큼 프록시 객체를 한 번에 초기화할 수 있습니다. 이를 코드로 확인해보겠습니다.

@Entity
@BatchSize(size = 10)
public class Owner {
		
		/* ... */	

}

위와 같이 식당 주인 엔티티 클래스에 @BatchSize를 적용한 후에 다음과 같은 테스트 코드를 실행하였습니다.

@Test
@DisplayName("클래스에 @BatchSize를 달면 프록시를 초기화할 때 초기화되지 않은 다른 프록시도 함께 초기화한다.")
void batchSizeOnClass() {
    /* given */
    final Owner proxy1 = entityManager.getReference(Owner.class, 1L);
    final Owner proxy2 = entityManager.getReference(Owner.class, 2L);
    final Owner proxy3 = entityManager.getReference(Owner.class, 3L);

    /* when */
    Hibernate.initialize(proxy1);

    /* then */
		// 쿼리 확인
}

Untitled.png

프록시 객체를 초기화할 때 @BatchSize의 크기만큼 다른 프록시 객체도 초기화합니다.

Collection level

Collection 수준도 Class 수준과 동일하게 동작합니다. 이를 그림으로 알아보겠습니다.

Untitled.png

영속성 컨텍스트 내에 식당 엔티티가 3개있고 식당의 메뉴 List<Menu>가 있습니다. 이때 List는 PersistentCollection으로 존재하여 원소의 지연 로딩을 지원합니다.

Untitled.png

만약 @BatchSize를 사용하지 않는다면 각각의 Collection에 접근할 때마다 SELECT 쿼리를 실행해야합니다.

Untitled.png

이때 Collection에 @BatchSize를 적용하면 영속성 컨텍스트 내에 초기화되지 않은 Collection들을 size만큼 한 번에 초기화합니다. 이를 실제 코드로 확인해보겠습니다.

@Entity
public class Restaurant {

		/* ... */

    @BatchSize(size = 10)
    @OneToMany
    @JoinColumn(name = "restaurant_id")
    private List<Menu> menus = new ArrayList<>();
}

이번에는 엔티티 클래스가 아닌 엔티티의 필드에 @BatchSize를 달아줍니다. 이후 다음과 같은 테스트 코드를 실행하였습니다.

@Test
@DisplayName("컬렉션에 @BatchSize를 달면 프록시를 초기화할 때 초기화되지 않은 다른 프록시도 함께 초기화한다.")
void batchSizeOnCollection() {
    /* given */
    final Restaurant restaurant1 = entityManager.find(Restaurant.class, 1L);
    final Restaurant restaurant2 = entityManager.find(Restaurant.class, 2L);
    final Restaurant restaurant3 = entityManager.find(Restaurant.class, 3L);

    /* when */
    Hibernate.initialize(restaurant1.getMenus());

    /* then */
    // 로그 쿼리 확인
}

Untitled.png

마찬가지로 PersistentCollection을 초기화할 때 다른 PersistentCollection도 조회하여 초기화합니다.

Class와 Collection의 차이

Class와 Collection의 동작 과정이 동일한데 왜 구분할까요? Class에만 붙이면 Class의 Collection도 적용되지 않을까요? 이를 확인하기 위해 다음과 같이 코드를 수정해보겠습니다.

@Entity
public class Restaurant {

		/* ... */

    // @BatchSize(size = 10)
    @OneToMany
    @JoinColumn(name = "restaurant_id")
    private List<Menu> menus = new ArrayList<>();
}

@Entity
@BatchSize(size = 10)
public class Menu {

		/* ... */

}

Collection에 @BatchSize를 붙이는 것이 아닌 Collection의 원소 클래스에 @BatchSize를 달고 테스트 코드를 실행하였습니다.

Untitled.png

예상과 달리 Collection에 @BatchSize가 적용되지 않아 N + 1 문제가 발생하였습니다. 즉, Collection에 @BatchSize를 적용하고 싶다면 Class가 아닌 Collection 수준에 적용해야한다는 것이죠. 왜 그럴까요?

⚠️ 여기서부터 다루는 내용은 제가 추측한 내용이니 재미로 읽어주세요. ⚠️

수많은 자료와 삽질을 거쳤으나 명확한 자료를 찾지 못해 제 나름대로 추측을 해봤습니다.

Untitled.png

Class의 경우 프록시 객체에 접근할 때 객체의 클래스를 런타임에 알 수 있습니다. 즉, Owner 프록시에 접근할 때 Owner의 클래스 정보를 얻을 수 있기 때문에 @BatchSize의 유무를 감지할 수 있습니다.

Untitled.png

그렇다면 Collection은 어떨까요? Collection도 클래스 정보를 가지고 있습니다. 그러나 제네릭 타입은 매우 특별한 성질을 가지고 있는데요. 바로 제네릭 타입은 컴파일 타임에 컴파일러에 의해 제거됩니다.

Untitled.png

다시 말해 Collection<Menu>는 런타임에 Raw 타입으로 변경되어 Collection으로 존재합니다. 따라서 Collection을 초기화할 때 Collection의 원소가 Menu인지, Owner인지 알 수가 없습니다. 따라서 클래스로부터 @BatchSize 정보를 얻을 수 없으니 Collection 필드에 직접 달아줘야한다고 볼 수 있습니다.

⚠️ 다시 한 번 말씀드립니다. 이번 절에서 다룬 내용은 추측입니다!!! 참고만 해주세요!!! ⚠️

@BatchSize의 특징

@BatchSize를 사용하면 데이터베이스에서 데이터를 검색할 때 한 번에 여러 데이터를 가져올 수 있습니다. 이로 인한 장단점은 다음과 같습니다.

장점

  • 지연 로딩에서 발생하는 N + 1 문제를 막을 수 있어 지연 로딩을 더 효과적으로 사용할 수 있다.

단점

  • 엔티티를 호출 할 때 이미 초기화 되었는지, 호출할 때 초기화하는 지 예상하기 어렵다.
  • 배치 사이즈를 “잘” 설정해야한다. 너무 크게 설정하면 불필요한 데이터를 불러와 메모리를 낭비하고, 너무 작게 설정하면 쿼리 수가 많아져 @BatchSize를 쓰는 의미가 퇴색된다.

마무리하며

이번 글에서는 Hibernate에서 지원하는 JPA 성능 최적화 옵션인 @BatchSize에 대해 알아보았습니다. @BatchSize의 적용 범위에 따른 동작 과정를 알아보고 이 둘의 차이가 무엇인지 추론해봤습니다.

정확한 자료가 아닌 추측으로 글을 작성했다는 점이 매우 아쉽지만 추측하는 과정이 재밌었고 나름 합리적이라 생각하여 글에 포함해봤습니다. 혹시나 정확한 정보를 아시는 분이 있으시다면 댓글로 남겨주시면 감사하겠습니다.

이 글에서 사용한 코드는 이 곳에서 확인하실 수 있습니다.

카테고리:

업데이트:

댓글남기기