연관 엔티티 식별자를 조회할 때, 쿼리문이 질의되지 않는다?
카테고리: Spring
들어가며
토이 프로젝트 진행 도중 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개의 Coupon
과 UserCoupon
이 있다고 가정합니다.
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 프록시 제대로 알고 쓰기
댓글 남기기