연관 엔티티 식별자를 조회할 때, 쿼리문이 질의되지 않는다?

Date:

카테고리:

태그:

들어가며

토이 프로젝트 진행 도중 JPA를 통해서 연관 엔티티의 식별자를 조회할 때, N+1 문제가 발생할 거라 예상했던 코드가 하나의 쿼리로 동작하는 것을 확인했습니다.
본 포스팅에서 연관되어 있는 엔티티를 조회할 때, 항상 쿼리가 질의되는 것만은 아니라는 것을 알아보겠습니다.

전체 코드는 github에서 확인할 수 있습니다.

엔티티 및 데이터

먼저 쿠폰 Coupon과 사용자에게 발급된 쿠폰 UserCoupon이 있고, 이 둘은 1:1 관계이고 fetch 전략은 LAZY라고 가정해보겠습니다.

@Entity
@Table(name = "coupon")
public class Coupon {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long couponId;

    @Column(name = "quantity")
    private Long quantity;

}

===

@Entity
@Table(name = "user_coupon")
public class UserCoupon {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userCouponId;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "coupon_id")
    private Coupon coupon;
    
}

그리고 아래와 같이 3개의 CouponUserCoupon이 있다고 가정합니다.

insert into coupon(coupon_id, quantity)
values (1, 111), (2, 222), (3, 333);

insert into user_coupon(user_coupon_id, coupon_id)
values (1, 1), (2, 2), (3, 3);

문제의 코드

먼저 제가 N+1이 발생할 거라 예상 했던 코드입니다.

@RequiredArgsConstructor
@Service
public class CouponService {

    ...

    @Transactional
    public void case1() {
        List<UserCoupon> userCoupons = userCouponRepository.findAll();

        // Coupon의 id 조회 메서드
        userCoupons.forEach(userCoupon -> userCoupon.getCoupon()
                                                    .getCouponId());
    }
}

위의 코드는 Usercoupon 엔티티에서 Coupon엔티티를 조회하는 코드입니다. 당연히 fetch 전략은 Lazy이기 때문에, Coupon 엔티티 조회를 위한 쿼리문때문에, N+1 문제가 발생할 줄 알았습니다.

하지만 실제로는 아래의 쿼리가 나갔습니다.

select u1_0.user_coupon_id,u1_0.coupon_id from user_coupon u1_0

그렇다면, 아래의 코드는 어떨까요?

@RequiredArgsConstructor
@Service
public class CouponService {

    ...

    @Transactional
    public void case2() {
        List<UserCoupon> userCoupons = userCouponRepository.findAll();

        // Coupon의 quntity 조회 메서드
        userCoupons.forEach(userCoupon -> userCoupon.getCoupon()
                                                    .getQuantity()
    }
}

위의 코드는 아래와 같은 쿼리가 나감을 확인했습니다.

select u1_0.user_coupon_id,u1_0.coupon_id from user_coupon u1_0 // UserCoupon 조회
select c1_0.coupon_id,c1_0.quantity from coupon c1_0 where c1_0.coupon_id=? // Coupon 조회
select c1_0.coupon_id,c1_0.quantity from coupon c1_0 where c1_0.coupon_id=? // Coupon 조회
select c1_0.coupon_id,c1_0.quantity from coupon c1_0 where c1_0.coupon_id=? // Coupon 조회

왜 Coupon 엔티티의 식별자를 조회할 때 쿼리가 나가지 않았을까?

그 이유는 식별자를 조회하는 경우 hivernate proxy패키지의 AbstractLazyInitializer 클래스가 미리 저장해둔 값을 바로 반환하기 때문인데요.
코드를 통해 확인해보겠습니다.


public class CouponService {

  ...

  @Transactional
  public void case1() {
      List<UserCoupon> userCoupons = userCouponRepository.findAll();
      // userCoupons.forEach(...) 실행
      userCoupons.forEach(userCoupon -> userCoupon.getCoupon()
                                                  .getCouponId());
  }
}
                     |
                     V
public interface ProxyConfiguration extends PrimeAmongSecondarySupertypes {

  ...  

  @RuntimeType
  public static Object intercept(
        @This final Object instance,
				@Origin final Method method,
				@AllArguments final Object[] arguments,
				@StubValue final Object stubValue,
				@FieldValue(INTERCEPTOR_FIELD_NAME
  ) throws Throwable {
    if ( interceptor == null ) {
      if ( method.getName().equals( "getHibernateLazyInitializer" ) ) {
        return instance;
      }
      else {
        return stubValue;
      }
    }
    else {
      //interceptor.intercept(...) 실행
      return interceptor.intercept( instance, method, arguments ); // interceptor: ByteBuddyInterceptor
    }
  }
}
                     |
                     V
public class ByteBuddyInterceptor extends BasicLazyInitializer implements ProxyConfiguration.Interceptor {

  @Override
  public Object intercept(Object proxy, Method thisMethod, Object[] args) throws Throwable {

    // this.invoke(...) 실행
    Object result = this.invoke( thisMethod, args, proxy ); // thisMethod: public java.lang.Long com.ayuconpon.coupon.domain.entity.Coupon.getCouponId()
    if ( result == INVOKE_IMPLEMENTATION ) {
      ...
    }
    else {
      return result;
    }
  }
}
                     |
                     V
public abstract class BasicLazyInitializer extends AbstractLazyInitializer {

  ...

  protected final Object invoke(Method method, Object[] args, Object proxy) throws Throwable {
    String methodName = method.getName();
    int params = args.length;

    if ( params == 0 ) {
      if ( "writeReplace".equals( methodName ) ) {
        return getReplacement();
      }
      else if ( !overridesEquals && "hashCode".equals( methodName ) ) {
        return System.identityHashCode( proxy );
      }
      // isUninitialized() -> Coupon 조회 쿼리가 질의되지 않음 -> true
      // method.equals( getIdentifierMethod ) -> 메서드의 이름은 (생략).getCouponId() -> true
      // 따라서 아래의 if문 조건은 true
      else if ( isUninitialized() && method.equals( getIdentifierMethod ) ) {
        // getIdentifier() 실행
        return getIdentifier();
      }
      else if ( "getHibernateLazyInitializer".equals( methodName ) ) {
        return this;
      }

        ...

    }
  }
}
                     |
                     V
public abstract class AbstractLazyInitializer implements LazyInitializer {

  ...                  

  @Override
  public final Object getIdentifier() {
    // isUninitialized() -> Coupon 조회 쿼리가 질의되지 않음 -> true
    // isInitializeProxyWhenAccessingIdentifier() -> JPA_PROXY_COMPLIANCE 설정은 따로 하지 않음 -> false
    // 따라서 아래의 조건은 false
    if ( isUninitialized() && isInitializeProxyWhenAccessingIdentifier() ) {
      initialize();
    }
    // Coupon 엔티티의 식별자 반환
    return id;
  }
}
                     |
                     V
ublic class ByteBuddyInterceptor extends BasicLazyInitializer implements ProxyConfiguration.Interceptor {

  @Override
  public Object intercept(Object proxy, Method thisMethod, Object[] args) throws Throwable {
    // this.invoke(...) 의 반환 값은 Coupon 엔티티의 식별자
    Object result = this.invoke( thisMethod, args, proxy );
    if ( result == INVOKE_IMPLEMENTATION ) {
      ...
    }
    else {
      //Coupon 엔티티의 식별자
      return result;
    }
  }
}

조금 길었지만, 위의 코드를 보면 이유를 알 수 있었습니다.

  • 엔티티 조회 쿼리가 질의 되지 않았을 경우
  • 조회 할려는 데이터가 엔티티의 식별자일 경우

위의 조건을 만족하면, 데이터베이스에 조회 쿼리가 질의되지 않음을 알 수 있었습니다.
끝까지 봐주셔서 감사합니다!

참고
오라클 공식 문서 : Query Hook Options
javadoc : JPA_PROXY_COMPLIANCE
Tecoble : JPA Hibernate 프록시 제대로 알고 쓰기

Spring 카테고리 내 다른 글 보러가기

댓글 남기기