[Spring] 트랜잭션 관리를 위한 TransactionTemplate의 활용
1. 트랜잭션 관리를 위한 TransactionTemplate의 활용
[ 트랜잭션 관리를 위한 TransactionTemplate의 활용 ]
스프링으로 개발을 하다 보면 선언적 트랜잭션을 자주 사용하게 된다. 선언적 트랜잭션(Declarative Transaction)이란 @Transactional 애노테이션을 기반으로 트랜잭션을 처리하는 방법을 의미한다.
예를 들어 다음과 같이 포인트를 충전하기 위한 비즈니스 로직이 있다고 하자.
@Component
class PointCharger(
private val fetchUserPort: FetchUserPort,
private val chargePointPort: ChargePointPort,
private val loadChargingTransactionPort: LoadChargingTransactionPort,
private val saveChargingTransactionPort: SaveChargingTransactionPort
private val saveChargingTransactionHistoryPort: SaveChargingTransactionHistoryPort,
) {
@Lock
fun charge(event: ChargePointEvent) {
val user = fetchUserPort.fetchByUserId(event.userId)
if (user == null) {
log.warn("사용자 조회 실패 userId = ${event.userId}")
return
}
val transaction = loadChargingTransactionPort.loadByTransactionNo(event.transactionNo)
if (transaction != null) {
log.error("기처리 충전 이력 존재(${event.transactionNo})")
return
}
val chargingTransaction = ChargingTransaction.createNew(
transactionNo = event.transactionNo,
accountId = event.accountId,
accountNumber = event.accountNumber,
amount = event.amount,
summary = event.summary,
)
val chargeResult = chargePointPort.charge(
chargingTransaction.amount,
user.userId,
chargingTransaction.summary
)
if (chargeResult.isFail()) {
throw RuntimeException("충전 실패")
}
saveChargingTransactionPort.save(chargingTransaction)
saveReceivedAlarmPort.save(
ReceivedAlarm(
amount = event.amount,
summary = event.summary,
)
)
}
}
위의 비즈니스 로직은 다음과 같은 순서로 처리되며, 이때 4번과 5번이 반드시 하나의 트랜잭션으로 묶여야 한다고 하자.
- 사용자 조회(API)
- 포인트 충전 이력 조회(DB)
- 포인트 충전 진행(API)
- 포인트 충전 이력 저장(DB)
- 사용자 노출 알림 저장(DB)
이때 스프링을 기반으로 개발을 하고 있다면 트랜잭션 처리를 위해 @Transactional 어노테이션을 떠올리고 사용할 수 있다.
@Component
class PointCharger(
private val fetchUserPort: FetchUserPort,
private val chargePointPort: ChargePointPort,
private val loadChargingTransactionPort: LoadChargingTransactionPort,
private val saveChargingTransactionPort: SaveChargingTransactionPort
private val saveChargingTransactionHistoryPort: SaveChargingTransactionHistoryPort,
) {
@Lock
@Transactional // 선언적 트랜잭션 처리
fun charge(event: ChargePointEvent) {
val user = fetchUserPort.fetchByUserId(event.userId)
if (user == null) {
log.warn("사용자 조회 실패 userId = ${event.userId}")
return
}
val transaction = loadChargingTransactionPort.loadByTransactionNo(event.transactionNo)
if (transaction != null) {
log.error("기처리 충전 이력 존재(${event.transactionNo})")
return
}
val chargingTransaction = ChargingTransaction.createNew(
transactionNo = event.transactionNo,
accountId = event.accountId,
accountNumber = event.accountNumber,
amount = event.amount,
summary = event.summary,
)
val chargeResult = chargePointPort.charge(
chargingTransaction.amount,
user.userId,
chargingTransaction.summary
)
if (chargeResult.isFail()) {
throw RuntimeException("충전 실패")
}
saveChargingTransactionPort.save(chargingTransaction)
saveReceivedAlarmPort.save(
ReceivedAlarm(
amount = event.amount,
summary = event.summary,
)
)
}
}
하지만 위의 로직 상에서 @Transactional을 적용하는 것은 2가지 문제가 있다.
- 처음 요청은 API 호출을 통한 사용자 조회인데, 불필요하게 트랜잭션이 빨리 선점됨
- 네트워크 호출이 결합되어 있어, 트랜잭션이 불필요한 시점에도 점유중인 상태가 됨
위와 같이 트랜잭션을 사용한다면 요청이 많아지는 시점에 데이터베이스 커넥션 점유 문제로, 모든 요청이 밀리면서 응답시간이 튀어 장애로 이어질 수 있다. 이를 해결하기 위해서는 정말 필요한 순간에만 트랜잭션을 점유하고 묶어줘야 한다.
먼저 첫 번째 문제를 해결하기 위해서는 앞선 포스팅에서 살펴보았듯 LazyConnectionDataSourceProxy를 사용할 수 있다. LazyConnectionDataSourceProxy를 사용하면 실제 데이터베이스 호출이 필요하기 전까지 트랜잭션 점유를 미룰 수 있고, 위의 코드 상으로는 2번 포인트 충전 이력 조회(DB) 부터 커넥션을 점유하게 될 것이다.
하지만 LazyConnectionDataSourceProxy를 적용하여도, 2~5번 처리 동안에는 트랜잭션이 점유된다. 따라서 3번 과정(포인트 충전 API)이 진행되는 동안 네트워크 지연이나 외부 서비스의 장애가 생겨 요청 시간이 길어진다면, 여전히 장애 지점이 될 수 있다.
이를 해결하기 위해서는 트랜잭션이 하나로 묶여야 하는 순간에만 트랜잭션을 묶어주어야 하는데, 이를 해결하기 위해 스프링이 제공하는 TransactionTemplate을 활용할 수 있다.
TransactionTemplate은 스프링이 제공하는 프로그래밍 방식의 트랜잭션 관리를 위한 도구로, RestTemplate이나 JdbcTempate과 동일하게 템플릿 콜백 패턴(Template Callback Pattern)으로 구현되어 있다. 이를 통해 코드가 작업 의도의 중심이 되어, 오직 수행하고자 하는 작업에만 집중할 수 있다. TransactionTemplate을 활용하려면, 다음과 같이 기존의 @Transactional 애노테이션을 제거하고 의존성 주입을 받아 사용해야 한다.
@Component
class PointCharger(
private val fetchUserPort: FetchUserPort,
private val chargePointPort: ChargePointPort,
private val loadChargingTransactionPort: LoadChargingTransactionPort,
private val saveChargingTransactionPort: SaveChargingTransactionPort
private val saveChargingTransactionHistoryPort: SaveChargingTransactionHistoryPort,
private val transactionTemplate: TransactionTemplate,
) {
@Lock
fun charge(event: ChargePointEvent) {
val user = fetchUserPort.fetchByUserId(event.userId)
if (user == null) {
log.warn("사용자 조회 실패 userId = ${event.userId}")
return
}
val transaction = loadChargingTransactionPort.loadByTransactionNo(event.transactionNo)
if (transaction != null) {
log.error("기처리 충전 이력 존재(${event.transactionNo})")
return
}
val chargingTransaction = ChargingTransaction.createNew(
transactionNo = event.transactionNo,
accountId = event.accountId,
accountNumber = event.accountNumber,
amount = event.amount,
summary = event.summary,
)
val chargeResult = chargePointPort.charge(
chargingTransaction.amount,
user.userId,
chargingTransaction.summary
)
if (chargeResult.isFail()) {
throw RuntimeException("충전 실패")
}
transactionTemplate.execute {
saveChargingTransactionPort.save(chargingTransaction)
saveReceivedAlarmPort.save(
ReceivedAlarm(
amount = event.amount,
summary = event.summary,
)
)
}
}
}
위와 같이 코드를 변경하게 되면 2번의 작업 시작/종료 동안 트랜잭션이 짧게 1차적으로 점유되었다가 해제되고, 4번의 작업이 시작할 때 다시 트랜잭션을 점유했다가 5번의 작업까지 마무리되면 트랜잭션을 해제하게 된다.
TransactionTemplate는 상태를 유지하지 않는다는 점에서 스레드 안전한(Thread-Safe) 객체라, 빈으로 등록해서 여러 서비스에 주입을 받을 수 있다. 또한 스프링 부트를 사용하는 경우에는 TransactionAutoConfiguration 클래스를 통한 자동 구성(Auto Configuration)으로 인해 기본적으로 등록되는 빈이 존재하므로 별도로 등록해줄 필요가 없다. 보다 자세한 내용은 공식 문서에서 확인할 수 있다.
위와 같이 TransactionTemplate를 활용한 코드를 작성하게 되면 스프링에 종속적인 기술을 활용하게 되어 프레임워크에 침투적인(Framework Invasive) 코드가 되었다고 문제를 삼을 수 있다. 따라서 이를 해결하기 위해서는 별도의 클래스를 분리하여 활용하면 된다.
@Service
class TransactionExecutor(
private val transactionManager: PlatformTransactionManager,
) {
private val transactionTemplate = TransactionTemplate(transactionManager)
private val readOnlyTransactionTemplate = TransactionTemplate(transactionManager).apply { isReadOnly = true }
fun <T : Any?> transactional(action: (TransactionStatus) -> T) {
transactionTemplate.execute(action)
}
fun <T : Any?> transactionalReadOnly(action: (TransactionStatus) -> T) {
readOnlyTransactionTemplate.execute(action)
}
}
참고로 개인적인 의견을 남기자면, @Transactional 역시 스프링 패키지에 존재하며 스프링의 AOP 방식으로 동작하므로 TransactionTemplate와 동일하게 프레임워크에 침투적인 기술이라고 생각한다. 또한 스프링이라는 프레임워크를 다른 프레임워크로 변경할 가능성을 생각하면 거의 제로에 육박하므로, 실용적인 선택을 하는 것이 바람직하다고 생각한다. 오히려 스프링과 같이 시장에 지배적인 프레임워크 기술을 활용하면 팀원들 간에 불필요한 합의가 없어져서 효율적인 측면도 있을 수 있다. 예를 들어 우리가 직접 구현한 TransactionExecutor는 팀에 새롭게 입사한 개발자들에게는 당연히 생소할 수 밖에 없으므로 내부 구현을 봐야할 필요가 있을 수 있다. 하지만 TransactionTemplate을 사용한다면 오히려 이전 회사에서도 사용했을 가능성도 있고, 이로 인해 인지 부하를 줄일 수 있다. 이는 마치 디자인 패턴을 사용하는 관점과 유사하다고도 바라볼 수 있는 것이다.
따라서 정답은 없으니 팀의 본위기와 본인의 성향 등 여러 가지를 고려하여 가치 판단을 내리도록 하자.