Dynamic Proxy in Java

노력을 즐기는 사람·2021년 12월 5일
1
post-thumbnail

들어가기 전

이전 포스팅에서 리플랙션을 사용한 애너테이션 기반 DI를 구현하면서 이런 생각이 들었습니다.

  • Spring Data JPA의 @Transactional 은 어디서 처리되고 있을까?
  • Spring Data JPA에서 Repository를 정의할 때 인터페이스만 구현 했는데 구현체는 어디에 있을까?

예측해보자면 Spring AOP와 Dynamic Proxy의 콜라보가 아닐까 싶습니다.
그리고 그걸 직접 찾아보기로 결심했습니다.
Spring Data JPA를 파헤치기 위해서는 Dynamic Proxy에 대한 이해가 필요합니다.
사실은 Spring AOP에 대한 이해도 필요합니다.
이번 포스팅에서는 Dynamic Proxy 부터 만나보겠습니다.

타겟 독자

사전 지식이 필요합니다!

  • Spring Data JPA를 사용해본사람
  • 리플렉션을 알고 있는 사람

위의 두 가지 조건을 만족한다면 제 포스팅을 더욱 재밌게 읽을 수 있을 것 같습니다.
이제 시작하겠습니다!

Proxy 패턴

Proxy의 사전적 의미는 대리인 입니다. 비서로 비유하면 딱 맞을 것 같습니다.
다짜고짜 그림부터 보겠습니다.

간단하게 말하면, Client가 실제 객체(target)를 호출하고 프록시 객체(Proxy)가 중간에서 어떠한 동작을 하는 것이 프록시 패턴입니다.

프록시 패턴은 실제 객체의 동작 이전과 이후에 공통적인 로직을 추가 할 때 유용합니다.
더욱 구체적인 예시를 들어보자면 로깅, 트랜잭션, 접근제어 등이 있겠습니다.

프록시 패턴의 핵심은 다음과 같습니다.

  • 프록시 객체는 실제 객체와 같은 타입이다. (같은 부모를 가진다. Object를 제외!)
  • 프록시 객체가 실제 객체의 로직에 영향을 주면 안된다.
  • 프록시는 계층을 가질 수 있다. (프록시의 프록시)
  • 실제 객체를 직접 수정하는 것보다 유지보수하기 좋다.

그렇지만 프록시에도 단점이 있습니다. 프록시를 작성하는게 귀찮다는 것입니다.
만약 프록시의 부모 인터페이스가 30개의 메서드를 가진다면 30개의 메서드를 모두 정의해야 합니다.
설령 30개의 메서드 모두 프록시가 필요하지 않더라도 정의해야만 합니다!
게다가, 2 계층의 프록시를 두겠다고 하면 재정의할 메서드는 60개가 됩니다.

이를 해결하기 위해서 자바는 리플렉션을 통한 Dynamic Proxy를 지원합니다.

Dynamic Proxy

그런데 Dynamic Proxy가 뭘까요?

Dynamic Proxy는 런타임 시점에 Proxy 클래스를 생성하는 것을 말합니다. 간단하죠?

자바는 Proxy 클래스로 Dynamic Proxy를 지원하고 있습니다.
Proxy 클래스 사용 예시를 살펴보며 Dynamic Proxy를 이해해보겠습니다.

실습하며 Proxy와 친해지기

Proxy 클래스는 자바의 리플렉션 API가 제공하는 클래스입니다. 이 녀석은 프록시 객체를 생성하기 위한 static 메서드를 제공합니다.

이제 Proxy 클래스를 사용해서 Spring Data JPA의 Repository를 따라 만들어보겠습니다.

우리가 작성할 클래스 계층입니다.

Spring Data JPA와 비교해서 설명해보겠습니다.

  • SimpleRepositoryInterfaceJpaRepository<T, ID> 의 역할입니다. 기본적인 CRUD 메서드 명세를 정의합니다.
  • StockRepository 는 사용자가 정의하는 비즈니스 로직의 Repository 입니다.
  • SimpleRepositoryImpl 는 기본적인 CRUD 메서드의 구현체를 제공합니다.

이제 위의 계층 구조를 직접 코딩해보겠습니다.

// Spring Data JPA가 제공하는 인터페이스
public interface SimpleRepositoryInterface {

	Stock findById(Long id);  // R 메서드 명세

	void save(Stock entity);  // C 메서드 명세
}

...
...

// 사용자 정의 Repository
public interface StockRepository extends SimpleRepositoryInterface { }

...
...

// Spring Data JPA가 제공하는 인터페이스의 구현체
public class SimpleRepositoryImpl implements SimpleRepositoryInterface {

	private static Long id = 1L; // ID Sequence

	private static final Map<Long, Stock> dataSource = new HashMap<>(); // DB

	@Override
	public Stock findById(Long id) {
		return dataSource.get(id);     // R 메서드 구현체
	}

	@Override
	public void save(Stock entity) {
		dataSource.put(id++, entity); // C 메서드 구현체
	}
}

이런 코드를 작성했습니다.
위 코드를 설명하기 위해서 Spring Data JPA 사용 방식을 떠올려 보겠습니다.

  • 우리 프로젝트에는 Stock 이라는 Entity 가 존재합니다.
  • Stock을 DB에 저장,조회,수정 등을 하기 위해서 StockRepository를 정의합니다. StockRepositoryJpaRepository를 상속합니다.
  • StockRepository는 인터페이스 타입으로 정의하며, 어떠한 구현체도 정의하지 않습니다.
  • 그렇지만 우리는 아무렇지 않게 save(), find() 같은 메서드를 호출해서 사용합니다. 그리고 정상적으로 동작합니다.

지금 생각하니 정말 마술 같습니다. 구현체를 정의하지 않았는데 동작한다니요.
저도 Proxy 를 사용해서 마술을 부려보겠습니다.
우리도 구현체를 정의한적이 없지만 런타임 시점에 구현체가 알아서 등록될 것입니다.
그리고 Transaction도 보장해보겠습니다.

마술을 부리기 전에 Transaction을 보장하기 위해서 비서(프록시)를 정의하겠습니다.
자바의 Dynamic Proxy에서는 InvocationHandler 를 정의해서 비서로 사용합니다.

public class DynamicProxyInvocationHandler implements InvocationHandler {

	private final Object target; // Target 객체의 인스턴스

	public DynamicProxyInvocationHandler(Object target) {
		this.target = target; // SimpleRepositoryTarget 주입
	}

	@Override
	public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
		System.out.println("START TRANSACTION " + method.getName()); // 트랜잭션 시작
		Object result = method.invoke(target, args); // Actual 메서드 호출
		System.out.println("COMMIT TRANSACTION " + method.getName()); // 트랜잭션 종료
		return result;
	}
}

최상단에 위치한 target 필드가 실제 객체입니다.
즉, target에는 Create, Read 구현체를 가지고 있는 SimpleRepositoryImpl의 인스턴스가 주입됩니다.
invoke() 로직을 보시면 저는 sout 을 통해서 트랜잭션을 보장하는 척을 하고 있습니다 ...

준비는 끝났습니다. 드디어 이제 프록시 객체를 만들어보겠습니다.
의외로 프록시 객체를 만드는 것은 아주 간단합니다. 코드를 살펴보시죠.

class StockRepositoryTest {

	private static SimpleRepositoryInterface repository;

	@BeforeAll
	static void DI() {
    		// 인터페이스 타입을 프록시 구현체로 인스턴스화 (Actual)
		SimpleRepositoryInterface targetObject = new SimpleRepositoryImpl(); 
        
        	// proxy 생성
		repository = (SimpleRepositoryInterface) Proxy.newProxyInstance(
				StockRepository.class.getClassLoader(), // 프록시 객체를 저장할 클래스 로더
				StockRepository.class.getInterfaces(),	// 프록시가 구현할 인터페이스들
				new DynamicProxyInvocationHandler(targetObject)); // 비서가 수행할 업무를 담은 InvocationHandler

	}

	@Test
	void 다이나믹_프록시_테스트() {
		repository.save(new Stock("삼성전자", 70000L));
		assertEquals("삼성전자", repository.findById(1L).name());
		assertEquals(70000L, repository.findById(1L).price());
	}

}

최상단에는 의존성 주입을 기다리는 SimpleRepositoryInterface가 존재합니다.

Spring Data JPA였다면 StockRepository 타입을 주입받아야 합니다.
그렇지만 이번 포스팅에서는 최상위 인터페이스 타입인 SimpleRepositoryInterface 타입을 사용합니다.
아직 이 부분의 마술은 밝혀내지 못했습니다 ㅠㅠ 아시는 분은 댓글 부탁드립니다!

그리고 DI() 메서드에서 다이나믹 프록시 객체를 주입합니다.

DI()가 성공하면 다이나믹_프록시_테스트() 메서드도 잘 동작합니다.

트랜잭션도 잘 동작(?)하고 있네요. 로그 메세지를 조금 더 고도화 해보겠습니다.

public class DYnamicProxyInvocationHandler .... {

	...
    	...
    
	@Override
	public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
		System.out.println("START TRANSACTION " + method.getName());

		if (method.getName().equals("findById")) {
			System.out.printf("SELECT * FROM stock WHERE id = %d\n", args[0]);
		}

		if (method.getName().equals("save")) {
			Stock entity = (Stock) args[0];
			System.out.printf("INSERT INTO stock (id, name, price) VALUES (?, %s, %d)\n", entity.name(), entity.price());
		}

		Object result = method.invoke(target, args);
		System.out.println("COMMIT TRANSACTION " + method.getName());
		return result;
	}
}

InvocationHandler를 이렇게 변경한 후 다시 테스트를 실행하겠습니다.

우리가 테스트 코드에서 작성한대로 잘 동작하네요!!

그런데 무언가 이상합니다. 클라이언트는 save()findById()를 호출합니다.
그런데 우리는 save() 메서드를 구현한 적이 없습니다. 이게 왜 동작하는 걸까요?
이 코드가 동작하려면 클라이언트가 invoke() 메서드를 호출해야하는게 아닐까요?
이유는 간단합니다. 다이나믹 프록시가 대신 구현해줬기 때문입니다.

다이나믹 프록시는 인터페이스의 모든 메서드를 구현한 프록시 객체를 리턴합니다.
프록시 객체는 클라이언트의 요청을 리플렉션 데이터로 변환하여 InvocationHandler에게 전달합니다.
동작 방식을 그림으로 보겠습니다.

다이나믹 프록시가 생성한 객체는 $Proxy 라는 이름을 가집니다.

$Proxyh라는 이름의 핸들러를 보유하고 있습니다.
$Proxy의 코드를 살펴보고 싶어서 IntelliJ의 디버거를 사용해봤지만 볼 수 없었습니다.
예상하기로, ClassLoader에 곧바로 저장되는 것이 아닐까 싶습니다. 그래서 Proxy.newProxyInstance() 에서 클래스 로더를 파라미터로 요구하는게 아닐까요?
아시는 분이 계시다면 댓글 부탁드립니다..

이 정도면 우리는 Dynamic Proxy와 친해졌다고 생각합니다.
포스팅이 많이 길어졌지만 아직 다루고 싶은 내용이 남았습니다.
다음 포스팅에서는 Spring Data JPA의 코드를 까보려고 합니다.

profile
노력하는 자는 즐기는 자를 이길 수 없다

2개의 댓글

comment-user-thumbnail
2021년 12월 19일

언젠가 프록시를 딥다이브 해보고 싶네요. 신기해라..

1개의 답글