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

스프링의 트랜잭션 추상화

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

오렌지색으로 표시한 코드가 비즈니스 로직이고, 나머지는 트랜잭션 처리이다.

 

트랜잭션 처리가 필요한 코드는 오렌지색 코드를 제외한 나머지 코드가 공통적으로 발생한다.

 

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

 

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

 

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

프록시

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

  • 타깃객체로의 접근 제어

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

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

프록시의 대표적인 예제는 jpa의 지연로딩이다.

jpa의 프록시는 db에 접근이 필요하다고 판단할 때만 db에 접근할 수 있는 타깃 객체를 반환하여 db에 접근하도록 하고,

db에 접근이 필요없다고 판단될 때는, 프록시를 통해 영속성 캐시 공간에서 데이터를 가져온다.

이를 통해 불필요한 db접근을 제어할 수 있는 것이다.

 

* 물론 프록시 또한 부가기능을 제공할 수 있지만 주요 목적은 타깃 객체로의 접근 제어이다. 따라서 부가기능만 제공한다면 프록시라고 할 수 없고 타깃 객체로의 접근 제어 역할을 반드시 해야한다.

 

데코레이터

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

 

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

 

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

 

컴파일 의존 프록시

[프록시]

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"))
  }
}

위와 같은 패턴을 컴파일 의존 프록시라고 정의하는 것은 아니지만, 나는 컴파일 의존 프록시라고 하겠다.

 

그 이유는 MemberServiceProxy의 참조속성인 private val memberServiceImpl: MemberService에 있다.

 

프록시가 접근제어 또는 부가기능을 제공하려는 타깃 객체의 클래스 타입을 코드를 통해 명시적으로 알고 있다.

 

이는 컴파일 의존하는 구조이다. 

 

컴파일 의존이란 프로그래밍 코드를 통해 의존성을 결정하는 것이다.

 

반대되는 개념이 런타임 의존인데 프로그래밍 코드가 아닌 xml설정이나 어노테이션을 통해 의존성을 결정하는 것이다.

(자세한 설명은 컴파일 타임 의존성과 런타임 의존성 참고)

 

프록시와 타깃 코드를 보면 비즈니스 로직과 트랜잭션 처리를 분리하였다.

 

추상화에 성공했다고 할 수 있다.

 

허나, 지금 구조에서 치명적인 단점이 있다.

 

첫번째, 방금 설명한 컴파일 의존 구조로 인해 이 프록시는 MemberServiceImpl 타입의 타깃 객체에만 사용 가능하다.

 

두번째, 타깃의 요청을 가로채기 위해 타깃과 같은 타입의 인터페이스를 구현해야한다.

 

 

이또한, MemberServiceImpl 타입의 타깃 객체에만 사용 가능하다는 것이고 

뿐만아니라 프록시를 만들기 위해 인터페이스를 구현해야하므로 만일 트랜잭션 처리가 없는 메서드가 존재하더라도 구현을 해야한다.

 

세번째,  아래와 같이 트랜잭션 처리를 필요로 하는 다른 메서드가 존재한다고 할 때, 트랜잭션 처리 코드가 중복적으로 나타나는 단점이 있다.

override fun update(member: Member) {
  val status = transactionManager.getTransaction(DefaultTransactionDefinition())
  try {

       // 타깃 메서드 호출
      memberServiceImpl.update(member)


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

위 패턴을 통한 트랜잭션 추상화에서 해결한 사항과 한계를 정리하면 아래와 같다.

 

해결사항

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

한계

  • 타깃과 같은 인터페이스 타입 구현, 프록시와 타깃의 의존관계를 프로그래밍 코드를 통해 결정
    -> 프록시를 해당 타깃에서 밖에 사용못함, 타깃 추가되면 타깃마다 프록시 새로 구현 필요
    -> 인터페이스의 모든 메서드를 구현해야함(트랜잭션 처리가 없는 메서드라도)
  • 메서드 마다 트랜잭션 코드 중복

 

다이내믹 프록시

다이내믹 프록시에서 다이내믹은 런타임 의존으로 생각하면 된다.

프록시와 타깃 객체의 의존관계를 코드를 통해 결정하지 않는다.

 

따라서 다양한 타입의 타깃에 재사용 가능하다.

 

- 다이내믹 프록시 예제

 

[프록시 팩토리]

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 methodNamePatter: String, // 실행하려는 타깃 메서드명 패턴

) : InvocationHandler {

 

   override fun invoke(proxy: Any?, method: Method?, args: Array<out Any>?): Any? {

       return if (method!!.name.startsWith(methodNamePatter)) {

           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>

 

먼저 FactoryBean<Any>의 구현체인 TxFactoryBean를 보자.

 

FactoryBean<Any>의 구현체를 스프링 빈으로 등록하면 getObject를 통해 반환되는 객체를 빈으로 등록한다.

 

Proxy 클래스의 newProxyInstance라는 스태틱 메서드를 통해 프록시 객체를 생성하여 반환한다.
인자값을 보면 타깃 객체의 인터페이스 타입과 타깃 객체를 래핑한 TxHandler를 받고 있다.

TxHandler는 트랜잭션 기능을 제공하는 데코레이터이다.

 

xml을 통해 빈 설정을 하면 아래와 같은 구조를 가질 수 있다.

 

 

InvocationHandler의 구현체 TransactionHandler의 설명을 더하겠다.

 

invoke 메서드를 통해 타깃 객체와 실행하는 메서드와 그 인자값이 전달된다.

코드 어디를 살펴보더라도 타깃 클래스 타입이 무엇인지 알 수 없다.

 

타깃 객체의 타입에 제한되지 않고 범용적으로 사용이 가능한 구조이다.

뿐만 아니라 실행 메서드 정보 또한 코드를 통해 알지 못한다. 파라미터를 통해 메서드 정보가 전달된다.

이는 같은 클래스 내에서 트랜잭션 처리하는 메서드가 여러 개이더라도
메서드마다 트랜잭션 처리하는 코드 중복에서 벗어남을 의미한다.

 

하지만 위와 같은 구조에서도 단점이 존재한다.

 

다이내믹이라는 용어에서 처럼 코드를 통한 타깃 의존 문제는 해결 되었지만

타깃 객체를 참조속성을 갖고 있다.

 

이는 공유객체로 사용할 수 없는 즉, 스프링 빈으로 등록하여 사용할 수 없음을 의미한다.

 

스프링 빈으로 등록하여 공유객체로 사용하면 동시성 문제에서 자유로울 수 없다.

 

따라서 InvocationHandler를 사용하려는 타깃은 ProxyFactoryBean을 통해 매번 인스턴스화 되도록 설정해야하며

 

이에따라 타깃마다 xml설정을 중복하는 구조가 나타난다.

 

하나 더 얘기하면 invoke 메서드 안의 if (method!!.name.startsWith(methodNamePatter)를 통해 실행하려는 실행하려는 타깃 메서드를 결정하는데 위 코드는 해당 패턴으로 메서드명이 시작하는 경우 트랜잭션 처리를 진행한다.


이와 같이 실행하려는 메서드를 결정하는 알고리즘을 메서드 알고리즘이라고 하는데 메소드 알고리즘은 타깃마다 다를 수 있다.

특정 패턴으로 시작하는 경우, 끝나는 경우, 메서드에 특정 어노테이션이 붙은 경우 등 굉장히 다양할 수 있다.

 

프록시가 메서드 선정 알고리즘을 의존하므로 메서드 선정 알고리즘이 바뀌게 되면 프록시 또한 새롭게 생성해야하는 단점이 있다.

 

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

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

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

  • 프록시가 타깃 객체를 상태로 갖고 있어 빈으로 등록하지 못하고 타깃마다 프록시 인스턴스를 생성함
  • 메서드 선정 알고리즘이 달라지는 경우 프록시 새롭게 생성해야함. 따라서 재사용 불가
  • 프록시를 적용할 타깃마다 xml 설정 중복

 

스프링 프록시 팩토리 빈

다이내믹 프록시가 컴파일 의존 프록시의 한계를 해결하였지만 여전히 한계가 존재한다.

해결방법에 대해 먼저 말을 한다면 아래와 같다.

  • 프록시가 타깃 객체를 상태로 갖고 있어 빈으로 등록하지 못함 -> 타깃 객체 정보를 리플렉션을 통해 파라미터로 전달
  • 메서드 선정 알고리즘이 달라지는 경우 프록시 새롭게 생성해야함 -> 메서드 선정 알고리즘 분리

예제를 통해 보자.

 

- 스프링 프록시 팩토리 빈

 

[스프링 프록시]

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

   }

}

 

이전에 InvocationHandler를 통해 프록시를 구현했던것과 달리 이번에는 MethodInterceptor를 통해 프록시를 구현하였다.

InvocationHandler는 타깃 객체를 인자로 받아야했기에 타깃을 참조속성으로 갖고 있었다.

 

허나, MethodInterceptor를 통해 실행되는 invoke 메서드는 MethodInvocation을 파라미터로 받는데

 

MethodInvocation에 타깃 정보가 전달된다.

 

즉, 공유 객체의 참조속성이 아닌 메서드의 파라미터로 전달되니 동시성 문제에서 자유로워져

 

싱글톤으로 구현하여 여러 타깃 객체들에게 재사용될 수 있다.

 

다음은 config 파일을 보자.(xm이 아닌 자바 config 방식으로 설정하였다.)

 

메서드 선정 알고리즘은 nameMatcher를 별도의 빈으로 등록하였고, MethodInterceptor 또한 빈으로 등록하였다.

 

그리고 이를 하나로 합쳐 같이 사용하기 위해 별도의 Advisor를 등록하였다.

 

만약 새로운 메서드 선정 알고리즘과 프록시가 생성된다면 각각을 스프링 빈으로 등록하고

 

사용하려는 조합으로  Advisor를 빈으로 등록하면 재사용 가능하다.

 

이렇게 프록시가 실행하는 메서드를 통해 타깃 객체정보를 전달해주고 메서드 선정 알고리즘을 분리하여

 

프록시를 스프링 빈으로 등록하여 여러 타깃객체에서 재사용 가능하도록 만들었다.

 

이렇게 해서 스프링은 변하는 부분과 변하지 않는 부분(공통 부분)을 분리하여 트랜잭션을 추상화하였다.

 

허나, 여전히 찜찜한 부분이 있다.

   @Bean

   @Qualifier("memberServiceProxy")

   fun memberService(): ProxyFactoryBean {

       val proxyFactoryBean = ProxyFactoryBean()

       proxyFactoryBean.setTargetName("memberServiceImpl")

       proxyFactoryBean.setInterceptorNames("customTransactionAdvisor")

       proxyFactoryBean.setProxyInterfaces(arrayOf(MemberService::class.java))

       return proxyFactoryBean

   }

 

바로 이 빈 등록 설정 부분이다.

 

사용하려는 타깃 객체마다 위와 같이 번거로운 설정을 반복적으로 하는 것은 설정의 중복 문제를 야기한다.

 

BeanPostProcessor

 

빈 후처리기라고 불리는 BeanPostProcsessor는 등록하려는 빈이 인스턴스화 되고 초기화 되기 이전 또는 이후에 인스턴스를 받아 커스텀 로직을 수행할 수 있도록 하는 인터페이스이다.

 

우리는 이 BeanPostProcessor를 통해 타깃 객체 대신 타깃을 감산 프록시를 각 인스턴스 대신 반환하여 스프링 싱글톤 레지스트리에 등록하도록 할 것이다.

 

그렇게하면 타깃 객체가 실행될 때 프록시가 부가기능을 실행하고 타깃 메서드를 실행시킬 수 있다.

 

스프링에서 이미 제공해주는 BeanPostProcessor인 DefaultAdvisorAutoProxyCreator를 사용하면 Advisor 타입로 등록된 빈을 찾아

Pointcut을 분석하여 타깃 객체에 대한 프록시를 생성하고 싱글톤 레지스트리에 등록될 수 있도록 반환한다.

 

기존에 설정한 ProxyFactoryBean 설정을 제거하고 아래 설정을 추가하기만 하면 된다.

 

@Bean

fun defaultAdvisorAutoProxyCreator(): DefaultAdvisorAutoProxyCreator {

   return DefaultAdvisorAutoProxyCreator()

}

 

이와 같은 방법으로 xml설정이나 자바 config의 중복문제도 해결 할 수 있다.

 

이렇게 해서 트랜잭션 추상화 과정을 알아보았다.

 

코드를 통해 프록시와 타깃의 의존관계를 결정했던 컴파일 의존 프록시에서

 

타깃 객체를 참조속성으로 전달하여 타깃 클래스 타입에 의존하지 않는 다이내믹 프록시,

 

그리고 MethodInterceptor를 통해 타깃 정보까지 메서드의 파라미터로 전달될 수 있도록 하여 동시성 문제에서 자유로워져

 

스프링 빈으로 등록하여 싱글톤으로 사용 가능하도록 한 스프링 프록시 패턴,

 

마지막으로 Advisor 타입을 자동으로 빈으로 등록해주는 DefaultAdvisorAutoProxyCreator라는 BeanPostProcessor를 통해

 

xml이나 config의 중복 문제를 해결한 사례까지 구현하였다.

 

위와 같은 프록시 패턴이 중요한 이유는 스프링이 aop를 구현하는데 주로 프록시 방식을 사용하기 때문이다.

 

따라서 스프링 aop를 프록시 aop라고 부르기도 한다.

반응형