History and Necessity of Loading Strategies

ORM (Object-Relational Mapping) frameworks emerged to solve the impedance mismatch between object-oriented programming and relational databases. During this process, how to efficiently load entities with associations became a critical challenge. Early ORM implementations used immediate loading for all associated entities, which caused performance degradation by loading unnecessary data into memory. Hibernate introduced a proxy-based lazy loading mechanism to solve this problem.

Hibernate has continuously improved loading strategies since its initial release in 2001. While early versions only supported simple lazy loading, later versions added various optimization techniques such as fetch join, batch fetching, and subselect fetching. These features were included in the JPA (Java Persistence API) 1.0 standard when it was released in 2006. Through JPA 2.0 (2009) and JPA 2.1 (2013), declarative fetch strategies like @EntityGraph were added, enabling more sophisticated control over associated entity loading. Today, the JPA standard and Hibernate implementation continue to evolve together, providing developers with various options.

Lazy Loading

What is Lazy Loading

Lazy Loading is a loading strategy that fetches associated entities from the database at the point they are actually used. When an entity is first loaded, a proxy object is injected instead of the associated entity. The actual database query is executed to fetch the data when a method on the proxy object is called.

How Proxy Objects Work

Lazy Loading is implemented through proxy objects. A proxy object is a fake object dynamically generated by Hibernate at runtime by extending the actual entity class. It provides the same interface as the real entity while internally delaying database query execution.

A proxy object has a target field that references the actual entity object. Initially, this target remains null. When a method on the proxy object is called (except getId()), the database query is executed to retrieve the actual entity and assign it to the target field through an initialization process. This proxy initialization is only possible when the persistence context is active. If you try to initialize a proxy after the persistence context has been closed when the transaction ends, a LazyInitializationException is thrown.

Real entities and proxy objects appear identical on the surface, but differences become apparent when using instanceof operations or the getClass() method. Therefore, you must use the equals() method when comparing entities. To check if an object is a proxy, you can use the Hibernate.isInitialized() method or the PersistenceUnitUtil.isLoaded() method.

Features

  • Does not fetch associated data immediately but retrieves it when actually used.
  • Used to optimize performance and reduce memory usage.
  • Reduces initial loading time when there are many associated entities.
  • Proxy initialization is only possible when the persistence context is active.

Example

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "member")
    private List<Order> orders;
}

Advantages

  • Reduces initial loading time.
  • Can reduce memory usage when there are many associated entities.
  • Reduces network traffic by selectively loading only necessary data.

Disadvantages

  • May cause N+1 problems as queries are executed every time associated entities are used.
  • LazyInitializationException occurs if proxies are used outside transaction boundaries.

Eager Loading

What is Eager Loading

Eager Loading is a loading strategy that joins and loads all data in a single query when fetching an entity. It immediately initializes real entity objects without using proxies and stores them in the persistence context.

Features

  • Fetches associated data all at once.
  • No need to execute additional queries when using associated entities.
  • Real entity objects are immediately loaded, not proxies.

Example

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(fetch = FetchType.EAGER, mappedBy = "member")
    private List<Order> orders;
}

Advantages

  • No need to execute additional queries when using associated entities.
  • LazyInitializationException does not occur.

Disadvantages

  • May increase initial loading time.
  • Can increase memory usage when there are many associated entities.
  • Performance waste occurs by loading even unused data.
  • N+1 problems can still occur when using JPQL with Eager Loading.

Detailed Explanation of N+1 Problem

What is N+1 Problem

The N+1 problem is a performance issue where a total of N+1 queries are executed: one initial query plus N queries to fetch associated entities. For example, if you query 10 members and then retrieve each member’s order list, one query for members plus 10 queries for orders are executed, resulting in 11 total queries.

Why N+1 Problems Occur with Lazy Loading

Lazy Loading fetches associated entities as proxies, so only one query is executed during the initial retrieval. However, when accessing associated entities in a loop, queries for proxy initialization are executed individually for each entity, ultimately causing the N+1 problem.

List<Member> members = em.createQuery("select m from Member m", Member.class)
    .getResultList();  // 1 query executed

for (Member member : members) {
    List<Order> orders = member.getOrders();
    orders.size();  // Additional query executed for each member (N queries)
}

When N+1 Problems Occur with Eager Loading

With Eager Loading, fetching a single entity using EntityManager.find() uses a join to retrieve everything at once. However, when querying multiple entities using JPQL or Criteria API, the JPQL query is executed as-is first. If associated entities configured with Eager Loading are discovered in the results, additional queries are executed for each, causing the N+1 problem.

Actual Query Log Example

-- Initial member query (1 query)
SELECT * FROM member;

-- Order queries for each member (N queries)
SELECT * FROM orders WHERE member_id = 1;
SELECT * FROM orders WHERE member_id = 2;
SELECT * FROM orders WHERE member_id = 3;
...

Solutions for N+1 Problem

Fetch Join

Using the join fetch keyword in JPQL allows you to retrieve associated entities in one query through SQL joins. This is the most common and effective solution for the N+1 problem.

List<Member> members = em.createQuery(
    "select m from Member m join fetch m.orders", Member.class)
    .getResultList();

Fetch join uses SQL INNER JOIN or LEFT OUTER JOIN to retrieve associated entities together, executing only one query. However, collection fetch joins can produce duplicate results, so you should use the distinct keyword together. When used with paging APIs (setFirstResult, setMaxResults), warning logs are output and paging is processed in memory, so caution is needed.

@EntityGraph

@EntityGraph is a feature added in JPA 2.1 that allows you to declaratively specify which associated entities to load together when querying entities. You can achieve effects similar to fetch join without JPQL.

public interface MemberRepository extends JpaRepository<Member, Long> {
    @EntityGraph(attributePaths = {"orders"})
    List<Member> findAll();

    @EntityGraph(attributePaths = {"orders", "orders.items"})
    @Query("select m from Member m")
    List<Member> findAllWithOrders();
}

@EntityGraph uses LEFT OUTER JOIN to load associated entities. You can specify multiple associated entities in attributePaths. You can also define reusable graphs by combining with @NamedEntityGraph. However, fetch join is more suitable for complex conditions or dynamic queries.

@BatchSize

@BatchSize is a method that sets the batch size to initialize multiple proxies at once to mitigate N+1 problems during lazy loading. It retrieves multiple entities at once using IN clauses instead of individual queries.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "member")
    private List<Order> orders;
}

Using @BatchSize retrieves entities in the specified batch size using WHERE IN clauses during proxy initialization. For example, if there are 1000 members and the batch size is 100, 1 member query plus 10 order queries are executed for a total of 11 queries, significantly mitigating the N+1 problem. Using the global setting hibernate.default_batch_fetch_size applies it to all entities at once.

@Fetch(FetchMode.SUBSELECT)

Specifying FetchMode.SUBSELECT in Hibernate’s @Fetch annotation allows you to retrieve associated entities at once using a subquery. This approach uses the first query’s results as a subquery to fetch associated entities.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "member")
    @Fetch(FetchMode.SUBSELECT)
    private List<Order> orders;
}

The SUBSELECT approach can solve N+1 problems by executing only one additional query for the entire result set. However, performance may actually degrade on databases where subquery performance is poor. It has no effect on single entity retrieval and is only useful when querying multiple entities.

Default FetchType Values

JPA has different default FetchType values depending on the association type, which is a design that considers the characteristics of each association.

  • @OneToOne: EAGER (default) - One-to-one relationships are usually used together, so immediate loading
  • @ManyToOne: EAGER (default) - The “one” side of many-to-one relationships is a single entity, so immediate loading
  • @OneToMany: LAZY (default) - The “many” side of one-to-many relationships is a collection, so lazy loading
  • @ManyToMany: LAZY (default) - Many-to-many relationships are collections, so lazy loading

This default value design is based on the judgment that single entities (ToOne) have low join costs and are likely to be used together, so they should be loaded immediately, while collections (ToMany) may have large amounts of data and may not be used, so lazy loading is reasonable. However, in practice, it is recommended to configure all associations as LAZY and use fetch join only when needed.

Best Practices for Production

In production environments, the most effective strategy is to use FetchType.LAZY by default for all associations and selectively use fetch join or @EntityGraph only when associated entities are needed for specific screens or APIs. EAGER is not recommended as it generates unpredictable queries and causes N+1 problems in JPQL.

The Open Session In View (OSIV) pattern keeps the persistence context open until the view layer, making lazy loading convenient. However, it holds database connections for long periods, which can cause connection shortage problems in applications with high real-time traffic. It is recommended to set Spring Boot’s spring.jpa.open-in-view option to false and load all necessary data within transactions.

If you attempt lazy loading outside transaction boundaries, LazyInitializationException will occur. You can prevent this problem by preloading necessary data through fetch join or proxy initialization within transactions in the service layer, or by converting to DTOs to pass to the view. This is also desirable from the perspective of separating the persistence context and presentation layer.

Summary

  • Lazy Loading uses proxies to load associated entities when they are actually used.
  • Eager Loading joins and loads associated entities together when querying entities.
  • N+1 problems can occur in both cases and can be solved with fetch join, @EntityGraph, @BatchSize, etc.
  • In production, it is recommended to use LAZY by default and apply fetch join only when necessary.