Spring JPA 와 Dynamic Proxy
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 의 함수가 호출될 수 있는 것이다.