본문 바로가기
IT 공부/자바와 웹 애플리케이션

[JPA] - N+1 문제 및 @BatchSize의 의미와 Hibernate의 캐싱 전략

by exdus3156 2024. 1. 25.

1. N+1 문제

JPA에서 엔티티가 서로 @OneToMany, @OneToOne, @ManyToOne 등으로 연결 관계를 형성한 경우, DB에서 엔티티를 가져올 때 연결된 객체를 어떻게 가져올지 전략을 잘 세워야 한다. 

세부적인 내용은 아래 포스팅에 작성했으니 참고.

 

[JPA] -연관관계가 있을 때 영속성 처리법과 cascade

JPA 기술은 너무나 편리하지만, 한편으로 신경써야 할 것도 꽤 많다. 특히 테이블 사이의 연관관계가 형성될 때, JpaRepository에서 영속 처리를 하면 연관 관계에 있는 다른 엔티티들은 도대체 어떻

linocraft.tistory.com

일반적으로 연결된 객체를 한 번에 조인으로 가져오지 않고, 필요한 순간에만 다시 DB에 요청을 해서 가져오는 형태를 사용한다. 이것을 FetchType.LAZY라고 부른다.

이때 @OneToMany의 경우, 이렇게 따로 쿼리를 날려서 가져오는 방식에는 성능 상의 문제가 하나 있다. N+1 이라고 불리는 문제다.

One에 해당하는 엔티티, 예를 들어 Board(게시판)이라고 하자. Board를 가져올 때, getReplies()를 통해 @OneToMany로 연결된 Reply(댓글)을 가져올 수 있다. 이때 다시 DB를 통해 쿼리가 실행된다. 문제는 Reply를 Board를 기준으로 하나씩 들고 온다는 것이다.

즉, board.getReplies(); 메소드를 실행하면 해당 board에 맞는 댓글들을 가져오는 것이다.

이것이 왜 문제가 될까?

DB를 지나치게 많이 사용해서 문제다.

board가 총 100개 이고, 각 board마다 getReplies()를 통해 댓글 엔티티도 함께 가져오려면 각 board마다 한 번씩 실행된다. 즉, board들을 가져오는 쿼리(1개) 그리고 각 board당 reply들을 가져오는 쿼리(100개)가 수행되는 것이다. 이래서 N+1 이라는 용어로 묘사된다.

 

 

2. @BatchSize(size = n)

이 문제를 해결하는 것이 바로 @BatchSize()다.

아래와 같은 코드로 사용한다.

@Entity
public class Board {
    //...
    
    @BatchSize(size = 20)
    @OneToMany(fetch = FetchType.LAZY)
    private Set<Reply> replies;

}

헷갈릴 수 있는데, size 값의 의미는 Reply(댓글)이 아니라 Board의 개수 기준을 말한다. 

size는 N+1에서 N을 해결하는 문제다. size가 20이면, board.getReplies(); 메소드가 실행될 때, board 20개에 대한 각각의 Reply(댓글)들을 한 번에 들고 오겠다는 것이다. (SQL에서는 IN 조건문으로 가능하다)

따라서 100개의 board가 있다면 replies를 요청하는 순간, 20개 board에 대한 Reply들을 한 번에 땡겨오는 것이다. 모든 각각의 board의 reply들을 사용하는 로직이 있다면 (이론적으로는...) 5+1 개의 쿼리를 날릴 것이다.

이렇게 한 번에 많은 수의 board의 reply들을 가져와 메모리에 저장한다. 이때 메모리에 미리 땡겨온 reply들을 캐싱하기 때문에 무작정 size 값을 키워서는 안 된다. 게다가 size가 크면 결국 한 번에 엄청난 양의 데이터를 가져와 먼저 처리해야하므로 빠르게 로직을 수행하기도 힘들어진다.

 

 

3. 사실은 쿼리가 여러 번 나뉜다

100개의 board를 가져오면 100+1 개의 쿼리를 날리게 되는데, 이를 해결하기 위해 @BatchSize(size=50)을 하면 어떻게 될까? 과연 100/50 + 1 을 수행해서 총 3개의 쿼리를 날리는 것일까?

막상 실험해보면 그렇게 동작하질 않는다.

예를 들어 한 번에 50개의 board의 reply들을 가져오질 않고 25개 + 25개 이런 식으로 나누는 것이다. 정확한 최적화 숫자는 약간씩 다르지만 정확한 숫자가 중요한게 아니라, 나누어진다는 현상이 중요한 이슈다.

그 이유는 Hibernate와 같은 JPA 기술은 보다 더 효율적으로 동작하기 위해 IN 쿼리에 대한 PreparedStatement를 미리 캐싱하는 전략을 짜기 때문이다.

Reply 엔티티에 대한 PreparedStatement는 내부적으로 SQL문을 다음과 같은 코드로 수행할 것이다.

SELECT * FROM reply r WHERE r.board_bno IN (.....)

그런데 PreparedStatement도 하나의 자원이다. 생성하는 것이 부담되는 자원이다. 그래서 Hibernate는 미리 특정 엔티티에 대한 IN 쿼리를 사용하는 PreparedStatement를 미리 만들어 캐싱한다. 

이때 IN에 들어가는 숫자가 중요해지는데, 1개, 2개, 3개, ... 이런 식으로 모든 size에 대한 PreparedStatement를 준비하는 것은 무리일 것이다. 따라서 1개~10개 사이즈에 대한 PreparedStatement를 미리 만들고, 이 이상의 size에 대해서는 12, 25, 50, 100 이런 식으로 올라갈 수 있다.

자, 만약 개발자가 @BatchSize(size = 35)이라고 설정하면 어떻게 될까? 미리 준비된 PreparedStatement 자원을 보자. 25개의 IN을 처리하는 PreparedStatement, 10개의 IN을 처리하는 PreparedStatement 두 개를 사용하면 될 것이다.

@BatchSize(size = 68)이라면 50개의 IN 처리 PreparedStatement, 12개의 IN 처리 PreparedStatement, 그리고 6개의 IN 처리 PreparedStatement 가 사용되어 총 3번의 쿼리로 나누어 처리된다.

 

 

# 출처

 

 

[SpringBoot / JPA] JPA Batch Size에 대한 고찰

JPA의 N+1 문제를 해결할 수 있는 전략으로 Batch Size를 설정하여 쿼리 수를 압도적으로 줄일 수 있습니다.@BatchSize( size = n )기술 블로그를 서칭하면서 상황에 맞게 Batch Size를 고려해야 한다라는 글

velog.io

 

 

default_batch_fetch_size 관련질문 - 인프런

안녕하세요 선생님 최근 default_batch_fetch_size 관련 질문과 비슷한 상황이지만 조금 다릅니다.현재 A 테이블과 B테이블이 one to many 로 연관관계가 있고 현재 A 테이블 기준으로 쿼리를 날린다음(테

www.inflearn.com