JPA - 지연 로딩(Lazy Loading)

StrayCat·2026년 2월 25일

연관된 Entity를 언제 가져올 것인가? — Fetch Type의 핵심을 이해해 봅니다.


1. 즉시 로딩 vs 지연 로딩

JPA에서 연관관계가 설정된 Entity를 조회할 때, 연관 Entity의 데이터를 언제 가져올지 선택할 수 있습니다.
이 설정을 Fetch Type 이라고 하며, 두 가지 방식이 있습니다.

Fetch Type설명
EAGER (즉시 로딩)주 Entity 조회 시, 연관된 Entity를 즉시 함께 가져옵니다
LAZY (지연 로딩)주 Entity만 먼저 가져오고, 연관 Entity는 실제로 필요할 때 가져옵니다

2. 각 연관관계 Annotation의 기본값

JPA의 연관관계 Annotation은 각각 기본 Fetch Type이 정해져 있습니다.

애너테이션기본 Fetch Type이유
@OneToManyLAZY컬렉션 단위 조회 → 데이터가 많을 수 있어 지연 로딩이 효율적
@ManyToOneEAGER단일 Entity → 바로 가져와도 부담이 적음
@OneToOneEAGER단일 Entity
@ManyToManyLAZY컬렉션 단위 조회
  • Annotation 이름 뒤에 Many가 붙으면 컬렉션 → LAZY
  • One이 붙으면 단일 → EAGER가 기본값입니다.

3. 즉시 로딩의 문제점 (EAGER)

아래 예시를 보겠습니다. FoodUser@ManyToOne (기본값: EAGER) 관계일 때, 단순히 음식 하나를 조회해 봅니다.

@Test
@DisplayName("아보카도 피자 조회")
void test1() {
    Food food = foodRepository.findById(2L).orElseThrow(NullPointerException::new);
    System.out.println("food.getName() = " + food.getName());
    // 아직 User 정보를 사용하지 않았는데도...
    System.out.println("food.getUser().getName() = " + food.getUser().getName());
}

음식 가격만 조회하려 했을 뿐인데, JPA가 자동으로 JOIN 쿼리를 실행하여 고객(User) 테이블까지 함께 조회합니다.

Hibernate: 
    select
        f1_0.id,
        f1_0.name,
        f1_0.price,
        u1_0.id,
        u1_0.name 
    from
        food f1_0 
    left join //findBuId (2) 만 했는데 자체적으로 User를 Join한 상황
        users u1_0 
            on u1_0.id=f1_0.user_id 
    where
        f1_0.id=?
        
food.getName() = 아보카도 피자
food.getUser().getName() = Kaneda
-- 원하는 것: food 테이블만 조회
-- 실제로 실행되는 것: food + user 테이블 JOIN 조회
SELECT food.*, user.*
FROM food
LEFT OUTER JOIN users user ON food.user_id = user.id
WHERE food.id = 2;

연관관계가 복잡하게 얽혀 있을수록 이 문제가 심각해집니다. 필요하지 않은 데이터까지 매번 불러오게 되어 성능에 악영향을 줍니다.

이것이 바로 EAGER 로딩을 남용하면 안 되는 이유입니다.


4. 지연 로딩(LAZY)의 동작 방식

UserFood@OneToMany (기본값: LAZY) 관계인 경우를 살펴봅니다.

@Entity
@Table(name = "users")
public class User {

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

    // 기본값이 LAZY — 필요한 시점까지 데이터를 가져오지 않습니다
    @OneToMany(mappedBy = "user")
    private List<Food> foodList = new ArrayList<>();
}
@Test
@Transactional
@DisplayName("Kaneda 고객 조회")
void test2() {
    // 1단계: User만 조회 (SELECT * FROM users WHERE name = 'Kaneda')
    User user = userRepository.findByName("Kaneda");
    System.out.println("user.getName() = " + user.getName());

    System.out.println("Kaneda가 주문한 음식 이름 조회");

    // 2단계: 실제로 foodList에 접근하는 시점에 추가 쿼리 실행
    // (SELECT * FROM food WHERE user_id = ?)
    for (Food food : user.getFoodList()) {
        System.out.println(food.getName());
    }
}

user.getFoodList()를 호출하는 순간, 그제서야 음식 테이블에 대한 SELECT 쿼리가 실행됩니다.
필요한 데이터만, 필요한 시점에 가져오는 것이 지연 로딩의 핵심입니다.


5. 지연 로딩과 영속성 컨텍스트의 관계

지연 로딩은 영속성 컨텍스트의 기능 중 하나입니다.
따라서 지연 로딩된 Entity 정보에 접근할 때, 반드시 영속성 컨텍스트(= 트랜잭션)가 살아있어야 합니다.

트랜잭션이 없을 때 발생하는 오류

@Test
// @Transactional 없음 — 영속성 컨텍스트가 존재하지 않는 상태
@DisplayName("Kaneda 고객 조회 실패")
void test3() {
    User user = userRepository.findByName("Kaneda");
    System.out.println("user.getName() = " + user.getName()); // 여기까지는 OK

    // foodList에 접근하는 순간 오류 발생
    // LazyInitializationException: could not initialize proxy - no Session
    for (Food food : user.getFoodList()) {
        System.out.println(food.getName());
    }
}

findByName() 호출 후 트랜잭션이 종료되면서 영속성 컨텍스트도 함께 닫힙니다.
이 상태에서 지연 로딩된 foodList에 접근하면 LazyInitializationException이 발생합니다.

지연 로딩을 사용할 때는 항상 트랜잭션 범위 안에서 연관 데이터에 접근해야 합니다.


6. 실제 개발 환경에서의 권장 전략

LAZY를 기본으로 설정하세요

// 권장 패턴: 모든 연관관계는 LAZY로 명시적으로 설정
@ManyToOne(fetch = FetchType.LAZY)  // 기본값이 EAGER이므로 명시 필요
@JoinColumn(name = "user_id")
private User user;

@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)  // 기본값과 동일하지만 명시 권장
private List<Food> foodList = new ArrayList<>();

필요한 경우에만 연관 데이터를 불러오는 것이 성능 면에서 유리합니다.

연관 데이터가 반드시 필요하다면 JPQL fetch join을 사용하세요

LAZY로 설정해도 여러 연관 Entity를 한 번에 가져와야 할 때가 있습니다.
이때는 fetch join을 사용하면 쿼리 한 번으로 해결할 수 있습니다.

// Repository에서 fetch join 사용 예시
@Query("SELECT u FROM User u JOIN FETCH u.foodList WHERE u.name = :name")
User findByNameWithFoods(@Param("name") String name);

7. N+1 문제 — 반드시 알아야 할 함정

지연 로딩을 사용하다 보면 N+1 문제를 마주치게 됩니다.
이는 JPA를 사용할 때 가장 흔하게 발생하는 성능 문제 중 하나입니다.

// 문제 상황
List<User> users = userRepository.findAll(); // 쿼리 1번 (N명의 유저 조회)

for (User user : users) {
    // 각 유저마다 foodList 조회 쿼리가 1번씩 추가 실행됨
    // 유저가 10명이면 → 총 쿼리 1 + 10 = 11번 실행
    user.getFoodList().forEach(food -> System.out.println(food.getName()));
}

유저 1명을 조회하는 쿼리 1번 + 각 유저의 음식 목록을 조회하는 쿼리 N번 = N+1 문제

해결 방법:

  • @EntityGraph 애너테이션 사용
  • JPQL fetch join 사용
  • @BatchSize 설정으로 IN 쿼리로 일괄 처리
// @EntityGraph를 활용한 N+1 해결
@EntityGraph(attributePaths = {"foodList"})
@Query("SELECT u FROM User u")
List<User> findAllWithFoods();

8. 기타

Spring Data JPA 3.x / Spring Boot 3.x 환경

  • Jakarta EE 네임스페이스로 전환되었습니다. (javax.persistence.*jakarta.persistence.*)
  • Hibernate 6.x 버전이 기본으로 사용되며, N+1 문제 탐지 및 쿼리 최적화 기능이 강화되었습니다.
  • @EntityGraph와 Querydsl, Spring Data JPA의 Projection을 조합하는 패턴이 널리 사용됩니다.

레거시 환경 (Spring Boot 2.x / Hibernate 5.x)

레거시 환경에서는 javax.persistence.* 패키지를 사용하며, Hibernate 5.x의 동작 방식을 이해하는 것도 여전히 중요합니다. 라이브러리 버전에 따라 동작 방식의 미묘한 차이가 있을 수 있으므로, 프로젝트 환경 확인이 필요합니다.


핵심 요약

항목내용
LAZY필요한 시점에 쿼리 실행
EAGER주 Entity 조회 시 즉시 JOIN 실행
기본값ToMany → LAZY, ToOne → EAGER
지연 로딩 조건반드시 트랜잭션(영속성 컨텍스트) 필요
권장 전략전체 LAZY + 필요 시 fetch join
주의 사항N+1 문제 항상 주의
profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글