Spring/이론

[트러블슈팅] JPA 지연로딩과 Proxy - LazyInitializationException 해결하기

JuNo_12 2025. 6. 5. 08:48

"JPA 고급 기능, 정말 써야 할까?" 시리즈 3편

들어가며

1편에서 연관관계 매핑의 복잡성을, 2편에서 상속관계 매핑의 실무 활용을 다뤘다면, 이번에는 지연로딩과 Proxy에 대해 이야기해보겠습니다.

처음 Proxy 개념을 접했을 때 정말 헷갈렸습니다. "가짜 객체가 뭐지?" "언제 실제 데이터를 가져오는 거지?" 이런 의문들이 계속 들더군요. 하지만 "왜 Proxy가 필요한가?"부터 이해하니 모든 게 명확해졌습니다.


문제 상황: 성능과 편의성의 딜레마

배달앱으로 이해하는 즉시로딩 vs 지연로딩

온라인 배달앱을 생각해보겠습니다:

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;
    private int amount;
    
    @ManyToOne
    private User user;      // 주문한 사용자
    
    @ManyToOne  
    private Restaurant restaurant;  // 음식점
    
    @OneToMany(mappedBy = "order")
    private List<OrderItem> orderItems;  // 주문 상품들
}

주문 목록을 조회할 때 어떻게 해야 할까요?


즉시로딩 방식

@ManyToOne(fetch = FetchType.EAGER)  // 즉시로딩
private User user;

// 주문 조회 시
Order order = orderRepository.findById(1L);
// SQL: SELECT o.*, u.*, r.*, oi.* FROM orders o 
//      JOIN users u ... JOIN restaurants r ... JOIN order_items oi ...
// 한 번에 모든 연관 데이터를 가져옴

 

배달앱 비유:

  • 주문 조회 버튼 클릭
  • → "주문정보 + 사용자정보 + 음식점정보 + 주문상품정보" 한번에 다 가져옴
  • → 화면에 바로 모든 정보 표시 가능

장점: 추가 쿼리 없음, LazyInitializationException 없음
단점: 불필요한 데이터까지 항상 로딩, 성능 저하


지연로딩 방식

@ManyToOne(fetch = FetchType.LAZY)   // 지연로딩
private User user;

// 주문 조회 시
Order order = orderRepository.findById(1L);
// SQL: SELECT * FROM orders WHERE id = 1
// Order 정보만 가져옴, user는 Proxy 객체

String userName = order.getUser().getName();
// 이 순간! SQL: SELECT * FROM users WHERE id = ?
// 실제로 User 정보 조회

 

배달앱 비유:

  • 주문 조회 버튼 클릭
  • → "주문정보"만 일단 가져옴
  • → 사용자 이름 클릭할 때 → 그때 "사용자정보" 가져옴

장점: 필요한 것만 로딩, 메모리 효율적
단점: 추가 쿼리 발생, LazyInitializationException 위험


Proxy의 역할과 원리

Proxy = 대리인

일상생활 예시로 이해해보겠습니다:

나: "김개발씨 전화번호 좀 알려줘"
비서: "네, 010-1234-5678입니다"

실제로는:
- 비서가 전화번호부 뒤져서 찾아줌
- 나는 김개발이 직접 알려준 줄 알음

Proxy = "나중에 실제 데이터 가져올게"라고 약속하는 가짜 객체

 

JPA에서의 Proxy 동작

Order order = orderRepository.findById(1L);
User user = order.getUser();  // 아직 Proxy 객체

System.out.println(user.getClass());
// 출력: class com.example.User$HibernateProxy$...

// 실제 데이터 접근 시점
String name = user.getName();  // 이 순간 실제 DB 조회!

 

핵심 포인트:

  • order.getUser()는 Proxy 객체 반환 (DB 조회 안 함)
  • user.getName()에서 실제 DB 조회 발생
  • 사용자는 Proxy인지 실제 객체인지 모름

 

Proxy의 내부 동작

// Hibernate가 생성하는 Proxy 클래스 (개념적)
public class User$Proxy extends User {
    private boolean initialized = false;
    private User target;
    
    @Override
    public String getName() {
        if (!initialized) {
            target = loadFromDatabase();  // 실제 DB 조회
            initialized = true;
        }
        return target.getName();
    }
}

처음 접근할 때만 DB에서 실제 객체를 가져오고, 그 다음부터는 캐시된 객체를 사용합니다.


LazyInitializationException의 함정

가장 흔한 에러 상황

@Service
public class OrderService {
    
    @Transactional
    public Order getOrder(Long id) {
        return orderRepository.findById(id);  // User는 Proxy로 설정
    }  // 이 지점에서 트랜잭션 종료, Hibernate Session 닫힘
}

@Controller
public class OrderController {
    
    public String orderDetail(@PathVariable Long id, Model model) {
        Order order = orderService.getOrder(id);  // 트랜잭션 밖
        
        String userName = order.getUser().getName();  // 💥 LazyInitializationException!
        model.addAttribute("userName", userName);
        return "order-detail";
    }
}

왜 에러가 발생할까?

  1. 트랜잭션 내에서: Order 조회, User는 Proxy 객체로 설정
  2. 트랜잭션 종료: Hibernate Session 닫힘
  3. 트랜잭션 밖에서: order.getUser().getName() 호출
  4. Proxy가 실제 데이터 가져오려 시도 → Session 없음 → 💥

핵심: Proxy는 Hibernate Session이 살아있을 때만 실제 데이터를 가져올 수 있습니다.

 

에러 메시지 해석

LazyInitializationException: failed to lazily initialize a collection of role: 
com.example.Order.user, could not initialize proxy - no Session

번역: "지연로딩하려고 했는데 Session이 없어서 못하겠어요"


실무에서의 해결 방법들

방법 1: FETCH JOIN (가장 명확한 해결책)

@Repository
public class OrderRepository {
    
    // 필요한 연관 엔티티를 명시적으로 함께 조회
    @Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.id = :id")
    Optional<Order> findByIdWithUser(@Param("id") Long id);
    
    // 여러 연관 엔티티 함께 조회
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.user " +
           "JOIN FETCH o.restaurant " +
           "WHERE o.id = :id")
    Optional<Order> findByIdWithDetails(@Param("id") Long id);
}

@Service
public class OrderService {
    
    @Transactional
    public Order getOrderWithUser(Long id) {
        return orderRepository.findByIdWithUser(id)
                .orElseThrow(() -> new OrderNotFoundException());
    }
}

 

장점:

  • 명시적이고 예측 가능한 쿼리
  • 성능 최적화 (필요한 것만 한 번에 조회)
  • LazyInitializationException 없음

 

방법 2: DTO 조회 (성능 최적화)

@Repository  
public class OrderRepository {
    
    // 필요한 데이터만 DTO로 조회
    @Query("SELECT new com.example.dto.OrderDetailDto(" +
           "o.id, o.amount, u.name, r.name) " +
           "FROM Order o " +
           "JOIN o.user u " +
           "JOIN o.restaurant r " +
           "WHERE o.id = :id")
    Optional<OrderDetailDto> findOrderDetailDto(@Param("id") Long id);
}

public class OrderDetailDto {
    private Long orderId;
    private int amount;
    private String userName;
    private String restaurantName;
    
    // 생성자, getter...
}

 

장점:

  • 네트워크 트래픽 최소화 (필요한 컬럼만 조회)
  • 메모리 효율적
  • 명확한 의도 (화면에 필요한 데이터만)

 

방법 3: 트랜잭션 범위 확장

@Service
@Transactional(readOnly = true)  // 서비스 레벨에서 트랜잭션 시작
public class OrderService {
    
    public OrderDetailResponse getOrderDetail(Long id) {
        Order order = orderRepository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException());
        
        // 트랜잭션 내에서 지연로딩 실행
        String userName = order.getUser().getName();
        String restaurantName = order.getRestaurant().getName();
        
        return OrderDetailResponse.builder()
                .orderId(order.getId())
                .amount(order.getAmount())
                .userName(userName)
                .restaurantName(restaurantName)
                .build();
    }
}  // 여기서 트랜잭션 종료

 

장점:

  • 기존 코드 수정 최소화
  • 지연로딩 활용 가능

단점:

  • 트랜잭션이 길어짐 (성능에 영향)
  • 어디서 쿼리가 나갈지 예측 어려움

 

방법 4: 즉시로딩으로 변경 (비추천)

@ManyToOne(fetch = FetchType.EAGER)  // 즉시로딩
private User user;

 

문제점:

  • 항상 연관 데이터 로딩 (불필요한 경우에도)
  • N+1 문제 발생 가능
  • 성능 저하

 

방법 5: Hibernate.initialize() 사용

@Service
@Transactional
public class OrderService {
    
    public Order getOrder(Long id) {
        Order order = orderRepository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException());
        
        // 트랜잭션 내에서 강제 초기화
        Hibernate.initialize(order.getUser());
        Hibernate.initialize(order.getOrderItems());
        
        return order;
    }
}

사용 시기: 레거시 코드에서 임시방편으로


실무에서의 Best Practice

기본 전략

  1. 기본은 지연로딩 (FetchType.LAZY)
  2. 필요시 FETCH JOIN으로 명시적 조회
  3. 화면별로 최적화된 쿼리 작성

 

패턴별 가이드라인

단건 조회 (상세 화면)

// FETCH JOIN으로 필요한 연관 엔티티 함께 조회
@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.user " +
       "JOIN FETCH o.restaurant " +
       "WHERE o.id = :id")
Optional<Order> findByIdWithDetails(@Param("id") Long id);

 

목록 조회 (리스트 화면)

// DTO로 필요한 정보만 조회
@Query("SELECT new com.example.dto.OrderSummaryDto(" +
       "o.id, o.amount, u.name, o.createdAt) " +
       "FROM Order o JOIN o.user u " +
       "WHERE u.id = :userId")
List<OrderSummaryDto> findOrderSummaryByUserId(@Param("userId") Long userId);

 

통계/집계 쿼리

// 연관 엔티티 로딩 없이 집계만
@Query("SELECT COUNT(o) FROM Order o WHERE o.user.id = :userId")
long countByUserId(@Param("userId") Long userId);

 

N+1 문제 해결

// ❌ N+1 문제 발생
List<Order> orders = orderRepository.findAll();  // 1번 쿼리
for (Order order : orders) {
    String userName = order.getUser().getName();  // N번 쿼리
}

// ✅ FETCH JOIN으로 해결
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();

// ✅ 또는 DTO로 해결
@Query("SELECT new OrderWithUserDto(o.id, o.amount, u.name) " +
       "FROM Order o JOIN o.user u")
List<OrderWithUserDto> findAllOrderWithUser();

 


결론: 명시적 제어가 답

지연로딩과 Proxy를 학습하며 깨달은 점은, "자동으로 처리되는 것"보다 "명시적으로 제어하는 것"이 더 안전하다는 것입니다.

핵심 원칙

  1. 기본 설정: FetchType.LAZY
  2. 필요시 조회: FETCH JOIN 또는 DTO
  3. 성능 측정: 실제 쿼리 확인하기
  4. 예측 가능: 언제 어떤 쿼리가 나갈지 명확하게

 

실무에서의 접근법

// 기본: 지연로딩으로 설정
@ManyToOne(fetch = FetchType.LAZY)
private User user;

// 필요시: 명시적 조회 메서드 제공
public Order findOrderWithUser(Long id) { /* FETCH JOIN */ }
public Order findOrderWithDetails(Long id) { /* 모든 연관 엔티티 */ }
public OrderDto findOrderDto(Long id) { /* DTO 조회 */ }

 

LazyInitializationException이 무서워서 즉시로딩을 쓰기보다는, 필요한 곳에서 명시적으로 조회하는 것이 더 좋은 해결책입니다.


다음 편 예고

다음 편에서는 영속성 전이(Cascade)와 트랜잭션 전파에 대해 다뤄보겠습니다. 편리해 보이는 Cascade 기능의 숨겨진 위험성과, 트랜잭션 전파 옵션들의 실무 활용법을 알아보겠습니다.

시리즈 목차

  1. 연관관계 매핑 vs Repository 패턴
  2. 상속관계 매핑 3가지 전략
  3. 지연로딩과 Proxy - LazyInitializationException 해결하기 ← 현재 글
  4. Cascade와 트랜잭션 전파 - 편의성 vs 안전성