프록시와 Mock
프록시(AOP)와 Mock(@MockitoBean)은 목적은 다릅니다. 하나는 부가기능을 끼워넣기 위해, 다른 하나는 테스트 격리를 위해 존재합니다. 하지만 구조적으로는 놀라울 정도로 닮아있습니다.
[클라이언트] → [대리 객체] → [실제 타겟]
- AOP 프록시: 트랜잭션, 로깅 등 부가기능을 가진 대리 객체가 타겟을 감싼다.
- @MockitoBean: 테스트용 가짜 객체가 스프링 컨텍스트에서 실제 빈을 대신한다.
두 경우 모두 클라이언트(호출자)는 대리 객체의 존재를 알 수 없습니다. 개발자들은 프록시를 통해 호출한 쪽에서 대리 객체를 통해 실제 객체를 숨기려고 했을까요?
1단계: 수동 프록시 — 가장 원시적인 대리 객체
AOP 관점: 데코레이터 패턴
프록시를 구현하는 가장 직관적인 방법은 인터페이스를 직접 구현한 프록시 클래스를 만드는 것입니다.
public interface Hello {
String sayHello(String name);
String sayHi(String name);
String sayThankYou(String name);
}
타겟이라는 클래스는 핵심 비즈니스 로직만 담당합니다.
public class HelloTarget implements Hello {
@Override
public String sayHello(String name) { return "Hello " + name; }
@Override
public String sayHi(String name) { return "Hi " + name; }
@Override
public String sayThankYou(String name) { return "Thank You " + name; }
}
프록시는 같은 인터페이스를 구현하고, 타겟을 감싸서 부가기능(대문자 변환)을 수행하도록 구현하였습니다.
public class HelloUppercase implements Hello {
private final Hello hello;
public HelloUppercase(Hello hello) {
this.hello = hello;
}
@Override
public String sayHello(String name) {
return hello.sayHello(name).toUpperCase(); // 부가기능 + 위임
}
@Override
public String sayHi(String name) {
return hello.sayHi(name).toUpperCase(); // 동일한 부가기능 반복
}
@Override
public String sayThankYou(String name) {
return hello.sayThankYou(name).toUpperCase(); // 또 반복
}
}
타겟 클래스의 코드를 손대지 않고, 클라이언트의 호출 방식도 변경하지 않은 채로 부가기능을 추가할 수 있게 되었습니다. 하지만 인터페이스의 모든 메서드를 구현해 위임하는 코드를 작성해야 하고 구현체가 많아질수록 중복된 코드도 늘어나는 문제가 생기게 되었습니다.
@Test
void manualProxy() {
Hello hello = new HelloUppercase(new HelloTarget());
assertEquals("HELLO TOBY", hello.sayHello("Toby"));
}
JDK 다이나믹 프록시 — 리플렉션으로 중복 제거
Java Reflection의 Proxy.newProxyInstance()를 활용하면, 런타임에 인터페이스 정보만으로 프록시 객체를 자동 생성할 수 있습니다. 부가기능은 InvocationHandler 하나에 집중시키면 됩니다.
public class UpperCaseHandler implements InvocationHandler {
private final Object target;
private final String pattern;
public UpperCaseHandler(Object target, String pattern) {
this.target = target;
this.pattern = pattern;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object ret = method.invoke(target, args); // 타겟에 위임 (콜백)
if (ret instanceof String && method.getName().startsWith(pattern)) {
return ((String) ret).toUpperCase(); // 부가기능은 딱 한 곳
}
return ret;
}
}
@Test
void dynamicProxy() {
Hello hello = (Hello) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class[] { Hello.class }, // 인터페이스 정보만 전달
new UpperCaseHandler(new HelloTarget(), "say") // 부가기능
);
assertEquals("HELLO TOBY", hello.sayHello("Toby"));
assertEquals("HI TOBY", hello.sayHi("Toby"));
assertEquals("THANK YOU TOBY", hello.sayThankYou("Toby"));
}
구조를 다시 보면, 변하지 않는 부가기능 코드가 템플릿이 되고, 변하는 타겟 메서드 호출은 Method 객체를 콜백으로 전달받아 실행합니다. 이전에 람다를 기반으로 처리했었던 템플릿/콜백 패턴과 유사합니다.
- 인터페이스에 메서드가 30개로 늘어나도 UpperCaseHandler 코드는 변경 불필요. 다이나믹 프록시가 추가된 메서드를 자동으로 invoke()에 연결.
- 부가기능 코드의 중복이 완전히 제거.
아직 해결하지 못한 문제
- 다이나믹 프록시를 스프링 빈으로 등록할 수 없다. Proxy.newProxyInstance()는 스태틱 팩토리 메서드이고, 생성자를 통한 빈 등록이 불가능하다.
- 클라이언트가 매번 프록시 생성 코드를 직접 작성해야 한다.
