n+1 문제 해결

최준호·2023년 10월 31일
1

Appling

목록 보기
5/12
post-thumbnail

🔴 N+1 이란

jpa와 같은 orm을 사용하다 보면 각 entity들 간의 연관관계에 따라 n+1 문제가 쉽게 발생한다.
여기서 n+1이란 내가 product를 10개를 select하고 싶어서 product를 가져왔다. 여기서 fetch가 lazy인 상태인데
만약 product 안에 optionList를 조회한다면 from option where product_id = ? 라는 쿼리로 +n 번이 또 조회되기 시작한다.

만약 여기서 +n이 10번이라고 가정해보자 우리는 product 한개를 조회하기 위해서 option을 추가로 조회할 경우 10번의 select가 생긴다. 여기서 큰 문제 없다고 데이터 잘 나온다고 넘겼을 경우 실제 서비스 이후에 누군가가 product 100개를 조회한다 가정하면 100 * 10 개로 상품을 100개 한번 조회당 select는 1100번이 발생하게 되는 것이다. 이건 서버 입장에서는 엄청난 성능저하를 유발하는 문제이다.

🔵 해결 방법

이러한 문제를 해결해야 하는 경우가 한두개가 아니다 이 문제를 해결해서 나아가는 방법을 알아보자!

🟢 fetch.EAGER

절대로 사용하지 않을 것을 추천 한다.

대부분 프로젝트를 보면 featch 전략을 LAZY로 잡아서 문제가 발생하는 것이다. EAGER로 바꾼다면 문제를 당장에 해결할 수도 있다.

하지만 EAGER 전략의 경우 product를 조회하는 경우 option 데이터가 필요 없을지라도 무조건 option을 select 하여 데이터를 가져온다. 이것은 또 하나의 거대한 성능 저하 이슈를 불러 일으키기 딱 좋다.

내가 이 사유를 쓰는 이유는 AI에게 질문 했을 경우 EAGER로 변경해보라고 답변을 많이 받았기 때문이다... EAGER는 정말 정말 정말 사용하지 않다가 정말 필요한 부분에서 사용하는 것이 좋다.

🟢 fetchJoin()

        List<ProductResponse> content = q.query()
            .select(Projections.constructor(ProductResponse.class, product))
            .from(product)
            .where(builder)
            .orderBy(product.createAt.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

초기에는 위와 같이 데이터를 select 해왔다. 결과는 당연히 n+1이 발생했지만 데이터를 그대로 잘 가져왔기에 일단 이렇게 처리해두었었다.

        List<ProductResponse> fetch = q.query()
            .select(Projections.constructor(ProductResponse.class, product))
            .from(product)
            .join(product.category).fetchJoin()
            .join(product.seller).fetchJoin()
            .leftJoin(product.optionList, option).fetchJoin()
            .where(builder)
            .orderBy(product.createAt.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

그 후 가장 먼저 수정한 내용이다. fetchJoin()을 통해 데이터를 join시 미리 fetch하여 가져오기 때문에 n+1 문제가 발생하지 않을 것을 기대했다.

하지만 n+1은 발생했다...
그 이유는 query 자체 문제가 아니라 query를 조회 할때마다 Projections에 의해서 생성자를 통해 데이터를 생성하게 되고 데이터마다 product에 접근하여 option을 조회하기 때문에 n+1이 발생했다.

🟢 Projections 사용하지 않기

        List<Product> fetch = q.query()
            .selectFrom(product)
            .join(product.category).fetchJoin()
            .join(product.seller).fetchJoin()
            .leftJoin(product.optionList, option).fetchJoin()
            .where(builder)
            .distinct()
            .orderBy(product.createAt.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
            
        List<ProductResponse> content = fetch.stream().map(ProductResponse::new)
            .collect(Collectors.toList());

최종적으로 나온 코드는 다음과 같다. 우선 Entity를 그대로 select하고 그 후에 ProductResponse (반환할 객체)로 map을 통해 생성해주는 것이다. 이렇게 하면 최초에 모든 데이터를 select해서 해당 데이터를 그대로 가져다 쓰기 때문에 n+1 문제가 발생하지 않았다.

다음 그림과 같이 select가 1번만 사용되는 것을 확인할 수 있었다!

🟢 default_batch_size

마지막으로

spring:
  jpa:
    properties:
      hibernate:
        default_batch_size: 100

n+1 이 발생할 경우 in 절로 바꿔주는 설정이다. 이 설정은 혹시나 다른 코드가 n+1로 날아갈 경우를 방지해주어 성능에 도움이 되므로 추가해두었다.

profile
코딩을 깔끔하게 하고 싶어하는 초보 개발자 (편하게 글을 쓰기위해 반말체를 사용하고 있습니다! 양해 부탁드려요!) 현재 KakaoVX 근무중입니다!

0개의 댓글