Spring Framework

Spring JPA 와 Dynamic Proxy

소농배 2022. 4. 24. 21:24

JPA 를 사용하여 Repository Bean 을 생성할때 class 가 아닌 interface 로 선언하여 사용하게 된다.

어떻게 interface 만으로 객체가 생성되어 빈으로 등록되는지, 함수의 구현 없이 이름을 정의하는 것 만으로 쿼리가 만들어져서 동작하는지 궁금하여 조사를 시작하게 되었다.

 

아래 테스트 코드에 사용된 라이브러리 버전은 아래와 같다.

 

implementation group: 'org.springframework', name: 'spring-context', version: '5.3.19'
//AOP
implementation group: 'org.aspectj', name: 'aspectjweaver', version: '1.9.9.1'
//JPA
implementation group: 'org.springframework.data', name: 'spring-data-commons', version: '2.6.4'
implementation group: 'org.springframework.data', name: 'spring-data-jpa', version: '2.6.4'
implementation group: 'org.hibernate', name: 'hibernate-entitymanager', version: '5.6.8.Final'
implementation group: 'org.springframework', name: 'spring-jdbc', version: '5.3.19'
implementation group: 'org.apache.commons', name: 'commons-dbcp2', version: '2.9.0'
implementation group: 'mysql', name: 'mysql-connector-java', version: '8.0.28'

 

JPA Repository 빈은 Dynamic Proxy 에 의해 새로운 Class 생성 및 객체를 만들어 Bean 으로 관리

@Configuration
public class PersistenceConfig {

	@Bean(destroyMethod = "close")
	public DataSource dataSource() {
		BasicDataSource dataSource = new BasicDataSource();
		dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
		dataSource.setUrl("jdbc:mysql://127.0.0.1:3306/woongs");
		dataSource.setUsername("root");
		dataSource.setPassword("admin");
		return dataSource;
	}
	@Bean
	public HibernateJpaVendorAdapter hibernateJpaVendorAdapter() {
		HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
		hibernateJpaVendorAdapter.setShowSql(true);
		return hibernateJpaVendorAdapter;
	}
	@Bean(name = "entityManagerFactory")
	public LocalContainerEntityManagerFactoryBean entityManagerFactory() {

		Properties properties = new Properties();
		properties.setProperty("spring.jpa.database", "sql-server");

		LocalContainerEntityManagerFactoryBean entityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
		entityManagerFactoryBean.setJpaVendorAdapter(hibernateJpaVendorAdapter());
		entityManagerFactoryBean.setDataSource(dataSource());
		entityManagerFactoryBean.setPackagesToScan("member");
		entityManagerFactoryBean.setJpaProperties(properties);
		return entityManagerFactoryBean;
	}
	@Bean
	public PlatformTransactionManager transactionManager() {
		JpaTransactionManager jpaTransactionManager = new JpaTransactionManager(entityManagerFactory().getObject());
		jpaTransactionManager.setDataSource(dataSource());
		return jpaTransactionManager;
	}
}
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
	Member findByLoginId(String loginId);
}
@Entity(name = "member")
public class Member {

	@Id
	@GeneratedValue(strategy = GenerationType.AUTO)
	@Column(name = "id")
	private Long id;

	@Column(name = "loginId")
	private String loginId;

	@Override
	public String toString() {
		return "Member{" +
			"id=" + id +
			", loginId='" + loginId + '\'' +
			'}';
	}
}
public class Main {

	public static void main(String[] args) {
		ApplicationContext applicationContext = new AnnotationConfigApplicationContext(MyConfig.class, PersistenceConfig.class);
		MemberRepository memberRepository = (MemberRepository) applicationContext.getBean("memberRepository");

		Member member = memberRepository.findByLoginId("woongs00");
		System.out.println(member);

	}
}

위와 같이 간단한 JPA 코드를 작성하여 실행해보면 Repository interface 의 Class 이름이 $Proxy 임을 확인할 수 있다.

이를 통해 Dynamic Proxy 라이브러리를 통해 생성된 Proxy 클래스가 주입되었음을 알 수 있다.

 

그렇다면 Repository Bean 은 왜 CGLIB 가 아니라 Dynamic Proxy 가 사용되었을까?

어떤 Proxy 클래스 생성 라이브러리를 사용할지에 대한 결정은 DefaultAopProxyFactory.java 에서 결정하게 된다.

@Override
public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException {
	if (!NativeDetector.inNativeImage() &&
			(config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config))) {
		Class<?> targetClass = config.getTargetClass();
		if (targetClass == null) {
			throw new AopConfigException("TargetSource cannot determine target class: " +
					"Either an interface or a target is required for proxy creation.");
		}
		if (targetClass.isInterface() || Proxy.isProxyClass(targetClass) || ClassUtils.isLambdaClass(targetClass)) {
			return new JdkDynamicAopProxy(config);
		}
		return new ObjenesisCglibAopProxy(config);
	}	
    else {	
       	return new JdkDynamicAopProxy(config);
	}
}

MemberRepository.java 인터페이스는 첫번째 분기의 세가지 조건 모두 false 여서 JdkDynamicAopProxy() 를 사용하여 Proxy 클래스가 생성되게 된다. 따라서 Dynamic Proxy 가 사용되게 된다. ( interface 이기 때문에 extends 를 사용하는 CGLIB 가 아닌 Dynamic Proxy 를 사용하는 것 같다)

 

Proxy 클래스는 JDKDynamicAopProxy.java 의 getProxy() 함수에서 Dynamic Proxy 라이브러리를 호출함으로서 생성된다.

@Override
public Object getProxy(@Nullable ClassLoader classLoader) {
	if (logger.isTraceEnabled()) {
		logger.trace("Creating JDK dynamic proxy: " + this.advised.getTargetSource());
	}
	return Proxy.newProxyInstance(classLoader, this.proxiedInterfaces, this);
}

Proxy.newProxyInstance() 로 호출된 MemberRepository 를 위한 Proxy 클래스 생성 파라미터를 보면 아래와 같다.

새롭게 생성될 Proxy 클래스는 위에 보이는 5가지의 interface 를 구현하게 될 것이다. 따라서 Repository 를 주입받는 빈들이 MemberRepository 타입으로 Proxy 클래스를 주입받을 수 있는 것이다.

또 한가지 중요한 점은 JdkDynamicAopProxy.java 가 InvocationHandler.java 를 구현하여 InvocationHandler 로 전달되고 있는것이다.

아래 그림에서 볼 수 있듯이 Proxy 클래스는 InvocationHandler 를 통하여 Target 클래스의 method 를 호출하게 된다.

 

이렇게 생성된 MemberRepository.java 의 Proxy 클래스는 타입이 Proxy.java 이고 아래와 같이 5개의 interface 를 구현하고 있다.

Proxy.newProxyInstance() 함수는 이렇게 만든 Proxy class 의 객체까지 생성하여 리턴을 해준다.

final Constructor<?> cons = cl.getConstructor(constructorParams);
final InvocationHandler ih = h;
if (!Modifier.isPublic(cl.getModifiers())) {
	AccessController.doPrivileged(new PrivilegedAction<Void>() {
		public Void run() {
			cons.setAccessible(true);
			return null;
        }
	});
}
return cons.newInstance(new Object[]{h});

 

Proxy 클래스에서 TargetClass 가 SimpleJpaRepository 로 지정되어있는 것은 JpaRepositoryFactory.java 에서 BaseClass 를 

SimpleJpaRepository.java 로 지정해주고 있기 때문이다.

@Override
protected Class<?> getRepositoryBaseClass(RepositoryMetadata metadata) {
	return SimpleJpaRepository.class;
}

그렇다고 하더라도 findByLoginId(String loginId) 는 SimpleJpaRepository.java 에 존재하지 않는 함수이다. 이 함수가 어떻게 동작하는지 확인해보자.

 

JPA Query Method 동작 방법

MemberRepository.java 에는 추상 method 인 findByLoginId(String loginId) 가 있다. 구현이 없는 이 함수가 Proxy 클래스를 통해서 어떻게 동작 하는지 알아보자.

 

memberRepository.findByLoginId("someLoginId") 를 호출하게 되면 Proxy 생성시에 전달했던 InvocationHandler 인 JdkDynamicAopProxy.java 의 invoke() 함수가 호출되는 것을 확인할 수 있다. 

이때 targetSource 가 SimpleJpaRepository.java 로 지정되어있다.

InvocationHandler 인 JdkDynamicAopProxy.java 에 의해서 Method 가 호출이 되는데 이때 쿼리를 만들기 위해 사용되는 Interceptor 는 QueryExecutorMethodInterceptor.java 이다. 

 

QueryExecutorMethodInterceptor.invoke() 의 리턴값을 그대로 전달하기 때문에 TargetSource 인 SimpleJpaRepository.java 에 해당 함수가 없이도 동작이 가능한 것이다.

 

이러한 JPA, Hibernate 를 통해 DB 로 부터 데이터를 가져오게 된다.

 

QueryExecutorMethodInterceptor 에는 처음 호출된 쿼리를 어떻게 알고 있었을까?

QueryExecutorMethodInterceptor.java 는 Repository 를 초기화할때 같이 생성된다. 

private Map<Method, RepositoryQuery> mapMethodsToQuery(RepositoryInformation repositoryInformation,
		QueryLookupStrategy lookupStrategy, ProjectionFactory projectionFactory) {
	return repositoryInformation.getQueryMethods().stream() //
			.map(method -> lookupQuery(method, repositoryInformation, lookupStrategy, projectionFactory)) //
			.peek(pair -> invokeListeners(pair.getSecond())) //
			.collect(Pair.toMap());
}

이때 Scan 한 모든 Repository 의 Query Method 를 전달받는다

이것으로 미리 Query Map 을 만들어 놓기 때문에 InvocationHandler 에 의해서 Query Method 가 호출되었을때 이미 해당 메서드에 대한 Query 를 알고 있을 수 있는 것이다.

 

SimpleJpaRepository.java 에 구현되어있는 메서드들은 어떻게 호출되는 것일까?

QueryMethod 의 경우에는 Interceptor 에 의해서 Target Class 인 SimpleJpaRepository.java 까지 호출되지 않고 처리됨을 확인할 수 있었다. 그렇다면 Target Class 에 구현되어있는 함수를 호출했을 경우에는 어떻게 호출이 되는지 확인해본다.

 

public static void main(String[] args) {
	ApplicationContext applicationContext = new AnnotationConfigApplicationContext(MyConfig.class, PersistenceConfig.class);
	MemberRepository memberRepository = (MemberRepository) applicationContext.getBean("memberRepository");
    
	Member member = new Member();
	member.setLoginId("Songree");
 	memberRepository.save(member);

	System.out.println(member);
}

Repository 의 save() 함수는 JpaRepository.java 가 CrudRepository.java 를 상속받고 있기 때문에 호출할 수 있다. 

 

save() 함수가 호출되면 QueryMethod 를 사용했을때처럼 Proxy Class 를 통하여 InvocationHandler 인 JdkDynamaicAopProxy.java 의 invoke() 함수가 호출이 된다.

 

하지만 QueryExecutorMethodInterceptor.invoke() 함수에서 QueryMethod 가 처리될때와는 다른 분기를 타게 되는데

private Object doInvoke(MethodInvocation invocation) throws Throwable {
	Method method = invocation.getMethod();
	if (hasQueryFor(method)) {
		RepositoryMethodInvoker invocationMetadata = invocationMetadataCache.get(method);
		if (invocationMetadata == null) {
			invocationMetadata = RepositoryMethodInvoker.forRepositoryQuery(method, queries.get(method));
			invocationMetadataCache.put(method, invocationMetadata);
		}
		return invocationMetadata.invoke(repositoryInformation.getRepositoryInterface(), invocationMulticaster,
				invocation.getArguments());
	}
    return invocation.proceed();
}

QueryMethod 를 호출하였을때는 Repository 를 빈으로 등록하는 과정에서 QueryMethod 에 해당하는 Query 들을 모두 만들어 Map 으로 저장해놓았기 때문에 hasQueryFor(method) 함수에서 true 를 리턴하게되면서 if 문 분기를 타게 된다.

 

하지만 save() 메서드는 QueryMethod 로 작성해놓지 않았기 때문에 invocation.proceed() 를 타게 된다.

 

위 두가지 경우의 RepositoryMethodInvocaker 는 서로 다른 타입의 클래스이다.

  • QueryMethod 를 호출한 경우 : RepositoryMethodInvoker$RepositoryQueryMethodInvoker
  • CrudRepository 의 save() 를 호출한 경우 : RepositoryMethodInvoker$RepositoryFragmentMethodInvoker

RepositoryMethodInvoker$RepositoryFragmentMethodInvoker 가 되었을때 Repository 를 빈으로 등록하면서 저장한 Fragements 들 중에서 해당 함수를 가지고 있는 구현체를 찾아 해당 Method 를 invoke 하게 된다.

 

interface 의 함수를 구현체의 함수로 바꿔주는 부분은 RepositoryComposition.invoke() 함수이다.

Object invoke(RepositoryInvocationMulticaster listener, Method method, Object[] args) throws Throwable {

	Method methodToCall = getMethod(method);

	if (methodToCall == null) {
		throw new IllegalArgumentException(String.format("No fragment found for method %s", method));
	}

	ReflectionUtils.makeAccessible(methodToCall);

	return fragments.invoke(metadata != null ? metadata.getRepositoryInterface() : method.getDeclaringClass(), listener,
			method, methodToCall, argumentConverter.apply(methodToCall, args));
}

위 함수에 Break Point 를 잡아서 확인을 해보면 파라미터로 전달된 method 는 interface 의 method 이지만 methodToCall 로 리턴받은 함수는 SimpleJpaRepository 의 함수임을 확인할 수 있다.

이렇게 Interface 의 함수를 실제로 호출한 Fragment 의 함수로 변경하기 때문에 save() 함수 호출이 가능한 것이다.

 

이 메커니즘은 Repository 에 Custom Interface 를 생성하고 구현하였을때도 동일하게 동작한다.

Custom Implment Repository 를 만들게되면 Fragment 에 SimpleJpaRepository 뿐만 아니라 Custom 구현체도 Fragment 리스트에 포함되게 된다.

덕분에 MemberRepository interface 의 custom method 를 호출하였을때 MemberRepositoryImpl 의 함수가 호출될 수 있는 것이다.