개요
스프링 MVC를 사용하여 개발하면 크게 다가오는 장점 중에 @Transactional이 있다.
요청을 처리하는 개별스레드의 ThreadLocal을 사용하여 트랜잭션을 데이터계층에 전달하는 것을
추상화한 AOP를 통해 트랜잭션을 간편하게하여 세부 비즈니스 로직에 집중할 수 있다.
하지만
1. 개발중에 실수로 transctional을 빼먹게 된다면 어떻게 될까? 컴파일단계에서는 해당 실수를 알 수 없다.
2. 영속성 컨텍스트 외부에서 프록시객체를 조회할 경우 런타임에서야 에러를 알아차릴 수 있다. 예를 들어:
@Transactional(readOnly = true)
fun getUserModelById(userId: Long) : UserModel {
val user = userReader.getById(userId)
return UserModel.from(user) // UserModel에 프록시가 있었고, 이를 영속성 컨텍스트 외부에서 호출했다면?
}
이러한 사이드 이펙트는 개발자가 쉽게 놓칠 수 있으며, 머피의 법칙의 `잘못될 수 있는 일은 결국 잘못된다` 를 통해 수정 중에 버그가 생길 수 있다.
확장함수와 수신객체 그리고 DSL
확장 함수란
코틀린의 기능 중 하나로, 기존 클래스 내부에 메소드를 작성하는 것이 아닌,
수신 객체를 지정하여 클래스 외부에서 해당 클래스의 메소드를 추가할 수 있다.
(수신 객체는 함수가 호출될 대상 객체를 의미한다)
이를 통해 클래스의 소스 코드를 변경하지 않고도 클래스의 기능을 확장할 수 있어, 외부라이브러리를 사용함에도 기능추가가 가능하다.
확장 함수는 특정 클래스의 인스턴스에서 호출할 수 있는 함수로 정의된다.
// String 클래스에 확장함수 추가
fun String.lastChar(): Char = this.get(this.length - 1)
val str = "Hello"
println(str.lastChar()) // Output: o
또한, 코틀린에서는 함수를 인자로 넘길 수 있기 때문에, 수신객체에서 실행되는 함수를 받을 수 있다.
(buy함수를 호출하려면 MyDsl이 반드시 필요하다.)
이를 통해 특정 DSL을 수신객체로 하는 함수를 만들고, 해당 block를 제한적으로 사용가능하도록 하는 제약사항을 만들수도 있는 것이다.
class MyDsl(
var age: Int = 0,
var money: Int = 0,
)
fun myDsl(block: MyDsl.() -> Unit): MyDsl {
val myDsl = MyDsl()
myDsl.block()
return myDsl
}
fun MyDsl.plusAge(){
age += 1
}
fun MyDsl.buy(){
money -= 1
}
fun main() {
val myDsl: MyDsl = myDsl {//this: MyDsl
buy()
plusAge()
}
println(myDsl) // MyDsl(age=1, money=-1)
}
확장함수와 트랜잭션 관리
이제, 코틀린의 확장 함수를 이용하여 스프링 트랜잭션 관리를 어노테이션에서 코드로 만들어 보자.
트랜잭션 AOP가 사용되는 지점을 TxScope가 this로 제공되도록 하고,
트랜잭션이 필요한 메소드를 수신 객체로 TxScope가 필요한 함수로 만들 것이다.
interface TxScope
interface TxManager {
fun <T> transactional(block: TxScope.() -> T): T
fun <T> readOnly(block: TxScope.() -> T): T
fun <T> newTransaction(block: TxScope.() -> T): T
fun <T> newReadOnlyTransaction(block: TxScope.() -> T): T
}
그리고 TxManager를 구현한 스프링의 SpringTxManager를 만든다. 기존 스프링 AOP를 그대로 사용한다.
또한 TxScope또한 상속하여, 해당 클래스의 메서드를 호출하면 TxScope하에서 모든 함수가 호출될 수 있게 한다.
(`val scope = TxScope(); scope.block();`의 역할을 상속으로 대체한다)
@Component
class SpringTxManager : TxManager, TxScope {
@Transactional
override fun <T> transactional(block: TxScope.() -> T): T {
return block() //return this.block()
}
@Transactional(readOnly = true)
override fun <T> readOnly(block: TxScope.() -> T): T {
return block()
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
override fun <T> newTransaction(block: TxScope.() -> T): T {
return block()
}
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
override fun <T> newReadOnlyTransaction(block: TxScope.() -> T): T {
return block()
}
}
@Service
class UserService(
private val userReader: UserReader,
private val userWriter: UserWriter,
private val springTxManager: SpringTxManager,
) {
fun getUserModelById(userId:Long) = springTxManager.transactional {
val user = userReader.getById(userId)
UserModel.from(user)
}
}
이제 서비스 코드에서 SpringTxManager를 호출하면, 기존의 다른 클래스를 호출하는 식의, 프록시 호출이 가능하다.
(private메소드에서 스프링 AOP가 동작하는 방법)
또한, transactionl 람다 내부에서는 TxScope가 this로 적용된다.
DI 제거
모든 @Transaction이 필요한 곳에 springTxManager 주입받는것은 피곤해보인다.
코틀린의 compaion object를 이용하면 스프링 빈을 사용하면서도, DI 의존관계를 제거할 수 있다.
@Component
class Tx(
private val springTxManager: SpringTxManager,
) {
init {
txManager = springTxManager
}
companion object {
lateinit var txManager: SpringTxManager
private set
}
}
코틀린의 compainon object를 그냥 사용해서는 스프링 빈의 의존성 주입을 받을 수 없기 때문에,
lateinit var를 사용하여 의존관계를 주입받는다.
이제 service에서 다음과 같이 사용할 수 있다:
@Service
class UserService(
private val userReader: UserReader,
private val userWriter: UserWriter,
) {
fun getUserModelById(userId:Long) = Tx.transactional {
val user = userReader.getById(userId)
UserModel.from(user)
}
}
또한, 코틀린에서는 클래스 외부에 함수를 정의할 수 있기 때문에 트랜잭션 관련 함수들을 단순하게 호출할 수 있습니다.
fun <T> transactional(block: TxScope.() -> T): T {
return Tx.txManager.transactional(block)
}
fun <T> readOnly(block: TxScope.() -> T): T {
return Tx.txManager.readOnly(block)
}
fun <T> newTransaction(block: TxScope.() -> T): T {
return Tx.txManager.newTransaction(block)
}
fun <T> newReadOnlyTransaction(block: TxScope.() -> T): T {
return Tx.txManager.newReadOnlyTransaction(block)
}
이제 트랜잭션 함수를 다음과 같이 간단하게 호출할 수 있습니다:
@Service
class UserService(
private val userReader: UserReader,
private val userWriter: UserWriter,
) {
fun getUserModelById(userId:Long) = transactional {
val user = userReader.getById(userId)
UserModel.from(user)
}
}
LazyInitializationException 문제 제거
이제 TxScope를 수신객체로 하는 함수들을 만들어 보자.
영속성 컨텍스트가 필요한 코드에는 TxScope.() -> T 형태의 함수를 반환하도록 한다.
class UserModel(
val id: Long,
val email: String?,
val nickname: String,
val point : Int,
val memberShipExpiredAt : LocalDateTime,
val role: Role
) {
companion object {
fun from(user: User): TxScope.() -> UserModel {
return {
UserModel(
id = user.id,
email = user.email,
nickname = user.nickname,
point = user.point,
memberShipExpiredAt = user.memberShipExpiredAt,
role = user.role
)
}
}
}
}
TxScope에서 사용되는 함수를 리턴한것이기 때문에 from(user)()과 같은 방식을 사용해야 한다.
@Service
class UserService(
private val userReader: UserReader,
private val userWriter: UserWriter,
) {
fun getUserModelById(userId: Long): UserModel = transactional {//this: TxScope
val user = userReader.getById(userId)
UserModel.from(user)(this) // == UserModel.from(user)()
}
}
정리
해당 방법을 통해 트랜잭션이 필요한 범위를 제한하는 방법을 사용해 봤다.
컴파일 타임에 에러를 조기에 발견할 수 있는 장점이 있고, 코드로써 AOP를 사용할 수 있게 됐다.
하지만 DSL의 사용은 복잡도를 올리므로, 장단을 비교한 뒤에 적절한 장소에 사용하자
실제 현업에서는 해당 방법을 사용하기에는 무척 조심스러울거 같다.
스프링을 다른 프레임워크로 옮기기는 것은 무척 큰 작업이 될 것이다.
스프링 트랜잭션이 쓰레드로컬을 사용하기 때문에,
기존 코드스타일이 쓰레드안정성을 보장하지않는다면 어차피 로직이 바뀌어야한다.
프레임워크나 라이브러리를 바꾸는 것은 실서비스에서는 무척이나 힘든 일이 될것이고,
굳이 이러한 방식을 사용할 필요가 없다고 생각이 들기도 한다. 즉 쓸데없는 데코레이터 패턴이 될 수 있다.
'Spring' 카테고리의 다른 글
[Spring] Spring MVC에서 코틀린 코루틴 안티패턴 (0) | 2024.11.28 |
---|