토비의 스프링 정복하기 11편 - 프록시가 나오게 된 이유

 


프록시와 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()는 스태틱 팩토리 메서드이고, 생성자를 통한 빈 등록이 불가능하다.
  • 클라이언트가 매번 프록시 생성 코드를 직접 작성해야 한다.