해당 포스팅은 인프런에서 제공하는 김영한 님의 '스프링 핵심원리 고급편'을 수강한 후 정리한 글입니다. 유료 강의를 정리한 내용이기에 제공되는 예제나 몇몇 내용들은 제외하였고, 정리한 내용을 바탕으로 글 작성자인 저의 언어로 다시 작성한 글이기에 서술이 부족하거나 잘못된 내용이 있을 수 있습니다. 그렇기에 해당 글은 개념에 대한 참고 정도만 해주시고, 강의를 통해 학습하시기를 추천합니다.
자바의 리플렉션 기술은 클래스나 메서드의 메타정보를 동적으로 획득하고, 코드도 동적으로 호출할 수 있다.
리플렉션을 사용하는 방법은 다음과 같다.
Class test = Class.forName("패키지경로.클래스명{$내부 클래스}")
: 클래스 메타정보를 획득 Method execute = test.getMethod("execute")
: 해당 클래스의 execute()
메서드 메타정보를 획득execute.invoke(target)
: 획득한 메서드 메타정보로 실제 인스턴스(target
)의 메서드를 호출한다.이렇듯 리플렉션을 사용하면 클래스나 메서드 정보를 동적으로 변경할 수 있으며, 이를 통해 비지니스 로직의 공통된 부분을 뽑아 공통 로직을 만들 수 있게 된다.
리플렉션을 사용하면 어플리케이션을 동적으로 유연하게 만들 수 있다. 그러나 리플렉션은 런타임에 동작하기 때문에 컴파일 시점에 오류를 잡을 수 없다는 단점이 존재한다. 이는 굉장히 치명적인 오류로 발생할 수 있다.
따라서 리플렉션은 일반적으로 사용하지 않으며, 프레임워크나 매우 일반적인 공통 처리가 필요할 때 부분적으로 주의해서 사용해야 한다.
프록시 패턴을 적용하기 위해서는 적용 대상의 수 만큼 많은 프록시 클래스를 만들어야 했다. 이는 적용 대상의 차이가 존재하기 때문에 발생하는 문제이며, 동적 프록시 기술을 통해 이를 해결할 수 있다.
동적 프록시 기술을 사용하면 개발자가 직접 프록시 객체를 만들지 않아도 런타임에 프록시 객체를 대신 만들어주며, 원하는 실행 로직을 지정해줄 수 있다.
public interface TestInterface { void execute(); }
public class TestImple implements TestInterface {
@Override
public void execute() { System.out.println("Test Execute"); }
}
JDK 동적 프록시는 인터페이스를 기반으로 프록시를 동적으로 만들어주기 때문에 인터페이스가 필수적이다. 위의 인터페이스와 구현체에 JDK 동적 프록시를 이용해 공통 로직을 적용하기 위해서는 InvocationHandler
인터페이스를 구현하면 된다.
public class TestInvocationHandler implemnts InvocationHandler {
private final Object target; // 동적 프록시가 호출할 대상
public TestInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method,
Object[] args) throws Throwable {
// args : 메서드 호출시 넘겨줄 인수
System.out.println("TestProxy Run");
Object result = method.invoke(target, args);
System.out.println("TestProxy End");
return result;
}
}
이렇게 구현된 프록시는 아래와 같이 사용할 수 있다.
void test() {
TestInterface target = new TestImpl();
TestInvocationHandler handler = new TestInvocationHandler(target);
TestInterface proxy = (TestInterface) Proxy.newProxyInterface(TestInterface.class.getClassLoader(), new Class[]{TestInterface.class}, handler);
proxy.execute();
}
위 메서드의 실행 결과는 다음과 같다.
TestProxy Run
Test Execute
TestProxy End
위 로직의 실행 순서는 다음과 같다.
execute()
를 실행한다.InvocationHandler
의 invoke()
를 호출하며, 그 구현체가 TestInvocationHandler
이므로 TestInvocationHandler.invoke()
가 호출된다.TestInvocationHandler
가 내부 로직을 수행하고, method.invoke(target, args)
를 호출하여 target
의 실제 객치인 TestImpl
를 호출한다.TestImpl
의 execute()
가 실행된다.TestImpl
의 execute()
가 종료되면 TestInvocationHandler
로 응답이 돌아와 나머지 로직을 수행하고 그 결과를 반환한다.이렇게 살펴본 JDK 동적 프록시의 장점은 같은 부가 기능 로직을 한번만 개발해서 공통으로 사용할 수 있다는 것이다. 위의 코드에서 인터페이스 TestInterface
와 구현체인 TestImpl
의 위치에 어느 인터페이스와 그 구현체가 와도 프록시 부분은 재사용이 가능하다. 따라서 적용 대상만큼 프록시 객체를 만들지 않아도 된다. 결과적으로 부가 기능 로직도 하나의 클래스에 모아 단일 책임 원칙(SRP)도 지킬 수 있게 되었다.
그러나 JDK 동적 프록시는 인터페이스가 필수이기 때문에 구현체를 만들기 앞서 항상 인터페이스를 생성해주어야 한다는 단점이 존재한다. 이를 해결하기 위해서는 CGLIB라는 바이트코드를 조작하는 특별한 라이브러리를 사용해야 한다.
CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이다. 이를 사용하면 인터페이스가 없이 구체 클래스만 가지고 동적 프록시를 만들어낼 수 있다. CGLIB를 직접 사용하는 경우는 거의 없고, 스프링의 ProxyFactory
가 이 기술을 편리하게 사용할 수 있게 해준다.
public class TestClass {
public void execute() {
System.out.println("Test Execute");
}
}
위의 콘크리트 클래스에 동적 프록시 로직을 적용하기 위해서는 MethodInterceptor
를 사용하면 된다.
public class TestMethodInterceptor implements MethodInterceptor {
private final Object target;
public TestMethodInterceptor(Object target) {
this.target = target;
}
@Override
public Object intercpet(Object obj, Method method,
Object[] args, MethodProxy proxy
) throws Throwable {
System.out.println("TestProxy Run");
Object result = method.invoke(target, args);
System.out.println("TestProxy End");
return result;
}
}
위의 예제를 살펴보면 알 수 있듯 기본적으로 JDK 동적 프록시와 그 사용에 있어 큰 차이가 존재하지 않는다. 그러나 이를 실행하는데 있어 약간의 차이가 있다.
void test {
TestClass target = new TestClass();
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(TestClass.class);
enhancer.setCallback(new TestMethodInterceptor(target));
TestClass proxy = (TestClass) enhancer.create();
proxy.execute();
}
코드를 살펴보면 CGLIB는 Enhancer
를 사용해 프록시를 생성하는 것을 알 수 있다. 이 때 프록시를 생성하고자 하는 구체 클래스(TestClass
)를 상속 받아서 프록시를 생성한다.
CGLIB는 클래스 기반으로 상속을 사용하기 때문에 몇가지 제약이 존재한다.