Java

Transaction & AOP

Daniel0617 2020. 2. 26. 02:16

글의 목적

  - 프록시 패턴 이해

  - 트랜잭션 동작 원리 이해

  - 트랜잭션을 통해 AOP 이해

 

이 글은 'Spring Transaction 관리'와 연관되어 있습니다. 서두에 해당 글 내용과 연관지어 작성하오니 링크된 블로그 글을 먼저 읽어보시길 바랍니다.

 

트랜잭션 코드의 문제점


트랜잭션을 아래와 같이 적용 할 경우 어떤 문제점이 발생할까? 잠시만 생각해보자...

    public class PaymentService{

    ...

        private PlatformTransactionManager transactionManager;

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

    // --- 트랜잭션 경계 설정 --> Start!!!

            TransactionStatus status =
                    this.transactionManager.getTransaction(new DefaultTransactionDefinition());

            try {
                paymentDao.account(account);          // 결제금액 저장
                paymentDao.paymentType(paymentType);  // 결제정보 저장(ex. 카드, 계좌이체 정보 등)
                this.transactionManager.commit(status);

            } catch(RuntimeException e) {
                this.transactionManager.rollback(status);
                throw e;
            }

    // --- 트랜잭션 경계 설정 --> End!!!

        }

    }

 

 우선 위의 코드 내용을 분석해보면, 크게 2가지로 분리되어 있다. 트랜잭션과 비즈니스 로직 코드로 분류 할 수 있으며, 좀 더 세부적으로 살펴보면 트랜잭션 설정은 시작과 종료가 정해져 경계가 지정되어 있으며, 해당 트랜잭션 코드 내에 비즈니스 로직(결제)이 존재하는 것을 볼 수 있다. 2개(트랜잭션, 비즈니스 로직)의 코드 내용은 값을 주고받고 있지 않을 뿐만 아니라 성격이 다르기 때문에 완전히 독립적인 코드라고 볼 수 있다. 

 

 만약 위의 구조로 계속해서 개발한다면, 어떻게 될까? PaymentService 패키지 내 트랜잭션이 필요로하는 코드가 발생 할 경우 동일하게 트랜잭션 경계 설정을 작성해줘야 하며, 계속해서 중복된 코드가 발생하게 될 것이다. 그렇다면, 어떻게 해야지.. 깔끔한 코드가 될 수 있을 것인가... 우리가 생각해봐야 할 부분은 비즈니스 로직 코드가 트랜잭션의 시작과 종료 사이에서 수행되야 한다는 점. 이 부분을 주목해야 할 필요가 있다.

 

Proxy Pattern

 


현재 코드 구조는 아래와 같다. 

위의 구조와 달리 클라이언트와 결합이 약해지고 유연한 확장이 가능한 코드를 개발하려면 어떻게 해야할까? 이때 생각해봐야 할 점이 인터페이스이다.

 

Client에서 PaymentService로 직접 연결을 통한 강한 결합으로 유연한 확장이 불가능한데, 위의 도식화된 그림을 다음과 같이 변경해보자.

 

위의 구조인 경우 인터페이스를 통해 비즈니스 로직(PaymentServiceImpl)과 트랜잭션 코드(PaymentServiceTx)를 분리 함으로써 서로 독립되게 작성될 수 있도록 도식화한 것이다. 위의 그림을 코드로 구현하면 아래와 같다.

public interface PaymentService{
	void pay(long account, String paymentType);
}
public class PaymentServiceImpl implements PaymentService {

      ...
      @Override
      public void pay(long account, String paymentType){
      	paymentDao.account(account);          // 결제금액 저장
        paymentDao.paymentType(paymentType);  // 결제정보 저장(ex. 카드, 계좌이체 정보 등)
      }

}
public class PaymentServiceTx implements PaymentService {
	
    private PlatformTransactionManager transactionManager;
    
    public void setTransactionManager(PlatformTransactionManager transactionManager){
    	this.transactionManager = transactionManager;
    }
    
    private PaymentService paymentService;
    
    public PaymentServiceTx(PaymentService paymentService) {
    	this.paymentService = paymentService;
    }    

    @Override
    public void pay(long account, String paymentType) {
    	TransactionStatus status =
        	this.transactionManager.getTransaction(new DefaultTransactionDefinition());
            
            try {
            
            // 생성자에서 전달 받은 PaymentService를 실행
            // 인터페이스를 통해 실제 비즈니스 로직(PaymentServiceImpl)를 실행시키는 것이다.
            this.paymentService.pay(account, paymentType);
            this.transactionManager.commit(status);
            
            } catch(RuntimeException e) {
            	this.transactionManager.rollback(status);
                throw e;
            }
     }
}

 

 

이런 경우 Client 측에서는 어떻게 사용될까? 

public class ClientRequest {
    ...    
	public void payment(long account, String paymentType){
		PaymentService paymentServiceImpl = new PaymentServiceImpl();
		PaymentService paymentServiceTx = new PaymentServiceTx(paymentServiceImpl);
		paymentServiceTx.pay(account, paymentType);
    	
	}
}

 

 코드를 보고 어떻게 변경되었는지 알 수 있겠는가? (개인적으로 작성자는 코드를 봤을 때 훨씬 더 수월하게 이해된다. 책 내용의 텍스트만으로는... 전혀 이해되지 않는다.)

 

 PaymentServiceImpl를 PaymentService로 추상화하고, PaymentServiceTx 클래스 생성자 매개변수로 전달해 PaymentServiceTx 내부적으로 pay() 메소드가 실행되었을 때 PaymentServiceImpl에 지정된 비즈니스 로직이 트랜잭션 범위 내에서 실행될 수 있도록 작성했다.

 

처음 트랜잭션과 비즈니스 로직이 합쳐져있던 코드와 비교해보면 어떤가..? 좀 더 깔끔하다고 느껴지지 않는가??? 작성자가 느끼기에 코드가 좀 더 명확하고 깔끔하다고 느껴지는 이유는 이전과 달리 비즈니스 로직 변경은 PaymentServiceImpl 클래스에서만 수정하면 되고, PaymentService 인터페이스를 받고 있는 클래스라면 PaymentServiceTx 생성자 매개변수로 전달되어 있으므로 확장에는 열려 있는 구조이다. 즉, OCP 원칙을 지킬 수 있는 코드라고 볼 수 있다.

 

But!!! 위의 프록시 패턴만으로는 완벽한 코드 구조라고 볼 수 없다. 왜냐하면, 인터페이스에 메소드가 추가될 경우 코드 작성 시 번거로움이 계속해서 발생하고, 코드 중복될 가능성이 크기 때문이다. 코드 중복의 경우 pay() 메소드 뿐만 아니라 예를 들어, 추가된 메소드 payCancel()에서도 트랜잭션이 필요한 경우 트랜잭션 경계 코드가 작성되야 하는데 이것은 pay() 메소드에서 작성되었던 코드와 중복된다.

 

해당 문제점을 해결하기 위해 메소드를 파악 할 수 있는 자바의 리플렉션과 추가로 팩토리 빈을 이용해 문제점을 해결 할 수 있다. 자세한 내용은 토비의 스프링 p.437 ~ 462를 참고하길 바란다.

 

스프링의 프록시 팩토리 빈


 스프링은 트랜잭션 기술의 추상화(PlatformTransactionManager)를 통해 기존 코드의 수정 없이 부가 기능을 추가해줄 수 있는 방법을 제시해줬다. 이러한 추상화 기법을 스프링에서는 프록시에서도 동일하게 제공해주고 있는데, 바로 ProxyFactoryBean이다. 일관된 방법으로 프록시를 만들 수 있게 도와주는 클래스이다.

 

아래 예제 코드를 살펴보자

    import static org.hamcrest.Matchers.is;
    import static org.junit.Assert.assertThat;
    import org.aopalliance.intercept.MethodInterceptor;
    import org.aopalliance.intercept.MethodInvocation;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    import org.springframework.aop.framework.ProxyFactoryBean;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.test.context.junit4.SpringRunner;

    

    @RunWith(SpringRunner.class)
    @SpringBootTest
    public class ProxyFactoryTest {
    
      @Test
      public void proxyFactoryTest(){
        ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
        proxyFactoryBean.setTarget(new HelloTarget());       // 타겟 설정
        proxyFactoryBean.addAdvice(new UppercaseAdvice());   // 부가기능을 담은 어드바이스 추가
    
        Hello proxiedHello = (Hello) proxyFactoryBean.getObject(); // 프록시를 갖고온다.
    
        assertThat(proxiedHello.sayHello("Taeyoon"), is("HELLO TAEYOON"));
        assertThat(proxiedHello.sayHi("Taeyoon"), is("HELLO TAEYOON"));
        assertThat(proxiedHello.sayThankYou("Taeyoon"), is("HELLO TAEYOON"));
      }

      static class UppercaseAdvice implements MethodInterceptor {
    
        @Override
        public Object invoke(MethodInvocation methodInvocation) throws Throwable {
          String ret = (String) methodInvocation.proceed();   // 리플렉션과 달리 타겟오브젝트를 전달할 필요가 없다.
                                                              // MethodInvocation은 메소드 정보와 함께 타깃 오브젝트를 알고 있기 때문이다.
          return ret.toUpperCase();  // 부가기능 적용
        }
      }

      static interface Hello{
        String sayHello(String name);
        String sayHi(String name);
        String sayThankYou(String name);
      }

      static 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;
        }
      }
    }

 

 이전에 설명했던 프록시 패턴 예제 코드와는 내용적으로 구조적으로 많이 다르다는 것을 알 수 있다. 내용적으로는  결제 기능(PaymentService)을 작성했던 코드가 대문자 문자열로 리턴해주는 코드로 변경되었는데, 해당 부분은 신경쓰지 말고 우선 위의 코드만 집중해서 살펴보자.

 

 구조적으로 무엇이 다른가??? 가장 중요한 부분은 부가기능(부가기능을 담당하는 것을 Advice라고 지칭한다)을 담당하는 클래스에서 타깃 오브젝트 내용도 확인할 필요 없이 순수한 부가기능만을 제공하고 있는 것이다. 이전에 작성한 프록시 패턴 코드에서는 PaymentService 인터페이스를 부가기능을 담당하는 클래스 PaymentServiceTx의 생성자 매개변수로 전달했지만 지금은 ProxyFactoryBean을 활용함으로써 각각의 기능과 목적 역할에만 집중할 수 있는 코드가 작성된 것이다.

 

 더 나아가 현재 작성된 코드는 타깃 오브젝트의 메소드에 부가기능(UppercaseAdvice 클래스에서 대문자로 변경해주는 .toUpperCase() 메소드)이 모두 사용될 수 있도록 적용되어 있는데, 이것을 메소드를 선정해 적용될 수 있도록 변경해보자.

    import static org.hamcrest.Matchers.is;
    import static org.junit.Assert.assertThat;
    import org.aopalliance.intercept.MethodInterceptor;
    import org.aopalliance.intercept.MethodInvocation;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    import org.springframework.aop.framework.ProxyFactoryBean;
    import org.springframework.aop.support.DefaultPointcutAdvisor;
    import org.springframework.aop.support.NameMatchMethodPointcut;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.test.context.junit4.SpringRunner;

    @RunWith(SpringRunner.class)
    @SpringBootTest
    public class ProxyFactoryTest {
    
      @Test
      public void proxyFactoryTest(){
        ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
        proxyFactoryBean.setTarget(new HelloTarget());

        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut(); // 메소드 이름을 비교해서 대상을 선정하는 알고리즘을 제공하는 포인트컷
        pointcut.setMappedName("sayH*"); // 이름 비교조건 설정. 'sayH'로 시작하는 모든 메소드를 선택하게 해준다.
        proxyFactoryBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice()));***
    
        Hello proxiedHello = (Hello) proxyFactoryBean.getObject();
    
        assertThat(proxiedHello.sayHello("Taeyoon"), is("HELLO TAEYOON"));
        assertThat(proxiedHello.sayHi("Taeyoon"), is("HELLO TAEYOON"));
    
	    // 메소드 이름이 포인트컷의 선정조건에 맞지 않으므로, 부가기능(대문자변환)이 적용되지 않는다.
        assertThat(proxiedHello.sayThankYou("Taeyoon"), is("HELLO TAEYOON")); 
      }

    

      ... // 위의 코드 내용과 동일하다

 

 변경된 것은 위의 내용과 같다. NameMatchMethodPointcut 클래스를 통해 메소드 선정 알고리즘을 적용하고, 해당 오브젝트와 함께 부가기능 오브젝트(UppercaseAdvice)를 함께 추가한다. (앞으로 메소드 선정 알고리즘을 담은 오브젝트를 '포인트컷'이라고 부르며, '**어드바이저'**는 어드바이스와 포인트컷을 묶은 오브젝트를 어드바이저라고 부른다. 포인트컷과 어드바이저가 무엇인지 위의 코드에서 찾아보자.)

 

 해당 내용을 다시 결제 트랜잭션 코드에 적용시켜보자

    import static org.hamcrest.Matchers.is;
    import static org.junit.Assert.assertThat;
    import org.aopalliance.intercept.MethodInterceptor;
    import org.aopalliance.intercept.MethodInvocation;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    import org.springframework.aop.framework.ProxyFactoryBean;
    import org.springframework.aop.support.DefaultPointcutAdvisor;
    import org.springframework.aop.support.NameMatchMethodPointcut;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.test.context.junit4.SpringRunner;
    import org.springframework.transaction.PlatformTransactionManager;
    import org.springframework.transaction.TransactionStatus;
    import org.springframework.transaction.support.DefaultTransactionDefinition;
    

    @RunWith(SpringRunner.class)
    @SpringBootTest
    public class PaymentServiceTest {
    
      @Test
      public void paymentServiceTest() {
    
        ProxyFactoryBean proxyFactoryBean = new ProxyFactoryBean();
        proxyFactoryBean.setTarget(new PaymentServiceImpl());
        NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
        pointcut.setMappedName("pay*"); // pay로 시작하는 메소드에 부가기능을 적용시켜 줄 수 있도록 포인트 컷을 작성한다.

        proxyFactoryBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new PaymentServiceTx()));

		PaymentService paymentService = (PaymentService) proxyFactoryBean.getObject();

        assertThat(paymentService.payCancel(1000, "Card"), is("PAYCANCEL(1000 / CARD"));
        assertThat(paymentService.payFinish(1000, "Card"), is("PAYFINISH(1000 / CARD"));
        assertThat(paymentService.paid(), is("PAID"));
      }

      static interface PaymentService{
        String payCancel(long account, String paymentType);
        String payFinish(long account, String paymentType);
        String paid();
      }

      static class PaymentServiceImpl implements PaymentService {

        @Override
        public String payCancel(long account, String paymentType) {
          return "PayCancel("+account+" / "+paymentType;
        }

        @Override
        public String payFinish(long account, String paymentType) {
          return "PayFinish("+account+" / "+paymentType;
        }

        @Override
        public String paid() {
          return "Paid";
        }
      }

      static class PaymentServiceTx implements MethodInterceptor {
        private PlatformTransactionManager transactionManager;
        
        public void setTransactionManager(PlatformTransactionManager transactionManager) {
          this.transactionManager = transactionManager;
        }

        @Override
        public Object invoke(MethodInvocation methodInvocation) throws Throwable {
          String ret =
              (String) methodInvocation.proceed(); // 타깃의 메소드를 실행한다. 타깃 메소드 전후로 필요한 부가기능을 넣을 수 있다.
          return ret.toUpperCase();
    
    		// 트랜잭션 부가기능을 적용한다면 아래와 같은 코드 구조이다.
			//      TransactionStatus status = this.transactionManager.getTransaction(new

			// DefaultTransactionDefinition());
			//      try {
			//        String ret = (String) methodInvocation.proceed(); // 타깃의 메소드를 실행한다. 타깃 메소드 전후로 필요한
			// 부가기능을 넣을 수 있다.
			//
			//        this.transactionManager.commit(status);
			//        return ret.toUpperCase();
			//      } catch (RuntimeException e){
			//        this.transactionManager.rollback(status);
			//        throw e;
			//      }
        }
      }
    }

 

 코드를 한눈에 보기 쉽게 하기 위하여 하나의 클래스 안에 모든 PaymentService 클래스를 작성했다. 여기서 중요한 점은 코드가 어떻게 동작할 것 같은가...? 트랜잭션이 적용되는 메소드는 어떤 것일까? 결과가 예측 되는가? 우선 정답은 pay로 시작하는 메소드에 트랜잭션이 모두 동작할 것이다. 즉, paid() 메소드 외에는 모두 트랜잭션 부가기능이 동작 할 수 있는 것이다. 

 

 서두에 프록시 패턴으로 트랜잭션을 적용했던 코드 구조와 비교해보면 프록시 팩토리 빈을 활용한 코드에서는 부가기능 클래스(ex. PaymentServiceTx)에서 타깃 오브젝트와 메소드를 신경쓸 필요가 없어졌다. 포인트 컷을 통해 타깃 클래스 메소드 중 부가기능이 적용되는 메소드를 선정하고, 어드바이스를 지정함으로써 불필요한 중복된 코드를 없애고 확장성에 용이한 코드 구조로 변경된 것이다.

 

 하지만 여기에도 문제점은 존재하는데.. 바로 트랜잭션 적용 대상이 되는 빈마다 일일이 프록시 팩토리 빈을 설정해줘야 한다는 부담이 남아 있다. 해당 문제점은 BeanPostProcessor 인터페이스를 구현해서 만드는 빈 후처리기를 통해 자동 프록시 생성을 만들 수 있다. 자세한 내용은 토비 스프링 '6.5 스프링 AOP'를 살펴보자.

 

AOP란?


 

 토비 스프링에서 Aspect란 '그 자체로 애플리케이션의 핵심 기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소이고, 핵심 기능에 부가되어 의미를 갖는 특별한 모듈을 가리킨다.'라고 설명해주고 있다. 해당 내용을 가장 잘 도식화 해준 것이 아래 이미지와 같다.

 

 핵심기능 코드 사이(ex. PaymentServiceImpl)에 침투한 부가기능(ex. PaymentServiceTx)을 독립적인 모듈인 Aspect로 구분해낸 것이다. Aspect를 독립된 측면로 분리한 덕에 핵심기능은 순수하게 그 기능을 담은 코드로만 존재하고, 독립적으로 살펴볼 수 있도록 구분된 면에 존재하게 되는 것이다.

 

 이렇듯 애플리케이션의 핵심적인 기능에서 부가적인 기능을 분리해서 Aspect라는 독특한 모듈로 만들어서 설계하고 개발하는 방법을 AOP(Aspect Oriented Programming)이라고 부른다.