[트러블슈팅] JPA 지연로딩과 Proxy - LazyInitializationException 해결하기
"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";
}
}
왜 에러가 발생할까?
- 트랜잭션 내에서: Order 조회, User는 Proxy 객체로 설정
- 트랜잭션 종료: Hibernate Session 닫힘
- 트랜잭션 밖에서: order.getUser().getName() 호출
- 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
기본 전략
- 기본은 지연로딩 (FetchType.LAZY)
- 필요시 FETCH JOIN으로 명시적 조회
- 화면별로 최적화된 쿼리 작성
패턴별 가이드라인
단건 조회 (상세 화면)
// 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를 학습하며 깨달은 점은, "자동으로 처리되는 것"보다 "명시적으로 제어하는 것"이 더 안전하다는 것입니다.
핵심 원칙
- 기본 설정: FetchType.LAZY
- 필요시 조회: FETCH JOIN 또는 DTO
- 성능 측정: 실제 쿼리 확인하기
- 예측 가능: 언제 어떤 쿼리가 나갈지 명확하게
실무에서의 접근법
// 기본: 지연로딩으로 설정
@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 기능의 숨겨진 위험성과, 트랜잭션 전파 옵션들의 실무 활용법을 알아보겠습니다.
시리즈 목차
- 연관관계 매핑 vs Repository 패턴
- 상속관계 매핑 3가지 전략
- 지연로딩과 Proxy - LazyInitializationException 해결하기 ← 현재 글
- Cascade와 트랜잭션 전파 - 편의성 vs 안전성