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 속성으로 FETCH와 LOAD 두 가지 모드가 있다. 차이를 정확히 이해해야 의도하지 않은 쿼리 발생을 방지할 수 있다.
| 타입 | 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 데이터 접근 계층을 설계할 수 있다.