스프링 공부를 하면서 가장 혼란스러웠던 부분인 트랜잭션에 대해 정리했다. 트랜잭션이 무엇인지, Service 단의 모든 메서드에 적용해야 하는지라는 간단한 의문부터 시작하여 공부를 진행했다.
또한, 한 번도 테스트 코드에서 트랜잭션 어노테이션을 사용해보지 않았는데, 최근 읽은 블로그를 통해 테스트 코드에 트랜잭션을 적용하는 것이 옳은지에 대한 고민을 해보았다.
공부하면서 얻은 개념을 확장하는 순서로 정리되어 있다. 마지막으로는 직접 테스트 상황을 설정하고 테스트 코드를 작성했는데, 이 과정에서 발생한 오류로부터 얻은 경험도 함께 정리한 글이다.
스프링에서는 @Transactional 어노테이션을 통해서 트랜잭션 처리를 지원한다. 트랜잭션은 시작 지점과 끝나는 지점이 존재한다. 시작하는 방법은 1가지 이지만, 끝나는 방법은 2가지. 트랜잭션이 끝나는 방법에는 모든 작업을 확정 짓는 커밋과 모든 작업을 무효화 시키는 롤백이 존재한다.
기존의 트랜잭션이 진행중일 때 또 다른 트랜잭션이 사용되면 복잡한 상황이 발생한다. 스프링에서 논리 트랜잭션을 도입해서 상황에 대한 설명을 쉽게 만들고 단순한 원칙에 따라 움직이도록 한다.
Spring의 트랜잭션 매니저 - 논리 트랜잭션
스프링이 트랜잭션 매니저를 통해 트랜잭션을 처리하는 단위
데이터베이스에 적용되는 트랜잭션 - 물리 트랜잭션
실제 데이터 베이스에 적용되는 트랜잭션으로, 커넥션을 통해 커밋/롤백 하는 단위
간단하게 트랜잭션의 성질 네가지를 정리해보면,
UserA : 10000원 / UserB : 20000원
Test1 (”트랜잭션이 없는 경우, 롤백 되지 않아, A 계좌에는 5000원, B 계좌에는 25000원이 있어야 한다.”)
1) A의 계좌에 10000원, B의 계좌에 20000원이 있음
2) A가 B에게 5000원 이체 요청해 계좌에서 5000원 빼기
3) 이후 의도적인 exception 발생
4) try 이후, finally 코드에서 B 사용자가 무조건 5000원을 받도록 설정
@Test
@DisplayName("트랜잭션 없는 경우 : A계좌에는 5000원, B계좌에는 25000원이 남아있다.")
void createNoneTransaction() {
System.out.println("Test:" + entityManager.getDelegate());
//When
assertThrows(RuntimeException.class, () ->
accountService.sendMoneyWithNonTransactional(userA.getAccount(), userB.getAccount(), 5000));
//Then
assertThat(userA.getAccount().getMoney()).isEqualTo(5000);
assertThat(userB.getAccount().getMoney()).isEqualTo(25000);
}
트랜잭션이 없으면, 의도적으로 발생시킨 Runtime Exception에도 롤백되지 않는다.
Test2 (”트랜잭션이 있는 경우, 롤백 되어, A 계좌에는 10000원, B 계좌에는 20000원이 있어야 한다.”)
1) A의 계좌에 10000원, B의 계좌에 20000원이 있음
2) A가 B에게 5000원 이체 요청해 계좌에서 5000원 빼기
3) 이후 의도적인 exception 발생
@Test
@Transactional
@DisplayName("Required 트랜잭션 있는 경우: A계좌에는 10000원, B계좌에는 20000원이 남아있다.")
void createTransaction1() {
System.out.println("Test:" + entityManager.getDelegate());
assertThrows(RuntimeException.class, () ->
accountService.sendMoneyWithTransactional_REQUIRED(userA.getAccount(), userB.getAccount(), 5000));
assertThat(userA.getAccount().getMoney()).isEqualTo(10000);
assertThat(userB.getAccount().getMoney()).isEqualTo(20000);
}
Error
트랜잭션 어노테이션이 존재하는 경우, Runtime Exception 발생 시 롤백되어서 A의 계좌에는 원래대로 10000원, B의 계좌에는 20000원이 남아있어야 했다. 그렇지만 여기서 계속해서 10000원이 아닌, 5000원이 조회가 되었다.
REQUIRED는 스프링이 제공하는 기본적인(DEFAULT) 전파 속성으로, 기본적으로 2개의 논리 트랜잭션을 묶어 1개의 물리 트랜잭션을 사용하는 것이다. 내부 트랜잭션은 기존에 존재하는 외부 트랜잭션에 참여하게 된다.
REQUIRES_NEW는 외부 트랜잭션과 내부 트랜잭션을 완전히 분리하는 전파 속성이다. 그래서 2개의 물리 트랜잭션이 사용되며, 각각 트랜잭션 별로 커밋과 롤백이 수행된다.
두 개는 서로 다른 물리 트랜잭션이므로, 내부 트랜잭션 롤백이 외부 트랜잭션 롤백에 영향을 주지 않는다. 그러므로 내부 트랜잭션이 롤백 호출은 실제 커넥션에 롤백을 호출하는 것이므로 트랜잭션이 끝나게 된다.
테스트와 메서드에 모두 @Transactional 이 달려있는 경우, 같은 트랜잭션이 두번 열리면서 롤백 처리가 되지 않는다. 10000원으로 롤백 실패하고, 그대로 5000원이 남아있다.
서비스 메서드 전파레벨을 REQUIRES_NEW로 수정할 경우, Test에서의 트랜잭션이 하나만 열리면서, 롤백 처리가 된다.
전파 레벨로 해결하지 않고, 애초에 테스트 코드에 @Transactional 붙이지 않았다면, 해결될 문제였다...
테스트 코드에서 무지성 트랜잭션 어노테이션을 적용할 경우, 롤백이 적용되지 않아 초기화가 되지 않을 수 있다. 이는 테스트 메서드 간의 격리가 불가능해 지기 때문에 개인적으로는 테스트 메서드에 트랜잭션 어노테이션 활용을 지양하려고 한다.