스프링의 트랜잭션 추상화
트랜잭션 추상화
우리는 추상화가 "공통적인 것을 취하고 차이점을 배제하는 것"이라는걸 잘 알고 있다.
추상화를 통해 공통적인 부분을 분리하여 처리하도록 한다면 차이점에 대해서만 집중하여 개발을 할 수 있다.
트랜잭션 추상화에서 적용된 공통점은 트랜잭션 처리이고, 차이점은 비즈니스 로직이다.
아래 코드를 보자.
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"))
}
}
프록시 구현 패턴을 보면 아래와 같다.
- 프록시가 타깃과 같은 상위 인터페이스를 구현함
- 타깃을 참조속성으로 주입
이와 같은 구조는 프록시를 해당 타입 이외에서는 재사용하지 못함을 의미한다.
코드를 통해 명시적으로 의존관계를 결정하였기에(컴파일 타임에 결정) 타깃 클래스가 달라지면 프록시 클래스를 새롭게 만들어한다.트랜잭션 처리는 위 구조에서 달라지는 부분이 거의 없을 텐데, 타깃 타입이 달라서 프록시 클래스를 새롭게 만드는 것은 코드 중복을 심각하게 야기할 것이다.
이외에도 직접 구현하기 때문에 트랜잭션 처리가 불필요한 메서드들도 오버라이딩하고,
트랜잭션 처리가 필요한 메서드가 여러개일때 코드가 중복되는 문제도 나타난다.
[프록시 직접 구현 - 해결사항]
- 트랜잭션 처리를 타깃 객체로부터 분리(비즈니스 로직과 기술적인 로직 분리)
[프록시 직접 구현 - 한계]
- 타깃 클래스마다 프록시 클래스를 설계해야하는 단점
- 인터페이스의 모든 메서드를 구현해야함
- 트랜잭션 처리 필요한 메서드에 코드 중복
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가 타깃을 래핑하고 트랜잭션 기능 제공하는 데코레이터이다.
트랜잭션 처리를 제공하는 데코레이터가 타깃을 래핑하고 있으니 데코레이터를 스프링 빈으로 등록하여 공유하지 못한다. 스프링 빈으로 등록하면 DI를 통해 편리하게 의존 관계를 설정할 수 있지만, 현재 구조에서는 타깃을 참조 속성으로 두었기에 전역적으로 공유하여 사용하면 동시성 문제가 발생할 가능성이 높다.
[다이내믹 프록시 - 해결사항]
- 타깃마다 타깃의 상위 인터페이스 타입 구현하여 프록시를 생성하는 구조에서 벗어남
- 메서드 마다 중복되는 코드 중복 해결
[다이내믹 프록시 - 한계]
- 메서드 선정 로직과 트랜잭션 처리 방식 중 하나라도 변경된 로직을 적용하고자 한다면 프록시를 새로 구현해야하고 코드가 중복됨
- 프록시가 타깃을 참조속성으로 가지고 있어 스프링 싱글톤 빈으로 등록할 수 없다.
- 프록시를 적용할 타깃마다 xml 설정 중복
3. 스프링 프록시 팩토리 빈
스프링에서는 ProxyFactoryBean을 통해 프록시를 통해 생성한다.
ProxyFactoryBean의 특징은 데코레이터와 타깃을 분리하고, 타깃 메서드 정보를 추출하여 데코레이터에 메서드 인자값으로 전달해주는 것이다.
타깃을 참조속성으로 두지 않고 실행 메서드 정보를 메서드 인자값으로 전달 하니 동시성 문제에서 자유로워져 공유객체로 등록할 수 있다.
[데코레이터]
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 적용 대상의 프록시를 대신 생성해준다. @Bean @Qualifier("memberServiceProxy") fun memberService(): ProxyFactoryBean { val proxyFactoryBean = ProxyFactoryBean() proxyFactoryBean.setTargetName("memberServiceImpl") proxyFactoryBean.setInterceptorNames("customTransactionAdvisor") proxyFactoryBean.setProxyInterfaces(arrayOf(MemberService::class.java)) return proxyFactoryBean }