JPA Batch size에 대한 의문점으로 시작된 N+1

전홍영·2025년 1월 13일

JPA

목록 보기
6/6

문제상황

게시글 목록 조회시에 페이징을 통해 객체를 가져오고 있다. 게시글 조회하는 쿼리를 보다가 쓸 데없이 In 절에 많은 값이 바인딩되는 것을 발견했다.

select l1_0.post_id,l1_0.likes_num,l1_0.created_time,l1_0.modified_time,u1_0.id,u1_0.email,u1_0.username,u1_0.nickname,u1_0.profile_image,u1_0.region,u1_0.role
from user_post_likes l1_0
left join users u1_0 on u1_0.id=l1_0.user_id
where l1_0.post_id in (90432,90431,90430,90429,90428,90427,90426,90425,90424,90423,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL);

이 쿼리는 user_post_like라는 테이블과 User를 조인하여 post id가 IN 절에 있는 값 중 하나와 일치하는 컬럼을 조회하는 쿼리이다. 이러한 유형의 쿼리가 여러개 실행되는 것을 발견했다.

왜 이러한 형태의 쿼리가 실행되는 것일까?

일단 제일 처음 실행되는 쿼리를 보자.

select p1_0.post_id,p1_0.post_content,p1_0.created_time,p1_0.image_id,p1_0.modified_time,p1_0.user_id 
from post p1_0 
order by p1_0.created_time desc 
limit 10,10;

이 쿼리가 실행됬다. 페이징을 적용하여 QueryDsl을 통해 쿼리가 실행되도록 했다. 이 쿼리를 통해서 게시글 목록을 조회하여 영속성 컨택스트에 저장이 될 것이다. 그후 실행된 쿼리들은 다음과 같다.

select h1_0.post_id,h1_0.hashtag
from post_hashtags h1_0
where h1_0.post_id in (90422,90421,90420,90419,90418,90417,90416,90415,90414,90413,NULL,NULL,NULL,...,NULL)

select i1_0.id,i1_0.convert_image_name,i1_0.created_time,i1_0.directory,i1_0.image_url,i1_0.modified_time 
from image i1_0 
where i1_0.id in (89989,89988,89987,89986,89985,89984,89983,89982,89981,89980,NULL,NULL,...,NULL)

select l1_0.post_id,l1_0.likes_num,l1_0.created_time,l1_0.modified_time,u1_0.id,u1_0.email,u1_0.username,u1_0.nickname,u1_0.profile_image,u1_0.region,u1_0.role
from user_post_likes l1_0 
left join users u1_0 on u1_0.id=l1_0.user_id
where l1_0.post_id in (90422,90421,90420,90419,90418,90417,90416,90415,90414,90413,NULL,NULL,...,NULL);

이 쿼리들의 공통점은 in을 통해서 id 값을 조건절에서 비교하는 점과 많은 null 값이 바인딩 된다는 점이다.

왜 이렇게 쿼리가 실행되는지 알아보자. 일단 영속성 컨텍스트에는 처음 조회한 게시글의 목록들이 존재할 것이다. 그리고 나중에 해당 게시글과 연관관계가 맺어진 객체들을 지연 로딩하여 사용시점에 쿼리를 실행하여 데이터를 조회할 것이다. 이 때, 위의 쿼리들이 실행될 것이다. 다음은 게시글 테이블과 연관된 테이블의 ERD이다.

Post 테이블은 Image와 User, User_post_likes, hashtags 테이블과 연관 관계를 맺고 있다. 이렇게 연관관계를 맺고 있는 테이블들의 데이터를 조회할 때 위의 문제가 되는 SQL 문들이 실행되는 것이다.

코드를 보면 Post 객체를 DTO로 변환할 때 해당 프록시 객체들을 진짜 DB에서 데이터를 가져올 때 SQL이 실행될 것이다.

public static PostResponse from(Post post) {
    return new PostResponse(post.getId(), post.getContent(), new ArrayList<>(post.getHashtags().stream().toList()), UserPostResponse.from(post.getUser()), ImageResponse.from(post.getImage()), post.getLikesCount(), post.getCreatedTime(), post.getModifiedTime());
}

실제로 디버깅 과정을 통해 보면 Post를 DTO로 변환하는 과정을 보면 객체에 프록시 객체나 컬렉션 래퍼가 바인딩되는 것을 볼 수 있다.

지연 로딩시에 엔티티는 프록시 객체를 통해서 지연 로딩을 수행하지만, 컬렉션 같은 경우 컬랙션 래퍼를 통해 지연로딩을 수행하는데 하이버네이트에서 이러한 컬렉션 래퍼 기능을 수행하는 것이 PersistentBag이라고 한다.

이렇게 프록시 객체나 컬렉션 래퍼를 통해 지연 로딩을 수행하는데 getXXX()를 통해 실제 데이터를 불러올 때 위의 문제가 되는 SQL이 실행된다. WHERER IN 문법을 통해서 영속성 컨택스트에 있는 게시글에 저장되어 있는 외래키를 이용해서 데이터를 조회한다. 왜 WHERE IN일까? 이를 이해하기 위해서는 N+1을 알아야 한다. N+1은 지연로딩시에 발생하는 문제인데 엔티티를 조회하고 해당 엔티티와 관련된 객체를 N번 더 조회하는 문제이다.

N+1 문제, fetch join? batch size? 무엇을 사용할까

N+1에 설명하는 것보다는 위의 예시로 설명하자면 나는 게시글이라는 Post 엔티티 목록을 조회했다. 이때 1번의 쿼리가 실행된다. 그 후 지연로딩 설정이 되어있는 게시글과 매핑된 엔티티를 조회하는데 각 게시글마다 연관되어 있는 엔티티를 조회하면 총 N번의 쿼리가 실행되게 된다. 이렇게 N+1번의 쿼리가 발생하여 리소스 낭비와 응답시간이 늘어나는 문제가 발생하는 것이다. 이를 해결하기 위한 방법으로는 추가 조회 쿼리를 없애는 방법과 추가 조회 쿼리를 한번으로 줄이는 방법이 있다. 없애는 방법은 Join을 해서 한 번에 많은 데이터를 가져오는 방법으로 fetch join, EntityGraph를 이용하는 것이다. 조회쿼리를 1번으로 줄이는 방법은 Batch size와 sub query를 이용하는 방법이다.

Hibernate에서는 후자의 방법을 추천한다고 한다. 왜냐하면 전자의 방법에는 여러 주의점이 존재하기 때문이다. fetch join을 사용할 때 4가지 정도의 문제가 존재한다.

1. 레코드 중복 문제

일대다 관계에서 fetch join을 사용하면 레코드가 중복되서 조회될 수 있다. 예를 들어 게시글-해시태그가 일대다 관계라고 하면 하나의 게시글에 여러 해시태그가 존재할 경우 게시글 * 해시태그의 수 만큼 조회가 될 것이다. 따라서 fetch join을 사용할 때는 distinct 옵션을 꼭 사용해야 한다.

2. 인메모리 페이지네이션

페이징을 처리할 때 fetch join을 사용한다면 다음과 같은 WARN 메시지가 나올 가능성이 있다.

HHH000104: firstResult/maxResults specified with collection fetch; applying in memoery!

이 메시지는 페이징 처리시에 fetch join을 사용할 경우 페이징이 전혀 적용되지 않고, 조건에 해당하는 모든 데이터를 가져와 메모리에 올려두고 사용한다는 경고이다. 따라서 조건에 해당하는 데이터를 모두 조회하여 인메모리에서 사용하기 때문에 성능상 문제가 있고 이는 큰 부작용을 초래할 수 있다.

이러한 문제 때문에 Hibernate5.2.13 버전부터 fail_on_pagination_over_collection_fetch 옵션을 제공하기 시작했다. 이 옵션은 컬렉션 데이터를 메모리 상에서 페이징으로 가져오려하면 오류를 발생시키는 옵션이다. 이를 통해 성능상의 문제를 조기에 발견하고 대처할 수 있도록 한다. (참조)

3. MultipleBagFetchException

두개 이상의 일대다 관계에 있는 엔티티를 fetch join할 수 없다. 예를 들어 게시글-해시태그, 게시글-댓글 이라고 가정하면 게시글은 두개 이상의 일대다 관계를 가지고 있다. 게시글의 id가 1이라고 가정하고 해시태그는 해시태그1, 해시태그2 댓글은 댓글1, 댓글2, 댓글3이라고 가정하고 fetch join을 한 결과는 다음과 같을 겻이다.

Post IDHashtagComment
1해시태그1댓글1
1해시태그1댓글2
1해시태그1댓글3
1해시태그2댓글1
1해시태그2댓글2
1해시태그2댓글3

이렇게 해시 태그와 댓글 입장에서는 중복되는 데이터가 조인되어 List에 담기기 때문에 hibernate는 이를 방지하기 위해서 오류를 발생하는 것이다.

4. alias

alias는 별칭이라는 의미인데, fetch join 시에 as을 사용하는 이유는 join시에 조건을 주기위해 on을 사용할 때 사용하려는 것이다. 하지만 이는 잘못된 사용이다. 왜냐하면 fetch join은 해당 테이블의 모든 데이터를 한번에 가져오려는 것인데 조건을 통해 필터링하게 되면 객체의 상태와 DB의 상태 일관성이 깨지게 되는 것이다. 따라서 fetch join 대상은 on, where 등에서 필터링 조건을 사용하면 안된다.

default_batch_fetch_size 옵션

이러한 문제들 때문에 hibernate는 default_batch_fetch_size 옵션을 통해서 각각의 자식 관계에 대한 쿼리를 한번에 묶어서 쿼리를 실행하도록 하여 N번의 쿼리 대신 중복되는 쿼리를 묶어서 실행하는것을 추천한다. 예를들어 게시글-댓글이 일대다 관계일 때 게시글은 1개 댓글이 20개 있으면 게시글 조회 1번 댓글 조회 20번해서 총 21번 sql이 실행될 것을 batch_size를 통해서 댓글을 in 문법을 통해 1번만 사용하도록 하여 총 2번의 쿼리가 실행될 수 있는 것이다.

batch size를 1000개로 하면 in 속에 1000개의 값이 바인딩될 수 있다는 의미이다. 만약 게시글의 수가 20개 각각의 게시글에 대한 댓글 수가 100개라고 가정하면 옵션을 적용하지 않았을 경우에는 게시글 전체 조회 1번, 댓글 조회 20 100 = 2000번 총 2001번이 될 것이다. 옵션을 키게 되면 게시글 전체 조회 1번 댓글 20 100 / 1000 = 2번 총 3번이 될 것이다. 이는 batch size를 1000개로 했을 경우이지만 이는 엄청난 차이를 보여준다. 만약 부모의 데이터의 수가 커지면 커질수록 차이는 커질 것이다.

다시 돌아와서 왜 null이 바인딩 되었을까?

따라서 N+1을 사용할 때 batch size를 통해 많이 해결하곤 한다. 나도 이를 사용해서 N+1문제를 해결하고자 하였는데 ?이 너무 많이 바인딩되고 null값이 바인딩되는 것이 이상하였다. 분명 IN 안에는 부모의 갯수만큼 바인딩이 되어야 하는데 null이 왜 바인딩 되는 걸까 찾아보았다.

결과적으로 아무런 문제가 아니었다.. 찾아보니깐 스프링 부트 3.1 부터는 하이버네이트 6.2을 사용하는데 성능 최적화를 위해서 hibernate에서 batch_size만큼의 in절에 값이 없어도 null값을 채워서 성능 향상을 시킨다는 것이었다.

왜 null을 채우게 되었을까?

SQL을 실행할 때 DB는 SQL 구문을 이해하기 위해 SQL을 파싱하고 분석하는 등의 복합적인 일을 처리하는데 이때 성능을 최적화하기 위해서 이미 실행된 SQL 구문은 파싱 결과를 내부에 캐싱하고 있다. 이렇게 해두면 다음에 같은 SQL 구문이 실해되어도 이미 파싱된 결과를 그대로 사용하면 성능을 최적화할 수 있기 때문이다.(이 때 파싱된 결과는 실행 결과가 아닌 구문 자체를 캐싱한다는 의미이다)

DB에서는 where in (?,?...?) 구문에서 ?의 갯수에 따라서 각각 다른 SQL 구문으로 인식한다. 예를 들어 다음의 SQL은 서로 다른 SQL 구문이기 때문에 각각 다르게 캐싱될 것이다.

WHERE comment.comment in (?)
WHERE comment.comment in (?, ?, ?)
WHERE comment.comment in (?, ?, ?, ?)

hibernate는 이를 최적화하기 위해서 ?를 batch size만큼 생성하고 해당 ?에 값이 바인딩되지 않아도 null값을 넣어 SQL 실행시에 캐싱되어 SQL을 실행하는 것이 성능적으로 좋기 때문에 ?을 batch size의 갯수만큼 생성한다고 한다. 따라서 동적으로 ?갯수를 변경하여 SQL을 실행하는 것보다 같은 구문 형식의 SQL을 실행하는 것이 성능적으로 좋고 null이 바인딩되는 것이 성능에 큰 지장을 주지 않는 것 같다.

결론적으로 null이 IN에 들어가는 것은 문제가 되지 않는다. 이는 hiberante가 제공하는 batch size를 이용한 N+1 문제를 해결하기 위한 방법으로 오히려 성능을 좋게 만들어준다. 오히려 내가 의문점을 가지고 문제를 해결할 부분은 다른 곳에 있었다. 이 글을 쓰다보니 나는 왜 batch size를 택했고 fetch join을 사용하면 더 성능이 악화될까? 라는 의문점이 생겼다. 따라서 fetch join의 단점과 장점을 알아보았고 batch size를 이용한 장점도 알아보았으니 둘 중 어느 것이 더 나은가? 혼용할 방법이 없을까에 대해 알아보자.

batch size가 최선일까?

N+1 방법을 해결하는 방법 중 추가 쿼리를 없애는 방법과 추가 쿼리를 최소화하는 방법을 중 나는 후자의 방법을 이용하고 있다. 이 방법은 위에서 언급했듯이 ToMany 관계일 경우 feth join을 사용하면 생길 수 있는 문제를 해결해주고 성능을 최소한으로 보장해준다. 따라서 최소한의 성능을 보장받고 싶다면 batch size를 사용하는 것이 올바른 방법이라고 생각된다. 테스트를 통해서 batch size만을 사용하여 게시글을 조회하면 성능이 어떻게 나올지 보자.

현재 게시글은 약 9만개의 게시글이 존재하고 게시글과 ToMany로 연관되어 있는 해시태그는 15만개 UserPostLike는 1만개, ToOne 관계인 Image는 10만개 User는 1만개의 데이터가 존재한다. 나는 ngrinder에 가상 사용자 10명이 동시에 1분동안 요청을 했을 경우 어떻게 동작할지 측정했다.

batch size가 100일때 지연 로딩을 통해 게시글 데이터 조회

batch size 옵션에 100~1000을 할당해주는게 보통이라고 한다. 만약 해당 옵션에서 설정해준 것보다 많은 데이터가 In에 들어가고자 하면 추가적으로 IN 쿼리가 더 나간다. 나는 100을 옵션에 할당하고 다른 fetch join은 사용하지 않고 테스트를 진행했다.
그결과 총 1175번의 요청이 성공했고 tps는 20 응답시간은 485.31ms(0.48초) 정도가 나왔다.

ToOne 관계인 엔티티들을 모두 fetch join하고 ToMany는 Batch size를 이용한 지연로딩을 통해 게시글 조회

ToMany 관계인 엔티티를 fetch join하면 문제가 생기기 떄문에 ToOne 관계인 엔티티들을 모두 fetch join하여 데이터를 한번에 불러오도록 코드를 수정했다. 다음은 queryDsl로 fetch join한 코드이다.

public List<Post> finAll(Pageable pageable) {
    return jpaQueryFactory.selectFrom(post)
            .innerJoin(post.user,  user).fetchJoin()
            .leftJoin(post.image, image).fetchJoin()
            .limit(pageable.getPage())
            .offset(pageable.getOffset())
            .orderBy(new OrderSpecifier[]{new OrderSpecifier<>(Order.DESC, post.createdTime)})
            .fetch();
}

게시글 조회시에 pagination을 적용해야 했기 때문에 limit와 offset을 설정해주었고 user는 null이 될 수 없기 때문에 inner join image는 nullable하여 left join하여 null값이 할당될 수 잇도록 하였다. ToOne 관계의 엔티티를 fetch join하여도 데이터 row 수를 증가시키지 않기 때문에 pagination 코드에도 문제가 생기지는 않을 것이다.

그 결과 모든 부분에서 batch size만 적용했을 때보다 성능이 떨어졌다. 쿼리의 수는 batch size만 적용했을 때보다 줄었지만 응답시간, tps 모두 악화되었다. 찾아보니 fetch join 시에 자식 데이터가 너무 많게 되면 해당 자식 엔티티의 데이터를 모두 가져오기 때문에 성능적으로 문제가 있다고 한다. 그러면 image의 데이터 수가 user보다 훨씬 많기 때문에 문제가 될까?라는 생각으로 fetch join을 user만 해보았다.

두개의 ToOne 관계인 엔티티들을 모두 fetch join 했을 때보다는 성능이 좋아졌다. 하지만 엄청난 개선 효과를 나타내지는 못했다. 이번에는 image만 fetch join 하고 테스트를 진행해보았다.

fetch join을 image만 했을 경우가 batch size만을 사용했을 때와 가장 성능적으로 비슷하였다. 내 예상대로면 데이터가 많은 엔티티를 fetch join하게 되면 성능적으로 좋지 않을 것이라고 생각하여 image를 fetch join했을 때보다 user를 fetch join했을 때가 더 효과적이라고 생각했지만 이는 반대였다. 이러한 이유를 찾아보니 image는 post와 1:1관계이고 user는 post와 N:1 관계임으로 row 데이터에 중복 데이터가 존재할 수 있다. 반면 image는 1:1 관계임으로 중복 데이터가 있은 가능성이 적다. 때문에 ManyToOne인 관계 데이터를 fetch join하게 되면 중복 데이터가 존재할 수 있고 이에 대한 처리를 할 때 리소스가 소비된다고 한다.

예를 들어서 N:1 관계에서 부모 데이터가 1000개 일 경우 자식 데이터가 부모마다 10개씩 있다고 하면 1000 x 10 = 10000개의 데이터가 fetch join시에 조회가 될 것이다. User가 여러개의 Post를 소유할 수 있으니 User는 중복되는 값이 생길 것이다. 그러나 영속성 컨텍스트에서는 각 id에 하나의 엔티티만 존재할 수 있다. 따라서 Post와 User를 fetch join하면 동일한 User 객체가 생성되고 이 객체를 영속성 컨텍스트에서 1차 캐시로 조회하고 같은 엔티티가 존재하면 이 객체를 메모리에서 삭제하는 일이 빈번하게 일어나기 때문에 중복되는 데이터가 많으면 많을 수록 성능이 악화될 수 있다. 반면 1:1은 중복되는 데이터가 거의 없기 때문에 객체를 생성하고 저장만 하면된다. ManyToOne과 OneToOne이 중 OneToOne을 fetch join하는 것이 더 효율적일 수 있다.

지금까지 ToMany 관계에는 batch size를 이용하고 ToOne은 fetch join을 이용하여 N+1 문제를 해결하는 방법과, 모두 batch size를 이용하는 방법, 일부만 fetch join을 사용하는 방법에 대해 테스트를 통해 알아보았다. 테스트 결과를 보면 batch size만 이용하는 방법과 fetch join을 일부분 사용하고 나머지는 batch size를 이용하는 방법 둘다 비슷한 성능을 보여줬다. 물론 성능 측정 방법이 잘못됬을 수 있지만 같은 환경에서 테스트한 것이기 때문에 어느정도는 신뢰성이 있다고 생각한다.

결과

테스트를 통해 fetch join과 batch size에 대해 보았다. 각각의 장단점이 있기 때문에 상황에 따라 알맞은 방법을 택해야한다. fetch join은 위에서 언급했던 주의 사항들이 존재하기 때문에 ToOne 관계일 때만 사용하여 한번에 데이터를 불러오는 것이 좋다. batch size는 ToMany 관계일 때 N번 실행되는 쿼리를 IN을 통해 쿼리 실행 수를 대폭 줄일 수 있지만 한번에 처리해야 할 데이터가 많아지면 메모리 부족이 올 수 있다는 단점도 존재한다. 따라서 어떠한 연관관계이던 해당 서비스의 특징에 맞게 사용해야 한다.

나는 batch size와 fetch join을 혼용하는 방식을 채택했다. 왜냐하면 fetch join하는 엔티티가 1:1 여관관계이기 때문에 fetch join시에 중복 데이터도 적을 가능성이 높고 한 번에 엔티티를 조회하는 것이 좋다고 판단했고 나머지 연관관계 엔티티들은 1:N, N:1 연관관계인 엔티티이어서 fetch join시에 부작용이 크다고 생각하였다. 테스트에서도 batch size만을 사용했을 때와 차이가 나지 않았고 batch size는 최소한의 성능을 보장해 주는 것이지 최고의 성능을 보장해주지 않는다고 알기 때문에 fetch join을 사용하였다.

fetch join과 batch size 외의 방법은 없을까?

batch size는 최소한의 성능을 보장해주고 ToOne 관계일 경우 fetch join을 사용하는게 좋다라고 한다. 하지만 1:N, N:M 관계일 때 fetch join을 사용하고자 할 때 생기는 문제점을 다른 해결책으로 해결할 수 있다면 fetch join을 사용하는 것도 좋을 수 있다고 생각한다. 예를 들어 ToMany 관계인 엔티티가 2개 이상일 경우 fetch join을 하면 MultipleBagFetchException 발생한다. 이를 해결하기 위해 List 대신 Set을 두어 중복되는 데이터를 방지하여 해결하는 방식을 통해 fetch join이 가능하도록 할 수 있다. 이렇듯 다양한 N+1 해결 방법이 존재할 수 있고 상황에 맞게 경우의 수를 여러개 두어서 테스트해보아야 할 것이다.

참고글

profile
Don't watch the clock; do what it does. Keep going.

0개의 댓글