❗이 게시물은 김영한님의 인프런 강좌 스프링 핵심 원리 - 고급편의 내용을 개인 정리를 위한 목적으로 작성하였습니다.
지난 게시물 [Spring] 프록시 패턴과 데코레이터 패턴을 통해 프록시 패턴과 데코레이터 패턴에 대해서 알아봤다. 이러한 패턴을 활용한 프록시를 통해 기존 코드를 변경하지 않고 로그 추적기라는 부가 기능을 적용할 수 있었다.
문제는 대상 클래스 수만큼 로그 추적을 위한 프록시 클래스를 만들어야 한다는 점이다.
자바가 기본으로 제공하는 JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈소스 기술을 활용하면 프록시 객체를 동적으로 만들어낼 수 있다. 프록시를 적용할 코드를 하나만 만들어두고 동적 프록시 기술을 사용해서 프록시 객체를 찍어내면 된다.
JDK 동적 프록시를 이해하기 위해서는 먼저 자바의 리플렉션 기술을 이해해야 한다. 리플렉션 기술을 사용하면 클래스나 메서드의 메타정보를 동적으로 획득하고, 코드도 동적으로 호출할 수 있다.
package hello.proxy.jdkdynamic;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
@Slf4j
public class ReflectionTest {
@Test
void reflection0() {
Hello target = new Hello();
//공통 로직1 시작
log.info("start");
String result1 = target.callA();
log.info("result={}", result1);
//공통 로직1 종료
//공통 로직2 시작
log.info("start");
String result2 = target.callB();
log.info("result={}", result2);
//공통 로직2 종료
}
@Test
void reflection1() throws Exception {
//클래스 정보
Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
Hello target = new Hello();
//callA 메서드 정보
Method methodCallA = classHello.getMethod("callA");
Object result1 = methodCallA.invoke(target);
log.info("result1={}", result1);
//callB 메서드 정보
Method methodCallB = classHello.getMethod("callB");
Object result2 = methodCallB.invoke(target);
log.info("result2={}", result2);
}
@Test
void reflection2() throws Exception {
//클래스 정보
Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
Hello target = new Hello();
//callA 메서드 정보
Method methodCallA = classHello.getMethod("callA");
dynamicCall(methodCallA, target);
//callB 메서드 정보
Method methodCallB = classHello.getMethod("callB");
dynamicCall(methodCallB, target);
}
private void dynamicCall(Method method, Object target) throws Exception {
log.info("start");
Object result = method.invoke(target);
log.info("result={}", result);
}
@Slf4j
static class Hello {
public String callA() {
log.info("callA");
return "A";
}
public String callB() {
log.info("callB");
return "B";
}
}
}
먼저 reflection0()를 보자. 공통 로직1과 공통 로직2는 호출하는 메서드만 다르고 전체 코드 흐름이 완전히 같다. 여기서 공통 로직1과 공통 로직2를 하나의 메서드로 뽑아서 합치기에는 중간에 호출하는 메서드가 달라서 어렵겠지만, target.callA(), target.callB() 이 부분만 동적으로 처리할 수라도 있다면 좋을 것이다. 이럴 때 사용하는 기술이 바로 리플렉션이다. 리플렉션은 클래스나 메서드의 메타정보를 사용해서 동적으로 호출하는 메서드를 변경할 수 있다.
그리고 reflection1()에서 리플렉션을 적용하였다. Class 객체를 통해 클래스 정보를 얻고, 이를 통해 원하는 메서드의 메타정보를 .getMethod()를 통해 Method 객체로 받은 다음, .invoke()를 통해 실제 인스턴스의 메서드를 호출한다. 이로써 클래스나 메서드 정보를 동적으로 변경할 수 있게 되었다. 기존의 callA(), callB() 메서드를 직접 호출하는 부분이 Method로 대체되면서 공통 로직을 만들 수 있게 된 것이다.
reflection2()에서는 dynamicCall()으로 공통 로직1, 공통 로직2를 한번에 처리할 수 있도록 공통 처리 로직으로 통합하였다. 기존에는 메서드 이름을 직접 호출했지만, 이제는 인자로 들어가는 Method라는 메타정보를 통해서 호출할 메서드 정보가 동적으로 제공된다. 즉, 리플렉션을 통해 Method라는 메타정보로 추상화한 덕분에 공통 로직을 만들 수 있게 되었다.
리플렉션은 분명 클래스와 메서드의 메타정보를 사용해서 애플리케이션을 동적으로 유연하게 만들 수 있지만, 리플렉션 기술은 런타임에 동작하기 때문에, 컴파일 시점에 오류를 잡을 수 없다. 지금까지 프로그래밍 언어가 발달하면서 타입 정보를 기반으로 컴파일 시점에 오류를 잡아준 덕분에 개발자가 편리해질 수 있었는데, 리플렉션은 그것에 역행하는 방식이다. 따라서 리플렉션은 프레임워크 개발이나 또는 매우 일반적인 공통 처리가 필요할 때 부분적으로 주의해서 사용해야 한다.
동적 프록시 기술을 사용하면 개발자가 직접 프록시 클래스를 만들지 않아도 된다. 프록시 객체를 동적으로 런타임에 개발자 대신 만들어주고, 동적 프록시에 원하는 실행 로직을 지정할 수 있다.
JDK 동적 프록시에 대해 이해하기 위해 예제 코드를 먼저 살펴보자.
package hello.proxy.jdkdynamic.code;
public interface AInterface {
String call();
}
package hello.proxy.jdkdynamic.code;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class AImpl implements AInterface {
@Override
public String call() {
log.info("A 호출");
return "a";
}
}
package hello.proxy.jdkdynamic.code;
public interface BInterface {
String call();
}
package hello.proxy.jdkdynamic.code;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class BImpl implements BInterface {
@Override
public String call() {
log.info("B 호출");
return "b";
}
}
이때 JDK 동적 프록시에 적용할 로직은 InvocationHandler 인터페이스를 구현해서 작성하면 된다. JDK 동적 프록시가 제공하는 InvocationHandler는 다음과 같다.
package java.lang.reflect;
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}
이제 구현 코드를 보자.
package hello.proxy.jdkdynamic.code;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
@Slf4j
public class TimeInvocationHandler implements InvocationHandler {
private final Object target;
public TimeInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = method.invoke(target, args);
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 reusltTime={}", resultTime);
return result;
}
}
생성자에서 인자로 받는 Object target이 동적 프록시가 호출할 대상이고, method.invoke()를 통해 리플렉션을 사용해서 target 인스턴스의 메서드를 실행한다. 이제 테스트 코드로 JDK 동적 프록시를 사용해보자.
package hello.proxy.jdkdynamic;
import hello.proxy.jdkdynamic.code.*;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Proxy;
@Slf4j
public class JdkDynamicProxyTest {
@Test
void dynamicA() {
AInterface target = new AImpl();
TimeInvocationHandler handler = new TimeInvocationHandler(target);
AInterface proxy = (AInterface) Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[]{AInterface.class}, handler);
proxy.call();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
}
@Test
void dynamicB() {
BInterface target = new BImpl();
TimeInvocationHandler handler = new TimeInvocationHandler(target);
BInterface proxy = (BInterface) Proxy.newProxyInstance(BInterface.class.getClassLoader(), new Class[]{BInterface.class}, handler);
proxy.call();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
}
}
동적 프록시는 Proxy.newProxyInstance()에서와 같이 클래스 로더 정보, 인터페이스, 그리고 핸들러 로직을 넣어주면 해당 인터페이스를 기반으로 생성되어 그 결과를 반환한다.
dynamicA() 실행 결과23:14:38.716 [main] INFO hello.proxy.jdkdynamic.code.TimeInvocationHandler - TimeProxy 실행
23:14:38.718 [main] INFO hello.proxy.jdkdynamic.code.AImpl - A 호출
23:14:38.719 [main] INFO hello.proxy.jdkdynamic.code.TimeInvocationHandler - TimeProxy 종료 reusltTime=0
23:14:38.720 [main] INFO hello.proxy.jdkdynamic.JdkDynamicProxyTest - targetClass=class hello.proxy.jdkdynamic.code.AImpl
23:14:38.720 [main] INFO hello.proxy.jdkdynamic.JdkDynamicProxyTest - proxyClass=class com.sun.proxy.$Proxy8
proxyClass=class com.sun.proxy.$Proxy8 이 부분이 동적으로 생성된 프록시 클래스 정보이다. 이것은 우리가 만든 클래스가 아니라 JDK 동적 프록시가 이름 그대로 동적으로 만들어준 프록시이다. 이 프록시는 TimeInvocationHandler 로직을 실행한다.
dynamicA()와 dynamicB() 둘을 동시에 함께 실행하면 JDK 동적 프록시가 각각 다른 동적 프록시 클래스를 만들어주는 것을 확인할 수 있다.
이처럼 JDK 동적 프록시 기술을 사용하면 프록시 객체를 적용 대상 만큼 만들지 않아도 된다. 그리고 같은 부가 기능 로직을 한번만 개발해서 공통으로 적용할 수 있다. 결과적으로 프록시 클래스를 수 없이 만들어야 하는 문제도 해결하고, 부가 기능 로직도 하나의 클래스에 모아서 단일 책임 원칙(SRP)도 지킬 수 있게 되었다.
그럼 지금까지 학습한 JDK 동적 프록시를 애플리케이션에 적용해보자.
JDK 동적 프록시는 인터페이스가 필수이기 때문에 V1 애플리케이션에만 적용할 수 있다. 먼저 LogTrace를 적용할 수 있는 InvocationHandler를 구현하자.
package hello.proxy.config.v2_dynamicproxy.handler;
import hello.proxy.trace.TraceStatus;
import hello.proxy.trace.logtrace.LogTrace;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class LogTraceBasicHandler implements InvocationHandler {
private final Object target;
private final LogTrace logTrace;
public LogTraceBasicHandler(Object target, LogTrace logTrace) {
this.target = target;
this.logTrace = logTrace;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
TraceStatus status = null;
try {
String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
status = logTrace.begin(message);
//target 호출
Object result = method.invoke(target, args);
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
LogTraceBasicHandler는 InvocationHandler 인터페이스를 구현해서 JDK 동적 프록시에서 사용된다. Method를 통해서 호출되는 메서드 정보와 클래스 정보를 동적으로 확인할 수 있다.
동적 프록시를 사용하도록 수동 빈 등록을 설정하자.
package hello.proxy.config.v2_dynamicproxy;
import hello.proxy.app.v1.*;
import hello.proxy.config.v2_dynamicproxy.handler.LogTraceBasicHandler;
import hello.proxy.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.lang.reflect.Proxy;
@Configuration
public class DynamicProxyBasicConfig {
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(OrderControllerV1.class.getClassLoader(),
new Class[]{OrderControllerV1.class},
new LogTraceBasicHandler(orderController, logTrace)
);
return proxy;
}
@Bean
public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
OrderServiceV1 orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
OrderServiceV1 proxy = (OrderServiceV1) Proxy.newProxyInstance(OrderServiceV1.class.getClassLoader(),
new Class[]{OrderServiceV1.class},
new LogTraceBasicHandler(orderService, logTrace)
);
return proxy;
}
@Bean
public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl();
OrderRepositoryV1 proxy = (OrderRepositoryV1) Proxy.newProxyInstance(OrderRepositoryV1.class.getClassLoader(),
new Class[]{OrderRepositoryV1.class},
new LogTraceBasicHandler(orderRepository, logTrace)
);
return proxy;
}
}
이전에는 프록시 클래스를 직접 개발했지만, 이제는 JDK 동적 프록시 기술을 사용해서 각각의 Controller, Service, Repository에 맞는 동적 프록시를 생성해주면 된다. 동적 프록시를 만들더라도 LogTrace를 출력하는 로직은 모두 같기 때문에 프록시는 모두 LogTraceBasicHandler를 사용한다.
이제 메인 코드에 동적 프록시 설정 @Import(DynamicProxyBasicConfig.class)을 추가하자.
no-log를 실행해도 동적 프록시가 적용되고, LogTraceBasicHandler가 실행되기 때문에 로그가 남는다. 이 부분을 로그가 남지 않도록 처리해야 한다.
메서드 이름을 기준으로 특정 조건을 만족할 때만 로그를 남기는 기능을 개발해보자.
package hello.proxy.config.v2_dynamicproxy.handler;
import hello.proxy.trace.TraceStatus;
import hello.proxy.trace.logtrace.LogTrace;
import org.springframework.util.PatternMatchUtils;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class LogTraceFilterHandler implements InvocationHandler {
private final Object target;
private final LogTrace logTrace;
private final String[] patterns;
public LogTraceFilterHandler(Object target, LogTrace logTrace, String[] patterns) {
this.target = target;
this.logTrace = logTrace;
this.patterns = patterns;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//메서드 이름 필터
String methodName = method.getName();
if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
return method.invoke(target, args);
}
TraceStatus status = null;
try {
String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
status = logTrace.begin(message);
//target 호출
Object result = method.invoke(target, args);
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
특정 메서드 이름이 매칭 되는 경우에만 LogTrace 로직을 실행한다. 이름이 매칭되지 않으면 실제 로직을 바로 호출한다.
스프링이 제공하는 PatternMatchUtils.simpleMatch()를 사용하면 단순한 매칭 로직을 쉽게 적용할 수 있다. 이에 적용할 패턴은 생성자를 통해서 외부에서 patterns를 받는다.
package hello.proxy.config.v2_dynamicproxy;
import hello.proxy.app.v1.*;
import hello.proxy.config.v2_dynamicproxy.handler.LogTraceFilterHandler;
import hello.proxy.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.lang.reflect.Proxy;
@Configuration
public class DynamicProxyFilterConfig {
private static final String[] PATTERNS = {"request*", "order*", "save*"};
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1 controllerImpl = new OrderControllerV1Impl(orderServiceV1(logTrace));
OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(
OrderControllerV1.class.getClassLoader(),
new Class[]{OrderControllerV1.class},
new LogTraceFilterHandler(controllerImpl, logTrace, PATTERNS));
return proxy;
}
@Bean
public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
OrderServiceV1 serviceImpl = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
OrderServiceV1 proxy = (OrderServiceV1) Proxy.newProxyInstance(
OrderServiceV1.class.getClassLoader(),
new Class[]{OrderServiceV1.class},
new LogTraceFilterHandler(serviceImpl, logTrace, PATTERNS));
return proxy;
}
@Bean
public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
OrderRepositoryV1 repositoryImpl = new OrderRepositoryV1Impl();
OrderRepositoryV1 proxy = (OrderRepositoryV1) Proxy.newProxyInstance(
OrderRepositoryV1.class.getClassLoader(),
new Class[]{OrderRepositoryV1.class},
new LogTraceFilterHandler(repositoryImpl, logTrace, PATTERNS));
return proxy;
}
}
적용할 패턴으로 PATTERNS에 request, order, save로 시작하는 메서드만 로그가 남도록 한다.
메인 코드에 @Import(DynamicProxyFilterConfig.class)로 설정을 추가하자.
JDK 동적 프록시는 인터페이스가 필수이다. 그렇다면 인터페이스 없이 클래스만 있는 경우에는 어떻게 동적 프록시를 적용할 수 있을까? 이것은 일반적인 방법으로는 어렵고 CGLIB라는 바이트코드를 조작하는 특별한 라이브러리를 사용해야 한다.
- CGLIB (Code Generator Library)
- CGLIB는 바이트 코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이다.
- CGLIB를 사용하면 인터페이스가 없어도 구체 클래스만 가지고 동적 프록시를 만들어낼 수 있다.
- CGLIB는 원래 외부 라이브러리인데, 스프링 프레임워크가 스프링 내부 소스 코드에 포함했다. 따라서 스프링을 사용한다면 별도의 외부 라이브러리를 추가하지 않아도 사용할 수 있다.
참고로 CGLIB를 직접 사용하는 경우는 거의 없고, 스프링의 ProxyFactory라는 것이 이 기술을 편리하게 사용하게 도와준다. 여기서는 로그 추적기를 적용하지 않고 간단한 예제 코드로만 살펴보자.
package hello.proxy.common.service;
public interface ServiceInterface {
void save();
void find();
}
package hello.proxy.common.service;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ServiceImpl implements ServiceInterface {
@Override
public void save() {
log.info("save 호출");
}
@Override
public void find() {
log.info("find 호출");
}
}
package hello.proxy.common.service;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ConcreteService {
public void call() {
log.info("ConcreteService 호출");
}
}
JDK 동적 프록시에서 실행 로직을 위해 InvocationHandler를 제공했듯이, CGLIB는 MethodInterceptor를 제공한다.
package org.springframework.cglib.proxy;
public interface MethodInterceptor extends Callback {
Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable;
}
intercept 메서드는 네 개의 파라미터로 구성된다. obj는 CGLIB가 적용된 객체이고, method는 호출된 메서드이며, args는 메서드를 호출하면서 전달된 인수, proxy는 메서드 호출에 사용된다.
package hello.proxy.cglib.code;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
@Slf4j
public class TimeMethodInterceptor implements MethodInterceptor {
private final Object target;
public TimeMethodInterceptor(Object target) {
this.target = target;
}
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
Object result = methodProxy.invoke(target, args);
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 reusltTime={}", resultTime);
return result;
}
}
TimeMethodInterceptor는 MethodInterceptor 인터페이스를 구현해서 CGLIB 프록시의 실행 로직을 정의한다. 여기서도 JDK 동적 프록시와 같이 proxy.invoke(target, args)에서 타겟을 동적으로 호출한다. 참고로 method를 사용해도 되지만, CGLIB는 성능상 MethodProxy proxy를 사용하는 것을 권장한다.
package hello.proxy.cglib;
import hello.proxy.cglib.code.TimeMethodInterceptor;
import hello.proxy.common.service.ConcreteService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.cglib.proxy.Enhancer;
@Slf4j
public class CglibTest {
@Test
void cglib() {
ConcreteService target = new ConcreteService();
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(ConcreteService.class);
enhancer.setCallback(new TimeMethodInterceptor(target));
ConcreteService proxy = (ConcreteService) enhancer.create();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
proxy.call();
}
}
CGLIB는 Enhancer를 사용해서 프록시를 생성한다. 그리고 enhancer.setSuperClass()에서 어떤 구체 클래스를 상속 받을지 지정하여 해당 구체 클래스를 상속 받아 프록시를 생성할 수 있다. enhancer.setCallback()에는 프록시에 적용할 실행 로직을 할당한다. 이후 enhancer.create()로 프록시를 생성한다.
14:37:18.445 [main] INFO hello.proxy.cglib.CglibTest - targetClass=class hello.proxy.common.service.ConcreteService
14:37:18.448 [main] INFO hello.proxy.cglib.CglibTest - proxyClass=class hello.proxy.common.service.ConcreteService$$EnhancerByCGLIB$$25d6b0e3
14:37:18.448 [main] INFO hello.proxy.cglib.code.TimeMethodInterceptor - TimeProxy 실행
14:37:18.454 [main] INFO hello.proxy.common.service.ConcreteService - ConcreteService 호출
14:37:18.455 [main] INFO hello.proxy.cglib.code.TimeMethodInterceptor - TimeProxy 종료 reusltTime=6
CGLIB를 통해서 생성된 클래스의 이름은 ConcreteService$$EnhancerByCGLIB$$25d6b0e3이며, CGLIB가 동적으로 생성하는 클래스 이름은 다음과 같은 규칙으로 생성된다.
대상클래스$$EnhancerByCGLIB$$임의코드
클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다.
- 부모 클래스의 생성자를 체크해야 한다.
→ CGLIB는 자식 클래스를 동적으로 생성하기 때문에 기본 생성자가 필요하다.- 클래스에
final키워드가 붙으면 상속이 불가능하다.
→ CGLIB에서는 예외가 발생한다.- 메서드에
final키워드가 붙으면 해당 메서드를 오버라이딩할 수 없다.
→ CGLIB에서는 프록시 로직이 동작하지 않는다.
InvocationHandler와 CGLIB가 제공하는 MethodInterceptor를 각각 중복으로 만들어서 관리해야 할까?