트랜잭션 추상화
우리는 추상화가 "공통적인 것을 취하고 차이점을 배제하는 것"이라는걸 잘 알고 있다.
추상화를 통해 공통적인 부분을 분리하여 처리하도록 한다면 차이점에 대해서만 집중하여 개발을 할 수 있다.
트랜잭션 추상화에서 적용된 공통점은 트랜잭션 처리이고, 차이점은 비즈니스 로직이다.
아래 코드를 보자.
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접근을 제어할 수 있는 것이다.
* 프록시가 부가기능을 제공할 수 있지만 주요 목적은 타깃 객체로의 접근 제어이다. 따라서 부가기능만 제공한다면 프록시라고 할 수 없고 타깃 객체로의 접근 제어 역할을 반드시 해야한다.
데코레이터
데코레이터는 부가기능 부여 역할을 한다.
프록시 패턴과 자주 쓰이며 프록시가 데코레이터에 요청을 전달하고 부가기능을 제공한 이후 타깃 객체를 호출하게 한다.
이제부터 프록시와 데코레이터를 통해 트랜잭션을 추상화하는 패턴에 대해 하나씩 알아보자.
프록시 구현 방식에 따른 트랜잭션 최적화
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")) } } |
위와 같은 패턴을 컴파일 의존 프록시라고 정의하는 것은 아니지만, 나는 컴파일 의존 프록시라고 하겠다.
그 이유는 MemberServiceProxy의 참조속성인 private val memberServiceImpl: MemberService에 있다.
프록시가 접근제어 또는 부가기능을 제공하려는 타깃 객체의 클래스 타입을 코드를 통해 명시적으로 알고 있다.
이는 컴파일 의존하는 구조이다.
컴파일 의존이란 프로그래밍 코드를 통해 의존성을 결정하는 것이다.
반대되는 개념이 런타임 의존인데 프로그래밍 코드가 아닌 xml설정이나 어노테이션을 통해 의존성을 결정하는 것이다.
(자세한 설명은 컴파일 타임 의존성과 런타임 의존성 참고)
프록시와 타깃 코드를 보면 비즈니스 로직과 트랜잭션 처리를 분리하였다.
추상화에 성공했다고 할 수 있다.
허나 위와 같은 구조에도 단점이 존재한다.
첫번째, 방금 설명한 컴파일 의존 구조로 인해 이 프록시는 타깃 객체에만 사용 가능하며 재사용이 불가하다.
두번째, 타깃의 요청을 가로채기 위해 타깃과 같은 타입의 인터페이스를 구현해야하므로 트랜잭션 처리가 불필요한 메서드들도 오버라이딩해야한다.
세번째, 아래와 같이 트랜잭션 처리를 필요로 하는 다른 메서드가 존재한다고 할 때, 메서드마다 트랜잭션 처리 코드가 중복적으로 나타나는 단점이 있다.
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 } } |
위 패턴을 통한 트랜잭션 추상화에서 해결한 사항과 한계를 정리하면 아래와 같다.
[컴파일 의존 프록시 - 해결사항]
- 트랜잭션 처리를 타깃 객체로부터 분리(비즈니스 로직과 기술적인 로직 분리)
[ 컴파일 의존 프록시 - 한계]
- 타깃과 같은 인터페이스 타입 구현, 프록시와 타깃의 의존관계를 프로그래밍 코드를 통해 결정
-> 해당 타깃에서만 프록시 사용 가능함, 타깃 추가되면 타깃마다 프록시 새로 구현 필요
-> 인터페이스의 모든 메서드를 구현해야함(트랜잭션 처리가 없는 메서드라도) - 메서드마다 트랜잭션 코드 중복
2. 다이내믹 프록시
다이내믹 프록시에서 다이내믹은 런타임 의존으로 생각하면 된다.
프록시와 타깃 객체의 의존관계를 코드를 통해 결정하지 않는다.
따라서 다양한 타입의 타깃에 재사용 가능하다.
예제를 통해 보자.
[프록시 팩토리]
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> |
먼저 FactoryBean<Any>의 구현체인 TxFactoryBean를 보자.
FactoryBean<Any>의 구현체를 스프링 빈으로 등록하면 getObject를 통해 반환되는 객체를 빈으로 등록한다.
(싱글톤 레지스트리에 타깃 객체를 래핑한 프록시가 등록됨)
Proxy 클래스의 newProxyInstance라는 스태틱 메서드를 통해 프록시 객체를 생성하여 반환한다.
인자값을 보면 타깃 객체의 인터페이스 타입과 타깃 객체를 래핑한 TxHandler를 받고 있다.
TxHandler는 트랜잭션 기능을 제공하는 데코레이터이다.
xml을 통해 빈 설정을 하면 아래와 같은 구조를 가질 수 있다.
InvocationHandler의 구현체 TransactionHandler의 설명을 더하겠다.
invoke 메서드를 통해 타깃 객체와 실행하는 메서드와 그 인자값이 전달된다.
코드 어디를 살펴보더라도 타깃 클래스 타입이 무엇인지 알 수 없다.
타깃 객체의 타입에 제한되지 않고 범용적으로 사용 가능한 구조이다.
뿐만 아니라 실행 메서드 정보 또한 코드를 통해 알지 못한다. 파라미터를 통해 메서드 정보가 전달된다.
이는 같은 클래스 내에서 트랜잭션 처리하는 메서드가 여러개 이더라도
메서드마다 트랜잭션 처리하는 코드 중복에서 벗어남을 의미한다.
하지만 위와 같은 구조에서도 단점이 존재한다.
다이내믹이라는 용어에서처럼 코드를 통한 타깃 의존 문제는 해결 되었지만 타깃 객체를 참조속성을 갖고 있다.
이는 공유객체로 사용할 수 없는 즉, 스프링 빈으로 등록하여 사용할 수 없음을 의미한다.
그래서 우리는 ProxyFactoryBean을 통해 타깃마다 프록시를 생성하는 방식을 선택했다.
이로인하여 타깃마다 xml설정이 중복되는 구조 또한 나타나게 됬다.
하나 더 얘기하면 invoke 메서드 안의 if (method!!.name.startsWith(methodNamePatter)를 통해 실행하려는 실행하려는 타깃 메서드를 결정하는데 위 코드는 해당 패턴으로 메서드명이 시작하는 경우 트랜잭션 처리를 진행한다.
이와 같이 실행하려는 메서드를 결정하는 알고리즘을 메서드 알고리즘이라고 하는데 메소드 알고리즘은 타깃마다 다를 수 있다.
특정 패턴으로 시작하는 경우, 끝나는 경우, 메서드에 특정 어노테이션이 붙은 경우 등 굉장히 다양할 수 있다.
프록시가 메서드 선정 알고리즘을 의존하므로 메서드 선정 알고리즘이 바뀌게 되면 프록시 또한 새롭게 생성해야하는 단점이 있다.
[다이내믹 프록시 - 해결사항]
- 타깃마다 타깃의 상위 인터페이스 타입 구현하여 프록시를 생성하는 구조에서 벗어남
- 메서드 마다 중복되는 부가기능 코드의 중복 해결
[다이내믹 프록시 - 한계]
- 프록시가 타깃 객체를 상태로 갖고 있어 빈으로 등록하지 못하고 타깃마다 프록시 인스턴스를 생성함
- 프록시를 적용할 타깃마다 xml 설정 중복
- 메서드 선정 알고리즘이 달라지는 경우 프록시 새롭게 생성해야함. 따라서 재사용 불가
3. 스프링 프록시 팩토리 빈
다이내믹 프록시가 컴파일 의존 프록시의 한계를 해결하였지만 여전히 한계가 존재한다.
예제를 통해 그 해결 방법에 대해 알아보자.
[데코레이터]
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를 통해 트랜잭션 처리를 구현하였다.
이는 프록시가 아닌 데코레이터이다.
config를 보면 memberService 빈을 등록하는 과정에서 별도의 프록시를 생성하고 트랜잭션 처리는 MethodInterceptor가 진행한다. 프록시는 데코레이터로 접근제어 역할만 진행하며 트랜잭션 처리는 데코레이터가 진행할 수 있도록 철저히 역할 분리를 하였다.
데코레이터인 MethodInterceptor를 통해 실행되는 invoke 메서드는 타깃 정보를 포함한 MethodInvocation을 인자로 받는다.
참조속성이 아닌 메서드의 파라미터로 타깃 정보가 전달되니 동시성 문제에서 자유로워져 싱글톤으로 등록하여 여러 타깃 객체들의 트랜잭션 처리를 진행할 수 있다.
다음은 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는 등록하려는 빈이 인스턴스화 되고 초기화 작업을 진행한다.
(init메서드 전후로 실행됨)
가장 대표적으로 사용되는 예시가 타깃 객체를 프록시로 래핑하여 프록시를 싱글톤 레지스트리에 등록하는 것이다.
우리는 이 BeanPostProcessor를 통해 트랜잭션 프록시를 싱글톤 레지스트리에 등록할 것이다.
그렇게하면 타깃 객체가 실행될 때 프록시가 부가기능을 실행하고 타깃 메서드를 실행시킬 수 있다.
스프링에서 이미 제공해주는 BeanPostProcessor인 DefaultAdvisorAutoProxyCreator를 사용하면 Advisor 타입로 등록된 빈을 찾아 Pointcut을 분석하여 타깃 객체에 대한 프록시를 생성하고 싱글톤 레지스트리에 등록될 수 있도록 반환한다.
기존에 설정한 ProxyFactoryBean 설정을 제거하고 아래 설정을 추가하기만 하면 된다.
@Bean
fun defaultAdvisorAutoProxyCreator(): DefaultAdvisorAutoProxyCreator {
return DefaultAdvisorAutoProxyCreator()
}
이와 같은 방법으로 xml설정과 자바 config의 중복문제를 해결 할 수 있다.
위와 같은 프록시 패턴이 중요한 이유는 스프링이 aop를 구현하는데 주로 프록시 방식을 사용하기 때문이다.
따라서 스프링 aop를 프록시 aop라고 부르기도 한다.
[참고] 스프링 트랜잭션 처리 디버깅 요약
스프링의 트랜잭션 처리에 사용되는 프록시는 CglibAopProxy 이다.
타깃 객체로 요청이 들어오면 CglibAopProxy가 요청을 가로채고
CglibAopProxy의 참조 속성(advised)에서 프록시를 list로 가져옴
(AdvisedSupport.getInterceptorsAndDynamicInterceptionAdvice)
TransactionInterceptor가 등록되어있으며 TransactionInterceptor가 타깃 객체 정보를 TransactionAspectSupport로 넘김
TransactionAspectSupport부터 본격적인 트랜잭션 처리가 시작된다.
스프링 프록시 방식에서는 위와 같이 프록시와 데코레이터를 확실하게 분리하여 접근제와 부가기능 구현에 대한 역할 분리를 명확하게 하였다.
위 정보들만으로도 충분히 디버깅이 가능할 것이다.
'Framework & Lib & API > 스프링' 카테고리의 다른 글
스프링 빈 생성 과정 분석 [4] - 디버깅 참고 자료 (0) | 2024.08.07 |
---|---|
[스프링] 스태틱 메서드가 아닌 스프링 싱글톤 빈을 사용해야하는 이유 (0) | 2024.06.29 |
AbstractRoutingDataSource에서 Transactional readonly값 false만 리턴하는 오류 해결 (2) | 2024.06.16 |
스프링 빈 생성 과정 분석 [3] - BeanFactoryPostProcessor, BeanPostProcessor (2) | 2024.04.20 |
스프링 빈 생성 과정 분석 [2] - BeanDefinitionRegistry, SingletonBeanRegistry (0) | 2024.04.20 |