스프링 3.1 - 프록시 패턴

김법우·2023년 4월 6일
0

Spring

목록 보기
2/4
post-thumbnail

프록시(Proxy)와 타깃(Target, Real Subject)

프록시와 타깃이란?

// UserServicTx
public class UserServiceTx implements UserService {
	...

	@Override
	public void upgradeLevels() {
		// 트랜잭션 경계설정 부가기능 적용
	  TransactionStatus status = this.transactionManager.getTransaction(
	      new DefaultTransactionDefinition());
	
	  try {
	      this.userService.upgradeLevels(); // 핵심기능 클래스에게 위임
	      transactionManager.commit(status);
	  } catch (Exception e) {
	      transactionManager.rollback(status);
	      throw e;
	  }
	}
	
	@Override
	public void add(User user) {
	  this.userService.add(user); // 핵심기능 클래스에게 위임
	}
...
// UserServiceImpl
public class UserServiceImpl implements UserService {

	...

	// 핵심기능 구현
  public void upgradeLevels() {
      List<User> users = userDao.getAll();

      for (User user : users) {
          // 현재 레벨에 따른 레벨 업그레이드 가능 여부 확인
          if (canUserUpgradeLevel(user)) {
              upgradeLevel(user);
          }
      }
  }
	...

실제 클라이언트는 UserService 의 유저 레벨 수정 메서드를 사용하기 위해 핵심기능을 구현한 UserServiceImpl 객체를 사용하는 것이 아니라 부가기능을 적용하고 핵심기능을 위임한 UserServiceTx 를 사용하게된다.

프록시
자신이 클라이언트가 사용할려는 실제 대상인 것처럼 위장해 클라이언트의 요청을 대신 받아주는 역할을 하는 객체

타깃
프록시를 통해 최종적으로 요청을 위임받아 처리하는 실제 객체

따라서 UserServiceTx 가 프록시, UserServiceImpl 이 타깃이된다. 위의 예시에서 프록시는 트랜잭션 경계설정에 대한 부가기능을 제공하고있다. 이처럼 핵심기능에 부가기능을 제공하는 프록시도 존재하지만 타깃에 접근하는 방법을 제어하거나 접근권한을 제어하기위한 프록시도 존재한다.

만약 위의 예시에 유저권한을 검사한뒤 유저 서비스를 사용할 수 있어야한다는 요구사항이 추가되어 유저권한에 따라 접근할 메서드를 제한하는 용도의 프록시 객체를 사용한다면 이것은 프록시 패턴이고, 예시처럼 부가기능을 위해 프록시 객체를 사용한다면 데코레이터 패턴이라고 구분한다.

데코레이터 패턴
데코레이터 패턴은 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여하는 용도로 프록시를 사용하는 패턴을 말한다.
컴파일 시점, 코드 상에 어떤 방법과 순서로 프록시와 타깃이 연결되어 사용하는지 정해져있지 않은 것을 의미한다. 위의 예시에서도 UserService 인터페이스를 통해 UserServiceTx 에 주입해주므로 UserServiceTx 는 다음에 다른 프록시가오는지 진짜 핵심기능 객체가 오는지 알지못하므로 이는 데코레이터 패턴이라고 할 수 있다.
⇒ 타깃의 코드를 수정하지 않으며 클라이언트가 호출하는 방법도 변경하지 않은채로 새로운 기능을 추가할 때 매우 유용

프록시 패턴
프록시 패턴은 타깃의 기능을 확장/변경하지 않고 타깃에 접근하는 방식을 제어하거나 접근권한을 제어하는 용도로 프록시를 사용하는 패턴을 말한다.
원격 오브젝트를 사용하는 경우 다른 서버에 존재하는 객체를 사용해야할때 클라이언트는 프록시를 사용하도록해 실제로 원격 오브젝트가 쓰이는 시점에 프록시를 통해 원격 오브젝트를 생성하고 사용하도록한다면 이것은 프록시 패턴이다.
⇒ 타깃의 기능 자체에 관여하지 않고 접근방법을 제어하는데 매우 유용
⇒ 프록시 패턴의 프록시 객체는 자신이 만들거나 사용할 타깃의 정보를 알고있어야하는 경우가 많다는 점, 부가기능 제공 용도가 아니라는 점에서 데코레이터 패턴에서 차이가 존재


프록시의 단점?

프록시는 핵심기능의 수정없이 쉽게 확장하고 제어할 수 있다는 점에서 매력적이지만 몇가지 단점이 존재한다.

  • 프록시를 매번 생성하는 것이 매우 번거롭다.
    • 프록시 객체는 핵심 기능과 동일한 인터페이스를 구현하므로 자신이 필요없는 메서드에 대한 모든 위임 처리를 해주어야한다.
  • 부가기능 코드가 중복될 가능성이 매우 높다.
    • 메서드가 많아지고 제공할 부가기능 코드가 범용적일수록 코드 중복은 늘어난다.

⇒ JDK 다이나믹 프록시로 해결해보자!


다이나믹 프록시

리플렉션

다이나믹 프록시는 기본적으로 리플렉션 API 를 통해 프록시를 만든다.

리플렉션 → 자바 코드 자체를 추상화해 접근하도록 만든 것

  • 클래스 오브젝트를 통한 클래스 코드의 메타 데이터(이름, 상속, 구현, 필드 및 각 타입, 메서드 등등
  • 메서드의 파라미터와 리턴 타입
  • 클래스에 포함된 메서드의 실행
public class ReflectionTest {
    @Test
    public void invokeMethod() throws Exception {
        String name = "length7";

        assertThat(name.length()).isEqualTo(7); // 기존 length 메서드

        Method lengthMethod = String.class.getMethod("length");
        assertThat((Integer) lengthMethod.invoke(name)).isEqualTo(7); // invoke 메서드
    }
}

리플렉션 API 중 Method 를 통해 String 클래스의 length 메서드를 가져와 invoke 를 통해 실행한 결과를 검증하는 코드이다. invoke 로 실행한 결과와 String.length 가 동일한 결과를 내는 것을 알 수 있다.


다이나믹 프록시 적용

  • 기존 프록시 패턴
public class HelloUppercase implements Hello {
    Hello hello;

    public HelloUppercase(Hello hello) {
        this.hello = hello;
    }

    @Override
    public String sayHello(String name) {
        return this.hello.sayHello(name).toUpperCase();
    }

    @Override
    public String sayHi(String name) {
        return this.hello.sayHi(name).toUpperCase();
    }

    @Override
    public String sayBanga(String name) {
        return this.hello.sayBanga(name).toUpperCase();
    }
}

// Client
@Test
public void helloUppercaseProxyTest() {
    String name = "kim beob woo";
    Hello helloProxy = new HelloUppercase(new HelloTarget());

    assertThat(helloProxy.sayHello(name)).isEqualTo("HELLO, KIM BEOB WOO");
    assertThat(helloProxy.sayHi(name)).isEqualTo("HI, KIM BEOB WOO");
    assertThat(helloProxy.sayBanga(name)).isEqualTo("BANGA, KIM BEOB WOO");
}
  • 다이나믹 프록시 패턴 by Reflection
public class UppercaseHandler implements InvocationHandler {
    Object target;

    public UppercaseHandler(Object target) {
        this.target = target;
    }

    @Override
		public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Object ret = method.invoke(target, args);

        if (ret instanceof String) {
            return ((String) ret).toUpperCase();
        } else {
            return ret;
        }
    }
}

// Client
@Test
public void dynamicHelloProxyTest() {
    String name = "kim beob woo";
    Hello helloProxy = (Hello) Proxy.newProxyInstance(
        getClass().getClassLoader(), // 동적 생성되는 다이내믹 프록시 클래스의 로딩에 사용할 클래스 로더
        new Class[]{Hello.class}, // 구현할 인터페이스
        new UppercaseHandler(new HelloTarget()) // 부가기능 및 위임 코드를 담은 핸들러
    );

    assertThat(helloProxy.sayHello(name)).isEqualTo("HELLO, KIM BEOB WOO");
    assertThat(helloProxy.sayHi(name)).isEqualTo("HI, KIM BEOB WOO");
    assertThat(helloProxy.sayBanga(name)).isEqualTo("BANGA, KIM BEOB WOO");
}

다이나믹 프록시는 프록시 팩토리에 의해 런타임 시 다이나믹하게 만들어지는 오브젝트이다. 위의 클라이언트 코드에서는 테스트를 위해 직접 생성하였다. 다이나믹 프록시 오브젝트는 (Hello) Proxy.newProxyInstance 를 통해 타깃 인터페이스와 동일한 타입으로 만들어진다.


장점이 뭘까?

다이나믹 프록시를 적용함으로써 얻는 가장 큰 효용은 핵심기능 인터페이스의 메서드 추가/수정 여부와는 관계없이 프록시 패턴을 동일하게 적용 할 수 있다. 기존 프록시 패턴은 메서드가 100개로 늘어나면 100개에 대한 부가기능을 처리하기 위해 모든 메서드를 구현해야한다. 기본적으로 핵심기능 인터페이스를 구현하기 떄문이다.

다이나믹 프록시는 리플렉션 API 를 사용해 부가기능을 적용하고 타깃에 위임하므로 확장성에 있어 매우 뛰어나다.

  • But,
    • 리플랙션은 매우 유연하고 막강하지만 의도치 않은 심각한 오류를 발생시킬 수 있다.
    • 런타임에 위임할 대상이 정해지기 때문에 해당 사항을 미리 인지하고 코드를 작성해야한다. 위의 예시에서도 ret 이 문자열인지 아닌지에 대한 여부를 기준으로 바로 위임할지 부가 기능을 적용할지를 판단한다.

팩토리 빈

핸들러를 장착한 다이나믹 프록시 , 어떻게 DI 할까?

  • 스프링의 빈은 기본적으로 클래스 이름과 프로퍼티로 정의된다.
    • 클래스 이름 → 리플렉션 API → 클래스 객체 생성
      Date now = (Date) Class.forName("java.util.Date").newInstance();
      따라서, 빈 정의에 나오는 클래스 이름이 필수적으로 필요하다.
  • 다이나믹 프록시는 클래스 이름을 알 수 없다.
    • 타깃 클래스도 Object 로 받아오는데, 어떤 클래스의 메서드에 부가기능을 제공할지 어찌아는가?

⇒ 스태틱 팩토리 메소드를 통해서만 만들 수 있다!


트랜잭션 프록시 팩토리 빈

public class TxProxyFactoryBean implements FactoryBean<Object> {

    PlatformTransactionManager transactionManager;
    String pattern;
    Object target;
    Class<?> serviceInterface;

		...

    @Override
    public Object getObject() {
        TransactionHandler handler = new TransactionHandler();
        handler.setPattern(this.pattern);
        handler.setTarget(this.target);
        handler.setTransactionManager(this.transactionManager);

        return Proxy.newProxyInstance(
            getClass().getClassLoader(), // 동적 생성되는 다이내믹 프록시 클래스의 로딩에 사용할 클래스 로더
            new Class[]{this.serviceInterface}, // 구현할 인터페이스
            handler // 부가기능 및 위임 코드를 담은 핸들러
        );
    }
		
		...
<bean class="springbook.user.service.TxProxyFactoryBean" id="userService">
  <property name="transactionManager" ref="transactionManager"/>
  <property name="target" ref="userServiceImpl"/>
  <property name="pattern" value="upgradeLevels"/>
  <property name="serviceInterface" value="springbook.user.service.UserService"/>
</bean>

context.xml 에서는 FactoryBean 을 사용하지만 DI 를 받는 쪽에서는 UserService 를 확장/위임한 다이나믹 프록시 객체를 주입받을 수 있다.
만약 다른 서비스 빈이 트랜잭션 경계설정을 사용해야한다면 아래와 같은 설정 파일 내용 추가로 적용 할 수 있다.

<bean class="springbook.user.service.TxProxyFactoryBean" id="otherService">
  <property name="transactionManager" ref="transactionManager"/>
  <property name="target" ref="otherServiceImpl"/>
  <property name="pattern" value="upgradeLevels"/>
  <property name="serviceInterface" value="springbook.user.service.OtherService"/>
</bean>
  • 클라이언트는 자신의 코드 수정 없이 트랜잭션 경계설정이 적용된 OtherService 를 사용가능
    • 프록시 팩토리 빈을 통해 기존의 프록시 기법을 빠르고 효과적으로 적용 가능

장점

  • 데코레이터 패턴을 적극적으로 사용하지 못하는 2가지 이유를 해결
    • 타깃이 구현하는 인터페이스를 구현하는 프록시 클래스를 일일이 만들어야한다.

      → 설정 파일만 추가하면 클라이언트 수정 없이 데코레이터 패턴 적용 가능

    • 부가적인 기능이 여러 메서드에 반복적으로 나타나 코드 중복이 발생.

      **→ 다이나믹 프록시를 사용하므로 메서드에 코드 중복이 발생하지 않음.**

      ⇒ DI 는 신이다.

단점

  • 설정 파일의 양을 절대 무시할 수 없다.
    • 프록시의 개수가 늘어나고 프록시를 적용할 타깃이 늘어난다면? 보안, 기능 검사, 트랜잭션, 캐싱의 4개 프록시를 200개 서비스 클래스에 적용하는 상상을 해보자 .. 몇천줄의 설정파일은 우습다.
    • 비슷한 패턴의 설정 파일이 계속해서 복붙됨 위의 OtherService 에서 볼 수 있지만 트랜잭션 경계설정을 위한 주입 부분이 계속해서 반복된다. 만약 패턴도 update, save 등을 규칙으로 한다면 반복은 더 늘어난다.
    • 동일한 기능을 하는 InvokeHandler 의 계속되는 생성
      @Override
      public Object getObject() {
          TransactionHandler handler = new TransactionHandler();
          handler.setPattern(this.pattern);
          handler.setTarget(this.target);
          handler.setTransactionManager(this.transactionManager);
      		...
      TransactionHandler 는 조금의 수정만 거치면 빈으로 등록이 가능하다. 하지만 매번 생성해 중복된 객체를 만들어 사용하고있다.

스프링의 프록시 팩토리 빈

기존의 프록시 팩토리 빈을 사용하는 방법

기본적으로 InvokeHandler 를 구현하는 TransactionHandler 는 재사용 가능하지만 매번 TxFactory 안에서 생성해 사용하고 있다. 이로 인해 트랜잭션 빈 팩토리는 극히 핸들러에 의존하고 있으며, 핸들러는 부가기능을 적용할 메서드를 선정하기 위해 타깃에도 극히 의존한다.

  • TransactionHandler(부가기능 및 메서드 선정 알고리즘)을 재사용 할 수는 없을까?
    • 부가기능을 가지는 InvokeHandler 는 타깃과 메서드 선정 알고리즘에 의존한다. 타깃이 다르고 메서드 선정 방법이 다르면 한번 생성한 TransctionHandler 를 재사용 할 수 없다는 의미가 된다. → 한번 빈으로 등록된 InvokeHandler 를 여러 프록시가 공유 할 수 없음
  • 팩토리 빈은 프록시 빈을 생성하는 책임만을 부여할 수 없을까?

스프링의 ProxyFactoryBean 사용하는 방법

어드바이스 : 타깃이 필요 없는 순수한 부가기능

어드바이스는 프록시가 제공하는 순수한 부가기능만을 구현한 것이다.

이전 InvokeHandler 가 타깃에 대한 정보를 가지고 있어 싱글톤으로 여러 프록시에서 공유가 불가능했던것에 비해, 어드바이스는 MethodInterceptor 를 구현하게 되는데 invoke 메서드의 파라미터로 MethodInvocation 이라는 객체가 전달된다. 해당 객체는 proceed 메서드를 가지고 있으며 이는 InvokeHandler 에서 method.invoke 와 동일하게 타깃의 메서드를 실행한다.

public class TransactionAdvice implements MethodInterceptor {
    PlatformTransactionManager transactionManager;

    public void setTransactionManager(PlatformTransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        TransactionStatus status = this.transactionManager.getTransaction(
            new DefaultTransactionDefinition()
        );

        try {
            Object ret = invocation.proceed();
            this.transactionManager.commit(status);
            return ret;
        } catch (RuntimeException e) {
            // InvokeHandler 와 다르게 InvocationTargetException 이 아닌 타깃 메서드 에러로 잡아 처리 할 수 있다.
            this.transactionManager.rollback(status);
            throw e;
        }
    }
}

여기에도 적용된 템플릿/콜백 패턴
재사용 가능한 기능을 만들어두고 바뀌는 부분만 외부에서 주입해 작업 흐름 중에 사용하게 하는 패턴.

MethodInterceptor 를 구현하는 어드바이스는 템플릿/콜백 패턴을 응용하여 어드바이스를 여러 프록시 빈에서 공유 할 수 있도록 한다. 구현을 강제하는 invoke 메서드의 파라미터로 전달되는 MethodInvocation 객체를 통해 타깃의 메서드를 호출한다. JdbcTemplate 에 전략 콜백을 제공하던 기억을 떠올리자.
⇒ 부가기능을 여러 프록시에서 공유!


포인트컷 : 부가기능 적용 대상 메서드 선정 방법

원래 메서드 선정 알고리즘 기능은 TransactionHandler 에서 메서드 이름 패턴을 사용하던 기능을 어드바이스로부터 분리한 것이다. 하지만 더이상 어드바이스, 포인트컷은 타깃에 대한 정보를 가질 수 없다.타깃의 정보를 가지고 있는 순간 빈으로 등록해 프록시끼리 공유가 불가능하다.

어드바이스는 단순히 부가기능을 제공한다. 포인트컷이 존재해야 정확히 타깃의 어떤 기능에 부가기능을 적용할지 정할 수 있게 된다.

<!--  트랜잭션 포인트컷-->
<bean class="org.springframework.aop.support.NameMatchMethodPointcut" id="transactionPointcut">
  <property name="mappedName" value="upgrade*"/> <-- upgrade 로 시작하는 모든 메서드!
</bean>

어드바이스, 포인트컷을 합쳐 어드바이저로!

<!-- 트랜잭션 어드바이스-->
<bean class="springbook.user.service.TransactionAdvice" id="transactionAdvice">
  <property name="transactionManager" ref="transactionManager"/>
</bean>

<!--  트랜잭션 포인트컷-->
<bean class="org.springframework.aop.support.NameMatchMethodPointcut" id="transactionPointcut">
  <property name="mappedName" value="upgrade*"/>
</bean>

<!--  트랜잭션 어드바이저-->
<bean class="org.springframework.aop.support.DefaultPointcutAdvisor" id="transactionAdvisor">
  <property name="transactionAdvice" ref="transactionAdvice"/>
  <property name="transactionPointcut" ref="transactionPointcut"/>
</bean>

<bean
  class="org.springframework.aop.framework.ProxyFactoryBean"
  id="userService"
>
  <property name="target" ref="userServiceImpl"/>

  <!--    어드바이스와 어드바이저를 동시에 설정하는 프로퍼티-->
  <property name="interceptorNames">
    <list>
      <value>transactionAdvisor</value>
    </list>
  </property>
</bean>

설정 파일이 늘어나긴했지만 트랜잭션 부가기능을 빈으로 등록하고 여러 빈에서 주입받아 재사용 가능하게 변경된 점을 유의깊게 봐야한다.

*⇒ 기존 프록시 빈 팩토리의 단점 중 하나인 코드 중복을 부가기능 및 메서드 선정 알고리즘 빈 등록 후 재사용을 통해 해결!*


OCP 를 지키는 ProxyBeanFactory

  • 부가기능은 어드바이스와 포인트컷의 묶음을 프록시에 주입하면 타깃에 적용 할 수 있다.
  • 프록시, ProxyFactoryBean 의 변경이 전혀 없이도 기능을 자유롭게 확장할 수 있으며 특정 부가기능의 수정이 팩토리에 영향을 미치지 않는다.

감탄만이 나온다. 이전 데코레이터 패턴처럼 여러개의 부가기능을 제공하고 싶을때 addAdvice 를 통해 어드바이스/포인트컷을 쌍으로 주입해주면 된다. 진정한 힘은 여러개의 서비스 클래스가 부가기능, 즉 프록시를 사용하고자 하는 경우인데 이떄 어드바이스나 포인트컷은 하나만 생성하고 빈에 등록하면 DI 를 통해 모든 서비스에 적용이 가능하다.


자동 프록시 생성기

불편함이 이끄는 발전

트랜잭션 경계 설정 코드를 완전히 비즈니스 로직에서 분리하고, DI 를 원하는 메서드에 트랜잭션 부가기능 적용 할 수 있도록 만들었지만 여전히 기존 프록시 빈 팩토리의 단점인 “중복 설정 파일”의 단점은 남아있다.

<bean
  class="org.springframework.aop.framework.ProxyFactoryBean"
  id="userService" **<-- 유저 서비스에 한정 따라서 밑의 코드는 모두 복붙되어야함.
>**
  <property name="target" ref="userServiceImpl"/>
  <!--    어드바이스와 어드바이저를 동시에 설정하는 프로퍼티-->
  <property name="interceptorNames">
    <list>
      <value>transactionAdvisor</value>
    </list>
  </property>
</bean>

⇒ 빈 후처리기를 통해 타깃 빈의 목록에 대해 자동으로 프록시를 만들도록 해보자!

빈 후처리기
빈 후처리기는 BeanPostProcessor 인터페이스를 구현하는 객체이다. 스프링 빈 오브젝트로 만들어지고난 뒤 빈 오브젝트를 다시 재가공 할 수 있도록 한다. 우리가 원하는 것은 어드바이저 포인트컷에 걸리는 타겟 빈을 어드바이저의 어드바이스가 적용된 프록시로 대체하는 후처리를 수행하는 후처리기이며 이는 DefaultAdvisorAutoProxyCreator 를 사용한다.

  1. 해당 빈 후처리기가 등록이 된 상태라면 스프링은 빈 오브젝트를 생성 할 때 마다 후처리기에 빈을 보낸다.
  2. 포인트컷을 사용해 해당 빈이 어드바이저 적용 대상인지 확인한다.
  3. 적용 대상이라면 내장된 프록시 생성기를 통해 현재 빈에 어드바이저를 적용한 프록시를 생성한다.
  4. 컨테이너에게 생성한 프록시를 전달한다.
  5. 최종적으로 컨테이너는 자신이 설정 파일을 통해 생성한 빈이 아닌 빈 후처리기가 반환한 오브젝트를 빈으로 등록하고 사용한다.

트랜잭션 어드바이저가 적용된 프록시 빈 자동 생성

<!--  타깃 서비스 클래스-->
<bean class="springbook.user.service.UserServiceImpl" id="userService">
  <property name="userDao" ref="userDao"/>
  <property name="mailSender" ref="mailSender"/>
</bean>

<!-- 트랜잭션 어드바이스-->
<bean class="springbook.user.service.TransactionAdvice" id="transactionAdvice">
  <property name="transactionManager" ref="transactionManager"/>
</bean>

<!--  트랜잭션 포인트컷-->
<bean class="springbook.user.service.NameMatchClassMethodPointcut" id="transactionPointcut">
  <property name="mappedClassName" value="*ServiceImpl"/>
  <property name="mappedName" value="upgrade*"/>
</bean>

<!--  트랜잭션 어드바이저-->
<bean class="org.springframework.aop.support.DefaultPointcutAdvisor" id="transactionAdvisor">
  <property name="transactionAdvice" ref="transactionAdvice"/>
  <property name="transactionPointcut" ref="transactionPointcut"/>
</bean>

<!--  자동 프록시 생성기-->
<bean class="org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator"/>

이전과 달라진점은 타깃에 대해 매번 설정파일을 추가하거나 수정해야하는 수고를 제거한 부분이다.

<!-- 이전 스프링 프록시 빈 팩토리 사용 설정 파일 일부 -->
<bean
  class="org.springframework.aop.framework.ProxyFactoryBean"
  id="userService"
>
  <property name="target" ref="userServiceImpl"/> **<-- 타깃 빈을 명시적으로 전달해야한다.**
  <property name="interceptorNames">
    <list>
      <value>transactionAdvisor</value>
    </list>
  </property>
</bean>

즉, 포인트컷을 통해 대상 타깃 클래스와 클래스내의 메서드를 선정하는 기능을 적극적으로 사용해 프록시 빈 조차도 자동으로 생성되도록 만들었다고 할 수 있다.
이로서 2번째 문제점이었던 설정 파일의 중복을 해결 할 수 있다!


정리

프록시와 다이나믹 프록시

  • 트랜잭션 경계설정은 동일한 구조로 구성 → like 컨택스트
  • 트랜잭션을 수행하는 비즈니스 로직에서 분리해 관리하고자 하는 것이 이번 내용의 핵심

프록시

  • 서비스에는 비즈니스 로직만을 관리하고 부가 기능은 해당 서비스 클래스를 상속하는 프록시 객체에서 담당.
    • 오버라이딩한 메서드에 부가기능 적용
    • 오버라이딩한 메서드에서 슈퍼 클래스의 메서드를 호출해 위임
  • 장점
    • 기존 비즈니스 로직에 대해 부가기능을 계속 확장 할 수 있음
  • 단점
    • 기본적으로 상속을 통해 부가기능을 적용하고, 기능을 위임하므로 매번 프록시를 생성하는 것이 매우 비효율적
      • 메서드가 늘어나면 새로운 메서드에 대한 위임 처리를 모두 해줘야함

다이나믹 프록시 + 빈 팩토리

  • 매번 새로운 메서드에 대한 위임 처리를 해줘야 하는 이유?
    • → 타깃 클래스를 상속해 프록시 객체를 만들었기 때문.
    • 인터페이스를 통해 부가기능을 가진 프록시를 자동으로 만들어주자!
  • JDK 다이나믹 프록시 사용
    • [InvocationHandler 를 구현하는 객체 (부가기능 + 대상 선정 알고리즘)] + [대상 객체(찐 로직 혹은 또다른 프록시)] + [대상 객체의 인터페이스] ⇒ 런타임에 동적으로 생성되는 프록시를 만들 수 있음
    • 다이나믹 프록시를 빈으로 등록하기 위해 빈 팩토리 추가
  • 장점
    • InvocationHandler 의 invoke 에서 리플렉션 API 를 통해 추상화된 자바 클래스 코드를 사용해 부가기능 및 대상 선정, 위임을 수행하므로 핵심기능 인터페이스의 메서드 추가/수정 여부와는 관계없이 프록시 패턴을 동일하게 적용 할 수 있다.
  • 단점
    • 트랜잭션 경계 설정과 같은 부가 기능은 해당 로직뿐만 아니라 다양한 서비스 클래스에서 사용 할 수 있어야하는데 한번 빈으로 등록된 InvokeHandler 를 여러 프록시가 공유 할 수 없다. ⇒ InvokeHandler 재사용 불가, 코드 중복 발생
    • 리플렉션을 사용하기 위해 타깃의 클래스 정보와 인터페이스 정보, 부가 기능을 넘겨줘야하므로 이것이 그대로 설정 파일에 나타나야한다 ⇒ 설정 파일의 무지막지한 중복 발생

스프링 프록시 빈 팩토리

  • 코드가 중복되는 이유?
    • InvokeHandler 가 타깃 클래스에 의존하고 있기 때문에 새 타깃이 온 경우 새로 만들어야함.

    • 타깃이 무엇이든간에 한번 만들어놓은 부가기능을 적용할 수는 없을까?

      어드바이스(부가기능)과 포인트컷(대상 선정 알고리즘), 대상 타깃 클래스를 빈으로 등록하고 스프링 프록시 팩토리 빈에 3가지를 주입해 자동으로 적용되도록 해보자!

  • 어드바이스와 포인트컷, 그리고 둘을 합친 어드바이저를 빈으로 등록
    • 부가 기능과 부가 기능을 적용할 타깃의 클래스 및 메서드를 선정하는 기능을 빈으로 등록해 주입받아 사용하도록 함으로서 재사용 가능하게 만든다.
    • 스프링의 프록시 팩토리 빈은 주입받은 어드바이저들을 주입받은 타깃에게 적용한 프록시를 생성해 반환한다.
  • 장점
    • 부가 기능과 대상 선정 알고리즘을 빈으로 등록해 재사용할 수 있음.
      • 부가 기능 코드가 타깃 클래스마다 중복해 생성될 필요가 없다!
  • 단점
    • 설정 파일이 조금 덜 무지막지하게 중복 발생

자동 프록시 빈 생성기

  • 설정 파일이 무지막지하게 생기는 이유?
    • 어드바이저는 재사용 가능하지만 기본적으로 프록시 팩토리 빈 → 타깃 객체를 대신하는 프록시를 생성, 따라서 타깃 1개당 1개 필요하므로 설정 파일이 늘어남

      ⇒ 포인트컷에 선정된 빈이라면 팩토리를 자동으로 생성해서 프록시 빈으로 만들자!

  • 빈 후처리기 중 하나인 자동 프록시 빈 생성기를 통해 프록시 빈을 타깃 빈 대신 스프링 컨테이너에 등록
    • 포인트컷을 극도로 활용, 등록된 빈 중 포인트컷의 선정 알고리즘에 해당하는 빈 객체에 대해 어드바이스를 적용한 프록시를 대신 등록
  • 장점
    • 설정 파일의 중복이 없어짐
      • 자동 프록시 빈 생성기 + 어드바이저(포인트컷 + 어드바이스) + 타깃 빈 ⇒ 클라이언트 입장에서는 자동으로 등록된 프록시 빈을 사용!
  • 단점
    • 눈에 보이지 않음
      • 지금 예제 코드 돌리는데 포인트컷 패턴을 잘못적어 트랜잭션 안먹는걸 찾는데 진짜 애먹음
      • 테스트 코드가 충분히검증해주지 않는다면 트랜잭션 프록시 같은건 더욱더 검증하기 어려울 수 있음 → 예외케이스에 롤백, 정상 케이스에 DB 에 커밋되는지 확인해야하기 때문
    • 조건이 달라짐에 따라 포인트컷 구현 코드 수정이 필요 → 해결 : 포인트컷 표현식! (다음 글에서 정리)

정리끝

개선을 해나가는 일련의 과정이 참 재미있었다. Nest.js + TypeORM 에서 트랜잭션을 사용할때 QueryRunner 를 통해 경계 설정을 하는데 이 코드가 중복되는게 무지막지하다. 코드 중복도 중복이고 다른 서비스 메서드를 실행중인 트랜잭션내에서 실행하기 위해서는 QueryRunner 나 EntityManager 를 함수 파라미터로 넘겨야하는데 이게 또 말이 아니다. 토스에서 만든 nesjts-aop 모듈 개념 + typeorm-trasnaction-cls-hook 의 개념을 잘 섞으면 멋지게 만들 수 도 있을것 같은데 도전을 해봐야겟다.

profile
개발을 사랑하는 개발자. 끝없이 꼬리를 물며 답하고 찾는 과정에서 공부하는 개발자 입니다. 잘못된 내용 혹은 더해주시고 싶은 이야기가 있다면 부디 가르침을 주세요!

0개의 댓글