Spring/JPA

[JPA] @Transaction을 붙이지 않았을 때 생기는 문제, JPA Proxy, LazyLoading과 EagerLoading N+1 문제

공부하고 기억하는 공간 2025. 2. 22. 00:46
728x90
반응형
SMALL

오늘부터 리팩토링 또는 트러블 슈팅위주로 포스팅하려고 한다.

오늘 해결했던 문제

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 로딩이 필요한 경우는 다음과 같다.

  • 항상 함께 조회해야 하는 데이터(예: UserUserProfile 같은 필수 연관 데이터)
  • 조회 시 한 번에 데이터를 가져오는 것이 성능상 유리한 경우

하지만 대부분의 경우 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개)

해결 방법

  1. JPQL에서 JOIN FETCH 사용

    @Query("SELECT s FROM Store s JOIN FETCH s.operatingHours WHERE s.id = :id")
    Store findByIdWithOperatingHours(@Param("id") Long id);
  2. @EntityGraph 활용

    @EntityGraph(attributePaths = {"operatingHours"})
    @Query("SELECT s FROM Store s WHERE s.id = :id")
    Store findByIdWithGraph(@Param("id") Long id);
  3. 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 전략을 선택하여 성능을 최적화하는 것이 중요하다.

728x90
반응형
SMALL