트랜잭션 전파 테스트
업데이트:
트랜잭션 전파 속성들이 실제로 잘 작동하는지 테스트를 통해 알아보려고 한다.
코드
Domain
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private Long amount;
private Account(Long amount) {
this.amount = amount;
}
public static Account create(Long price) {
return new Account(price);
}
public void sendMoney(Long amount) {
this.amount -= amount;
}
public void receiveMoney(Long amount) {
this.amount += amount;
}
}
Account 클래스는 간단한 계좌 클래스이다. 보유 금액을 필드로 가지고 있다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Pay {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private Long senderId;
@Column
private Long receiverId;
@Column
private Long amount;
private Pay(Long senderId, Long receiverId, Long amount) {
this.senderId = senderId;
this.receiverId = receiverId;
this.amount = amount;
}
public static Pay create(Long senderId, Long receiverId, Long amount) {
return new Pay(senderId, receiverId, amount);
}
}
Pay 클래스는 계좌이체에 대한 정보를 담고 있다. 금액을 보낸 계좌의 id, 받는 계좌의 id, 금액의 양을 필드로 가지고 있다.
Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
}
public interface PayRepository extends JpaRepository<Pay, Long> {
}
JpaRepository를 상속받았다. Jpa에 대한 내용은 생략하도록 하겠다.
Service
@Service
@Slf4j
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
@Transactional
public AccountResponse create(AccountRequest request) {
log.info("currentTransactionName : {}", TransactionSynchronizationManager.getCurrentTransactionName());
Account account = request.toEntity();
return AccountResponse.create(accountRepository.save(account));
}
@Transactional(readOnly = true)
public AccountResponse show(Long id) {
log.info("currentTransactionName : {}", TransactionSynchronizationManager.getCurrentTransactionName());
Account account = accountRepository.findById(id)
.orElseThrow();
return AccountResponse.create(account);
}
@Transactional
public void sendMoney(Long senderId, Long amount){
log.info("currentTransactionName : {}", TransactionSynchronizationManager.getCurrentTransactionName());
Account senderAccount = accountRepository.findById(senderId)
.orElseThrow();
senderAccount.sendMoney(amount);
}
@Transactional
public void receiveMoney(Long receiverId, Long amount){
log.info("currentTransactionName : {}", TransactionSynchronizationManager.getCurrentTransactionName());
Account senderAccount = accountRepository.findById(receiverId)
.orElseThrow();
senderAccount.receiveMoney(amount);
}
}
AccountService는 계좌를 생성하고 조회하는 서비스다. 또한 출금, 입금에 대한 로직을 가지고 있다.
@Service
@Slf4j
@RequiredArgsConstructor
public class PayService {
private final AccountService accountService;
private final PayRepository payRepository;
@Transactional
public PayResponse create(PayRequest request) {
log.info("currentTransactionName : {}", TransactionSynchronizationManager.getCurrentTransactionName());
return PayResponse.create(payRepository.save(request.toEntity()));
}
@Transactional(readOnly = true)
public PayResponse show(Long id) {
log.info("currentTransactionName : {}", TransactionSynchronizationManager.getCurrentTransactionName());
return PayResponse.create(payRepository.findById(id)
.orElseThrow());
}
@Transactional
public void transfer(TransferRequest request) {
log.info("currentTransactionName : {}", TransactionSynchronizationManager.getCurrentTransactionName());
Long senderId = request.getSenderId();
Long receiverId = request.getReceiverId();
Long amount = request.getAmount();
send(senderId, amount);
receive(receiverId, amount);
}
private void send(Long senderId, Long amount) {
log.info("Sender ID: {}", senderId.toString());
accountService.sendMoney(senderId, amount);
}
private void receive(Long receiverId, Long amount) {
log.info("Receiver ID: {}", receiverId.toString());
accountService.receiveMoney(receiverId, amount);
}
}
PayService는 계좌이체를 위한 서비스다. TransferRequest
에는 senderId, receiverId, amount가 담겨있다. 이를 이용해 계좌이체를 실행한다. 하나의 작업 단위로 묶기 위해 @Transactional
을 선언했다.
테스트 코드
@SpringBootTest
public class PayServiceTest {
@Autowired
private AccountService accountService;
@Autowired
private PayService payService;
private AccountResponse sender;
private AccountResponse receiver;
@BeforeEach
void setup() {
sender = accountService.create(new AccountRequest(10000L));
receiver = accountService.create(new AccountRequest(20000L));
}
@Test
@DisplayName("계좌이체 - 성공")
void transferTest() {
//given
TransferRequest request = new TransferRequest(sender.getId(), receiver.getId(), 5000L);
//when
payService.transfer(request);
//then
assertEquals(accountService.show(sender.getId()).getAmount(), 5000L);
assertEquals(accountService.show(receiver.getId()).getAmount(), 25000L);
}
}
간단한 테스트 코드이다. transfer를 통해 계좌 이체가 됐는지 확인하는 테스트로 테스트 내용보다는 프로덕션 코드의 @Transcational
의 전파 속성에 따른 변화를 살펴보려한다.
로그를 보다 잘 보기 위해 application.yml
에 다음 속성을 추가했다.
logging:
pattern:
console: "%white(%d{HH:mm:ss.SSS}) %cyan([%thread]) %highlight([%-5level]) %yellow([%logger{0}]) - %boldWhite(%m%n)"
전파 속성에 따른 변화
전파 속성 | 설명 |
---|---|
Required(default) | 모든 트랜잭션 매니저가 지원한다. 미리 시작된 트랜잭션이 있으면 참여하고 없으면 새로 시작한다. |
Supports | 이미 시작된 트랜잭션이 있으면 참여하고 없으면 트랜잭션 없이 진행한다. |
Mandatory | 이미 시작된 트랜잭션이 있으면 참여하고 없으면 예외를 뱉는다. |
Requires_new | 항상 새로운 트랜잭션을 시작한다. 이미 시작된 트랜잭션이 있으면 트랜잭션을 잠시 보류한다. |
Nested | 이미 진행중인 트랜잭션이 있으면 중첩 트랜잭션을 시작한다. 중첩 트랜잭션이란 트랜잭션 안에 트랜잭션을 만드는 것으로, 먼저 시작된 부모 트랜잭션의 커밋과 롤백에는 영향을 받지만 자신의 커밋과 롤백은 부모 트랜잭션에게 영향을 주지 않는다. |
Never | 이미 진행중인 트랜잭션이 있으면 예외를 발생시키며 트랜잭션을 사용하지 않도록 강제한다. |
Not_supported | 이미 진행중인 트랜잭션이 있으면 이를 보류하고 트랜잭션을 사용하지 않도록 한다. |
log.info("currentTransactionName : {}", TransactionSynchronizationManager.getCurrentTransactionName());
log.info("Sender ID: {}", senderId.toString());
log.info("Receiver ID: {}", receiverId.toString());
이 로깅을 통해서 나온 결과를 통해 PayService의 transfer와 AccountService의 send, receive 메서드 간의 전파 속성 변화를 확인하겠다.
로직의 흐름은 transfer → send → sendMoney → receive → receiveMoney이며 transfer, sendMoney, receiveMoney에 currentTransactionName 로깅이, send와 receive에 id 로깅이 존재한다.
기본설정
@Transactional과 조회만 있는 메서드에 readOnly 속성만 주었다.
currentTransactionName : com.perenok.study.transaction.pay.PayService.transfer
Sender ID: 1
currentTransactionName : com.perenok.study.transaction.pay.PayService.transfer
Receiver ID: 2
currentTransactionName : com.perenok.study.transaction.pay.PayService.transfer
모두 같은 트랜잭션인 것을 확인할 수 있다. 이유는 @Transcational
의 기본 속성은 Required로 미리 시작된 트랜잭션이 있으면 참여하고 없으면 새로 시작한다. transfer에서 트랜잭션이 시작되어 모두 이 트랜잭션에 참여하게 된 것이다.
Supports
transfer에 @Transcational
을 없애고 sendMoney, receiveMoney에 supports 속성을 추가해서 테스트를 돌려보았다.
currentTransactionName : null
Sender ID: 1
currentTransactionName : com.perenok.study.transaction.account.AccountService.sendMoney
Receiver ID: 2
currentTransactionName : com.perenok.study.transaction.account.AccountService.receiveMoney
트랜잭션이 존재하면 해당 트랜잭션을 사용하고 존재하지 않을 경우 트랜잭션 없이 진행하는 속성이다. 트랜잭션 관련 처리가 없어 dirty checking이 일어나지 않아 테스트가 실패하게 된다. 로깅을 보면 트랜잭션 자체는 생성이 된 것을 확인할 수 있다.
정리하면 내부적으로 트랜잭션 관련된 처리는 없지만 실패할 경우 트랜잭션 rollback 해야할 경우에 사용할 수 있다.
Mandatory
Supports와 동일하게 설정해서 테스트를 돌려보았다.
org.springframework.transaction.IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory'
상위 메서드인 transfer에 @Transcational
이 없어 IllegalTransactionStateException
를 발생시킨다. 제대로 트랜잭션 어노테이션을 추가하면 작동하는 것을 확인할 수 있다.
Requires_new
transfer에 @Transcational
를 선언하고 하위 메서드들에 requires_new 속성을 추가한다.
currentTransactionName : com.perenok.study.transaction.pay.PayService.transfer
Sender ID: 1
currentTransactionName : com.perenok.study.transaction.account.AccountService.sendMoney
Receiver ID: 2
currentTransactionName : com.perenok.study.transaction.account.AccountService.receiveMoney
결과를 보면 transfer에서 sendMoney, receiveMoney로 트랜잭션이 새로 선언된 것을 확인할 수 있다.
실제 기존 트랜잭션을 중단하는 것은 아니기 때문에 기존 트랜잭션을 방치한 상태로 새로운 트랜잭션을 생성하게 된다.
그 뜻은 물리적으로 데이터베이스 커넥션을 새로 얻는다는 것이다. 요청이 많은 특정 서비스에 사용할 경우 데이터베이스 커넥션을 얻기 위해 대기하는 리소스 데드락(Resource Deadlock)을 유발할 가능성이 있으므로 주의해서 사용해야 한다.
NESTED
하위 메서드에 속성을 추가하고 테스트를 돌려보자.
org.springframework.transaction.NestedTransactionNotSupportedException: JpaDialect does not support savepoints - check your JPA provider's capabilities
그러면 이렇게 에러를 마주하게 될 것이다. 이러한 예외가 뜨는 이유는 Jpa에서는 Savepoint 기능을 지원하지 않기 때문이다.
JDBC 3.0 이후부터 지원하는 기능으로써 Jpa에서는 변경 감지(dirty checking)을 통해 update를 최대한 지연하다 발생시키기 때문에 중첩된 트랙잭션 경계를 설정할 수 없어 savepoint 기능을 지원하지 않는다.
Never
transfer에 @Transcational
을 선언하고 하위 메서드에 never 속성을 추가하고 테스트를 돌려보자.
org.springframework.transaction.IllegalTransactionStateException: Existing transaction found for transaction marked with propagation 'never'
그러면 위와 같은 예외를 볼 수 있다. 상위 메서드에 트랜잭션이 있으면 예외를 던지므로 트랜잭션이 없는 상태를 강제한다.
transfer에 어노테이션을 삭제하고 돌려보면 예외는 일어나지 않지만 테스트는 실패하게 된다.
currentTransactionName : null
Sender ID: 1
currentTransactionName : com.perenok.study.transaction.account.AccountService.sendMoney
Receiver ID: 2
currentTransactionName : com.perenok.study.transaction.account.AccountService.receiveMoney
Not_supported
currentTransactionName : com.perenok.study.transaction.pay.PayService.transfer
Sender ID: 1
currentTransactionName : com.perenok.study.transaction.account.AccountService.sendMoney
Receiver ID: 2
currentTransactionName : com.perenok.study.transaction.account.AccountService.receiveMoney
로깅을 보면 각각의 메서드마다 트랜잭션이 설정된 것을 볼 수 있다. 이렇게만 보면 Required_new와 동일해 보이지만 테스트는 실패한다. 왜냐하면 하위 메서드에서는 트랜잭션 관련 처리가 일어나지 않아 update가 일어나지 않기 때문이다.
정리
이렇게 각각의 속성에 대해서 직접 테스트 해보며 어떻게 돌아가는지 확인했다. 이 테스트를 진행한 이유는 학습에 대한 것도 있지만 프로젝트에서 조회수 로직 구현을 하다 전파 속성으로 인한 오류를 접한 적이 있기 때문이다. 어떤 에러인지 후에 글을 써보려고 한다.
트러블슈팅
영속성 이슈
처음에 테스트를 위해 코드 작성 했을 때 update가 일어나지 않는 이슈가 있었다. 이를 해결해 나가는 과정을 기록하여 후에 동일한 문제가 발생할 때 빠르게 체크할 수 있도록 하려고한다.
먼저 기존 PayService의 코드를 보자.
@Transactional
public void transfer(TransferRequest request) {
log.info("currentTransactionName : {}", TransactionSynchronizationManager.getCurrentTransactionName());
Long senderId = request.getSenderId();
Long receiverId = request.getReceiverId();
Long amount = request.getAmount();
send(senderId, amount);
receive(receiverId, amount);
}
private void send(Long senderId, Long amount) {
log.info("Sender ID: {}", senderId.toString());
AccountResponse accountResponse = accountService.show(senderId);
Account senderAccount = accountResponse.toEntity(); // new Account(...)
senderAccount.sendMoney(amount);
}
private void receive(Long receiverId, Long amount) {
log.info("Receiver ID: {}", receiverId.toString());
AccountResponse accountResponse = accountService.show(receiverId);
Account receiverAccount = accountResponse.toEntity();
receiverAccount.receiveMoney(amount);
}
AccountResponse를 통해 new Account로 선언된 account 객체를 PayService에서 sendMoney, receiveMoney 메서드를 실행시켰다. 이렇게 선언된 account는 영속성 컨텍스트에 저장되어 있지 않으므로 변경이 일어난 후 트랜잭션이 commit 되어도 dirty checking이 되지 않는다. 이를 해결하기 위해서는 두가지 방법이 있다.
- 반환값을 Dto가 아닌 Entity로 변경
- 이 방법을 사용하면 영속성이 그대로 유지가 되어 문제가 빠르게 해결된다. 하지만 나는 서비스 레이어의 로직을 짤 때 반환값을 모두 Dto로 변환하여 Controller 단에서 Entity 객체의 의존성을 없애는 것을 지향한다. 그러므로 public으로 열려있는 메서드의 반환값을 Entity로 변환하는 것은 기각
- account를 사용하는 로직을 AccountService로 이동
- 현재 문제가 되는 account 조회부터 update 로직을 AccountService로 책임을 옮긴다. 이를 통해 AccountService 내에서 영속성 컨텍스트에 저장된 account를 변화시키므로 commit시 dirty checking이 일어나게 된다.
수정 후 코드
- PayService
@Transactional
public void transfer(TransferRequest request) {
log.info("currentTransactionName : {}", TransactionSynchronizationManager.getCurrentTransactionName());
Long senderId = request.getSenderId();
Long receiverId = request.getReceiverId();
Long amount = request.getAmount();
send(senderId, amount);
receive(receiverId, amount);
}
private void send(Long senderId, Long amount) {
log.info("Sender ID: {}", senderId.toString());
accountService.sendMoney(senderId, amount);
}
private void receive(Long receiverId, Long amount) {
log.info("Receiver ID: {}", receiverId.toString());
accountService.receiveMoney(receiverId, amount);
}
- AccountService
@Transactional
public void sendMoney(Long senderId, Long amount){
log.info("currentTransactionName : {}", TransactionSynchronizationManager.getCurrentTransactionName());
Account senderAccount = accountRepository.findById(senderId)
.orElseThrow();
senderAccount.sendMoney(amount);
}
@Transactional
public void receiveMoney(Long receiverId, Long amount){
log.info("currentTransactionName : {}", TransactionSynchronizationManager.getCurrentTransactionName());
Account senderAccount = accountRepository.findById(receiverId)
.orElseThrow();
senderAccount.receiveMoney(amount);
}
수정을 통해 기존에 update가 일어나지 않던 문제를 해결했다.
참고
http://wonwoo.ml/index.php/post/966
https://velog.io/@tmdgh0221/스프링-테스트-케이스에서의-Transactional-유의점
https://reiphiel.tistory.com/entry/understanding-of-spring-transaction-management-practice
댓글남기기