[JPA] @Transaction을 붙이지 않았을 때 생기는 문제, JPA Proxy, LazyLoading과 EagerLoading N+1 문제
오늘부터 리팩토링 또는 트러블 슈팅위주로 포스팅하려고 한다.
오늘 해결했던 문제
LazyLoading으로 설정한 엔티티가 가져와지지 않는 문제
2025-02-21T21:31:27.016+09:00 WARN 5521 --- [nio-8080-exec-6] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.server.delivery.model.store.entity.Store.operatingHours: could not initialize proxy - no Session]
위 warn 로그의 내용을 미약한 영어 독해 능력으로 해독해보자면 Lazy로 설정한 객체를 초기화할 수 없는 문제로 보인다. 그리고 세션이 없다는 문구가 마지막에 있다.
이에 대해서 JPA LazyLoading과 Proxy개념을 알아보고 @Transaction과 어떻게 상호작용하는지 적어보겠다.
JPA의 Lazy Loading과 프록시 객체
JPA를 사용하면 엔티티의 연관 관계를 설정할 때 LAZY
(지연 로딩)와 EAGER
(즉시 로딩) 중 하나를 선택할 수 있다. 이를 잘 활용하면 성능을 최적화할 수 있지만, 적절하지 않게 설정하면 성능 저하나 LazyInitializationException
과 같은 문제가 발생할 수 있다. 이번 포스팅에서는 Lazy 로딩 방식과 프록시 객체, 그리고 Eager 로딩과 N+1 문제에 대해 알아보겠다.
1. Lazy가 동작하는 방식
Lazy Loading(지연 로딩)은 실제 엔티티가 필요한 순간까지 데이터를 조회하지 않고, 프록시 객체를 활용하여 참조를 유지하는 방식이다. JPA에서는 기본적으로 @OneToMany
, @ManyToMany
관계에 대해 Lazy 로딩이 기본 설정으로 되어 있다.
@Entity
public class Store {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "store", fetch = FetchType.LAZY)
private List<OperatingHours> operatingHours;
}
위와 같이 fetch = FetchType.LAZY
를 설정하면, Store
엔티티를 조회할 때 operatingHours
필드는 바로 조회되지 않고 프록시 객체로 남아 있다. 이를 실제로 접근하는 순간(store.getOperatingHours().size()
)에 쿼리가 실행된다.
2. @Transactional을 적용하면 수행하는 동작
@Transactional
어노테이션이 적용된 메서드 내부에서는 같은 트랜잭션을 공유하기 때문에 Lazy 로딩이 정상적으로 동작한다. 하지만 트랜잭션이 종료된 후 Lazy 객체를 조회하려고 하면 LazyInitializationException
이 발생한다.
@Service
public class StoreService {
@Transactional
public Store getStore(Long id) {
return storeRepository.findById(id).orElseThrow();
}
}
이처럼 @Transactional
이 적용된 메서드에서 Lazy 필드를 참조하면 정상적으로 데이터를 가져올 수 있다.
3. JPA의 프록시 방식
JPA는 Lazy 로딩을 위해 프록시 객체를 활용한다. Hibernate는 실제 엔티티가 아닌 프록시 객체를 먼저 반환하고, 해당 객체에 접근하는 순간 쿼리를 실행한다.
Store store = entityManager.getReference(Store.class, 1L);
System.out.println(store.getClass()); // Proxy 객체 확인
위 코드에서 store.getClass()
를 출력하면 class com.server.delivery.model.store.entity.Store_$$_jvst123
처럼 프록시 클래스가 생성된 것을 확인할 수 있다.
4. 연관 관계별 기본 Fetch 전략
JPA에서 연관 관계에 따른 기본 Fetch 전략은 다음과 같다.
연관 관계 | 기본 Fetch 전략 |
---|---|
@ManyToOne |
EAGER |
@OneToOne |
EAGER |
@OneToMany |
LAZY |
@ManyToMany |
LAZY |
@ManyToOne
과 @OneToOne
관계는 기본적으로 EAGER
로 설정되므로 필요에 따라 LAZY
로 변경하는 것이 좋다.
5. 언제 Eager 로딩이 유리할까?
Eager 로딩이 필요한 경우는 다음과 같다.
- 항상 함께 조회해야 하는 데이터(예:
User
와UserProfile
같은 필수 연관 데이터) - 조회 시 한 번에 데이터를 가져오는 것이 성능상 유리한 경우
하지만 대부분의 경우 EAGER
는 피하는 것이 좋다. 특히 다대일(@ManyToOne
)이나 일대일(@OneToOne
) 관계에서도 필요하지 않다면 LAZY
로 변경하는 것이 성능 최적화에 도움이 된다.
6. Eager 로딩의 N+1 문제와 해결 방법
Eager 로딩을 사용할 때 연관된 엔티티가 많으면 N+1 문제가 발생할 수 있다. 예를 들어, Store
엔티티를 조회할 때 operatingHours
가 Eager 로딩이라면 다음과 같은 문제가 생긴다.
SELECT * FROM store; -- 1개의 쿼리 (N=1)
SELECT * FROM operating_hours WHERE store_id = ?; -- 추가 쿼리 (N개)
해결 방법
JPQL에서
JOIN FETCH
사용@Query("SELECT s FROM Store s JOIN FETCH s.operatingHours WHERE s.id = :id") Store findByIdWithOperatingHours(@Param("id") Long id);
@EntityGraph
활용@EntityGraph(attributePaths = {"operatingHours"}) @Query("SELECT s FROM Store s WHERE s.id = :id") Store findByIdWithGraph(@Param("id") Long id);
Hibernate의 Batch Size 설정
spring.jpa.properties.hibernate.default_batch_fetch_size=100
Batch Size를 설정하면 한 번에 여러 개의 연관 데이터를 가져올 수 있어 N+1 문제를 완화할 수 있다.
7. 정리
LAZY
는 프록시 객체를 활용하여 실제 필요할 때만 데이터를 가져온다.@Transactional
을 사용하면 트랜잭션 내에서 Lazy 로딩이 가능하다.@ManyToOne
과@OneToOne
은 기본적으로EAGER
이므로 필요에 따라LAZY
로 변경하는 것이 좋다.- Eager 로딩은 항상 필요한 데이터를 함께 조회할 때 유리하지만, N+1 문제가 발생할 수 있다.
JOIN FETCH
,@EntityGraph
,Batch Size
등의 방법으로 N+1 문제를 해결할 수 있다.
적절한 Fetch 전략을 선택하여 성능을 최적화하는 것이 중요하다.