프록시 패턴

dawn·2021년 11월 28일
0

스프링

목록 보기
2/2

요구사항 추가

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

가장 어려문 문제는 원본 코드를 전혀 수정하지 않고, 로그 추적기를 도입하는 것이다. 이 문제를 해결하려면 프록시(Proxy)의 개념을 먼저 이해해야 한다.

프록시 패턴

프록시의 주요 기능

프록시를 통해서 할 수 있는 일은 크게 2가지로 구분할 수 있다.

    1. 접근 제어
      - 권한에 따른 접근 차단
    • 캐싱
      - 지연 로딩
    1. 부가 기능 추가
      - 원래 서버가 제공하는 기능에 더해서 부가 기능을 수행한다.
      - 예) 요청 값이나, 응답 값을 중간에 변형한다.
      - 예) 실행 시간을 측정해서 추가 로그를 남긴다.
      프록시 객체가 중간에 있으면 크게 접근 제어부가 기능 추가를 수행할 수 있다.

GOF 디자인 패턴

둘다 프록시를 사용하는 방법이지만 GOF 디자인 패턴에서는 이 둘을 의도(intent)에 따라서 프록시 패턴과 데코레이터 패턴으로 구분한다.

  • 프록시 패턴: 접근 제어가 목적
  • 데코레이터 패턴: 새로운 기능 추가가 목적
    둘다 프록시를 사용하지만, 의도가 다르다는 점이 핵심이다. 용어가 프록시 패턴이라고 해서 이 패턴만 프록시를 사용하는 것은 아니다. 데코레이터 패턴도 프록시를 사용한다.

참고: 프록시라는 개념은 클라이언트 서버라는 큰 개념안에서 자연스럽게 발생할 수 있다. 프록시는 객체안에서의 개념도 있고, 웹 서버에서의 프록시도 있다. 객체안에서 객체로 구현되어있는가, 웹 서버로 구현되어 있는가 처럼 규모의 차이가 있을 뿐 근본적인 역할은 같다.

그런데 이 데이터가 한번 조회하면 변하지 않는 데이터라면 어딘가에 보관해두고 이미 조회한 데이터를 사용하는 것이 성능상 좋다. 이런 것을 캐시라고 한다.
프록시 패턴의 주요 기능은 접근 제어이다. 캐시도 접근 자체를 제어하는 기능 중 하나이다.

데코레이터 패턴 - 부가기능 추가

기본 로그에 ****을 덧붙이는 코드를 구현해보자!! 핵심키워드는 기본 클래스가 전혀 변경되지 않았다는 것이다

  1. 인터페이스
public interface Component {
	String operation();
}
  1. 실제 서버 구현
@Slf4j
public class RealComponent implements Component{
	@Override
	public String operation() {
		log.info("RealComponent 실행");
		return "data";
	}
}
  1. 프록시 구현
    프록시는 실제 구현서버를 알고있어야한다!!!
@Slf4j
public class MessageDecorator implements Component {

	private Component component;

	public MessageDecorator(Component component) {
		this.component = component;
	}

	@Override
	public String operation() {
		log.info("MessageDecorator 실행");

		String result = component.operation();
		String decoResult = "****" + result + "****";
		log.info("MessageDecorator 꾸미기 적용 전={}, 적용 후={}", result, decoResult);
		return decoResult;
	}
}
  1. 클라이언트 구현
@Slf4j
public class DecoratorPatternClient {
	private Component component;

	public DecoratorPatternClient(Component component) {
		this.component = component;
	}

	public void execute() {
		String result = component.operation();
		log.info("result = {}", result);
	}
}
  1. 테스트코드
public class DecoratorPatternTest {

	@Test
	void noDecorator() {
		RealComponent realComponent = new RealComponent();
		DecoratorPatternClient client = new DecoratorPatternClient(realComponent);
		client.execute();
	}

	@Test
	void decorator1() {
		RealComponent realComponent = new RealComponent();
		MessageDecorator messageDecorator = new MessageDecorator(realComponent);
		DecoratorPatternClient client = new DecoratorPatternClient(messageDecorator);
		client.execute();
	}
}

구현하면서 느끼는건데 이래서 추상화를 시키라고 하는거구나.. 만약 클라이언트가 인터페이스가 아닌 실제 서버를 주입받고 있으면 변경이 불가능할테니.. 깨달았다!!!!

데코레이터 패턴 - 체이닝

프록시 패턴은 체이닝이 가능하다. 내가 동생에게 심부름을 시켰는데 동생은 친구에게 심부름을 시킬 수 있는것처럼.
위의 데코레이터 코드에서 로그를 꾸미는 데코레이터 기능애 더해서 실행 시간을 측정하는 기능까지 추가해보자.

TimeDecorator 클래스 추가

@Slf4j
public class TimeDecorator implements Component {

	private Component component;

	public TimeDecorator(Component component) {
		this.component = component;
	}

	@Override
	public String operation() {
		log.info("TimeDecorator 실행");
		long startTime = System.currentTimeMillis();
		String result = component.operation();
		long endTime = System.currentTimeMillis();
		long resultTime = endTime - startTime;
		log.info("TimeDecorator 종료 resultTime={}ms", resultTime);
		return result;
	}
}

프록시 패턴

 * 캐시 역할
 */
@Slf4j
public class CacheProxy implements Subject {

	private Subject target; //realSubject
	private String cacheValue;

	public CacheProxy(Subject target) {
		this.target = target;
	}

	@Override
	public String operation() {
		log.info("프록시 호출");
		if (cacheValue == null) {
			cacheValue = target.operation(); //데이터 담아두기
		}
		return cacheValue;
	}
}

구체 클래스 기반 프록시

인터페이스가 아닌 구체클래스도 프록시를 구현할 수 있다.

클래스 기반 프록시의 단점
super(null) : OrderServiceV2 : 자바 기본 문법에 의해 자식 클래스를 생성할 때는 항상 super() 로 부모 클래스의 생성자를 호출해야 한다. 이 부분을 생략하면 기본 생성자가 호출된다. 그런데 부모 클래스인 OrderServiceV2 는 기본 생성자가 없고, 생성자에서 파라미터 1개를 필수로 받는다. 따라서 파라미터를 넣어서 super(..) 를 호출해야 한다.
프록시는 부모 객체의 기능을 사용하지 않기 때문에 super(null) 을 입력해도 된다. 인터페이스 기반 프록시는 이런 고민을 하지 않아도 된다.

public OrderServiceConcreteProxy(OrderServiceV2 target, LogTrace logTrace)
  {
super(null);
this.target = target;
this.logTrace = logTrace;
}

@Bean 만들기
프록시를 리턴한다는것을 잘 보자!

@Configuration
public class ConcreteProxyConfig {
       @Bean
       public OrderControllerV2 orderControllerV2(LogTrace logTrace) {
          OrderControllerV2 controllerImpl = new
                    OrderControllerV2(orderServiceV2(logTrace));
                return new OrderControllerConcreteProxy(controllerImpl, logTrace);
      }
      @Bean
      public OrderServiceV2 orderServiceV2(LogTrace logTrace) {
          OrderServiceV2 serviceImpl = new
 			 OrderServiceV2(orderRepositoryV2(logTrace));
          return new OrderServiceConcreteProxy(serviceImpl, logTrace);
      }
      @Bean
      public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace) {
          OrderRepositoryV2 repositoryImpl = new OrderRepositoryV2();
          return new OrderRepositoryConcreteProxy(repositoryImpl, logTrace);
      }
}

인터페이스 기반 프록시와 클래스 기반 프록시

프록시

프록시를 사용한 덕분에 원본 코드를 전혀 변경하지 않고, V1, V2 애플리케이션에 LogTrace 기능을 적용할 수 있었다.

인터페이스 기반 프록시 vs 클래스 기반 프록시

  • 인터페이스가 없어도 클래스 기반으로 프록시를 생성할 수 있다.
  • 클래스 기반 프록시는 해당 클래스에만 적용할 수 있다. 인터페이스 기반 프록시는 인터페이스만 같으면 모든 곳에 적용할 수 있다.
  • 클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다.
    • 부모 클래스의 생성자를 호출해야 한다.(앞서 본 예제)
    • 클래스에 final 키워드가 붙으면 상속이 불가능하다.
    • 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다.

이렇게 보면 인터페이스 기반의 프록시가 더 좋아보인다. 맞다. 인터페이스 기반의 프록시는 상속이라는 제약에서 자유롭다. 프로그래밍 관점에서도 인터페이스를 사용하는 것이 역할과 구현을 명확하게 나누기 때문에 더 좋다.
인터페이스 기반 프록시의 단점은 인터페이스가 필요하다는 그 자체이다. 인터페이스가 없으면 인터페이스 기반 프록시를 만들 수 없다.

참고: 인터페이스 기반 프록시는 캐스팅 관련해서 단점이 있는데, 이 내용은 강의 뒷부문에서 설명한다.

이론적으로는 모든 객체에 인터페이스를 도입해서 역할과 구현을 나누는 것이 좋다. 이렇게 하면 역할과 구현을 나누어서 구현체를 매우 편리하게 변경할 수 있다. 하지만 실제로는 구현을 거의 변경할 일이 없는 클래스도 많다.
인터페이스를 도입하는 것은 구현을 변경할 가능성이 있을 때 효과적인데, 구현을 변경할 가능성이 거의 없는 코드에 무작정 인터페이스를 사용하는 것은 번거롭고 그렇게 실용적이지 않다. 이런곳에는 실용적인 관점에서 인터페이스를 사용하지 않고 구체 클래스를 바로 사용하는 것이 좋다 생각한다. (물론 인터페이스를 도입하는 다양한 이유가 있다. 여기서 핵심은 인터페이스가 항상 필요하지는 않다는 것이다.)

결론

실무에서는 프록시를 적용할 때 V1처럼 인터페이스도 있고, V2처럼 구체 클래스도 있다. 따라서 2가지 상황을 모두 대응할 수 있어야 한다.

너무 많은 프록시 클래스

지금까지 프록시를 사용해서 기존 코드를 변경하지 않고, 로그 추적기라는 부가 기능을 적용할 수 있었다. 그런데 문제는 프록시 클래스를 너무 많이 만들어야 한다는 점이다. 잘 보면 프록시 클래스가 하는 일은 LogTrace 를 사용하는 것인데, 그 로직이 모두 똑같다. 대상 클래스만 다를 뿐이다. 만약 적용해야 하는 대상 클래스가 100개라면 프록시 클래스도 100개를 만들어야한다.
프록시 클래스를 하나만 만들어서 모든 곳에 적용하는 방법은 없을까? 바로 다음에 설명할 동적 프록시 기술이 이 문제를 해결해준다.

리플렉션

지금까지 프록시를 사용해서 기존 코드를 변경하지 않고, 로그 추적기라는 부가 기능을 적용할 수 있었다. 그런데 문제는 대상 클래스 수 만큼 로그 추적을 위한 프록시 클래스를 만들어야 한다는 점이다.
로그 추적을 위한 프록시 클래스들의 소스코드는 거의 같은 모양을 하고 있다.
자바가 기본으로 제공하는 JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈소스 기술을 활용하면 프록시 객체를 동적으로 만들어낼 수 있다. 쉽게 이야기해서 프록시 클래스를 지금처럼 계속 만들지 않아도 된다는 것이다.

JDK 동적 프록시를 이해하기 위해서는 먼저 자바의 리플렉션 기술을 이해해야 한다.
리플렉션 기술을 사용하면 클래스나 메서드의 메타정보를 동적으로 획득하고, 코드도 동적으로 호출할 수 있다.

@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 종료
    }
      @Slf4j
    static class Hello {
        public String callA() {
            log.info("callA");
            return "A";
        }
        public String callB() {
            log.info("callB");
            return "B";
        }
    }
}
  • 공통 로직1과 공통 로직2는 호출하는 메서드만 다르고 전체 코드 흐름이 완전히 같다.
    - 먼저 start 로그를 출력한다.
    - 어떤 메서드를 호출한다.
    - 메서드의 호출 결과를 로그로 출력한다.
  • 여기서 공통 로직1과 공통 로직 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"); //해당 클래스의 call 메서드 메타정보를 획득한다.
        Object result1 = methodCallA.invoke(target); //획득한 메서드 메타정보로 실제 인스턴스의 메서드를 호출한다. 여기서 methodCallA 는 Hello 클래스의 callA() 이라는 메서드 메타정보이다.
        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();
        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 : 첫 번째 파라미터는 호출할 메서드 정보가 넘어온다. 이것이 핵심이다. 기존에는
    메서드 이름을 직접 호출했지만, 이제는 Method 라는 메타정보를 통해서 호출할 메서드 정보가 동적으로 제공된다.
  • Object target : 실제 실행할 인스턴스 정보가 넘어온다. 타입이 Object 라는 것은 어떠한 인스턴스도 받을 수 있다는 뜻이다. 물론 method.invoke(target) 를 사용할 때 호출할 클래스와 메서드 정보가 서로 다르면 예외가 발생한다.

주의

리플렉션을 사용하면 클래스와 메서드의 메타정보를 사용해서 애플리케이션을 동적으로 유연하게 만들 수 있다. 하지만 리플렉션 기술은 런타임에 동작하기 때문에, 컴파일 시점에 오류를 잡을 수 없다.
예를 들어서 지금까지 살펴본 코드에서 getMethod("callA") 안에 들어가는 문자를 실수로 getMethod("callZ") 로 작성해도 컴파일 오류가 발생하지 않는다. 그러나 해당 코드를 직접 실행하는 시점에 발생하는 오류인 런타임 오류가 발생한다.
가장 좋은 오류는 개발자가 즉시 확인할 수 있는 컴파일 오류이고, 가장 무서운 오류는 사용자가 직접 실행할 때 발생하는 런타임 오류다.
따라서 리플렉션은 일반적으로 사용하면 안된다. 지금까지 프로그래밍 언어가 발달하면서 타입 정보를 기반으로 컴파일 시점에 오류를 잡아준 덕분에 개발자가 편하게 살았는데, 리플렉션은 그것에 역행하는 방식이다.
리플렉션은 프레임워크 개발이나 또는 매우 일반적인 공통 처리가 필요할 때 부분적으로 주의해서 사용해야 한다.

JDK 동적 프록시 - 소개

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

주의
JDK 동적 프록시는 인터페이스를 기반으로 프록시를 동적으로 만들어준다. 따라서 인터페이스가
필수이다.

기본 예제 코드

JDK 동적 프록시를 이해하기 위해 아주 단순한 예제 코드를 만들어보자.
간단히 A , B 클래스를 만드는데, JDK 동적 프록시는 인터페이스가 필수이다. 따라서 인터페이스와 구현체로 구분했다.

AInterface
주의: 테스트 코드(src/test)에 위치한다.

package hello.proxy.jdkdynamic.code;
public interface AInterface {
        String call();
}

AImpl

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

JDK 동적 프록시 InvocationHandler

JDK 동적 프록시에 적용할 로직은 InvocationHandler 인터페이스를 구현해서 작성하면 된다.

public interface InvocationHandler {
       public Object invoke(Object proxy, Method method, Object[] args)
          throws Throwable;
}
profile
안녕하세요

0개의 댓글