본문 바로가기
Framework & Lib/스프링

스프링의 트랜잭션 추상화

by 코딩공장공장장 2024. 6. 25.

트랜잭션 추상화


우리는 추상화가 "공통적인 것을 취하고 차이점을 배제하는 것"이라는걸 잘 알고 있다.

추상화를 통해 공통적인 부분을 분리하여 처리하도록 한다면 차이점에 대해서만 집중하여 개발을 할 수 있다.

트랜잭션 추상화에서 적용된  공통점은 트랜잭션 처리이고, 차이점은 비즈니스 로직이다. 

 

아래 코드를 보자.

override fun create(member: Member) {
  val status = transactionManager.getTransaction(DefaultTransactionDefinition())
  try {
      // 비즈니스 로직
      val id = memberRepo.create(member)
      memberRepo.addRole(MemberRole(id, "USER"))

      transactionManager.commit(status)
  } catch (e: Exception) {
      transactionManager.rollback(status)
      throw e
  }
}

 

트랜잭션 처리 코드는 트랜잭션을 사용하는 곳에서 공통적으로 발생하는 로직이다.

트랜잭션 추상화를 통해 공통적인 트랜잭션 처리를 분리 가능하다면 아래와 같은 장점을 얻을 수 있다.

  • 중복 코드 제거(책임 분리) : 비즈니스 로직은 실제 비즈니스 규칙에만 집중할 수 있고 트랜잭션 처리는 트랜잭션 기능을 적용한 부분에서만 처리할 수 있다.
  • 유연성 향상 : 트랜잭션 관리 방식이 변경될 때 비즈니스 로직은 변경할 필요가 없다.

이제 트랜잭션 추상화를 이루는 다양한 패턴의 코드를 예제를 통해 알아볼텐데 먼저 용어에 대해 알아보자.

 

스프링 트랜잭션 디자인 패턴 - 프록시와 데코레이터


프록시

프록시 패턴을 사용하는 목적은 아래와 같다.

  • 타깃객체로의 접근 제어

프록시가 될 수 있는 조건은 아래와 같다.

  • 클라이언트는 프록시에 요청하는지 타깃 객체에 요청하는지 알 수 없다.
  • 프록시는 타깃 객체를 래핑하고 있다.(참조 가능해야함)

 

* 프록시가 부가기능을 제공할 수 있지만 주요 목적은 타깃 객체로의 접근 제어이다.

따라서 부가기능만 제공한다면 프록시라고 할 수 없고 타깃 객체로의 접근 제어 역할을 반드시 해야한다.

 

데코레이터

데코레이터는 부가기능 부여 역할을 한다.

프록시 패턴과 자주 쓰이며 프록시가 데코레이터에 요청을 전달하고 부가기능을 제공한 이후 타깃 객체를 호출하게 한다.

이제부터 프록시와 데코레이터를 통해 트랜잭션을 추상화하는 패턴에 대해 하나씩 알아보자.

 

프록시 구현 방식에 따른 트랜잭션 최적화


1. 프록시 직접 구현

타깃과 같은 인터페이스를 상속받고 타깃을 참조속성으로 선언

 

[프록시]

class MemberServiceProxy(
  private val transactionManager: PlatformTransactionManager,
  private val memberServiceImpl: MemberService, // 타깃 의존
) : MemberService { // 타깃과 같은 인터페이스 구현
  override fun create(member: Member) {
      val status = transactionManager.getTransaction(DefaultTransactionDefinition())
      try {
           // 타깃 메서드 호출
          memberServiceImpl.create(member)

          transactionManager.commit(status)
      } catch (e: Exception) {
          transactionManager.rollback(status)
          throw e
      }
  }
}​


[타깃 객체]

class MemberServiceImpl(private val memberRepo: MemberRepository) : MemberService {
  override fun create(member: Member) {
      val id = memberRepo.create(member)
      memberRepo.addRole(MemberRole(id, "USER"))
  }
}

 

프록시 구현 패턴을 보면 아래와 같다.

  1. 프록시가 타깃과 같은 상위 인터페이스를 구현함 
  2. 타깃을 참조속성으로 주입

이와 같은 구조는 프록시가 래핑한 타깃 타입과 다른 경우 재사용하지 못함을 의미한다.

코드를 통해 명시적으로 의존관계를 결정하였기에(컴파일 타임에 결정) 타깃 클래스가 달라지면 프록시 클래스를 새롭게 만들어한다.트랜잭션 처리는 위 구조에서 달라지는 부분이 거의 없을 텐데, 타깃 타입이 달라서 프록시 클래스를 새롭게 만드는 것은 코드 중복을 심각하게 야기할 것이다.

 

이외에도 직접 구현하기 때문에 트랜잭션 처리가 불필요한 메서드들도 오버라이딩하고, 

트랜잭션 처리가 필요한 메서드가 여러개일때 코드가 중복되는 문제도 나타난다.

 

[프록시 직접 구현 - 해결사항]

  • 트랜잭션 처리를 타깃 객체로부터 분리(비즈니스 로직과 기술적인 로직 분리)

[프록시 직접 구현 -  한계]

  • 타깃 클래스마다 프록시 클래스를 설계해야하는 단점
  • 인터페이스의 모든 메서드를 구현해야함
  • 트랜잭션 처리 필요한 메서드에 코드 중복

 

2. 다이내믹 프록시

다이내믹 프록시는 자바 리플렉션 api를 이용해 동적으로 프록시를 생성하는 방식이다.

Proxy.newInstance 메서드에 타깃의 상위 인터페이스 타입과 타깃을 래핑하고 트랜잭션 기능을 부여한 데코레이터 객체를 인자값으로 넣어주면 프록시를 만들어준다.

상위 클래스 타입을 인자값으로 넣어주면 해당 타입으로 프록시를 생성해주므로 타깃 타입마다 프록시를 재설계 해야하는 단점을 해결할 수 있다.

 

허나 이 구조에서도 단점이 존재한다. 코드를 통해 알아보자.

 

[프록시 팩토리]

class TxProxyFactoryBean(
   private val target: Any,
   private val transactionManager: PlatformTransactionManager,
   private val methodNamePatter: String,
   private val serviceInterface: Class<*>?,
) : FactoryBean<Any> {
   override fun getObjectType(): Class<*>? = serviceInterface

   override fun getObject(): Any? =
       // 프록시 생성하여 반환
       Proxy.newProxyInstance(
           javaClass.classLoader,
           arrayOf(serviceInterface),
           TransactionHandler(target, transactionManager, methodNamePatter),
       )
}

 

[트랜잭션 처리 InvocationHandler]

class TransactionHandler(
   private val target: Any,  // 타깃을 참조속성으로 갖고 있음, but 구체적인 타입은 알지 못함
   private val transactionManager: PlatformTransactionManager,
   private val methodNamePattern: String, // 실행하려는 타깃 메서드명 패턴
) : InvocationHandler {

   override fun invoke(proxy: Any?, method: Method?, args: Array<out Any>?): Any? {
       return if (method!!.name.startsWith(methodNamePattern)) {
           invokeTransaction(method, args)
       } else {
           method.invoke(target, args!!.get(0))

       }
   }

   private fun invokeTransaction(method: Method?, args: Array<out Any>?): Any? {
       val status = transactionManager.getTransaction(DefaultTransactionDefinition())
       try {
           val returnVal = method?.invoke(target, args!!.get(0))
           transactionManager.commit(status)
           return returnVal
       } catch (e: InvocationTargetException) {
           transactionManager.rollback(status)
           throw e.targetException
       } catch (e: RuntimeException) {
           transactionManager.rollback(status)
           throw e
       }
   }
}

 

[xml 설정]

<bean id=”meberService” class=”TxProxyFactoryBean”>
       <property name=”target” ref=“memberServiceImpl” />
       <property name=”transactionManager” ref=“transactionManager” />
       <property name=”pattern” ref=“transactional” />
       <property name=”serviceInterface” ref=“패키지경로.memberService” />
</bean>

TransactionHandler가 타깃을 래핑하고 트랜잭션 기능 제공하는 데코레이터이다.

트랜잭션 처리를 제공하는 로직과 트랜잭션 대상을 선정하는 알고리즘 그리고 타깃까지 모두 하나의 객체에 정의해야한다.

Proxy.newProwxyInstace 메서드에서 InvcationHandler 타입으로 받는 인자값에 위 모든 사항을 정의한 객체를 전달해애야한다.

따라서 코드가 묶여 버리기에 재사용성이 떨어지고, 여전히 타깃을 참조타입으로 갖고있어 공유객체로 사용불가하다.

두 코드가 묶여서 있어 선택적으로 조합하여 사용해야하는 경우 코드 중복을 일으킬 수 있다.

 

 

[다이내믹 프록시 - 해결사항]

  • 타깃마다 타깃의 상위 인터페이스 타입 구현하여 프록시를 생성하는 구조에서 벗어남
  • 메서드 마다 중복되는 코드 중복 해결

[다이내믹 프록시 - 한계]

  • 메서드 선정 로직과 트랜잭션 처리 방식 중 하나라도 변경된 로직을 적용하고자 한다면 프록시를 새로 구현해야하고 코드가 중복됨 
  • 프록시가 타깃을 참조속성으로 가지고 있어 스프링 싱글톤 빈으로 등록할 수 없다.
  • 프록시를 적용할 타깃마다 xml 설정 중복

 

3. 스프링 프록시 팩토리 빈

스프링에서는 ProxyFactoryBean을 통해 프록시를 통해 생성한다.

ProxyFactoryBean을 사용하면 타깃의 메서드 정보를 추출하여 데코레이터에 파라미터로 전달해준다.

따라서 트랜잭션 처리 대성 선정 알고리즘과 트랜잭션 처리 로직을 분리하여 재사용하는 구조를 갖출 수 있고,

객체의 참조속성이 아닌 메서드 인자값으로 전달하니 동시성 문제에서 자유로워져 스프링 빈(공유객체)으로 등록할 수 있다.

 

[참고]

처리 대상을 선정하는 알고리즘을 pointcut이라고 하며 데코레이터 처럼 부가기능을 제공하는 역할을 advice라고 한다. 

advice와 pointcut을 합쳐놓을 것을 advisor라고 한다.

 

[데코레이터]

class CustomTransactionAdvice(
   private val transactionManager: PlatformTransactionManager,
) : MethodInterceptor {

   override fun invoke(invocation: MethodInvocation): Any? {
       val status = transactionManager.getTransaction(DefaultTransactionDefinition())

       try {
           val returnVal = invocation.proceed()
           transactionManager.commit(status)
           return returnVal
       } catch (e: RuntimeException) {
           transactionManager.rollback(status)
           throw e
       }
   }
}

 

[config]

@Configuration
class SpringProxyConfig(
   private val txManager: PlatformTransactionManager,
) {

   @Bean
   @Qualifier("nameMatcher")
   fun nameMatcher(): Pointcut {
       var aspectPointcut = AspectJExpressionPointcut()
       aspectPointcut.expression = "execution(public void com.springstudy.transaction.proxy.compile" +
           ".MemberServiceImpl" +
           ".create" +
           "(com.springstudy.transaction.proxy.sample.MemberDto))"
       return aspectPointcut
   }

   @Qualifier("txAdvice")
   @Bean
   fun customTransactionAdvice(): MethodInterceptor {
       val customTxAdvice = CustomTransactionAdvice(txManager)
       return customTxAdvice
   }

   @Bean
   fun customTransactionAdvisor(): Advisor {
       return DefaultPointcutAdvisor(nameMatcher(), customTransactionAdvice())
   }

   @Bean
   @Qualifier("memberServiceProxy")
   fun memberService(): ProxyFactoryBean {
       val proxyFactoryBean = ProxyFactoryBean()
       proxyFactoryBean.setTargetName("memberServiceImpl")
       proxyFactoryBean.setInterceptorNames("customTransactionAdvisor")
       proxyFactoryBean.setProxyInterfaces(arrayOf(MemberService::class.java))
       return proxyFactoryBean
   }
}

 

MethodInterceptor가 트랜잭션 처리를 담당하는 데코레이터이다.

코드를 보면 실행 해야할 메서드 정보를 파라미터로 전달 받는 것을 알 수 있다.

메서드 정보를 추출하는 것은 스프링에서 대신 진행해준다.

 

또한 구현방식을 조금 바꿔 트랜잭션 대상 메서드를 선정하는 로직과 트랜잭션 처리 데코레이터를 분리하였다.

Advisor 객체에 데코레이터와 메서드 선정 알고리즘을 주입하는 방식으로 조합하여 사용할 수 있다.

이를 통해 메서드 선정 알고리즘과 데코레이터 둘중 하나가 달라지면 새로운 빈으로 등록하고 advisor에 주입하면 되기에 기존 빈들은 재사용할 수 있다.

 

[스프링 프록시 - 해결사항]

    • ProxyFactoryBean을 통해 데코레이터와 타깃을 분리하고, 타깃 메서드 정보를 추출하여 인자값으로 전달해주는 방식으로 동시성 문제에서 자유로워지고 데코레이터를 스프링 빈으로 등록 가능하게 함

[스프링 프록시 - 한계]

  • 빈 설정 중복 문제

 

Config 중복 해결 - BeanPostProcessor

스프링의 DefaultAdvisorAutoProxyCreator를 빈으로 등록하면 Advisor 타입의 빈이 정의한 Pointcut 대상 타깃 객체들의 프록시를 알아서 생성하여 등록해준다.

 

*DefaultAdvisorAutoProxyCreator는 BeanPostProcessor의 하위 구현체로 빈 인스턴스화 이후 프록시 생성 및 등록을 담당하는 빈 후처리기이다.

@Bean
fun defaultAdvisorAutoProxyCreator(): DefaultAdvisorAutoProxyCreator {
   return DefaultAdvisorAutoProxyCreator()
}

 

위 처럼 등록하면 아래와 같이 proxyFactoryBean을 통한 설정을 하지 않고 Component 어노테이션만 붙여 스프링 빈으로만 등록하면 DefaultAdvisorAutoProxyCreator가 pointcut 적용 대상의 프록시를 대신 생성해준다.

 

 

 

반응형