EntityGraph란?
JPA의 EntityGraph는 엔티티 조회 시 어떤 연관관계를 함께 로딩할지 선언적으로 제어하는 기능입니다. JPQL의 JOIN FETCH와 비슷하지만, 쿼리 변경 없이 로딩 전략을 유연하게 교체할 수 있다는 점이 핵심 차별점입니다.
Spring JPA N+1 해결 전략에서 다룬 것처럼 N+1 문제의 근본 원인은 LAZY 로딩입니다. EntityGraph는 이 문제를 쿼리 수정 없이 해결하는 우아한 방법을 제공합니다.
@NamedEntityGraph 정의
엔티티 클래스에 @NamedEntityGraph를 선언하여 재사용 가능한 그래프를 정의합니다.
@Entity
@NamedEntityGraph(
name = "Order.withItemsAndProduct",
attributeNodes = {
@NamedAttributeNode(value = "orderItems", subgraph = "items-product")
},
subgraphs = {
@NamedSubgraph(
name = "items-product",
attributeNodes = @NamedAttributeNode("product")
)
}
)
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems = new ArrayList<>();
private LocalDateTime orderDate;
}
subgraphs를 활용하면 2단계 이상의 중첩 연관관계도 한 번에 로딩할 수 있습니다. 위 예시에서는 Order → OrderItem → Product를 단일 쿼리로 가져옵니다.
Spring Data JPA에서 사용하기
Spring Data JPA의 @EntityGraph 어노테이션으로 Repository 메서드에 바로 적용합니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Named EntityGraph 참조
@EntityGraph(value = "Order.withItemsAndProduct")
List<Order> findByMemberId(Long memberId);
// Ad-hoc EntityGraph (인라인 정의)
@EntityGraph(attributePaths = {"member", "orderItems"})
Optional<Order> findWithDetailsById(Long id);
// JPQL + EntityGraph 조합
@EntityGraph(attributePaths = {"orderItems.product"})
@Query("SELECT o FROM Order o WHERE o.orderDate > :date")
List<Order> findRecentWithProducts(@Param("date") LocalDateTime date);
}
| 방식 | 장점 | 단점 |
|---|---|---|
| @NamedEntityGraph | 재사용 가능, 복잡한 서브그래프 지원 | 엔티티 클래스에 선언 필요 |
| attributePaths (Ad-hoc) | Repository에서 바로 정의, 간결 | 복잡한 그래프에 부적합 |
| EntityManager API | 동적 생성, 런타임 유연성 | 코드량 증가 |
FETCH vs LOAD 타입
@EntityGraph의 type 속성은 그래프에 포함되지 않은 속성의 로딩 전략을 결정합니다.
// FETCH: 그래프에 없는 속성은 무조건 LAZY
@EntityGraph(
value = "Order.withItemsAndProduct",
type = EntityGraphType.FETCH // 기본값
)
List<Order> findAll();
// LOAD: 그래프에 없는 속성은 엔티티에 정의된 FetchType 따름
@EntityGraph(
value = "Order.withItemsAndProduct",
type = EntityGraphType.LOAD
)
List<Order> findAllWithLoad();
| 타입 | 그래프에 포함된 속성 | 그래프에 없는 속성 |
|---|---|---|
| FETCH | EAGER 로딩 | 강제 LAZY |
| LOAD | EAGER 로딩 | 엔티티 FetchType 유지 |
대부분의 경우 FETCH 타입이 권장됩니다. 불필요한 EAGER 로딩을 방지하여 성능을 예측 가능하게 만들기 때문입니다.
EntityManager API로 동적 그래프 생성
런타임에 조건에 따라 로딩 전략을 바꿔야 할 때 EntityManager의 createEntityGraph()를 활용합니다.
@Service
@RequiredArgsConstructor
public class OrderQueryService {
private final EntityManager em;
public Order findWithDynamicGraph(Long orderId, boolean includeProducts) {
EntityGraph<Order> graph = em.createEntityGraph(Order.class);
graph.addAttributeNodes("member");
Subgraph<OrderItem> itemGraph = graph.addSubgraph("orderItems");
if (includeProducts) {
itemGraph.addAttributeNodes("product");
}
return em.find(Order.class, orderId,
Map.of("jakarta.persistence.fetchgraph", graph));
}
}
힌트 키로 jakarta.persistence.fetchgraph를 사용하면 FETCH 타입, jakarta.persistence.loadgraph를 사용하면 LOAD 타입으로 동작합니다.
EntityGraph vs JOIN FETCH 비교
| 기준 | EntityGraph | JOIN FETCH |
|---|---|---|
| 쿼리 분리 | 쿼리와 로딩 전략 분리 | 쿼리에 로딩 전략 포함 |
| 재사용성 | 동일 쿼리에 다른 그래프 적용 가능 | 쿼리마다 별도 작성 |
| 페이징 | 컬렉션 로딩 시 in-memory 페이징 주의 | 동일한 제약 |
| 동적 전환 | 런타임에 그래프 교체 가능 | 쿼리 자체를 변경해야 함 |
| SQL 제어 | Hibernate가 SQL 생성 | 작성한 JPQL이 곧 SQL |
카테시안 곱 문제와 해결
여러 컬렉션을 동시에 EntityGraph로 로딩하면 카테시안 곱(Cartesian Product)이 발생하여 결과 행이 폭증합니다.
// ⚠️ 위험: 두 개의 컬렉션을 동시에 EAGER 로딩
@EntityGraph(attributePaths = {"orderItems", "payments"})
List<Order> findByMemberId(Long memberId);
// → MultipleBagFetchException 또는 카테시안 곱 발생
해결 방법은 컬렉션 하나만 EntityGraph로 로딩하고, 나머지는 @BatchSize로 처리하는 것입니다.
@Entity
public class Order {
@OneToMany(mappedBy = "order")
private List<OrderItem> orderItems; // EntityGraph로 로딩
@BatchSize(size = 100)
@OneToMany(mappedBy = "order")
private List<Payment> payments; // BatchSize로 로딩
}
// Repository: 컬렉션 하나만 그래프에 포함
@EntityGraph(attributePaths = {"orderItems.product"})
List<Order> findByMemberId(Long memberId);
Spring JPA Projection 최적화에서 다룬 것처럼, 읽기 전용 조회에서는 Projection을 함께 활용하여 불필요한 컬럼 로딩을 줄이는 것이 좋습니다.
실전 팁: 용도별 그래프 분리
하나의 엔티티에 여러 Named EntityGraph를 정의하여 API별로 다른 로딩 전략을 적용합니다.
@Entity
@NamedEntityGraphs({
@NamedEntityGraph(
name = "Order.summary",
attributeNodes = @NamedAttributeNode("member")
),
@NamedEntityGraph(
name = "Order.detail",
attributeNodes = {
@NamedAttributeNode("member"),
@NamedAttributeNode(value = "orderItems", subgraph = "items-detail")
},
subgraphs = @NamedSubgraph(
name = "items-detail",
attributeNodes = {
@NamedAttributeNode("product"),
@NamedAttributeNode("coupon")
}
)
)
})
public class Order { /* ... */ }
// 목록 API → 가벼운 그래프
@EntityGraph("Order.summary")
Page<Order> findAll(Pageable pageable);
// 상세 API → 무거운 그래프
@EntityGraph("Order.detail")
Optional<Order> findDetailById(Long id);
정리
JPA EntityGraph는 쿼리와 로딩 전략을 분리하여 유연한 페치 제어를 가능하게 합니다. FETCH 타입으로 불필요한 로딩을 차단하고, 컬렉션은 하나만 그래프에 포함하며, 용도별로 Named Graph를 분리하는 것이 핵심 원칙입니다. JOIN FETCH보다 재사용성이 뛰어나므로, 동일 엔티티를 다양한 API에서 다르게 조회해야 할 때 특히 유용합니다.