우테코/요즘카페

N+1 해결하기 (발생하는 쿼리 80% 줄이기)

dev_kong 2023. 9. 19. 22:10
728x90
728x90

N + 1

ORM을 사용하다 보면 무조건 만나게되는 문제가 있다.
바로 N+1.

 

우리 서비스 역시 N + 1 문제를 마주쳤다.

카페조회

@Entity  
public class Cafe {  

    @Embedded  
    private Images images;  

    @Embedded  
    private Detail detail; 
}

@Embeddable  
public class Images {  

    @ElementCollection(fetch = FetchType.LAZY)  
    @CollectionTable(name = "image")  
    private List<String> image;
}

 

@Embeddable  
public class Detail {  

    @ElementCollection(fetch = FetchType.LAZY)  
    private List<AvailableTime> availableTimes;

}

다른 필드는 다 제외하고 N+1문제가 발생하는 필드만 가져왔다.


보다시피 Cafe 엔티티가 ImagesDetail 을 갖고 있는데,

각각 @ElemnetCollection 으로 테이블을 생성하고있다.

 

만약 Cafe 데이터가 5개가 있다고 가정했을 때, 모든 카페를 조회하는 경우에는
각각의 카페마다 Image와, available table에 포함된 내용을 조회하기 위해 총 11개의 쿼리가 발생한다.

5개의 카페조회 쿼리 + Image 조회 쿼리 * 5 + availableTime 조회 쿼리 * 5 = 11

해결 해야 하나?

근데 사실 쿼리가 많이 나갈 뿐 서비스가 돌아가는데는 지장이 없다.
굳이 이걸 수정해야 할까??

 

빠른 WAS의 응답속도를 위해서는 외부와 통신 비용을 줄여야 한다.
그런데 DB와 통신하는데는 많은 비용이 든다.

 

만약 우리의 서비스가 새로운 기능을 추가하는데, 이 기능에서 모든 카페를 조회 해야 한다면...
(등록된 카페의 수 * 2) + 1 만큼의 쿼리가 발생할 텐데,
비용적으로 너무나 큰 낭비일 것이다.

해결

N+1을 직접 겪어본 적은 없었지만,
fetchJoin 을 써서 해결한다는 사실은 이미 알고 있었다.

 

fetchJoin을 써서 해결을 해보자

@Query("""  
        SELECT c  
        FROM Cafe c  
        LEFT JOIN FETCH c.images.urls  
        LEFT JOIN FETCH c.detail.availableTimes  
        WHERE c.id = :id  
        """)  
Optional<Cafe> findById(@Param("id") final long id);

이렇게 해놓고 위 코드를 실행시켜보면

 

MultipleBagFetchException이 뜬다.

MultipleBagFetchException

나야 뭐 JPA가 처음이라 낯설지만, JPA를 사용하는 사람들은 한번씩은 겪는 예외라고 한다.

 

이 Exception은 2개 이상의 ~ToMany 자식 테이블에 Fetch Join을 선언할 때 발생한다고 한다.
왜지.

 

일단 Bag이 뭔지 궁금해졌다.
Bag은 자료구조의 일종인데, MultiSet 이라고도 한다.
List 처럼 중복을 허용하고, Set 처럼 순서를 보장하지 않는다.
.. 뭐 이딴게 있나 싶다..

 

그건 그렇고 그래서, 왜 Bag 이 여러개면 예외가 발생하는지 생각해보자.
지금 위의 코드인 Cafe와 Images, 그리고 AvailableTime을 예시로 들어보자.

 

예를 들어, cafe1 이 두개의 이미지(image1, image2) 를 갖고있고, 2개의 availableTime(at1, at2)을 갖고있다고 해보자.

이 두개의 테이블을 조인을 해서 cafe1을 조회 하게 되면 다음과 같은 결과가 나올거다.

 

cafe1 - image1 - at1
cafe1 - image1 - at2
cafe1 - image2 - at1
cafe1 - image2 - at2

 

총 4개의 row가 생성된다.
그리고 hibernate가 관계 매핑을 해주는데,
여기서부터 hibernate가 뚝딱거리기 시작한다.

 

image1, image1, image2, image2 이렇게 중복된 모든 데이터를 넣어줘야 하는건지,
아니면 image1, image2 하나씩만 넣어주는 건가, 아니면 image1은 두개고, image2는 하나인건가? 뭐지? 나 어떻게 하지..?

이렇게 뚝딱 거리면서 고장 나버린다.

그래서 고장나기 전에 MultipleBagException이 발생시켜 고장날 일을 미연에 방지한다.

 

고장이라고 표현 했지만, 진짜 고장나는건 아니고, 실제 데이터와 다르게 객체가 매핑 될 수 있다.
즉, 정합성이 맞지 않는 문제가 발생할 수 있기에, 예외를 통해 이를 방지한다.

 

이 예외를 피하기 위해선 두가지 방법이 있다.

Set

~ToMany 가 걸려있는 컬렉션의 자료형을 List가 아닌, Set으로 바꿔주면 된다.
위에서 말했듯 Multple~ 은 중복된 데이터를 관계매핑을 해줘야 할지 말지 결정을 못하기에 발생하는 Exception이다.

 

만약에 자료형이 Set이라면 hibernate가 고민할 필요가 없다.
그냥 다 집어넣으면 된다. 그럼 Set이 알아서 중복을 제거해준다.

 

문제는 현재 모든 로직이 List를 기반으로 되어있기 때문에
이걸 Set으로 변경하기엔 너무나 큰 대공사가 이뤄져야 한다.


현재 단계에서는 무리가 있다고 판단하여, Set은 사용하지 않았다.

Batch Size

두번째 방법은 Batch Size 를 이용하는 방법이다.
Batch Size는 1개씩 사용 되는 조건문을 in절로 묶어서 조회할 수 있게끔 해준다.

spring.jpa.properties.hibernate.default_batch_fetch_size=100

 

무슨 소리냐 하면 findAll() 을 통해 Cafe를 조회하는 경우
다음과 같은 N + 1 문제가 발생한다.

 

    select
        c1_0.id 
    from
        cafe c1_0

%% 여기서 부터  %%
    select
        a1_0.cafe_id,
        a1_0.id,
        a1_0.time 
    from
        available_time a1_0 
    where
        a1_0.cafe_id=?

    select
        a1_0.cafe_id,
        a1_0.id,
        a1_0.time 
    from
        available_time a1_0 
    where
        a1_0.cafe_id=?
%% 여기 까지 cafe의 갯수 만큼 반복 %%

만약 카페가 100개라면, 201개의 쿼리가 발생할 것 이다.


그런데 Batch Size를 활용하면,

다음과 같은 쿼리가 발생한다.

    select
        c1_0.id 
    from
        cafe c1_0

    select
        a1_0.cafe_id,
        a1_0.id,
        a1_0.time 
    from
        available_time a1_0 
    where
        a1_0.cafe_id in (?,?) %% in 절로 변경 %%

    select
        i1_0.cafe_id,
        i1_0.id,
        i1_0.name 
    from
        image i1_0 
    where
        i1_0.cafe_id in (?,?) %% in 절로 변경 %%

이때 in절에 들어가는 cafe_id의 갯수는 설정한 Batch Size 와 일치한다.

만약 Batch Size가 100 이고, DB에 등록된 Cafe의 갯수가 101 개 라면, 총 5개의 쿼리가 발생할것이다.

Batch Size 설정 시 발생하는 쿼리 수 = 1 + N/BatchSize(올림)

FetchJoin과 Batch Size를 함께 활용할 수도 있다.

@Query("""  
        SELECT c  
        FROM Cafe c  
        LEFT JOIN FETCH c.images  
        """)  
List<Cafe> findAll();

Fetch Join을 하나만 걸고, Batch Size를 설정하면 다음과 같이 쿼리가 발생한다.

    select
        c1_0.id,
        i1_0.cafe_id,
        i1_0.id,
        i1_0.name 
    from
        cafe c1_0 
    left join
        image i1_0 
            on c1_0.id=i1_0.cafe_id

    select
        a1_0.cafe_id,
        a1_0.id,
        a1_0.time 
    from
        available_time a1_0 
    where
        a1_0.cafe_id in (?,?)

그럼 ImagesavailableTime 중 어떤 것에 FetchJoin을 걸지 고민하다가,
우리 서비스에선 image는 최대 10개 available~ 은 7개로 고정이기에,
무조건적으로 개수가 많지는 않지만, 조금이라도 많을 확률이 있는 images에 fetchJoin을 걸었다.

 

Pagable과 FetchJoin

비회원인 경우 Pageable을 받아, Cafe 정보를 5개씩 응답해준다.
위에서 FetchJoin과 BatchSize의 조합으로 N+1을 해결했으니, 이 로직에도 똑같이 적용해보았다.

@Query("""  
        SELECT c  
        FROM Cafe c  
        LEFT JOIN FETCH c.images  
        """)  
Slice<Cafe> findSliceBy(Pageable pageable);

그런데 쿼리를 확인해보니 이상하다.

    select
        c1_0.id,
        i1_0.cafe_id,
        i1_0.id,
        i1_0.name 
    from
        cafe c1_0 
    left join
        image i1_0 
            on c1_0.id=i1_0.cafe_id %% ?? Limit 어디감?? %%

    select
        a1_0.cafe_id,
        a1_0.id,
        a1_0.time 
    from
        available_time a1_0 
    where
        a1_0.cafe_id in (?,?)

Limit 절은 온데 간데 없고,

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

이런 warning만 하나 덩그러니 남아있다.


warning의 내용은 쿼리 수행 결과를 모두 어플리케이션 메모리에 올려서 페이징 처리를 수행했다. 라는 의미이다.
이는 곧, OOM이 발생할 수 있는 여지가 있다는 뜻과 같다.

 

결론만 말하자면, 1:N(또는 M:N) 관계에서 paging 과 fetchJoin을 동시에 사용할 수가 없다.
hibernate측 에서 이걸 제한 하고 있다.

 

package org.hibernate.query.sqm.internal;

protected List<R> doList() {  
    verifySelect();  

    final SqmSelectStatement<?> sqmStatement = (SqmSelectStatement<?>) getSqmStatement();  
    final boolean containsCollectionFetches = sqmStatement.containsCollectionFetches() || AppliedGraphs.containsCollectionFetches(getQueryOptions());  
    final boolean hasLimit = hasLimit( sqmStatement, getQueryOptions());  
    final boolean needsDistinct = containsCollectionFetches  
         && ( sqmStatement.usesDistinct() || hasAppliedGraph( getQueryOptions()) || hasLimit);  

    final List<R> list = resolveSelectQueryPlan()  
         .performList(executionContextFordoList(containsCollectionFetches, hasLimit, needsDistinct));  

    if ( needsDistinct ) {  
        final int first = !hasLimit || getQueryOptions().getLimit().getFirstRow() == null  
            ? getIntegerLiteral( sqmStatement.getOffset(), 0 )  
            : getQueryOptions().getLimit().getFirstRow();  

        final int max = !hasLimit || getQueryOptions().getLimit().getMaxRows() == null  
            ? getMaxRows( sqmStatement, list.size() )  
            : getQueryOptions().getLimit().getMaxRows();  

        if ( first > 0 || max != -1 ) {  
            final int toIndex;  
            final int resultSize = list.size();  
            if ( max != -1 ) {  
            toIndex = first + max;  
            } else {  
            toIndex = resultSize;  
            }         
            if ( first > resultSize ) {  
                return new ArrayList<>(0);  
            }         
            return list.subList( first, toIndex > resultSize ? resultSize : toIndex );  
        }   
    }   
    return list;  
}

음 hibernate가 List를 만들어 반환하는 부분이다.
final boolean containsCollectionFetches 이 라인에서 CollectionFetch 가 일어나는 쿼리인지 boolean 값으로 갖고있고,
final boolean hasLimit = hasLimit( sqmStatement, getQueryOptions());이 라인을 통해 쿼리(SQM)에 Limit가 포함되어 있는지 boolean 값으로 갖고 있는다.

 

그리고 executionContextFordoList 를 통해 쿼리를 실행한다.

// method가 너무 길어서 hasLimit와 containsCollectionFetches가 모두 true인 경우의 코드만 가져옴.

protected DomainQueryExecutionContext executionContextFordoList(
    boolean containsCollectionFetches, 
    boolean hasLimit, 
    boolean needsDistinct
) {  
    final DomainQueryExecutionContext executionContextToUse;  
    if ( hasLimit && containsCollectionFetches ) {  
        boolean fail = getSessionFactory().getSessionFactoryOptions().isFailOnPaginationOverCollectionFetchEnabled();  
        if (fail) {  
            throw new HibernateException(  
               "firstResult/maxResults specified with collection fetch. " +  
                     "In memory pagination was about to be applied. " +  
                     "Failing because 'Fail on pagination over collection fetch' is enabled."  
                );  
        } else {  
         QueryLogging.QUERY_MESSAGE_LOGGER.firstOrMaxResultsSpecifiedWithCollectionFetch();  
        }  
    final MutableQueryOptions originalQueryOptions = getQueryOptions();  
    final QueryOptions normalizedQueryOptions;  
    if ( needsDistinct ) {  
        normalizedQueryOptions = omitSqlQueryOptionsWithUniqueSemanticFilter( originalQueryOptions, true, false );  
    } else {  
        normalizedQueryOptions = omitSqlQueryOptions( originalQueryOptions, true, false );  
    }      
    if ( originalQueryOptions == normalizedQueryOptions ) {  
        executionContextToUse = this;  
    } else {  
        executionContextToUse = new DelegatingDomainQueryExecutionContext( this ) {  
            @Override  
            public QueryOptions getQueryOptions() {  
                return normalizedQueryOptions;  
            }         
        };
    }   
}

ㅎ.. 재밌다.
대충 보면, 쿼리에 Limit가 있고, CollectionFetch를 하는 경우,

final QueryOptions normalizedQueryOptions;  
    if ( needsDistinct ) {  
        normalizedQueryOptions = omitSqlQueryOptionsWithUniqueSemanticFilter( originalQueryOptions, true, false );  
    } else {  
        normalizedQueryOptions = omitSqlQueryOptions( originalQueryOptions, true, false );  
    }  

여길 통해 쿼리를 수정하는데, 2번째 파라미터(true)의 네이밍이 omitLimit이다. 즉, Limit를 무시하고 쿼리를 실행한다는 것을 알수 있다.

그리고 나서 다시 doList에서 limit의 개수만큼 subList하는 것을 확인 할 수 있다.


재밌당 ㅎ

 

이것도 hibernat가 데이터를 어떻게 처리할 지 몰라서 이렇게 막아버린거다.
예를 들어, cafe2개가 각각 사진 3개를 갖고 있는데 이걸 JOIN을 걸고 limit를 2로 걸면 어떻게 될까?

 

이런식으로 row가 생성되고 여기서 limit2 를 걸게 되면,

cafe1 - img1
cafe1 - img2

이런 식으로 나와버린다.
내가 원하던 결과와는 너무 다른 결과가 나온다.


아까 MultipleBagException 을 다루며 봤듯, hibernate는 생각보다 멍청해서 이런경우의 데이터를 처리할때 뚝딱거리다가 고장나버린다.

아까는 exception을 터뜨려 파업 해버리지만,
이번엔 꼼수를 부려서 모든 데이터를 메모리에 적재하고, subList를 통해 결과를 반환한다.
그래도 양심은 있어서 HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory 이런 warning은 띄워준다.

해결

해결은 간단하다. fetchJoin을 안쓰면 된다.
그리고 이미 batchSize 위에서 걸었기 때문에 실행을 해보면,

    select
        c1_0.id 
    from
        cafe c1_0 limit ?,
        ?

    select
        a1_0.cafe_id,
        a1_0.id,
        a1_0.time 
    from
        available_time a1_0 
    where
        a1_0.cafe_id in (?,?)

    select
        i1_0.cafe_id,
        i1_0.id,
        i1_0.name 
    from
        image i1_0 
    where
        i1_0.cafe_id in (?,?)

이런 식으로 in 절로 알아서 잘 가져온다!

쿼리가 얼마나 줄었나???

우리 서비스에선 5개의 카페 정보를 응답하는 api 가 매우 매우 매우 많이 사용된다.
기존의 경우에는 11개의 쿼리가 발생했으나
n+1 개선 이후 단 두개의 쿼리만 발생한다.
N+1개선을 통해 메인으로 사용되는 API의 쿼리를 1/5 로 줄일 수 있었다.

728x90
728x90