[Diary] Spring ORM에서는 N + 1을 항상 신경쓰자.
본문 바로가기

Development/Diary

[Diary] Spring ORM에서는 N + 1을 항상 신경쓰자.

프로젝트를 진행하다가 드디어 N + 1을 마주치는 상황이 생겼습니다. 사실 이 문제가 정확히 언제 발생할 수 있는지 이해가 안 되었는데, 이렇게 쉽게 마주쳐서 기뻤습니다 후후

문제 상황

여기 장바구니 엔티티가 있다.

장바구니는 User, Store, Product를 @ManyToOne으로 가지고 있다.

여기서 FetchType을 명시하지 않았으므로, 자동으로 LAZY가 설정된다.

아래는 이제 Response DTO를 만들기 위해서 만든 메서드이다.

여기서 Cart와 연관된 Product, Store를 get 한다.

이제 여기서 N + 1 문제가 발생한다.

 

대략 아래 log는, 위 메서드가 실행되면서 만들어지는 쿼리이다.

2024-08-30T22:52:07.102+09:00  INFO 5292 --- [nio-8080-exec-1] s.j.domain.cart.service.CartService      : User ID: 1의 장바구니를 조회합니다.
Hibernate: 
    select
        c1_0.cart_id,
        c1_0.cart_total_price,
        c1_0.created_at,
        ... 중략
        c1_0.user_id 
    from
        p_carts c1_0 
    where
        c1_0.user_id=? 
        and c1_0.is_deleted=false
Hibernate: 
    select
        p1_0.product_id,
        p1_0.created_at,
        ... 중략
    from
        p_products p1_0 
    join
        p_stores s1_0 
            on s1_0.store_id=p1_0.store_id 
    left join
        p_users u1_0 
            on u1_0.user_id=s1_0.user_id 
    left join
        user_roles r1_0 
            on u1_0.user_id=r1_0.user_user_id 
    where
        p1_0.product_id=?

쿼리가 두 개가 생겼다.

문제 원인

N + 1 문제

N+1 문제는 데이터베이스와 객체 관계 매핑(ORM) 프레임워크(예: Hibernate)에서 자주 발생하는 성능 문제 중 하나이다.

이 문제는 기본적으로 다음과 같은 방식으로 발생한다:

  1. 1개의 쿼리로 N개의 객체(예: 사용자 리스트)를 데이터베이스에서 조회한다.
  2. 조회한 각 객체에 대해 추가로 N개의 쿼리가 실행되어 연관된 다른 객체(예: 각 사용자의 주문 리스트)를 조회한다.

즉, N개의 객체를 조회할 때 1번의 메인 쿼리와 추가로 N번의 서브 쿼리가 실행되며, 결과적으로 총 N+1번의 쿼리가 발생하게 된다.

위 상황의 경우, Cart 엔티티를 조회하기 위한 1개의 쿼리와, 이 엔티티와 연관된 Product와 Store, User의 데이터를 얻기 위해 1개의 쿼리가 더 실행됐다. (단, 여기서는 @ManyToOne 연관 관계였으므로 1개만 더 쿼리 됨)

문제 해결

N + 1 문제 해결에는 여러 방법이 있다. 

 

  • JOIN FETCH 사용:
    • 연관된 엔티티를 한 번의 쿼리로 함께 가져올 수 있도록 JOIN FETCH를 사용한다.
  • @EntityGraph 사용:
    • EntityGraph를 사용하여 특정 엔티티와 연관된 엔티티를 한 번에 로드하도록 설정할 수 있다.
  • Batch Fetching
    • Hibernate 설정에서 배치 단위로 fetch 하도록 활성화하여 한 번에 여러 연관된 엔티티를 가져오도록 할 수 있다.

@EntityGraph 사용

이 중에서 @EntityGraph을 사용했다. JOIN FETCH는 쿼리가 길어지고, 이게 더 가독성이 좋기 때문이다.

@EntityGraph(attributePaths = {"user", "product", "store"})
@Query("SELECT c FROM Cart c WHERE c.user.userId = :userId AND c.user.isDeleted = false")
List<Cart> findCartsByUserId(@Param("userId") Long userId);

아래는 똑같은 메서드를 실행했을 때 결과이다.

2024-08-30T23:13:01.112+09:00  INFO 26052 --- [nio-8080-exec-1] s.j.domain.cart.service.CartService      : User ID: 1의 장바구니를 조회합니다.
Hibernate: 
    select
        c1_0.cart_id,
        c1_0.cart_total_price,
        c1_0.created_at,
        c1_0.created_by,
        c1_0.deleted_at,
        c1_0.deleted_by,
        c1_0.is_deleted,
... 중략
    from
        p_carts c1_0 
    join
        p_products p1_0 
            on p1_0.product_id=c1_0.product_id 
    join
        p_stores s2_0 
            on s2_0.store_id=c1_0.store_id 
    join
        p_users u2_0 
            on u2_0.user_id=c1_0.user_id 
    where
        c1_0.user_id=? 
        and u2_0.is_deleted=false

한 번에 모든 관련 데이터를 fetch 했기 때문에 쿼리가 한 번만 만들어졌다.

마치며

항상 이론적으로만 듣던 N + 1문제를 인식하고, '이거 N + 1 문제 생기지 않나?'를 꾸준히 생각하다가, 이렇게 직접 마주쳐서 해결하니 뿌듯했다.