[강의] 김영한님의 스프링 핵심 원리 - 고급편 (프록시 패턴과 동적 프록시)

크리링·2023년 5월 13일
0
post-thumbnail

출처 : 스프링 핵심 원리 - 고급편
코드 : Github - 고급편






v1 - 인터페이스와 구현 클래스 - 스프링 빈으로 수동 등록
v2 - 인터페이스 없는 구체 클래스 - 스프링 빈으로 수동 등록
v3 - 컴포넌트 스캔으로 스프링 빈 자동 등록



V1

OrderRepositoryV1

public interface OrderRepositoryV1 {
	void save(String itemId);
}

OrderRepositoryV1Impl

public class OrderRepositoryV1Impl implements OrderRepositoryV1 {
 	@Override
 	public void save(String itemId) {
 		//저장 로직
 		if (itemId.equals("ex")) {
 			throw new IllegalStateException("예외 발생!");
 		}
 		sleep(1000);
 	}
    
 	private void sleep(int millis) {
 		try {
 			Thread.sleep(millis);
 		} catch (InterruptedException e) {
 			e.printStackTrace();
 		}
 	}
}



OrderServiceV1

public interface OrderServiceV1 {
 	void orderItem(String itemId);
}

OrderServiceV1Impl

public class OrderServiceV1Impl implements OrderServiceV1 {
 	private final OrderRepositoryV1 orderRepository;
    
 	public OrderServiceV1Impl(OrderRepositoryV1 orderRepository) {
 		this.orderRepository = orderRepository;
 	}
    
 	@Override
 	public void orderItem(String itemId) {
 		orderRepository.save(itemId);
 	}
}



OrderControllerV1

@RequestMapping //스프링은 @Controller 또는 @RequestMapping 이 있어야 스프링 컨트롤러로
인식
@ResponseBody
public interface OrderControllerV1 {
 	@GetMapping("/v1/request")
 	String request(@RequestParam("itemId") String itemId);
    
 	@GetMapping("/v1/no-log")
 	String noLog();
}

OrderControllerV1Impl

@Slf4j
public class OrderControllerV1Impl implements OrderControllerV1 {
 	private final OrderServiceV1 orderService;
    
 	public OrderControllerV1Impl(OrderServiceV1 orderService) {
 	this.orderService = orderService;
 	}
    
 	@Override
 	public String request(String itemId) {
 		orderService.orderItem(itemId);
 		return "ok";
 	}
    
 	@Override
 	public String noLog() {
 		return "ok";
 	}
}



AppV1Config

스프링 빈으로 수동 등록

@Configuration
public class AppV1Config {
 	@Bean
 	public OrderControllerV1 orderControllerV1() {
 		return new OrderControllerV1Impl(orderServiceV1());
 	}
 	@Bean
 	public OrderServiceV1 orderServiceV1() {
 		return new OrderServiceV1Impl(orderRepositoryV1());
 	}
 	@Bean
 	public OrderRepositoryV1 orderRepositoryV1() {
 		return new OrderRepositoryV1Impl();
 	}
}



Application - 코드 추가

@Import(AppV1Config.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app") //주의
public class ProxyApplication {
 	public static void main(String[] args) {
 		SpringApplication.run(ProxyApplication.class, args);
 	}
}
  • @Import(AppV1Config.class) : 클래스를 스프링 빈으로 등록한다. 일반적으로 @Configuration 같은 설정 파일 등록할 때 사용하지만, 스프링 빈으로 등록할 때도 있다.
  • @SpringBootApplication(scanBasePackages = "hello.proxy.app") : @ComponentScan의 기능과 같다. 컴포넌트 스캔을 시작할 위치를 지정한다. 이 값을 설정하면 해당 패키지와 그 하위 패키지를 컴포넌트 스캔한다.






V2 - 인터페이스 없는 구체 클래스 - 스프링 빈 수동 등록

OrderRepositoryV2

public class OrderRepositoryV2 {
 	public void save(String itemId) {
 	//저장 로직
 		if (itemId.equals("ex")) {
 			throw new IllegalStateException("예외 발생!");
 		}
 		sleep(1000);
 	}
    
 	private void sleep(int millis) {
 		try {
 			Thread.sleep(millis);
 		} catch (InterruptedException e) {
 			e.printStackTrace();
 		}
 	}
}



OrderServiceV2

public class OrderServiceV2 {
 	private final OrderRepositoryV2 orderRepository;
    
 	public OrderServiceV2(OrderRepositoryV2 orderRepository) {
 		this.orderRepository = orderRepository;
 	}
    
 	public void orderItem(String itemId) {
 		orderRepository.save(itemId);
 	}
}



OrderControllerV2

@Slf4j
@RequestMapping
@ResponseBody
public class OrderControllerV2 {
 	private final OrderServiceV2 orderService;
    
 	public OrderControllerV2(OrderServiceV2 orderService) {
 		this.orderService = orderService;
 	}
    
 	@GetMapping("/v2/request")
 	public String request(String itemId) {
 		orderService.orderItem(itemId);
 		return "ok";
 	}
    
 	@GetMapping("/v2/no-log")
 	public String noLog() {
 		return "ok";
 	}
}



AppV2Config

@Configuration
public class AppV2Config {
 	@Bean
 	public OrderControllerV2 orderControllerV2() {
 		return new OrderControllerV2(orderServiceV2());
 	}
    
 	@Bean
 	public OrderServiceV2 orderServiceV2() {
 		return new OrderServiceV2(orderRepositoryV2());
 	}
    
 	@Bean
 	public OrderRepositoryV2 orderRepositoryV2() {
 		return new OrderRepositoryV2();
 	}
}



Application

@Import({AppV1Config.class, AppV2Config.class})
@SpringBootApplication(scanBasePackages = "hello.proxy.app")
public class ProxyApplication {
 	public static void main(String[] args) {
 		SpringApplication.run(ProxyApplication.class, args);
 	}
}






V3 - 컴포넌트 스캔으로 스프링 빈 자동 등록

OrderRepositoryV3

@Repository
public class OrderRepositoryV3 {

 	public void save(String itemId) {
 		//저장 로직
 		if (itemId.equals("ex")) {
 			throw new IllegalStateException("예외 발생!");
 		}
 		sleep(1000);
 	}
    
 	private void sleep(int millis) {
 		try {
 			Thread.sleep(millis);
 		} catch (InterruptedException e) {
 			e.printStackTrace();
 		}
 	}
}



OrderServiceV3

@Service
public class OrderServiceV3 {
 	private final OrderRepositoryV3 orderRepository;
 	public OrderServiceV3(OrderRepositoryV3 orderRepository) {
 		this.orderRepository = orderRepository;
 	}
    
 	public void orderItem(String itemId) {
 		orderRepository.save(itemId);
 	}
}



OrderControllerV3

@Slf4j
@RestController
public class OrderControllerV3 {
 	private final OrderServiceV3 orderService;
    
 	public OrderControllerV3(OrderServiceV3 orderService) {
 		this.orderService = orderService;
 	}
    
 	@GetMapping("/v3/request")
 	public String request(String itemId) {
 		orderService.orderItem(itemId);
 		return "ok";
 	}
    
 	@GetMapping("/v3/no-log")
 	public String noLog() {
 		return "ok";
 	}
}

Application 에서 @SpringBootApplication(scanBasePackages = "hello.proxy.app") 를 사용했고, 각각 @RestController , @Service , @Repository 애노테이션을 가지고 있기 때문에 컴포넌트 스캔의 대상이 된다.






추가 요구사항

  • 원본 코드를 전혀 수정하지 않고, 로그 추적기를 적용해라.
  • 특정 메서드는 로그를 출력하지 않는 기능
    • 보안상 일부는 로그를 출력하면 안된다.
  • 다음과 같은 다양한 케이스에 적용할 수 있어야 한다.
    • v1 - 인터페이스가 있는 구현 클래스에 적용
    • v2 - 인터페이스가 없는 구체 클래스에 적용
    • v3 - 컴포넌트 스캔 대상에 기능 적용




프록시, 프록시 패턴, 데코레이터 패턴

서버와 클라이언트의 기본 개념

클라이언트는 서버에 필요한 것을 요청하고, 서버는 클라이언트의 요청을 처리



직접 호출

간접 호출

대리자를 영어로 프록시라 한다.



대체 가능

서버와 프록시는 같은 인터페이스를 사용해야 한다. 그리고 클라이언트가 사용하는 서버
객체를 프록시 객체로 변경해도 클라이언트 코드를 변경하지 않고 동작할 수 있어야 한다.



런타임(애플리케이션 실행 시점)에 클라이언트 객체에 DI를
사용해서 Client -> Server 에서 Client -> Proxy 로 객체 의존관계를 변경해도 클라이언트 코드를 전혀 변경하지 않아도 된다. 클라이언트 입장에서는 변경 사실 조차 모른다.
DI를 사용하면 클라이언트 코드의 변경 없이 유연하게 프록시를 주입할 수 있다.



프록시 주요 기능

  • 접근 제어
    • 권한에 따른 접근 차단
    • 캐싱
    • 지연로딩
  • 부가 기능 추가
    • 원래 서버가 제공하는 기능에 더해서 부가 기능 수행
    • ex) 로그, 요청 값이나 응답 값 중간에 변형



GOF 디자인 패턴

새로운 기능 추가가 목적
프록시 사용은 동일



프록시 패턴과 GOF 패턴의 의도

  • 프록시 패턴 : 다른 객체에 대한 접근을 제어하기 위해 대리자를 제공
  • 데코레이터 패턴 : 객체에 추가 책임(기능)을 동적으로 추가하고, 기능 확장을 위한 유연한 대안 제공



정리

프록시를 사용하고 해당 프록시가 접근 제어가 목적이라면 프록시 패턴이고, 새로운 기능을 추가하는 것이 목적이라면 데코레이터 패턴이다.



한계

문제는 프록시 클래스를 너무 많이 만들어야 한다는 점이다. 잘 보면 프록시 클래스가 하는 일은
LogTrace 를 사용하는 것인데, 그 로직이 모두 똑같다. 대상 클래스만 다를 뿐이다. 만약 적용해야 하는
대상 클래스가 100개라면 프록시 클래스도 100개를 만들어야한다.






동적 프록시

리플렉션

공통 로직을 메서드로 뽑아서 공통화
리플렉신은 클래스나 메섣드의 메타정보를 사용해서 동적으로 호출하는 메서드를 변경할 수 있다.

Reflection 테스트1

@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);
}



Reflection 테스트 2

@Test
void reflection2() throws Exception {
 	Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello");
 
 	Hello target = new Hello();
 	Method methodCallA = classHello.getMethod("callA");
 	dynamicCall(methodCallA, target);
 	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);
}
  • dynamicCall(Method method, Object target)
    • 공통 로직1, 공통 로직2를 한번에 처리할 수 있는 통합된 공통 처리 로직
    • Method method : 핵심. 기존에는 메서드 이름을 직접 호출
    • Object target : 실제 실행할 인스턴스 정보



정리

정적인 target.callA(), target.callB() 코드를 리플렉션을 사용해 Method라는 메타정보로 추상화했다.


한계

getMethod("callA") 안에 실수로 getMethod("callZ")로 작성해도 컴파일 오류가 발생하지 않는다. => 리플렉션은 일반적으로 사용하면 안된다.






JDK 동적 프록시

개발자가 직접 프록시 클래스를 만들지 않아도 된다. 프록시 객체를 동적으로 런타임에 개발자 대신 만들어준다. 동적 프록시에 원하는 실행 로직을 지정할 수 있다.



예제

AInterface

public interface AInterface {
 	String call();
}

AImpl

@Slf4j
public class AImpl implements AInterface {
 	@Override
 	public String call() {
 		log.info("A 호출");
 		return "a";
 	}
}



BInterface

public interface BInterface {
 	String call();
}

BImpl

@Slf4j
public class BImpl implements BInterface {
 	@Override
 	public String call() {
 		log.info("B 호출");
 		return "b";
 	}
}

JDK 동적 프록시 InvocationHandler

InvocationHandler

public interface InvocationHandler {
 	public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
}
  • Object proxy : 프록시 자신
  • Method method : 호출한 메서드
  • Object [] args : 메서드를 호출할 때 전달한 인수



TimeInvocationHandler

@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 종료 resultTime={}", resultTime);
 		return result;
 	}
}
  • Method.invoke(target, args) : 리플렉션을 사용해서 target 인스턴스의 메서드를 실행



JdkDynamicProxyTest

@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(AInterface.class.getClassLoader(), new Class[] {AInterface.class}, handler)
    • 동적 프록시는 java.lang.reflect.Proxy를 통해 생성
    • 클래스 로더 정보, 인터페이스 그리고 핸들러 로직을 넣어주면 된다. 그러면 해당 인터페이스를 기반으로 동적 프록시를 생성하고 결과를 반환



출력 결과

TimeInvocationHandler - TimeProxy 실행
AImpl - A 호출
TimeInvocationHandler - TimeProxy 종료 resultTime=0
JdkDynamicProxyTest - targetClass=class hello.proxy.jdkdynamic.code.AImpl
JdkDynamicProxyTest - proxyClass=class com.sun.proxy.$Proxy1



생성된 JDK 동적 프록시

proxyClass=class com.sun.proxy.$Proxy1이 동적으로 생성ㄷ된 프록시 클래스 정보



실행 순서

  1. 클라이언트는 JDK 동적 프록시의 call() 을 실행한다.
  2. JDK 동적 프록시는 InvocationHandler.invoke() 를 호출한다. TimeInvocationHandler 가 구현체로 있으로 TimeInvocationHandler.invoke() 가 호출된다.
  3. TimeInvocationHandler 가 내부 로직을 수행하고, method.invoke(target, args) 를 호출해서 target 인 실제 객체( AImpl )를 호출한다.
  4. AImpl 인스턴스의 call() 이 실행된다.
  5. AImpl 인스턴스의 call() 의 실행이 끝나면 TimeInvocationHandler 로 응답이 돌아온다. 시간 로그를 출력하고 결과를 반환한다.

동적 프록시 클래스 정보

dynamicA()dynamicB() 둘을 동시에 실행하면 동적 프록시가 다른 클래스 임을 확인할 수 있다.

proxyClass=class com.sun.proxy.$Proxy1 //dynamicA
proxyClass=class com.sun.proxy.$Proxy2 //dynamicB



정리

JDK 동적 프록시 기술 덕분에 적용 대상 만큼 프록시 객체를 만들지 않아도 된다. 그리고 같은 부가 기능
로직을 한번만 개발해서 공통으로 적용할 수 있다. 만약 적용 대상이 100개여도 동적 프록시를 통해서
생성하고, 각각 필요한 InvocationHandler 만 만들어서 넣어주면 된다.
결과적으로 프록시 클래스를 수 없이 만들어야 하는 문제도 해결하고, 부가 기능 로직도 하나의 클래스에
모아서 단일 책임 원칙(SRP)도 지킬 수 있게 되었다.



한계

JDK 동적 프록시는 인터페이스가 필수적
V2 애플리케이션처럼 인터페이스 없이 클래스만 있는 경우에는 어떻게 동적 프록시 적용 ? => CGLIB






CGLIB

  • 바이트 코드를 조작해서 동적으로 클래스를 생성하는 기술
  • 인터페이스가 없어도 구체 클래스만 가지고 동적 프록시를 만들어낼 수 있다.
  • 스프링을 사용한다면 별도의 외부 라이브러리를 추가히자 않아도 사용할 수 있다.

우리가 직접 CGLIB를 사용하는 경우은 거의 없다. 스프링의 ProxyFactory라는 것이 이 기술을 편리하게 사용하게 도와준다.



예제

ServiceInterface

public interface ServiceInterface {
 	void save();
 	void find();
}

ServiceImpl

@Slf4j
public class ServiceImpl implements ServiceInterface {
 	@Override
 	public void save() {
 		log.info("save 호출");
 	}
    
 	@Override
 	public void find() {
 		log.info("find 호출");
 	}
}

ConcreteService

@Slf4j
public class ConcreteService {
 	public void call() {
 		log.info("ConcreteService 호출");
 	}
}



CGLIB 예제

MethodInterceptor - CGLIB 제공

public interface MethodInterceptor extends Callback {
 	Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable;
}
  • obj : CGLIB가 적용된 객체
  • method : 호출된 메서드
  • args : 메서드를 호출하면서 전달된 인수
  • proxy : 메서드 호출에 사용



TimeMethodInterceptor

@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 proxy) throws Throwable {
 		log.info("TimeProxy 실행");
 		long startTime = System.currentTimeMillis();
 		Object result = proxy.invoke(target, args);
 		long endTime = System.currentTimeMillis();
 		long resultTime = endTime - startTime;
 		log.info("TimeProxy 종료 resultTime={}", resultTime);
 		return result;
 	}
}
  • proxy.invoke(target, args) : 실제 대상을 동적으로 호출



CglibTest

@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();
 	}
}
  • Enhancer : CGLIB는 Enhancer를 사용해서 프록시를 생성
  • enhancer.setSuperclass(ConcreteService.class); : CGLIB는 구체 클래스를 상속 받아서 프록시를 생성할 수 있다. 어떤 구체 클래스를 상속 받을지 지정한다.
  • enhancer.setSuperclass(ConcreteService.class); : 프록시에 적용할 실행 로직을 할당
  • enhancer.create() : 프록시를 생성한다.



실행 결과

CglibTest - targetClass=class hello.proxy.common.service.ConcreteService
// 생성된 클래스의 이름
CglibTest - proxyClass=class hello.proxy.common.service.ConcreteService$ $EnhancerByCGLIB$$25d6b0e3
TimeMethodInterceptor - TimeProxy 실행
ConcreteService - ConcreteService 호출
TimeMethodInterceptor - TimeProxy 종료 resultTime=9



그림 정리



제약

  • 클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다.
    • 부모 클래스의 샛엉자를 체크해야 한다. -> CGLIB는 자식 클래스를 동적으로 생성하기 때문에 기본 생성자가 필요
    • 클래스에 final 키워드가 붙으면 상속이 불가능 -> CGLIB 에서 예외 발생
    • 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다. -> CGLIB에서는 프록시 로직이 동작하지 않는다.



0개의 댓글