본문 바로가기
🛠 백엔드/Spring

N+1 문제 해결을 위한 최고의 선택은?

by meteorfish 2025. 3. 13.
728x90

현재 상황

기존 코드의 경우, 키워드를 통한 Blueprint를 검색하는 JPQL을 사용하고 있다. 이 경우에 당연하게도 Blueprint에 있는 연관관계에 의해 N+1이 발생하게 된다.

// Blueprint

@OneToMany(mappedBy = "blueprint")
private List<OrderBlueprint> orderBlueprints = new ArrayList<>();

@OneToMany(mappedBy = "blueprint")
private List<CartBlueprint> cartBlueprints = new ArrayList<>();
// BlueprintRepository

@Query(value = "SELECT b FROM Blueprint b WHERE (b.blueprintName LIKE %:keyword% OR b.creatorName LIKE %:keyword%) AND b.inspectionStatus = :status AND b.isDeleted = false")
Page<Blueprint> findAllNameAndCreatorContaining(@Param("keyword") String keyword, @Param("status") InspectionStatus status, Pageable pageable);

현재 모든 연관관계가 지연 로딩이 설정되어 있다. Blueprint를 조회하게 되면 연관관계를 가진 orderBlueprints와 cartBlueprints를 가져오게 된다. 이 경우, Blueprint의 orderBlueprints와 cartBlueprints의 ID마다 조회하는 쿼리가 실행하므로 N+1이 발생한다.

이를 해결하기 위해 가장 간단한 해결 방법인 Fetch Join을 적용했다.

 

@Query(value = """
    SELECT DISTINCT b
    FROM Blueprint b
    LEFT JOIN FETCH b.orderBlueprints
    WHERE b IN :blueprints
""")
List<Blueprint> findWithOrderBlueprints(@Param("blueprints") List<Blueprint> blueprints);

 

또한, FETCH JOIN 사용 시 페이징 사용이 불가능하다. 따라서, 도면 이름과 작가 이름으로 Blueprint을 검색하는 쿼리를 먼저 수행하고, 받아온 결과들을 바탕으로 orderBlueprint, cartBlueprint와의 페치 조인을 하도록 구현하였다.

@Query(value = """
    SELECT DISTINCT b
    FROM Blueprint b
    LEFT JOIN FETCH b.orderBlueprints
    WHERE b IN :blueprints
""")
List<Blueprint> findWithOrderBlueprints(@Param("blueprints") List<Blueprint> blueprints);

@Query(value = """
    SELECT DISTINCT b
    FROM Blueprint b
    LEFT JOIN FETCH b.cartBlueprints
    WHERE b IN :blueprints
""")
List<Blueprint> findWithCartBlueprints(@Param("blueprints") List<Blueprint> blueprints);

 

수행결과는 아래와 같이 N+1 문제를 해결할 수 있었다.

    select *
    from
        blueprint b
    where
        b.blueprint_name like replace(?, '\\', '\\\\') 
        OR b.creator_name like replace(?, '\\', '\\\\')
    order by
        b.id desc 
    limit
        ?, ?


    select
        count(b.id) 
    from
        blueprint b 
    where
        b.blueprint_name like replace(?, '\\', '\\\\') 
        OR b.creator_name like replace(?, '\\', '\\\\')


    select
        distinct *
    from
        blueprint b 
    left join
        order_blueprint ob 
            ON b.id=ob.blueprint_id
    where
        b.id in (?, ?, ?, ?, ?, ?, ?, ?)


    select
        distinct *
    from
        blueprint b 
    left join
        order_blueprint ob 
            ON b.id=ob.blueprint_id 
    where
        b.id in (?, ?, ?, ?, ?, ?, ?, ?)

실행 결과

 

 

 

FETCH JOIN vs. @EntityGraph

FETCH JOIN

SELECT DISTINCT *
FROM BLUEPRINT B
LEFT JOIN ORDER_BLUEPRINT OB
    ON B.ID = OB.BLUEPRINT_ID 
    AND (OB.IS_DELETED = 0) 
WHERE
    (B.IS_DELETED = 0) 
    AND B.ID IN (?, ?, ?);

 

EntityGraph

SELECT *
FROM BLUEPRINT B 
LEFT JOIN ORDER_BLUEPRINT OB 
    ON B.ID = OB.BLUEPRINT_ID 
    AND (OB.IS_DELETED = 0) 
WHERE
    (B.IS_DELETED = 0) 
    AND B.ID IN (?, ?, ?);

 

두 게 모두 LEFT JOIN을 통해 Blueprint와 Order_Blueprint를 조인하고 있다.  그러나 내부적으로 EntityGraph는 Left Outer Join을 사용하고, Fetch Join은 inner join을 사용한다. 페치 조인의 경우 중복된 데이터가 나오기 때문에 JPQL에서 DISTINCT를 사용하여 중복을 제거했다.

 

FETCH JOIN의 중복 이유

fetch join 사용 시, inner join을 사용하는 이는 교집합을 의미한다. 따라서, 조건에 따라 join되는 모든 값들이 나오게 된다.

출처:https://devraphy.tistory.com/602

예를 들어서 위는 FETCH JOIN을 이용한 TEAM과 MEMBER의 조인 결과이다. ManUnited라는 팀이 각각 Park Ji Sung, C.Ronaldo와 매치가되어 2번의 중복된 결과가 나오게 되는 것이다.

 

@EnriryGraph가 중복이 발생하지 않는 이유

 

@EntityGraph도 left outer join을 사용하는데 왜 중복 문제가 발생하지 않는 것일까?

그 이유는 내부적으로 두 개의 쿼리를 실행하기 때문이다. 그 이후 두 쿼리의 결과를 조립하여 결과로 출력하기 때문에 중복이 발생하지 않는다. 따라서 중복 문제가 해결된다고 한다. 그러나 관련된 자료가 없어 확인하는데 어려움이 있다.

-- 1. 부모 엔티티 조회 (Blueprint)
SELECT B.*
FROM BLUEPRINT B
WHERE B.ID IN (?, ?, ?);

-- 2. 연관된 자식 엔티티 조회 (OrderBlueprint)
SELECT OB.*
FROM ORDER_BLUEPRINT OB
WHERE OB.BLUEPRINT_ID IN (?, ?, ?);

 

FETCH JOIN의 한계

페치 조인은 두 개 이상의 콜렉션을 페치할 수 없다. 그 이유는 콜렉션 사이의 카테시안 곱이 발생하여 성능적으로 영향을 미칠 수 있기 때문이다. 

 또한 페이징 API 사용이 불가능하다. 페이징 쿼리(LIMIT, OFFSET 등)가 올바르게 동작하려면, 조인된 컬렉션(@OneToMany)을 고려하지 않고 개별 엔티티 단위로 페이징이 수행되어야 한다. 즉, JPA는 FETCH JOIN을 사용할 경우, 전체 결과 집합에서 개별 엔티티 기준으로 페이징을 적용할 방법이 없기 때문에 예외를 발생할 수 있다.

 

결론

사용 상황적절한 선택

특정한 조건(WHERE, ORDER BY)을 추가해야 할 때 FETCH JOIN
findById() 같은 기본 JPA 메서드에서 JOIN을 사용하고 싶을 때 @EntityGraph
컬렉션(@OneToMany)을 포함하는 JOIN을 수행할 때 @EntityGraph (중복 방지)
중복 데이터가 발생할 가능성이 없는 @ManyToOne 관계 FETCH JOIN
성능 최적화가 필요한 경우 (JPQL 기반) FETCH JOIN

 

성능 면에서

EntityGraph의 경우, 기본적으로 LEFT OUTER JOIN만 제공하기 때문에 추가적인 조건 추가가 어렵다. 반면 페치 조인은 원하는 WHERE 절 추가가 가능하여 필요한 데이터만 조회가 가능하다. 또한, 여러 개의 관계를 한 번에 로드하는게 가능하다. 

이번 프로젝트에선 FETCH JOIN을 이용하기로 했다. cartBlueprint, orderBlueprint와의 연관관계를 수행하기 위해 FETCH JOIN 사용이 더 간편했으면 페이지네이션 사용을 위해선 FETCH JOIN 쿼리를 분리해야 했다.

 

 

 

728x90