Hibernate Fetch 전략 심화

Hibernate Fetch 전략이 중요한 이유

JPA/Hibernate 성능 문제의 대부분은 Fetch 전략에서 시작된다. 엔티티 연관관계를 잘못 로딩하면 N+1 쿼리가 발생하고, 반대로 무조건 EAGER로 설정하면 불필요한 데이터까지 조회하여 메모리를 낭비한다. 이 글에서는 Hibernate의 Fetch 전략을 FetchType, @BatchSize, @Fetch, EntityGraph, Subselect 수준까지 심화하여 다룬다.

FetchType: LAZY vs EAGER

JPA 기본 Fetch 전략은 연관관계 타입에 따라 다르다.

연관관계 기본 FetchType 이유
@ManyToOne EAGER 단일 객체이므로 부담 적음
@OneToOne EAGER 단일 객체
@OneToMany LAZY 컬렉션이므로 대량 데이터 위험
@ManyToMany LAZY 컬렉션

실무 원칙: 모든 연관관계를 LAZY로 설정하고, 필요한 시점에 명시적으로 로딩하는 것이 정석이다.

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 기본 EAGER → LAZY로 변경 (필수!)
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

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

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "payment_id")
    private Payment payment;
}

N+1 문제와 해결 패턴

LAZY 로딩은 N+1 문제를 유발할 수 있다. 주문 10건을 조회하면서 각 주문의 고객 정보에 접근하면 총 11번의 쿼리가 실행된다.

// N+1 발생 코드
List<Order> orders = orderRepository.findAll(); // 1번 쿼리
for (Order order : orders) {
    order.getCustomer().getName();  // 주문마다 1번씩 → N번 추가
}
// 총 1 + N번 쿼리 실행!

해결 1: JPQL fetch join

@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") OrderStatus status);

// 단일 쿼리로 Order + Customer 한 번에 로딩
// SELECT o.*, c.* FROM orders o
// INNER JOIN customers c ON o.customer_id = c.id
// WHERE o.status = ?

주의: 컬렉션 fetch join은 페이징과 함께 사용할 수 없다. Hibernate가 메모리에서 페이징을 수행하므로 대량 데이터에서 OOM이 발생한다.

// ❌ 위험: 컬렉션 fetch join + 페이징
@Query("SELECT o FROM Order o JOIN FETCH o.items")
Page<Order> findAllWithItems(Pageable pageable);
// HHH90003004: firstResult/maxResults specified with collection fetch; 
// applying in memory! ← 전체를 메모리에 올린 후 페이징

// ✅ 해결: @BatchSize 또는 EntityGraph 사용

해결 2: @BatchSize (IN 절 배치)

@BatchSize는 LAZY 연관관계를 접근할 때 N개를 묶어서 IN 절로 조회한다. N+1을 1+⌈N/batchSize⌉로 줄인다.

@Entity
public class Order {

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    @BatchSize(size = 100)  // 고객을 100명씩 IN 절로 조회
    private Customer customer;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 50)   // 주문 아이템을 50건씩 묶어서 조회
    private List<OrderItem> items;
}

// 실행되는 SQL (주문 200건 조회 시)
// 1. SELECT * FROM orders WHERE ...
// 2. SELECT * FROM customers WHERE id IN (?, ?, ..., ?)  -- 최대 100개
// 3. SELECT * FROM customers WHERE id IN (?, ?, ..., ?)  -- 나머지
// 4. SELECT * FROM order_items WHERE order_id IN (?, ..., ?)  -- 50개씩

글로벌 설정으로 전체 엔티티에 기본 batch size를 적용할 수도 있다.

# application.yml
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

해결 3: @Fetch(FetchMode.SUBSELECT)

SUBSELECT는 원본 쿼리를 서브쿼리로 재사용하여 단 1번의 추가 쿼리로 모든 연관관계를 로딩한다.

@Entity
public class Order {

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

// 1차: SELECT * FROM orders WHERE status = 'PENDING'
// 2차: SELECT * FROM order_items 
//      WHERE order_id IN (SELECT id FROM orders WHERE status = 'PENDING')
// → 항상 정확히 2번의 쿼리로 해결
전략 쿼리 수 페이징 적합한 경우
fetch join 1 ❌ (컬렉션) ToOne 관계, 소량 데이터
@BatchSize 1+⌈N/size⌉ 범용, 글로벌 기본값
SUBSELECT 2 전체 컬렉션 로딩
EntityGraph 1 ⚠️ (주의) 동적 Fetch 계획

EntityGraph: 동적 Fetch 계획

EntityGraph는 엔티티 정의를 변경하지 않고 쿼리 시점에 Fetch 전략을 지정한다.

Named EntityGraph

@Entity
@NamedEntityGraphs({
    @NamedEntityGraph(
        name = "Order.withCustomer",
        attributeNodes = @NamedAttributeNode("customer")
    ),
    @NamedEntityGraph(
        name = "Order.withCustomerAndItems",
        attributeNodes = {
            @NamedAttributeNode("customer"),
            @NamedAttributeNode(value = "items", subgraph = "items-subgraph")
        },
        subgraphs = @NamedSubgraph(
            name = "items-subgraph",
            attributeNodes = @NamedAttributeNode("product")
        )
    )
})
public class Order {
    // ...
}

// Repository에서 사용
public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph("Order.withCustomer")
    List<Order> findByStatus(OrderStatus status);

    @EntityGraph("Order.withCustomerAndItems")
    Optional<Order> findDetailById(Long id);
}

Ad-hoc EntityGraph (Spring Data JPA)

public interface OrderRepository extends JpaRepository<Order, Long> {

    // attributePaths로 간단하게 지정
    @EntityGraph(attributePaths = {"customer", "items", "items.product"})
    @Query("SELECT o FROM Order o WHERE o.status = :status")
    List<Order> findByStatusWithDetails(@Param("status") OrderStatus status);
}

FETCH vs LOAD 타입

// FETCH: EntityGraph에 명시된 속성은 EAGER, 나머지는 LAZY
@EntityGraph(
    attributePaths = {"customer"},
    type = EntityGraph.EntityGraphType.FETCH  // 기본값
)
List<Order> findByStatus(OrderStatus status);

// LOAD: EntityGraph에 명시된 속성은 EAGER, 나머지는 엔티티 정의 따름
@EntityGraph(
    attributePaths = {"customer"},
    type = EntityGraph.EntityGraphType.LOAD
)
List<Order> findByStatus(OrderStatus status);

@OneToOne LAZY 로딩 함정

@OneToOne의 비주인(mappedBy) 쪽은 LAZY가 동작하지 않는다. Hibernate는 프록시를 만들기 위해 연관 엔티티 존재 여부를 확인해야 하므로 어차피 쿼리가 실행된다.

@Entity
public class Order {
    // 주인 쪽: LAZY 정상 동작
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "payment_id")
    private Payment payment;
}

@Entity
public class Payment {
    // 비주인 쪽: LAZY 설정해도 EAGER처럼 동작!
    @OneToOne(mappedBy = "payment", fetch = FetchType.LAZY)
    private Order order;
}

// 해결 방법 1: @MapsId 사용 (같은 PK 공유)
@Entity
public class Payment {
    @Id
    private Long id;  // Order와 같은 PK

    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    @JoinColumn(name = "id")
    private Order order;
}

// 해결 방법 2: optional = false (NOT NULL 보장)
@OneToOne(mappedBy = "payment", fetch = FetchType.LAZY, optional = false)
private Order order;
// Hibernate가 null 체크 쿼리를 생략할 수 있음

// 해결 방법 3: bytecode enhancement
// build.gradle
// hibernate { enhance { enableLazyInitialization = true } }

Hibernate 6: Fetch Profile

Hibernate 6에서는 @FetchProfile로 상황에 따라 Fetch 전략을 전환할 수 있다.

@Entity
@FetchProfile(
    name = "order-with-details",
    fetchOverrides = {
        @FetchProfile.FetchOverride(
            entity = Order.class,
            association = "items",
            mode = FetchMode.JOIN
        ),
        @FetchProfile.FetchOverride(
            entity = Order.class,
            association = "customer",
            mode = FetchMode.JOIN
        )
    }
)
public class Order { ... }

// 사용
Session session = entityManager.unwrap(Session.class);
session.enableFetchProfile("order-with-details");
Order order = session.get(Order.class, orderId);
// → items, customer 모두 JOIN으로 로딩

실전 성능 디버깅

# SQL 로그로 N+1 감지
spring:
  jpa:
    show-sql: false  # 프로덕션에서는 false
    properties:
      hibernate:
        format_sql: true
        # 쿼리 통계 활성화
        generate_statistics: true
        # 느린 쿼리 로깅 (500ms 이상)
        session.events.log.LOG_QUERIES_SLOWER_THAN_MS: 500

logging:
  level:
    org.hibernate.SQL: DEBUG           # SQL 쿼리 출력
    org.hibernate.orm.jdbc.bind: TRACE # 바인드 파라미터 출력
    org.hibernate.stat: DEBUG          # 실행 통계
// 테스트에서 쿼리 수 검증
@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    private EntityManager em;

    @Test
    void shouldNotCauseNPlusOne() {
        SessionFactory sf = em.unwrap(Session.class)
            .getSessionFactory();
        Statistics stats = sf.getStatistics();
        stats.setStatisticsEnabled(true);
        stats.clear();

        List<Order> orders = orderRepository
            .findByStatusWithDetails(OrderStatus.PENDING);

        // 연관관계 접근 (N+1 유발 지점)
        orders.forEach(o -> {
            o.getCustomer().getName();
            o.getItems().size();
        });

        // 쿼리 수 검증: fetch join이면 1~2회
        assertThat(stats.getQueryExecutionCount())
            .isLessThanOrEqualTo(2);
    }
}

마무리

Hibernate Fetch 전략은 JPA 성능의 핵심이다. LAZY를 기본으로, 필요한 시점에 명시적으로 로딩하는 것이 정석이며, 상황에 따라 적절한 전략을 선택해야 한다.

실무 체크리스트:

  • @ManyToOne, @OneToOne: 반드시 fetch = LAZY로 변경
  • 글로벌 BatchSize: default_batch_fetch_size: 100 설정
  • ToOne 관계: fetch join으로 해결
  • 컬렉션 + 페이징: @BatchSize 또는 별도 쿼리 분리
  • @OneToOne 비주인: @MapsId 또는 optional=false 적용

Hibernate 영속성 컨텍스트 심화와 함께 읽으면 1차 캐시와 Fetch 전략의 상호작용을 이해할 수 있고, Spring JPA Batch Insert 최적화에서 쓰기 성능까지 잡을 수 있다.

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