ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA N+1 문제와 @EntityGraph로 해결하기
    프로젝트, 트러블슈팅 2026. 3. 1. 23:24

    시작하면서

    프로젝트에서 위시리스트 목록을 조회하는 API를 만들었는데 쿼리가 이상하게 많이 나갔다. 찜 목록 5개를 조회했는데 DB에는 11번의 쿼리가 날아가고 있었다. 원인은 JPA의 N+1 문제였고, @EntityGraph로 해결했다. 이 과정을 정리해봤다.


    N+1란? 

    코드만 보면 쿼리가 한 번 나갈 것처럼 생겼는데, 실제로는 N번이 추가로 나가는 현상이다.

    위시리스트 5개를 조회하는 상황을 예로 들어보자.

    List<Wishlist> wishlists = wishlistRepository.findByUserId(1L);
    
    for (Wishlist wishlist : wishlists) {
        wishlist.getProduct().getName();            // 상품 정보 접근
        wishlist.getProduct().getBrand().getName(); // 브랜드 정보 접근
    }
    

    이 코드가 실제로 날리는 쿼리는 이렇다.

    SELECT * FROM wishlists WHERE user_id = 1;        -- 위시리스트 조회 1번
    
    SELECT * FROM products WHERE product_id = 100;    -- 상품 조회
    SELECT * FROM products WHERE product_id = 101;
    SELECT * FROM products WHERE product_id = 102;
    SELECT * FROM products WHERE product_id = 103;
    SELECT * FROM products WHERE product_id = 104;    -- 위시리스트 5개만큼 5번
    
    SELECT * FROM brands WHERE brand_id = 1;          -- 브랜드 조회
    SELECT * FROM brands WHERE brand_id = 2;
    SELECT * FROM brands WHERE brand_id = 3;
    SELECT * FROM brands WHERE brand_id = 4;
    SELECT * FROM brands WHERE brand_id = 5;          -- 또 5번
    
    -- 총 11번
    

    위시리스트 하나를 조회했는데 연관된 상품, 브랜드를 가져오려고 추가 쿼리가 계속 나간다. 위시리스트가 100개면 201번, 1000개면 2001번이 된다.

    이게 왜 일어나냐면 JPA는 기본적으로 연관된 엔티티를 LAZY 로딩으로 가져오기 때문이다. 처음 findByUserId()를 호출할 때는 위시리스트만 가져오고, getProduct()를 호출하는 시점에 그제서야 상품을 조회하는 쿼리를 날린다. 루프를 돌면서 5번 호출하니 5번의 쿼리가 나가는 것이다.


    @EntityGraph로 해결하기

    @EntityGraph는 처음부터 연관된 엔티티를 JOIN으로 한 번에 가져오도록 지시하는 어노테이션이다. attributePaths에 함께 조회할 필드명을 적어주면 된다.

    @EntityGraph(attributePaths = {
        "product",                       // Product 함께 조회
        "product.brand",                 // Product의 Brand 함께 조회
        "product.category",              // Product의 Category 함께 조회
        "product.category.parentCategory"
    })
    List<Wishlist> findByUserId(Long userId);
    

    이제 실행되는 쿼리는 단 1번이다.

    SELECT w.*, p.*, b.*, c.*
    FROM wishlists w
    LEFT JOIN products p ON w.product_id = p.product_id
    LEFT JOIN brands b ON p.brand_id = b.brand_id
    LEFT JOIN categories c ON p.category_id = c.category_id
    WHERE w.user_id = 1;
    

    위시리스트가 100개든 1000개든 쿼리는 항상 1번이다.


    attributePaths 경로 표현 규칙

    필드명은 엔티티에 선언된 필드 이름 그대로 써야 한다. .으로 연결해서 깊이 있는 연관관계도 표현할 수 있다.

    엔티티 구조가 이렇다면,

    @Entity
    public class Wishlist {
        @ManyToOne(fetch = FetchType.LAZY)
        private Product product;
    }
    
    @Entity
    public class Product {
        @ManyToOne(fetch = FetchType.LAZY)
        private Brand brand;
    
        @ManyToOne(fetch = FetchType.LAZY)
        private Category category;
    }
    
    @Entity
    public class Category {
        @ManyToOne(fetch = FetchType.LAZY)
        private Category parentCategory;
    }
    

    attributePaths는 이렇게 된다.

    @EntityGraph(attributePaths = {
        "product",                        // Wishlist → Product
        "product.brand",                  // Wishlist → Product → Brand
        "product.category",               // Wishlist → Product → Category
        "product.category.parentCategory" // Wishlist → Product → Category → ParentCategory
    })
    

    한 가지 주의할 점은 필드명 오타다. 엔티티에 product라고 선언돼 있는데 products라고 쓰면 런타임에서 에러가 터진다.


    JPQL과 함께 쓸 때

    @Query로 커스텀 쿼리를 쓸 때도 @EntityGraph를 같이 사용할 수 있다.

    @EntityGraph(attributePaths = {"product", "product.brand", "product.category"})
    @Query("SELECT w FROM Wishlist w WHERE w.user.id = :userId AND w.deletedAt IS NULL")
    List<Wishlist> findActiveByUserId(@Param("userId") Long userId);
    

    @EntityGraph vs JOIN FETCH

    비슷한 역할을 하는 JOIN FETCH도 있다. 둘의 차이를 간단히 정리하면,

    비교 항목 @EntityGraph JOIN FETCH

    코드 간결성 간결함 JPQL 직접 작성 필요
    JOIN 타입 LEFT JOIN 고정 INNER/LEFT 선택 가능
    복잡한 조건 제한적 자유로움
    권장 상황 단순 조회 복잡한 쿼리

    단순 조회라면 @EntityGraph가 훨씬 간결하고, 복잡한 조건이 필요하면 JOIN FETCH를 쓰는 게 낫다.

    // JOIN FETCH 방식
    @Query("SELECT w FROM Wishlist w " +
           "JOIN FETCH w.product p " +
           "JOIN FETCH p.brand " +
           "WHERE w.user.id = :userId")
    List<Wishlist> findByUserId(@Param("userId") Long userId);
    

    주의할 점

    1:N 관계에 @EntityGraph를 쓸 때는 조심해야 한다. 예를 들어 상품에 리뷰가 1000개 달려 있으면, 위시리스트 5개를 조회할 때 리뷰 5000개를 한 번에 JOIN해서 가져온다. 카테시안 곱으로 데이터가 폭발적으로 늘어나서 오히려 성능이 더 나빠질 수 있다.

    // 이런 건 조심
    @EntityGraph(attributePaths = {
        "product",
        "product.reviews",      // 1:N 관계
        "product.reviews.user"  // 또 JOIN
    })
    

    1:N 관계는 쿼리를 분리하는 게 정석이다.

    방법 1. 쿼리 두 번으로 분리

    위시리스트와 상품은 @EntityGraph로 한 번에 가져오고, 리뷰는 별도로 가져온다.

    // 위시리스트 + 상품만 먼저 가져오기
    @EntityGraph(attributePaths = {"product", "product.brand"})
    List<Wishlist> findByUserId(Long userId);
    
    // 리뷰는 IN 쿼리로 별도로 가져오기
    List<Review> findByProductIdIn(List<Long> productIds);
    

    서비스에서는 이렇게 쓴다.

    List<Wishlist> wishlists = wishlistRepository.findByUserId(userId);
    
    List<Long> productIds = wishlists.stream()
        .map(w -> w.getProduct().getId())
        .toList();
    
    List<Review> reviews = reviewRepository.findByProductIdIn(productIds);
    

    findByProductIdIn()은 WHERE product_id IN (1, 2, 3, 4, 5) 쿼리로 리뷰를 한 번에 가져온다. 쿼리가 2번으로 끝나고 데이터 폭발도 없다.


    마무리

    N+1 문제는 코드만 봐서는 눈에 잘 안 띄는데 실제 쿼리 로그를 보면 바로 보인다. 개발할 때 쿼리 로그를 켜두고 확인하는 습관이 중요하다. @EntityGraph는 간단한 경우엔 필드명 몇 개만 나열하면 되니까 N+1 문제에 대한 첫 번째 해결책으로 쓰기 좋다.

Designed by Tistory.