N+1문제를 해결하기 위한 방법들 (기본편)

이윤설·2024년 7월 3일

N+1 문제란?

JPA를 사용할 때, 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수(n) 만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상을 말한다.

고작 1개의 쿼리가 더 발생하는게 무슨 문제냐고 생각할 수도 있을 것이다.
하지만 만약 10만명이 넘는 데이터의 특정 정보를 가져와야 한다면 20만 개의 쿼리를 DB에 날리게 되어 매우 큰 오버헤드가 발생할 것이다.

@Entity
@Getter
@Setter
public class Icecream {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	private String name;

	@OneToMany(mappedBy = "icecream", cascade = CascadeType.ALL)
	private List<Review> reviews = new ArrayList<>();
}

@Entity
@Getter
@Setter
public class Review {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	private String content;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "icecream_id")
	private Icecream icecream;
}

위와 같은 2개의 Entity가 있다고 가정해보자.
이를 SQL 쿼리문으로 작성해보면 아래와 같다.

CREATE TABLE icecream (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255)
);

CREATE TABLE review (
    id INT PRIMARY KEY AUTO_INCREMENT,
    content TEXT,
    icecream_id INT,
    FOREIGN KEY (icecream_id) REFERENCES icecream(id)
);

Lazy Loading vs Eager Loading

🍫 Lazy Loading

  • Review 엔티티를 로드할 때, icecream 필드는 즉시 로드되지 않는다.
  • icecream 필드는 프록시 객체로 대체된다.
  • Review 객체의 다른 필드(id, content)에 접근할 때는 데이터베이스 쿼리가 발생하지 않는다.
  • icecream 필드를 실제로 사용할 때(예: review.getIcecream().getName()와 같이 접근할 때) 데이터베이스에서 Icecream 정보를 로드한다.

Lazy Loading은 연관된 객체를 실제로 필요할 때까지 로드하지 않고,
프록시 객체로 대체하여 성능을 최적화한다. 그러나 이로 인해 N+1 문제가 발생할 수 있다.

N+1 문제는 다음과 같은 상황에서 발생한다.
예를 들어, Icecream 엔티티가 있고 각 Icecream에는 여러 개의 Review가 연결되어 있다. Lazy Loading이 설정된 상태에서 Icecream 목록을 가져온 후, 각 Icecream의 Review를 접근하려 할 때 N+1 쿼리 문제가 발생할 수 있다.
Icecream 마다 Review를 가져오기 위해 추가적인 데이터베이스 쿼리가 실행되기 때문이다. 즉, review를 곧바로 접근하지 않고, icecream에 접근한 후에 review에 접근 가능하다.

SQL: select review from icecream where id =1;
JPA:

List<Icecream> icecreams = icecreamRepository.findAll();
for (Icecream icecream : icecreams) {
    // 각 Icecream의 Reviews를 가져오려 할 때 N+1 문제 발생 가능성
    List<Review> reviews = icecream.getReviews();
}

🍫 Eager Loading
Eager Loading은 연관된 객체를 즉시 로드하는 방식이다. Icecream을 조회할 때 연결된 모든 review 객체를 프록시 객체가 아닌 실제 객체로 가져온다. 이 방식은 Lazy Loading의 N+1 문제를 피할 수 있지만, 데이터베이스에 불필요한 부하를 줄 수 있어 실무에서는 사용을 지양하는 경향이 있다.

정리)
1. SQL 쿼리를 사용하면 N+1 문제는 발생하지 않는다. 이는 한 번의 쿼리로 필요한 모든 데이터를 가져올 수 있기 때문이다. 오직 JPA를 사용할 때만 발생한다.
2. Lazy Loading을 사용하면 N+1 문제가 발생할 가능성이 크다.
3. Eager Loading을 사용하면 연관된 객체를 즉시 로드하여 N+1 문제를 해결할 수 있지만, 이로 인해 한 번에 많은 데이터를 가져오기 때문에 데이터베이스 부하가 크게 발생할 수 있다. 이는 실무에서는 성능 저하의 요인이 될 수 있다. 그러므로 대규모 데이터셋에서는 사용을 자제하는 것이 좋다.

코드

@Entity
@Getter
@Setter
public class Icecream {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	private String name;

	@OneToMany(mappedBy = "icecream", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
	private List<Review> reviews = new ArrayList<>();
}
----------------------------------------------------
@Entity
@Getter
@Setter
public class Review {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	private String content;

	@ManyToOne
	@JoinColumn(name = "icecream_id")
	private Icecream icecream;
}

현재 Icecream의 reviews에 지연 로딩이 설정되어있다.
reviews를 접근하는 부분에서 n+1문제가 발생할 것이다.

	@Test
	void testLazyLoading() {
		// When
		Icecream loadedIcecream = icecreamService.getIcecreamById(1L).orElseThrow();

		// Then
		assertThat(loadedIcecream).isNotNull();
		assertThat(Hibernate.isInitialized(loadedIcecream.getReviews())).isFalse();
	}
    ------------------------
    @Test
	void testNPlusOneProblem() {
		// 영속성 컨텍스트 초기화
		entityManager.clear();

		// Hibernate 통계를 초기화
		Statistics statistics = entityManager.unwrap(Session.class).getSessionFactory().getStatistics();
		statistics.clear();

		// When
		Icecream loadedIcecream = icecreamService.getIcecreamByIdWithReviews_NplusOne(1L);

		// Reviews에 접근하여 초기화 트리거
		int reviewCount = loadedIcecream.getReviews().size();

		// 쿼리 개수 확인
		long queryCount = statistics.getPrepareStatementCount();
		System.out.println("쿼리 개수: " + queryCount);

		// Then
		assertThat(loadedIcecream).isNotNull();
		assertThat(reviewCount).isGreaterThan(0); // Ensure reviews are loaded
		assertThat(queryCount).isEqualTo(2); // 1 (Icecream 조회) + 1 (Reviews 조회)
	}
  1. testLazyLoading: Hibernate.isInitialized()는 주어진 프록시나 영속성 컬렉션이 초기화되었는지를 확인한다. 만약 엔티티나 컬렉션이 초기화되었다면 (즉, 실제 데이터가 데이터베이스에서 로드되었다면), 이 메서드는 true를 반환한다.
    만약 초기화되지 않았다면 (즉, 데이터가 아직 프록시 상태에 있고 데이터베이스에서 로드되지 않았다면), 이 메서드는 false를 반환한다.

  2. testNPlusOneProblem():

@Query("SELECT i FROM Icecream i where i.id = :id")
Icecream getIcecreamByIdWithReviews_NplusOne(Long id);

a. 위 코드는 Icecream을 select 했음에도 불구하고, 로그를 보면 Icecream 뿐만 아니라, Review도 select 한다. 이것은 두 객체가 연관관계를 갖고 있기 때문이다.

b. 로그에 총 2개의 SELECT문이 있다면 n+1 문제가 발생한 것이다. 왜냐하면 "1개의 리뷰"를 불러와야 하는데, "icecream 객체도 함께 불러오는 바람에 n+1 문제가 발생한 것"이다.
로그를 확인해보자.

    select
        i1_0.id,
        i1_0.name 
    from
        icecream i1_0 
    where
        i1_0.id=?
Hibernate: 
    select
        i1_0.id,
        i1_0.name 
    from
        icecream i1_0 
    where
        i1_0.id=?
2024-07-05T15:53:33.999+09:00 DEBUG 11176 --- [practice] [    Test worker] org.hibernate.SQL                        : 
    select
        r1_0.icecream_id,
        r1_0.id,
        r1_0.content 
    from
        review r1_0 
    where
        r1_0.icecream_id=?
Hibernate: 
    select
        r1_0.icecream_id,
        r1_0.id,
        r1_0.content 
    from
        review r1_0 
    where
        r1_0.icecream_id=?
쿼리 개수: 2

실제로 SELECT문이 2개가 발생했으므로 n+1 문제가 발생했음을 알 수 있다.

c. 만약 Manufacturer라는 클래스가 Icecream과 연관관계를 가졌다면,

@Query("SELECT i FROM Icecream i where i.id = :id")
Icecream getIcecreamByIdWithReviews_NplusOne(Long id);

Icecream, Manufacturer, Review 총 3개를 select 할 것이고, N+2가 발생한다.

cf. 프록시 객체

프록시 객체(Proxy Object)는 실제 객체의 대리자 역할을 하는 객체다. 주로 객체 지향 프로그래밍에서 사용되며, 특히 지연 로딩(Lazy Loading)과 관련하여 많이 활용된다.

  1. 실제 객체의 대리자: 프록시 객체는 실제 객체를 대신하여 클라이언트에게 서비스를 제공한다. 클라이언트는 프록시 객체를 통해 실제 객체에 접근하며, 프록시 객체는 필요한 경우 실제 객체를 생성하거나 접근한다.

  2. 지연 로딩(Lazy Loading) 구현: 주로 프록시 객체는 지연 로딩을 구현하는 데 사용된다. 예를 들어, JPA에서는 연관 관계에 Lazy Loading을 설정하면 연관된 객체에 대한 접근을 지연시킬 수 있다. 이때 프록시 객체가 생성되어 클라이언트에게 반환되고, 클라이언트가 실제 객체의 메서드를 호출할 때 데이터베이스에서 필요한 정보를 로드한다.

  3. 실제 객체와 동일한 인터페이스: 프록시 객체는 실제 객체와 동일한 인터페이스를 구현하므로 클라이언트는 프록시 객체를 사용하는 동안에는 실제 객체와 같은 방식으로 메서드를 호출할 수 있다.

  4. 성능 최적화: 프록시 객체는 필요한 경우에만 실제 객체를 생성하거나 접근하기 때문에 메모리 사용량을 줄이고, 성능을 최적화하는 데 도움을 준다. 예를 들어, 대규모 데이터베이스 응용 프로그램에서 지연 로딩을 사용하면 초기 데이터 로딩 시간을 줄일 수 있다.

해결방법

  1. Fetch Join 사용
  2. EntityGraph 사용
  3. BatchSize 설정

대표적으로 위 3가지 방법이 많이 사용된다.

Fetch join

간단하게 얘기해서 Eager Loading을 선택적으로 사용한다는 것이다.
좀 더 전문적으로 얘기하면 JPQL 쿼리나 Criteria API를 사용하여 개발자가 직접 명시적으로 로딩 전략을 선택할 수 있는 기법이다.
Fetch join은 일반적으로 INNER JOIN을 사용하며, LEFT JOIN 혹은 RIGHT JOIN도 가능하다. fetch join은 @Query()에 "(LEFT OR RIGHT)JOIN FETCH"라는 구문을 추가하여 작성하면 된다.

@Entity
@Getter
@Setter
public class Icecream {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	private String name;

	@OneToMany(mappedBy = "icecream", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
	private List<Review> reviews = new ArrayList<>();
}
-------------------------------------
@Service
public class IcecreamService {

	@Autowired
	private IcecreamRepository icecreamRepository;

	public List<Icecream> getAllIcecreams() {
		return icecreamRepository.findAll();
	}

	public Optional<Icecream> getIcecreamById(Long id) {
		return icecreamRepository.findById(id);
	}
	
    
	public Icecream getIcecreamByFetchJoin(Long id) {
		return icecreamRepository.findByIdWithReviews(id);
	}
}
-------------------------------------
public interface IcecreamRepository extends JpaRepository<Icecream, Long> {
	
    // fetch join
	@Query("SELECT i FROM Icecream i JOIN FETCH i.reviews WHERE i.id = :id")
	Icecream findByIdWithReviews(@Param("id") Long id);
}    
    

테스트코드로 확인해보자.

@Test
	void testFetchJoin() {
		// 영속성 컨텍스트 초기화
		entityManager.clear();

		// When
		Icecream loadedIcecream = icecreamService.getIcecreamByIdWithReviews(1L);

		// 쿼리 개수 확인
		long queryCount = statistics.getPrepareStatementCount();
		System.out.println("쿼리 개수: " + queryCount);

		// Then
		assertThat(loadedIcecream).isNotNull();
		assertThat(Hibernate.isInitialized(loadedIcecream.getReviews())).isTrue();
		assertThat(queryCount).isEqualTo(1); // Fetch Join은 1개의 쿼리로 모든 데이터를 로드
	}
    select
        i1_0.id,
        i1_0.name,
        r1_0.icecream_id,
        r1_0.id,
        r1_0.content 
    from
        icecream i1_0 
    join
        review r1_0 
            on i1_0.id=r1_0.icecream_id 
    where
        i1_0.id=?
Hibernate: 
    select
        i1_0.id,
        i1_0.name,
        r1_0.icecream_id,
        r1_0.id,
        r1_0.content 
    from
        icecream i1_0 
    join
        review r1_0 
            on i1_0.id=r1_0.icecream_id 
    where
        i1_0.id=?
쿼리 개수: 1

로그를 보면 Fetch Join은 기본적으로 Inner Join을 사용함을 알 수 있다.

🧸 단점과 한계
jpql에서 fetch join을 하게 된다면 하드코딩을 하게 된다는 단점이 있다.
물론 여러가지 단점들(페이지네이션, 컬렉션 관련)이 더 있지만 심화 내용은
2편에서 기술하도록 하겠다.

🧸 정리
1. Fetch Join은 Eager Loading을 선택적으로 사용한다는 것이다. 즉 필요한 데이터만 골라서 Eager Loading하는 것, 사용될 때 확정된 값을 한번에 join에서 select해서 가져오는 행위이다.
2. Fetch join은 Lazy Loading과 함께 사용한다.
3. fetch join은 @Query()에 "(LEFT OR RIGHT) JOIN FETCH"라는 구문을 추가하여 작성하면 된다.

EntityGraph

@Entity
@Getter
@Setter
@NamedEntityGraph(name = "Icecream.reviews", attributeNodes = @NamedAttributeNode("reviews"))
public class Icecream {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	private String name;

	@OneToMany(mappedBy = "icecream", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
	private List<Review> reviews = new ArrayList<>();
}
---------------------------------------------------
public interface IcecreamRepository extends JpaRepository<Icecream, Long> {

	// 2. entityGraph
	@EntityGraph(value = "Icecream.reviews")
	@Query("SELECT i FROM Icecream i where i.id = :id")
	Icecream getIcecreamByIdWithReviews(@Param("id") Long id);

	@EntityGraph(value = "Icecream.reviews")
	@Query("SELECT i FROM Icecream i")
	List<Icecream> getAllIcecreamsWithReviews();
}

Icecream을 불러올 때 reviews를 한번에 불러오기 위한 EntityGraph를 설정해보자.

  • IceCream
  1. @NamedEntityGraph는 "Icecream.reviews"라는 이름의 엔티티 그래프를 정의한다.
  2. @NamedAttributeNode("reviews")는 reviews 컬렉션 속성을 Eager 로딩하도록 지정한다.
  3. reviews 속성은 기본적으로 FetchType.LAZY로 설정되어 있지만, 이 엔티티 그래프를 사용할 때는 Eager 로딩된다.
  • IcecreamRepository
  1. @EntityGraph(value = "Icecream.reviews")를 사용하여 getIcecreamByIdWithReviews와 getAllIcecreamsWithReviews 메소드에서 정의된 엔티티 그래프를 적용한다.
    이 메소드들이 호출될 때, Icecream 엔티티와 연관된 reviews 컬렉션이 Eager 로딩된다.
@Test
	void testEntityGraph() {
		// 영속성 컨텍스트 초기화
		entityManager.clear();

		// When
		Icecream loadedIcecream = icecreamService.getIcecreamByIdWithReviews(1L);

		// 쿼리 개수 확인
		long queryCount = statistics.getPrepareStatementCount();
		System.out.println("쿼리 개수: " + queryCount);

		// Then
		assertThat(loadedIcecream).isNotNull();
		assertThat(Hibernate.isInitialized(loadedIcecream.getReviews())).isTrue();
		assertThat(queryCount).isEqualTo(1); // EntityGraph는 1개의 쿼리로 모든 데이터를 로드
	}
    select
        i1_0.id,
        i1_0.name,
        r1_0.icecream_id,
        r1_0.id,
        r1_0.content 
    from
        icecream i1_0 
    left join
        review r1_0 
            on i1_0.id=r1_0.icecream_id 
    where
        i1_0.id=?
Hibernate: 
    select
        i1_0.id,
        i1_0.name,
        r1_0.icecream_id,
        r1_0.id,
        r1_0.content 
    from
        icecream i1_0 
    left join
        review r1_0 
            on i1_0.id=r1_0.icecream_id 
    where
        i1_0.id=?
쿼리 개수: 1

테스트 코드를 보면
1. EntityGraph는 "Left Join"을 사용
2. 쿼리가 1개 발생
임을 확인할 수 있다.

Fetch Join Vs. @EntityGraph

fetch join에서는 "join"을. @EntityGraph에서는 "left join"을 하였다.
이것은 각각 "Inner Join". "Left Outer Join"이다.
성능적으로는 Inner Join이 더 낫기 때문에 Fetch Join이 더 낫다고 판단하면 안된다.
@EntityGraph는 다양한 기능들을 제공한다.

동적 로딩 전략 적용

Fetch Join으로는 Lazy 또는 Eager 로딩을 변경할 수 없다.
하지만 @EntityGraph를 사용하면 쿼리 실행 시점에 동적으로 로딩 전략을 선택할 수 있다. 이것은 프로그램 실행 중에 코드를 변경하면 변경내용이 실시간으로 적용된다는 의미가 아니다.
같은 엔티티에 대해 상황에 따라 다른 로딩 전략을 적용할 수 있다는 의미다.
단, 엔티티 클래스에 @ManyToOne(fetch = FetchType.LAZY)와 같이 설정하면, 이 관계는 기본적으로 지연 로딩된다. 이 설정은 애플리케이션 전체에서 고정되며, 실행 중에 변경할 수 없다.

public interface IcecreamRepository extends JpaRepository<Icecream, Long> {
    // 기본 조회 (Lazy 로딩)
    Optional<Icecream> findById(Long id);

    // Fetch Join을 사용한 조회
    @Query("SELECT i FROM Icecream i JOIN FETCH i.reviews WHERE i.id = :id")
    Optional<Icecream> findByIdWithReviewsFetchJoin(@Param("id") Long id);

    // EntityGraph를 사용한 조회 (reviews 즉시 로딩)
    @EntityGraph(attributePaths = "reviews")
    @Query("SELECT i FROM Icecream i WHERE i.id = :id")
    Optional<Icecream> findByIdWithReviewsEntityGraph(@Param("id") Long id);

    // EntityGraph를 사용한 조회 (reviews와 manufacturer 즉시 로딩)
    @EntityGraph(attributePaths = {"reviews", "manufacturer"})
    @Query("SELECT i FROM Icecream i WHERE i.id = :id")
    Optional<Icecream> findByIdWithReviewsAndManufacturerEntityGraph(@Param("id") Long id);
}
--------------------------------------------------
@Service
@Transactional(readOnly = true)
public class IcecreamService {

    private final IcecreamRepository icecreamRepository;

    public IcecreamService(IcecreamRepository icecreamRepository) {
        this.icecreamRepository = icecreamRepository;
    }

    public Icecream getIcecream(Long id, boolean includeReviews, boolean includeManufacturer) {
        if (includeReviews && includeManufacturer) {
            return icecreamRepository.findByIdWithReviewsAndManufacturerEntityGraph(id)
                    .orElseThrow(() -> new RuntimeException("Icecream not found"));
        } else if (includeReviews) {
            return icecreamRepository.findByIdWithReviewsEntityGraph(id)
                    .orElseThrow(() -> new RuntimeException("Icecream not found"));
        } else {
            return icecreamRepository.findById(id)
                    .orElseThrow(() -> new RuntimeException("Icecream not found"));
        }
    }

    public Icecream getIcecreamWithReviewsFetchJoin(Long id) {
        return icecreamRepository.findByIdWithReviewsFetchJoin(id)
                .orElseThrow(() -> new RuntimeException("Icecream not found"));
    }
}

EntityGraph를 사용하면 동적으로 if-else문으로 설정할 수 있는데 반해,
Fetch Join은 그렇지 않다.
따라서 일반적으로 조건이 단순하면 Fetch Join을, 여러 관계를 동적으로 로딩해야 하는 경우 EntityGraph를 고려할 수 있다.

다중 컬렉션 fetch 가능

Fetch Join:
기본적으로 하나의 컬렉션만 fetch join할 수 있다.
여러 컬렉션을 fetch join하면 카테시안 곱이 발생하여 데이터 중복과 성능 문제가 생길 수 있다. 하지만 일대일(OneToOne) 또는 다대일(ManyToOne) 관계는 여러 개를 fetch join할 수 있다.

@EntityGraph:
여러 관계(컬렉션 포함)를 동시에 지정할 수 있다.
하지만 내부적으로는 여전히 여러 컬렉션을 동시에 fetch하는 것의 한계가 있다.
JPA 구현체(Hibernate)는 이를 최적화하여 여러 쿼리로 분리하여 실행할 수 있다.

@Entity
public class Sandwich {
    @Id @GeneratedValue
    private Long id;
    
    private String name;
    
    @OneToOne(fetch = FetchType.LAZY)
    private Bread bread;
    
    @ManyToOne(fetch = FetchType.LAZY)
    private Sauce sauce;
    
    @OneToMany(mappedBy = "sandwich")
    private List<Filling> fillings;
    
    @OneToMany(mappedBy = "sandwich")
    private List<Topping> toppings;
}

@Entity
public class Bread {
    @Id @GeneratedValue
    private Long id;
    private String type;
}

@Entity
public class Sauce {
    @Id @GeneratedValue
    private Long id;
    private String name;
}

@Entity
public class Filling {
    @Id @GeneratedValue
    private Long id;
    private String name;
    @ManyToOne
    private Sandwich sandwich;
}

@Entity
public class Topping {
    @Id @GeneratedValue
    private Long id;
    private String name;
    @ManyToOne
    private Sandwich sandwich;
}

public interface SandwichRepository extends JpaRepository<Sandwich, Long> {
    // 1. fetch join - 다중 컬렉션 (OneToOne 또는 ManyToOne 관계)
    @Query("SELECT s FROM Sandwich s JOIN FETCH s.bread JOIN FETCH s.sauce")
    List<Sandwich> findAllWithBreadAndSauce();

    // 2. fetch join - 다중 컬렉션 (OneToMany 관계 -> 예외 발생)
    @Query("SELECT s FROM Sandwich s JOIN FETCH s.fillings JOIN FETCH s.toppings")
    List<Sandwich> findAllWithFillingsAndToppings();

    // 3. EntityGraph - 다중 컬렉션 (OneToOne 또는 ManyToOne 관계)
    @EntityGraph(attributePaths = {"bread", "sauce"})
    List<Sandwich> findAllWithBreadAndSauceGraph();

    // 4. EntityGraph - 다중 컬렉션 (OneToMany 관계 포함)
    @EntityGraph(attributePaths = {"bread", "sauce", "fillings", "toppings"})
    List<Sandwich> findAllWithEverything();
}

BatchSize

Fetch Join:
연관된 엔티티를 한 번의 쿼리로 모두 가져온다.
예를 들어, Review 엔티티 내 데이터가 10개라면 10개를 한 번에 로딩한다.

BatchSize:
BatchSize는 연관된 엔티티를 지연 로딩할 때 작동하는 최적화 기법이다.

단순하게 Icecream을 부모 테이블, Review를 자식 테이블라고 가정했을 때,
처음 데이터를 조회할 때 부모 테이블의 데이터가 10개 조회되면, 연관된 자식을 조회할 때 부모 id 10개를 알고 있으니 그 부모 id 10개를 분할해서 자식을 조회하는 방식이라고 할 수 있다.

연관된 엔티티에 처음 접근할 때, 지정된 BatchSize만큼의 엔티티를 한 번에 로드할 수 있다. 이후 추가적인 엔티티에 접근할 때마다, 아직 로드되지 않은 엔티티들을 BatchSize만큼 로드한다.
실제로 접근하지 않는 엔티티는 로드되지 않는다.

예를 들어, BatchSize가 3이고 Review 엔티티가 10개 있다면:

첫 번째 Review에 접근할 때: 처음 3개의 Review를 로드한다.
4번째 Review에 접근할 때: 다음 3개(4,5,6번)의 Review를 로드한다.
7번째 Review에 접근할 때: 다음 3개(7,8,9번)의 Review를 로드한다.
10번째 Review에 접근할 때: 마지막 1개의 Review를 로드한다.
만약 7번째 이후의 Review에 접근하지 않는다면, 8,9,10번 Review는 로드되지 않는다.

만약 Review가 10개인데 BatchSize를 1로 설정하면 N+1이 발생할 것이다. 따라서 Batchsize를 필요에 알맞게 잘 설정하는 것이 중요하다.

작은 BatchSize (예: 1-3):
데이터가 자주 변경되거나 일부만 사용될 때
메모리 제약이 심한 환경

중간 크기 BatchSize (예: 5-20):
대부분의 경우에 적합
성능과 메모리 사용의 균형

큰 BatchSize (예: 50-100):
대부분의 관련 엔티티가 항상 필요할 때
네트워크 지연이 큰 환경

하지만 실제로는 이렇게 동작하지 않는다.

Batchsize는 쿼리 수를 조절하는 것이 아니다.

원리

Batchsize는 Hibernate에서 관리하는 컬렉션(fetch type이 LAZY인 경우)에 대해 영속성 컨텍스트에서 한 번에 가져올 엔티티의 수를 지정하는 것이기는 하지만, 하지만 여기서 주의할 점은 이 어노테이션이 쿼리를 줄이는 것이 아니라 영속성 컨텍스트의 동작을 조정한다는 점(메모리 관리를 위한 것)이다.
따라서 영속성 컨텍스트에 캐시된 Icecream 엔티티의 리뷰들을 가져올 때에도 한 번의 쿼리로 처리될 수 있다.

@Test
	void testBatchSize() {
		statistics.clear();

		Icecream icecream = icecreamService.getIcecreamByIdWithReviewsBatchSize(1L);

		// 모든 리뷰에 접근하여 지연 로딩 트리거
		List<Review> reviews = icecream.getReviews();
		for (Review review : reviews) {
			System.out.println(review.getContent());
		}

		long queryCount = statistics.getPrepareStatementCount();
		System.out.println("총 쿼리 수: " + queryCount);

		assertThat(reviews.size()).isEqualTo(3);
		// assertThat(queryCount).isEqualTo(4); // Icecream 1개 + Review 3개 -> JPA 최적화로 인해 이렇게 작동하지 않음
		assertThat(queryCount).isEqualTo(2);
	}

따라서 @BatchSize(size = 1)을 사용하더라도, Hibernate가 쿼리를 어떻게 실행할지에 따라 실제 쿼리 수는 달라질 수 있다.
이 테스트에서는 Icecream 엔티티와 그에 딸린 리뷰들을 모두 가져오는 쿼리가 예상과는 다르게 4번이 아닌 2번 실행되었으며, 이는 Hibernate의 내부 최적화에 의한 것이다.

따라서 "적절한 배치 사이즈 설정 전략"이라는 것도 존재하는데, 아래 글을 읽어보면 좋을 것 같다.

https://velog.io/@joonghyun/SpringBoot-JPA-JPA-Batch-Size%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EC%B0%B0

profile
화려한 외면이 아닌 단단한 내면

0개의 댓글