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 최적화에서 쓰기 성능까지 잡을 수 있다.