본문 바로가기

Development/Spring

[Spring] Spring Data JPA에서 getReferenceById vs findById (지연로딩과 즉시로딩)

Spring Data JPA로 데이터의 조회를 구현할 때, 사용할 수 있는 메서드중, getReferenceById와 findById 두 메서드가 있습니다.

이 글에서는 이 둘의 차이점을 비교해보려 합니다.

getRefereceById (구: getOne(ID), findOne(ID), getById(ID))

내부적으로 EntityManager의 getReference() 메서드를 호출합니다.

getReference() 메서드를 호출하면 Proxy 객체를 리턴합니다. 실제 쿼리는 Proxy 객체를 통해 최초로 데이터에 접근하는 시점에 실행됩니다. (지연 로딩(Lazy Loading))

이때 데이터가 존재하지 않는 경우에는 EntityNotFoundException이 발생합니다. 

아래는 실제 구현체 코드입니다.

 

SimpleJpaRepository.class의 일부

public T getReferenceById(ID id) {
        Assert.notNull(id, "The given id must not be null");
        return this.entityManager.getReference(this.getDomainClass(), id);
    }

findById

내부적으로 EntityManager의 find() 메서드를 호출합니다.

이 메서드는 영속성 컨텍스트의 캐시에서 값을 조회한 후 영속성 컨텍스트에 값이 존재하지 않는다면 실제 데이터베이스에서 데이터를 조회합니다. (즉시 로딩(eager loading))

이 메서드는 특정 ID를 가진 엔티티를 리포지토리에서 찾습니다

리턴 값으로 Optional 객체를 전달합니다. 즉 잘못된 Id를 넘겨주더라도 예외가 발생하지 않을 수 있습니다.

아래는 실제 구현 코드입니다.

    public Optional<T> findById(ID id) {
        Assert.notNull(id, "The given id must not be null");
        Class<T> domainType = this.getDomainClass();
        if (this.metadata == null) {
            return Optional.ofNullable(this.entityManager.find(domainType, id));
        } else {
            LockModeType type = this.metadata.getLockModeType();
            Map<String, Object> hints = this.getHints();
            return Optional.ofNullable(type == null ? this.entityManager.find(domainType, id, hints) : this.entityManager.find(domainType, id, type, hints));
        }
    }

두 메서드의 주요 차이

getReferenceById()는 엔티티를 찾지 못하면 예외를 발생 시키고, findById()는 예외를 안 일으킨다는 차이도 있을 수 있지만, 이 둘의 주요 차이는 로딩 방식입니다.

getReferenceById()가 지연 로딩(lazy), findById는 즉시 로딩(eager) 입니다.

 

Spring은 우리가 Transaction 내에서 명시적으로 엔티티를 사용하려고 시도할 때까지 데이터베이스 요청을 보내지 않습니다.

각 Transaction 은 작업을 수행하는 전용 영속성 컨텍스트를 가지고 있습니다.

때때로, 영속성 컨텍스트를 트랜잭션 범위 밖으로 확장할 수 있지만, 이는 일반적이지 않고 특정 시나리오에만 유용합니다. 영속성 컨텍스트가 트랜잭션에 대해 어떻게 작동하는지 확인해봅시다.

즉시 로딩

findById를 사용하면 해당 메서드가 호출될 때 즉시 데이터베이스에서 엔티티를 조회하고, 결과를 반환합니다. 즉, 메서드 호출 시점에서 데이터베이스와의 통신이 발생하며, 해당 트랜잭션 범위 내에서 엔티티가 영속성 컨텍스트에 로드됩니다.

이것은 Managed 상태입니다. 따라서 엔티티에 대한 모든 변경사항은 데이터베이스에 반영됩니다.

트랜잭션 외부에서는 엔티티가 detached 상태로 이동하고, 엔티티가 다시 managed 상태로 이동하기 전까지는 변경사항이 반영되지 않습니다.

지연 로딩에 관하여

지연 로딩된 엔티티는 약간 다르게 동작합니다.

Spring은 영속성 컨텍스트 내에서 엔티티들을 명시적으로 사용할 때까지 로드하지 않습니다.

Spring은 데이터베이스에서 엔티티를 지연해서 가져오기 위해 빈 프록시 placeholder를 할당합니다.

이 프록시 객체와 어떠한 상호작용이 없다면, 트랜잭션 외부에서 빈 프록시로 남아있고, 그것에 대한 어떤 호출이든 LazyInitializationException을 발생시킵니다.

그러나 프록시 객체를 호출하거나, 내부 정보를 필요로 하는 방식으로 이 프록시 객체와 상호작용한다면, 실제 데이터베이스 요청이 이루어집니다.

 

예를 보겠습니다. (지연 로딩에 이해를 위해 OSIV 설정을 false로 합니다.)

@Override
@Transactional
    public ProductResponseDto getProduct(Long number) {
        Product product = productDAO.selectProduct(number);

        ProductResponseDto productResponseDto = ProductResponseDto.builder()
                .number(product.getNumber())
                .name(product.getName())
                .price(product.getPrice())
                .stock(product.getStock())
                .build();
                
        return productResponseDto;
    }

위 코드에서 selectProduct 메서드는 아래와 같이 구현되어 있습니다.

@Override
    public Product selectProduct(Long number) {
        System.out.println("Before query");
        Product selectedProduct = productRepository.getReferenceById(number);
        System.out.println("After query");
        return selectedProduct;
    }

위 코드를 실행시키면 다음과 같이 출력됩니다.

Before query
After query

user와 상호작용하는 어떠한 코드가 없으므로, 결국 hibernate는 Product를 조회하는 쿼리를 실행하지 않습니다.

@Override
    public Product selectProduct(Long number) {
        Product selectedProduct = productRepository.getReferenceById(number);
        System.out.println("Before query");
        System.out.println(selectedProduct.getName());
        System.out.println("After query");
        return selectedProduct;
    }

다음와 같이 수정하면, 아래와 같이 출력됩니다.

Before query
Hibernate: 
    select
        p1_0.number,
        p1_0.created_at,
        p1_0.name,
        p1_0.price,
        p1_0.stock,
        p1_0.updated_at 
    from
        product p1_0 
    where
        p1_0.number=?
string
After query

 

 

여기서는 Hibernate가 쿼리를 발생시킵니다. (Product이름은 string입니다..!)

참고로 프록시 객체에서 @Id(DB에서 PK)는 Hibernate 쿼리가 필요없이 바로 조회가 되지만, 그 외 속성에 대해 접근하려 한다면 쿼리가 발생합니다.

외부 메서드에서 프록시 객체를 사용

 @Override
 // @Transactional
    public ProductResponseDto getProduct(Long number) {
        Product product = productDAO.selectProduct(number);

        ProductResponseDto productResponseDto = ProductResponseDto.builder()
                .number(product.getNumber())
                .name(product.getName())
                .price(product.getPrice())
                .stock(product.getStock())
                .build();
        System.out.println(productResponseDto.getName());
        return productResponseDto;
    }

이제 selectProduct 메서드를 호출하여 리턴받은 Product 객체를 Service 레이어에서 받아 사용할 것 입니다. 

@Transactional를 지우고 실행해보겠습니다.

위 코드를 실행하면 아래와 같은 예외가 발생합니다.

Before query
2024-02-21T17:07:23.895+09:00 ERROR 61396 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.hibernate.LazyInitializationException: could not initialize proxy [org.spring.study.data.entity.Product#1] - no Session] with root cause

org.hibernate.LazyInitializationException: could not initialize proxy [org.spring.study.data.entity.Product#1] - no Session
	at org.hibernate.proxy.AbstractLazyInitializer.initialize(AbstractLazyInitializer.java:165) ~[hibernate-core-6.4.1.Final.jar:6.4.1.Final]
	at org.hibernate.proxy.AbstractLazyInitializer.getImplementation(AbstractLazyInitializer.java:314) ~[hibernate-core-6.4.1.Final.jar:6.4.1.Final]
	at org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor.intercept(ByteBuddyInterceptor.java:44) ~[hibernate-core-6.4.1.Final.jar:6.4.1.Final]
	at org.hibernate.proxy.ProxyConfiguration$InterceptorDispatcher.intercept(ProxyConfiguration.java:102) ~[hibernate-core-6.4.1.Final.jar:6.4.1.Final]
	at org.spring.study.data.entity.Product$HibernateProxy$v0eDAZvT.getName(Unknown Source) ~[main/:na]
	at org.spring.study.data.dao.impl.ProductDAOImpl.selectProduct(ProductDAOImpl.java:34) ~[main/:na]
    ...

 

selectProduct 메서드 안에서 System.out.println(selectedProduct.getName()); 부분을 실행하려는데 LazyInitializationException이 발생했습니다.

그 이유는 @Transactional 어노테이션이 없다면,  getReferenceById 메서드가 리턴이 된 후 Transaction이 끝나고 Session이 닫힙니다.

그리고 Session 닫히고 나서 프록시 객체를 사용했기 때문에, 지연 로딩을 할 수 없어 에러가 발생합니다.

(OSIV = false를 한 이유가 이를 재현하기 위해서 인데, OSIV = true 라면 세션을 요청이 끝날 때 까지 쭉 열려있기 때문에 LazyInitializationException 재현이 불가능합니다.) 

@Transactional 서비스 내에서 getReferenceById 사용 시 동작

@Transactional 어노테이션이 적용된 Service 메서드에서는 어떻게 동작하는지 확인해보겠습니다.

@Override
    @Transactional
    public ProductResponseDto getProduct(Long number) {
        Product product = productDAO.selectProduct(number);

        ProductResponseDto productResponseDto = ProductResponseDto.builder()
                .number(product.getNumber())
                .name(product.getName())
                .price(product.getPrice())
                .stock(product.getStock())
                .build();
        System.out.println("Success " + productResponseDto.getName());
        return productResponseDto;
    }

 

이제 엔티티와 상호작용하는 코드를 추가하고 로그를 살펴보겠습니다.

Before query
1
After query
Hibernate: 
    select
        p1_0.number,
        p1_0.created_at,
        p1_0.name,
        p1_0.price,
        p1_0.stock,
        p1_0.updated_at 
    from
        product p1_0 
    where
        p1_0.number=?
Success string

@Transactional 어노테이션이 적용이 된 getProduct 메서드 내에서 엔티티를 직접 사용하면 원할히 동작합니다.

이것이 가능한 이유는 @Transactional 어노테이션이 메서드에 대한 트랜잭션 범위를 유지하고 있기 때문입니다. 이 범위 내에서는 데이터베이스 연결이 유지되므로, 필요한 시점에 지연 로딩이 가능하게 됩니다. 

따라서, @Transactional 메서드 내에서 getReferenceById 를 호출하고 그 객체에 접근하면, 그 접근 시점에 데이터베이스에서 실제 데이터를 로드하기 때문에 에러가 발생하지 않는 것입니다. 

새로운 리포지토리 트랜잭션을 가진 @Transactional 서비스

좀 더 복잡한 예시를 살펴보겠습니다. 호출될 때마다 별도의 트랜잭션을 생성하는 리포지토리 메서드가 있다고 가정합니다.

@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
User getReferenceById(Long id);

Propagation.REQUIRES_NEW는 외부 트랜잭션이 전파되지 않고 리포지토리 메서드가 자체 영속성 컨텍스트를 생성한다는 의미입니다. 이 경우 트랜잭션 서비스를 사용하더라도 Spring은 이 경우 외부 트랜잭션과 내부 트랜잭션은 서로 다른 지속성 컨텍스트를 사용하므로 데이터를 공유할 수 없습니다.

@Test
void whenFindUserReferenceUsingInsideServiceThenThrowsExceptionDueToSeparateTransactions() {
    assertThatExceptionOfType(LazyInitializationException.class)
      .isThrownBy(() -> transactionalServiceWithNewTransactionRepository.findAndUseUserReference(EXISTING_ID));
}

참고 자료