*인프런 김영한 강사님의 강좌를 참고하여 정리한 내용입니다.*
지금까지 템플릿 메서드, 콜백, 전략, 프록시 패턴에 대해 정리해보았다. 디자인 패턴은 왜 필요할까? 여러가지 이유가 있지만 대표적으로 유지보수성과 코드의 재사용성을 향상시키기 위해서다.
공통 관심사항(AOP)을 적용하는 예시를 통해, 핵심 기능과 부가 기능의 분리를 위해 다양한 디자인 패턴을 적용했다.
템플릿 메서드는 추상 클래스를 통해 해결했지만, 상속 때문에 생기는 강한 결합이 있었다. 이를 개선한 전략 패턴은 클래스의 상속이 아닌 인터페이스의 위임을 통해 역할과 구현을 분리했지만, 결국? 원본 코드에 변형이 생긴다.
이를 해결하기 위해 프록시 패턴을 사용했다. 프록시 패턴은 객체를 추상화한 인터페이스를 구현하는 실제 객체와 프록시 객체를 두어 클라이언트는 프록시 객체를 거쳐 실제 객체를 호출한다! 이렇게 부가 기능을 프록시 객체로 분리함으로써 원본 코드에는 변경이 생기지 않게 되었다!
원본 객체의 수에 따라 프록시 객체를 생성해야 한다는 문제가 있다!! 만약 객체의 수가 100개, 200개.. n개를 넘어간다면 과연 올바르게 유지보수가 가능할까?
이러한 문제를 어떻게 해결할 수 있을까?
동일한 부가 기능을 부여한다는 관점에서 굳이 객체마다 프록시 객체를 만들지 않고 동적으로 자동으로 프록시 객체를 생성하여 적용하면 문제를 해결할 수 있다!
자바의 기본적으로 제공하는 JDK 동적 프록시 기술이나 CGLIB을 사용하면 프록시 객체를 동적으로 만들어낼 수 있다고 한다. 이러한 동적 프록시 기술을 이해하기 위해서는 이에 기반이 되는 리플렉션 기술을 이해해야 한다.
메타정보(Metadata)란 데이터를 설명하는 데이터이다. 예를 들어, 클래스의 메타정보는 클래스의 이름, 메서드, 필드, 상위 클래스, 인터페이스 등과 같은 클래스의 구조와 관련된 정보를 포함한다.
// 메타정보로 추출할 클래스
static class Hello {
public String callA() {
log.info("callA");
return "A";
}
public String callB() {
log.info("callB");
return "B";
}
]
@Test
void reflectionTest() throws Exception {
// 실제 인스턴스
Hello traget = new Hello();
// 메타 데이터를 통한 호출
// Hello 클래스 정보
Class metaHello = Class.forName("hello.~~.packageName$Hello");
Method callA = metaHello.getMethod("callA");
Object resultA = callA.invoke(target);
Method callB = metaHello.getMethod("callB");
Object resultB = callB.invoke(target);
// 인스턴스 호출
target.callA();
target.callB();
}
[메타 데이터 획득]
- Class.forName("hello.~~.packageName"): 클래스 메타정보를 획득한다. 호출하는 내부 클래스는 구분을 위해
$를 사용한다.- classHello.getMethod("callA"): 해당 클래스의
callA메서드 메타정보를 획득한다.
Class 타입을 통해서 메타 데이터를 획득한다.
[실제 인스턴스 호출]- callA.invoke(target): 메서드 메타정보로 실제 인스턴스의 메서드를 호출한다. Method 타입의
callA는Hello클래스의callA()메서드 메타정보.methodCallA.invoke(인스턴스);를 호출하면서 인스턴스를 넘기면 해당 메타정보를 통해 해당 인스턴스의 메서드를 찾아 실행한다.
그렇다면 target.callA() 처럼 그냥 실제 인스턴스를 통해 메서드를 직접 호출하면 되지 왜 이렇게 불필요한 과정을 통해 메서드를 호출할까?
@Test
void reflectionTest() throws Exception {
// 실제 인스턴스
Hello traget = new Hello();
// 메타 데이터를 통한 호출
// Hello 클래스 정보
Class metaHello = Class.forName("hello.~~.packageName$Hello");
Method callA = metaHello.getMethod("callA");
// Object resultA = callA.invoke(target);
dynamicCall(callA, target);
Method callB = metaHello.getMethod("callB");
// Object resultB = callB.invoke(target);
dynamicCall(callB, target);
}
private void dynamicCall(Method method, Object target) throws Exception {
log.info("start");
Object result = method.invoke(target);
log.info("result = {}", result);
}
보다시피 메타 데이터를 사용하지 않은 경우 callA(), callB() 와 같이 메서드를 직접 호출해야 한다. 뿐만 아니라 공통 기능을 분리함에도 dynamicCallA, dynamicCallB 와 같이 호출하고자 하는 메서드에 따라 구현이 필요하기 때문에 재사용을 할 수 없다.
하지만 메타 데이터를 사용한다면 호출하는 부분을 메서드의 인자로 target을 동적으로 받아 result를 호출할 수 있다. 따라서 동적으로 Method 라는 메타정보를 활용함으로써 메서드를 재사용할 수 있다.
이 또한 문제점이 있는데.. 리플렉션 기술은 런타임 시점에 동작하기 때문에 컴파일 시점에 오류를 잡을 수 없다.
Method callB = metaHello.getMethod("callNone");
이와 같이 메타 정보를 호출할 때 메서드 명을 잘못 작성해도 컴파일 시점에 오류가 발생하지 않고, 코드를 직접 실행하는 시점에 런타임 오류가 발생한다.
따라서, 리플렉션은 프레임워크의 개발이나 매우 일반적인 공통 처리가 필요할 때 부분적으로 주의해서 사용해야 한다고 한다.
앞서 프록시 패턴을 통해 공통 기능과 부가 기능을 성공적으로 분리할 수 있었다. 하지만 100개의 클래스가 있다면 100개의 프록시를 만들수는 없는법..
런타임 시점에 프록시 객체가 동적으로 생성된다면? 그것을 가능하게 하는것이 바로 동적 프록시 기술이다. (인터페이스 기반으로 동작)

기존의 프록시 패턴을 사용하던 구조이다.

JDK 동적 프록시를 사용하면 자동으로 동적으로 프록시 객체를 생성해준다.
다이어그램을 예제 코드로 이해해보자!
/**
* A, B 인터페이스와 각 구현체
*/
public interface AInterface {
String call();
}
public class AImpl implements AInterface {
@Overrie
public String call() {
log.info("A 호출!");
return "A";
}
}
public interface BInterface {
String call();
}
public class BImpl implements BInterface {
@Overrie
public String call() {
log.info("B 호출!");
return "B";
}
}
package java.lang.reflect;
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
}
동적 프록시에 적용할 로직(AImp, BImpl)은, InvocationHandler 인터페이스를 구현하여 사용해야 한다.
public class TimeInvocatinoHandler implements InvocationHandler {
// 프록시와 동일하게 호출할 원본 객체 target
private Object target;
// 생성자 주입 code..
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 시간을 측정하는 로직..
Object result = method.invoke(target, args);
return result;
}
}
메타 데이터 정보 method의 invoke 인자로 원본 객체 target을 실행한다. 참고로 Handler 클래스는 invoke 메서드를 직접 호출하지 않고 Proxy 객체의 인자로 전달하는 용도이다. (추후에 인터페이스의 메서드를 호출하게 되면 Handler의 invoke가 실행된다.)
@Test
void dynamicA() {
// 원본 객체.
AInterface target = new AImpl();
// Handler 구현체, Dynamic Proxy에 적용할 핸들러 로직이며 인자로 사용된다.
TimeInvocationHandler handler = new TimeInvocationHandler(target);
/**
* 클래스 로더 정보 / 인터페이스 / 핸들러 로직이 필요
* 해당 인터페이스를 기반으로 Dynamic Proxy 인스턴스 생성
*/
AInterface proxy = (AInterface) Proxy
.newProxyInstance(
AInterface.class.getClassLoader(),
new Class[]{AInterface.class},
handler);
proxy.call();
}

call()을 실행한다.InvocationHandler.invoke()를 호출하는데, 전달받은 구현체 TimeInvocationHandler의 invoke()를 호출한다.TimeInvocationHandler의 내부 로직을 수행하고, method.invoke(target, args)를 호출한다.method는 proxy.call()에 따라 call() 메서드의 메타 데이터이다.target의 실제 객체인 AImpl을 호출하고 call() 메서드를 수행한다.AInterface, BInterface에 따른 프록시 객체를 생성하지 않고 하나의 TimeInvocationHandler 인터페이스를 통해 동적으로 프록시 객체를 생성하고 공통 기능 로직을 수행했다. 메타 데이터의 실제 호출 대상이되는 target을 동적으로 변경할 수 있다는점 덕분에 동적으로 프록시 객체를 생성할 수 있다.
또한 컴파일 시점에 오류를 확인할 수 있게 되었다!
동적 프록시를 생성하는 다른 기술이 있는데, JDK 동적 프록시 기술은 인터페이스 기반으로 동작하지만 CGLIB은 클래스를 기반으로 동적으로 프록시를 생성한다.
JDK 동적 프록시에서는 실행 로직을 위해 InvocationHandler 인터페이스를 사용하며 이를 구현한 클래스를 사용했다. CGLIB도 마찬가지로 MethodInterceptor를 구현해야 한다.
public class TimeMethodInterceptor implements MethodInterceptor {
// 프록시와 동일하게 호출할 원본 객체 target
private Object target;
// 생성자 주입 code..
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
// 시간을 측정하는 로직..
Object result = proxy.invoke(target, args);
return result;
}
}
JDK 동적 프록시의 InvocationHandler와 아주 유사한 구조인데, Method보다는 MethodProxy를 invoke하는것을 권장한다고 한다.
@Test
void cglib() {
// 실제 사용할 구현 객체
ConcreteService target = new ConcreteService();
TimeMethodInterceptor interceptor = new TimeMethodInterceptor(target);
Enhancer enhancer = new Enhancer();
/**
* 상속 받을 구체 클래스 정보 / 인터셉트 로직이 필요
* 해당 인터페이스를 기반으로 Dynamic Proxy 인스턴스 생성
*/
enhancer.setSuperclass(ConcreteService.class);
enhancer.setCallback(interceptor);
ConcreteService proxy = (ConcreteService) enhancer.create();
proxy.call();
}
JDK 동적 프록시는 Proxy를 통해 프록시를 생성하지만, CGLIB은 Enhancer를 통해 프록시를 생성한다.
