JPA EntityGraph 페치 전략

JPA EntityGraph란?

JPA @EntityGraph는 엔티티 조회 시 연관 관계의 페치 전략을 동적으로 지정하는 기능이다. 엔티티에 FetchType.LAZY로 선언해두고, 특정 쿼리에서만 필요한 관계를 JOIN FETCH로 로딩할 수 있다. N+1 문제를 해결하면서도 기본 지연 로딩의 이점을 유지하는 핵심 도구다.

// 엔티티: 기본은 LAZY
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();
}

// Repository: 특정 쿼리에서만 Eager 로딩
public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"user", "items"})
    List<Order> findAllByUserId(Long userId);

    // EntityGraph 없는 메서드: user, items 모두 LAZY
    List<Order> findByStatus(OrderStatus status);
}

attributePaths vs Named EntityGraph

EntityGraph를 정의하는 두 가지 방법이 있다.

방법 1: attributePaths (인라인)

// Repository 메서드에 직접 지정 — 간단한 경우 권장
@EntityGraph(attributePaths = {"user", "items", "items.product"})
List<Order> findAllWithDetails();

방법 2: @NamedEntityGraph (엔티티에 선언)

@Entity
@NamedEntityGraph(
    name = "Order.withUserAndItems",
    attributeNodes = {
        @NamedAttributeNode("user"),
        @NamedAttributeNode(value = "items", subgraph = "items-product")
    },
    subgraphs = {
        @NamedSubgraph(
            name = "items-product",
            attributeNodes = @NamedAttributeNode("product")
        )
    }
)
public class Order { ... }

// Repository에서 참조
@EntityGraph(value = "Order.withUserAndItems")
List<Order> findAllByUserId(Long userId);
방법 장점 단점 사용 시점
attributePaths 간결, 한눈에 파악 깊은 중첩 표현 어려움 1~2 depth 관계
@NamedEntityGraph subgraph로 깊은 중첩, 재사용 선언 복잡, 엔티티 오염 3+ depth, 여러 곳 재사용

FETCH vs LOAD 타입

@EntityGraph에는 type 속성으로 FETCHLOAD 두 가지 모드가 있다. 차이를 정확히 이해해야 의도하지 않은 쿼리 발생을 방지할 수 있다.

타입 EntityGraph에 명시된 속성 명시되지 않은 속성
FETCH (기본값) EAGER 로딩 LAZY로 강제 (엔티티 설정 무시)
LOAD EAGER 로딩 엔티티 설정 따름
// FETCH 타입: user만 Eager, 나머지(items 등)는 모두 LAZY
@EntityGraph(attributePaths = {"user"}, type = EntityGraphType.FETCH)
List<Order> findAllFetchUser();

// LOAD 타입: user는 Eager, 나머지는 엔티티에 선언된 FetchType 따름
@EntityGraph(attributePaths = {"user"}, type = EntityGraphType.LOAD)
List<Order> findAllLoadUser();

대부분의 경우 FETCH(기본값)가 안전하다. 명시하지 않은 관계가 예상치 못하게 EAGER 로딩되는 것을 방지한다.

컬렉션 페치와 카테시안 곱 문제

EntityGraph로 여러 컬렉션(OneToMany)을 동시에 페치하면 카테시안 곱(Cartesian Product)이 발생한다. 이것은 JPA EntityGraph의 가장 큰 함정이다.

@Entity
public class Order {
    @OneToMany(mappedBy = "order")
    private List<OrderItem> items;      // 5개

    @OneToMany(mappedBy = "order")
    private List<OrderComment> comments; // 3개
}

// ❌ 두 컬렉션 동시 페치 → 카테시안 곱 (5 × 3 = 15행)
@EntityGraph(attributePaths = {"items", "comments"})
List<Order> findAllWithItemsAndComments();
// Hibernate: MultipleBagFetchException 또는 중복 결과

해결 방법 3가지:

// 해결 1: Set으로 변경 (중복 제거, 순서 보장 안 됨)
@OneToMany(mappedBy = "order")
private Set<OrderItem> items;

@OneToMany(mappedBy = "order")
private Set<OrderComment> comments;

// 해결 2: 쿼리 분리 (권장)
@EntityGraph(attributePaths = {"items"})
@Query("SELECT o FROM Order o WHERE o.id = :id")
Optional<Order> findWithItems(@Param("id") Long id);

@EntityGraph(attributePaths = {"comments"})
@Query("SELECT o FROM Order o WHERE o.id = :id")
Optional<Order> findWithComments(@Param("id") Long id);

// 해결 3: @BatchSize로 IN 쿼리 (Hibernate 전용)
@OneToMany(mappedBy = "order")
@BatchSize(size = 100)  // SELECT ... WHERE order_id IN (?, ?, ...)
private List<OrderItem> items;

JPQL JOIN FETCH vs EntityGraph

비교 JOIN FETCH EntityGraph
선언 위치 JPQL 쿼리 안 어노테이션
Spring Data 메서드 이름 쿼리 사용 불가 사용 가능
JOIN 타입 제어 LEFT/INNER 선택 가능 항상 LEFT JOIN
WHERE 조건 JOIN 대상에 조건 가능 불가
페이징 컬렉션 페치 시 메모리 페이징 동일 문제
// EntityGraph의 핵심 장점: 메서드 이름 쿼리와 조합
@EntityGraph(attributePaths = {"user"})
List<Order> findByStatusAndCreatedAtAfter(OrderStatus status, LocalDateTime after);
// @Query 없이도 JOIN FETCH 효과!

// JOIN FETCH는 @Query 필수
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = :status AND o.createdAt > :after")
List<Order> findByStatusWithUser(@Param("status") OrderStatus status, @Param("after") LocalDateTime after);

동적 EntityGraph (런타임 결정)

API 요청에 따라 로딩할 관계를 동적으로 결정해야 할 때 EntityManager를 직접 사용한다.

@Repository
public class OrderCustomRepository {

    @PersistenceContext
    private EntityManager em;

    public List<Order> findWithDynamicGraph(Long userId, List<String> fetchPaths) {
        EntityGraph<Order> graph = em.createEntityGraph(Order.class);

        for (String path : fetchPaths) {
            if (path.contains(".")) {
                // 중첩 관계: "items.product"
                String[] parts = path.split("\.");
                Subgraph<?> subgraph = graph.addSubgraph(parts[0]);
                subgraph.addAttributeNodes(parts[1]);
            } else {
                graph.addAttributeNodes(path);
            }
        }

        return em.createQuery(
                "SELECT o FROM Order o WHERE o.user.id = :userId", Order.class)
            .setParameter("userId", userId)
            .setHint("jakarta.persistence.fetchgraph", graph)
            .getResultList();
    }
}

// 사용: 클라이언트 요청에 따라 로딩 범위 결정
// GET /orders?include=user,items.product
List<String> includes = List.of("user", "items.product");
orderCustomRepo.findWithDynamicGraph(userId, includes);

페이징과 EntityGraph 주의점

컬렉션 관계에 EntityGraph를 적용하면서 Pageable을 사용하면 Hibernate가 메모리에서 페이징한다. 이는 대량 데이터에서 OOM을 유발한다.

// ⚠️ 위험: 컬렉션 페치 + 페이징 → 메모리 페이징
// HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
@EntityGraph(attributePaths = {"items"})
Page<Order> findByUserId(Long userId, Pageable pageable);

// ✅ 해결: 2단계 쿼리
// 1단계: ID만 페이징 조회
@Query("SELECT o.id FROM Order o WHERE o.user.id = :userId")
Page<Long> findOrderIds(@Param("userId") Long userId, Pageable pageable);

// 2단계: ID로 EntityGraph 조회
@EntityGraph(attributePaths = {"user", "items"})
@Query("SELECT o FROM Order o WHERE o.id IN :ids")
List<Order> findAllWithDetailsByIds(@Param("ids") List<Long> ids);

이 2단계 패턴은 N+1 해결 전략과 함께 프로덕션 JPA의 핵심 기법이다.

정리

JPA EntityGraph는 엔티티의 기본 LAZY 설정을 유지하면서 쿼리 단위로 페치 전략을 변경하는 강력한 도구다. attributePaths로 간단하게, @NamedEntityGraph로 깊은 중첩을 표현하고, FETCH/LOAD 타입 차이를 이해해야 한다. 카테시안 곱 문제는 쿼리 분리나 @BatchSize로 해결하고, 페이징은 2단계 쿼리 패턴을 적용하면 트랜잭션 관리와 함께 견고한 JPA 데이터 접근 계층을 설계할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux