이전 포스팅에서 스프링 트랜잭션을 공부했고 그 과정에서 @Transactional 애노테이션을 알아봤다.
이렇게 애노테이션 하나로 트랜잭션을 시작하는 것을 '선언적 트랜잭션'이라고 한다.
이 @Transactional 애노테이션은 여러가지 옵션들이 있는데 굉장히 중요한 부분이다. 하나씩 차근차근 알아보자!
@Transactional 옵션들
이번엔 @Transactional 애노테이션이 가지고 있는 여러 속성들을 알아보자.
public @interface Transactional {
String value() default "";
String transactionManager() default "";
Class<? extends Throwable>[] rollbackFor() default {};
Class<? extends Throwable>[] noRollbackFor() default {};
Propagation propagation() default Propagation.REQUIRED;
Isolation isolation() default Isolation.DEFAULT;
int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
boolean readOnly() default false;
String[] label() default {};
}
value, transactionManager
트랜잭션을 사용하려면, 먼저 스프링 빈에 등록된 어떤 트랜잭션 매니저를 사용할지 알아야 한다. @Transactional 애노테이션도 결국 트랜잭션 매니저를 사용하는거기 때문에 트랜잭션 매니저를 지정해줘야 하고 지정할 때는 value, transactionManager 둘 중 하나에 트랜잭션 매니저의 스프링 빈의 이름을 적어주면 된다. 지정하지 않으면 기본으로 등록된 트랜잭션 매니저를 사용하기 때문에 대부분 생략한다. 그러나 트랜잭션 매니저가 둘 이상이면 다음처럼 트랜잭션 매니저의 이름을 지정해서 구분하면 된다.
@Transactional("memberTxManager")
public void member() {...}
@Transactional("orderTxManager")
public void order() {...}
- 참고로, 애노테이션에서 속성이 하나인 경우 위 예처럼 value는 생략하고 값을 바로 넣을 수 있다.
rollbackFor
트랜잭션 내 로직을 수행 중 예외가 발생하면 스프링 트랜잭션의 기본 정책은 다음과 같다.
- 언체크 예외(런타임 예외라고도 함)인 RuntimeException, Error 또는 그 하위 예외가 발생하면 롤백
- 체크 예외인 Exception 또는 그 하위 예외들은 커밋
이 옵션을 사용해서 기본 정책에 '추가적으로' 어떤 예외가 발생했을 때 롤백을 하라고 지정할 수 있다.
@Transactional(rollbackFor = Exception.class)
이렇게 하면 Exception부터 그 하위 예외들이 발생해도 롤백한다.
rollbackForClassName이라는 속성도 있는데 이는 문자로 넣는 경우이다. 그냥 rollbackFor를 사용하면 된다.
noRollbackFor
이는 rollbackFor와 반대 개념이다. 기본 정책에 추가로 어떤 예외가 발생하면 롤백하면 안되는지 지정할 수 있다. 마찬가지로 noRollbackForClassName도 있다.
propagation
이는 트랜잭션 전파에 대한 내용이다. 매우 중요하다. 우선 가능 옵션 리스트는 다음과 같다.
전파타입 | 설명 |
REQUIRED ⭐️ | 기본값으로 설정되는 전파 타입입니다. 기존에 활성화된 트랜잭션에 자식 트랜잭션이 합류하여 하나의 물리 트랜잭션으로 취급합니다. 기존 트랜잭션이 없다면 새로 트랜잭션을 만듭니다. |
REQUIRES_NEW ⭐️ | 기존에 활성화된 트랜잭션이 있더라도 합류하지 않고 별개의 트랜잭션으로 취급하여 수행되는 전파 타입입니다. |
SUPPORTS | 기존에 활성화된 트랜잭션이 있다면 합류를 하고, 활성화된 트랜잭션이 없다면 합류하지 않고 트랜잭션 없이 그대로 작업을 수행합니다. 트랜잭션이 그다지 필요없는 SELECT 쿼리에 적용하면 성능 향상을 기대할 수 있다고도 합니다. |
NOT_SUPPORTED | 트랜잭션을 지원하지 않는다는 의미입니다. 그래서, 기존 트랜잭션이 없으면 트랜잭션이 없이 진행하고, 기존 트랜잭션이 있어도 트랜잭션이 없이 진행합니다. |
MANDATORY | 기존에 활성화된 트랜잭션이 존재할 경우 해당 트랜잭션에 합류하며, 존재하지 않을 경우 예외를 발생시킵니다. 쉽게 말해 기존 트랜잭션이 반드시 있어야 합니다. |
NEVER | 기존에 활성화된 트랜잭션이 존재할 경우 예외를 발생시키며, 활성화된 트랜잭션이 없을 경우 작업을 수행합니다. |
NESTED | 기존 트랜잭션이 없으면 새로운 트랜잭션을 만들고 기존 트랜잭션이 있으면 중첩 트랜잭션을 만듭니다. 중첩 트랜잭션은 외부(기존) 트랜잭션에 영향을 받지만, 중첩 트랜잭션은 외부 트랜잭션에 영향을 주지 않습니다. 즉, 중첩 트랜잭션이 롤백 되어도 외부 트랜잭션은 커밋할 수 있지만 외부 트랜잭션이 롤백되면 중첩 트랜잭션도 같이 롤백됩니다. (중첩 트랜잭션은 JPA에서는 사용할 수 없는 옵션입니다) |
참고로, 트랜잭션 옵션 중 isolation, timeout, readOnly는 트랜잭션이 처음 시작될 때만 적용된다. 즉, 트랜잭션에 참여하는 경우에는 적용되지 않는다. 예를 들어, REQUIRED를 통한 트랜잭션 시작이나 REQUIRES_NEW를 통한 트랜잭션 시작 시점에만 적용되고 트랜잭션에 참여하는 트랜잭션은 기존 트랜잭션을 시작할 때 적용했던 옵션을 따라 사용된다.
propagation - REQUIRED
기본값은 REQUIRED인데, 이 옵션은 위에 설명한 그대로 기존에 활성화된 트랜잭션이 있으면 그 트랜잭션에 합류한다. 좀 더 정확히 말하면 '물리 트랜잭션'과 '논리 트랜잭션'으로 나뉘어지는데 다음 그림을 보자.
1. 클라이언트에서 트랜잭션을 가지는 서비스를 호출하면 트랜잭션이 시작된다.
2. 하나의 트랜잭션에서 실행되던 로직에서 새로운 트랜잭션을 가지는 또다른 서비스를 호출하여 트랜잭션 두개가 생성된다.
3. 이 두개의 트랜잭션은 논리 트랜잭션으로 나뉘어지고 이 두개의 논리 트랜잭션을 크게 묶어 하나의 물리 트랜잭션으로 합류된다.
이런 흐름이 REQUIRED 옵션이다. 이렇게 하나의 물리 트랜잭션으로 합류가 된다. 그리고 이 물리 트랜잭션이 실제 데이터베이스와 통신하는 트랜잭션이고 논리 트랜잭션들은 애플리케이션 레벨에서 트랜잭션을 시작하고 종료(커밋 또는 롤백)하는 트랜잭션이다.
이 경우, 중요한 두가지 규칙이 있다.
- 두 개의 논리 트랜잭션이 모두 커밋되어야 물리 트랜잭션이 커밋된다.
- 두 개의 논리 트랜잭션 중 하나라도 롤백이라면 물리 트랜잭션은 롤백된다.
다음 그림을 보고 이와 같이 정하자.
최초 시작되는 트랜잭션을 외부 트랜잭션, 내부 로직에서 또다른 트랜잭션을 만들려 하는 곳을 내부 트랜잭션이라고 칭하자.
둘 다 논리 트랜잭션이고 이 두개의 논리 트랜잭션이 모두 커밋되어야 하나의 물리 트랜잭션(실제 데이터베이스에 커밋 또는 롤백을 날리는)이 커밋된다고 했다. 실제로 코드를 통해서 어떻게 동작하는지 눈으로 직접 확인해보자!
@Test
void inner_commit() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("outer.isNewTransaction() = {}", outer.isNewTransaction());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("inner.isNewTransaction() = {}", inner.isNewTransaction());
log.info("내부 트랜잭션 커밋");
txManager.commit(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}
- 외부 트랜잭션(outer)를 시작한다. 최초로 트랜잭션이 수행되면 신규 트랜잭션이 되고 그 값을 보관하고 있기 때문에 outer.isNewTransaction()을 호출하면 `true`를 반환한다.
- 외부 트랜잭션이 수행중인데 내부 트랜잭션(inner)을 추가로 수행했다. 내부 트랜잭션을 시작하는 시점에는 이미 외부 트랜잭션이 진행중인 상태이다. 이 경우 내부 트랜잭션은 외부 트랜잭션에 참여한다.
- 내부 트랜잭션이 외부 트랜잭션에 참여한다는 뜻은 내부 트랜잭션이 외부 트랜잭션을 그대로 이어 받아서 따른다는 뜻이다. 다른 관점으로 보면 외부 트랜잭션의 범위가 내부 트랜잭션까지 넓어진다는 뜻이다.
- 내부 트랜잭션은 이미 진행중인 외부 트랜잭션에 참여하므로 신규 트랜잭션이 아니다. 그러므로 inner.isNewTransaction()을 호출하면 `false`가 된다.
- 내부 트랜잭션을 커밋한 후 외부 트랜잭션도 커밋했다. (당연히 내부 트랜잭션을 먼저 커밋하고 외부 트랜잭션이 커밋되는게 순서가 맞다)
실행 결과
2024-12-11T16:15:04.484+09:00 INFO 75930 --- [ Test worker] cwchoiit.tx.propagation.BasicTxTest : 외부 트랜잭션 시작
2024-12-11T16:15:04.485+09:00 DEBUG 75930 --- [ Test worker] o.s.j.d.DataSourceTransactionManager : Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2024-12-11T16:15:04.486+09:00 DEBUG 75930 --- [ Test worker] o.s.j.d.DataSourceTransactionManager : Acquired Connection [HikariProxyConnection@-397934204 wrapping conn0: url=jdbc:h2:mem:3b843fdd-51b1-4753-80e2-eff01972f785 user=SA] for JDBC transaction
2024-12-11T16:15:04.487+09:00 DEBUG 75930 --- [ Test worker] o.s.j.d.DataSourceTransactionManager : Switching JDBC Connection [HikariProxyConnection@-397934204 wrapping conn0: url=jdbc:h2:mem:3b843fdd-51b1-4753-80e2-eff01972f785 user=SA] to manual commit
2024-12-11T16:15:04.487+09:00 INFO 75930 --- [ Test worker] cwchoiit.tx.propagation.BasicTxTest : outer.isNewTransaction() = true
2024-12-11T16:15:04.487+09:00 INFO 75930 --- [ Test worker] cwchoiit.tx.propagation.BasicTxTest : 내부 트랜잭션 시작
2024-12-11T16:15:04.487+09:00 DEBUG 75930 --- [ Test worker] o.s.j.d.DataSourceTransactionManager : Participating in existing transaction
2024-12-11T16:15:04.487+09:00 INFO 75930 --- [ Test worker] cwchoiit.tx.propagation.BasicTxTest : inner.isNewTransaction() = false
2024-12-11T16:15:04.487+09:00 INFO 75930 --- [ Test worker] cwchoiit.tx.propagation.BasicTxTest : 내부 트랜잭션 커밋
2024-12-11T16:15:04.487+09:00 INFO 75930 --- [ Test worker] cwchoiit.tx.propagation.BasicTxTest : 외부 트랜잭션 커밋
2024-12-11T16:15:04.487+09:00 DEBUG 75930 --- [ Test worker] o.s.j.d.DataSourceTransactionManager : Initiating transaction commit
2024-12-11T16:15:04.488+09:00 DEBUG 75930 --- [ Test worker] o.s.j.d.DataSourceTransactionManager : Committing JDBC transaction on Connection [HikariProxyConnection@-397934204 wrapping conn0: url=jdbc:h2:mem:3b843fdd-51b1-4753-80e2-eff01972f785 user=SA]
2024-12-11T16:15:04.488+09:00 DEBUG 75930 --- [ Test worker] o.s.j.d.DataSourceTransactionManager : Releasing JDBC Connection [HikariProxyConnection@-397934204 wrapping conn0: url=jdbc:h2:mem:3b843fdd-51b1-4753-80e2-eff01972f785 user=SA] after transaction
- 로그를 보면 꽤나 흥미로운데, 내부 트랜잭션을 시작할 때 `Participating in existing transaction` 이라는 메시지를 확인할 수 있다. 이 메시지는 내부 트랜잭션이 기존에 존재하는 외부 트랜잭션에 참여한다는 뜻이다. 근데 어떻게 알까? 아까 위에서 말한 신규 트랜잭션인지를 확인해서 신규 트랜잭션이 아닌 경우에 전파 속성의 기본값인 기존 트랜잭션에 참여하는 방식을 사용하기 때문이다.
- 그리고 내부 트랜잭션을 커밋하고 나서 커밋 로그가 전혀 찍혀있지 않다. 그리고 외부 트랜잭션을 커밋한다는 로그가 나오고 나서 커밋 로그가 찍혀있다. 왜 그럴까? 트랜잭션을 커밋하는 순간 그 트랜잭션은 더이상 사용할 수 없다. 아예 끝난다. 그런데 외부 트랜잭션이 남아있는데 내부 트랜잭션을 커밋한다고 해서 물리 트랜잭션을 커밋해버리면 외부 트랜잭션에서는 작업했던 내용을 반영할 수 없기 때문이다.
- 그래서 스프링은 이렇게 여러 트랜잭션이 함께 사용되는 경우, 처음 트랜잭션을 시작한 외부 트랜잭션이 실제 물리 트랜잭션을 관리하도록 한다. 따라서 외부 트랜잭션이 커밋되어야만 실제 물리 트랜잭션을 커밋한다는 로그가 찍히는 것이다.
- 그래서 내부 트랜잭션이 정상적으로 커밋이 되고, 외부 트랜잭션도 커밋이 되어야만 커밋을 한다는 로그가 찍히는 모습이다.
여기서 그럼, 둘 중 하나가 롤백을 한다면 결국 물리 트랜잭션이 롤백이 되는데 이 때 외부 트랜잭션과 내부 트랜잭션이 롤백을 할 때 처리되는 방식이 다르다. 이것을 이해해야 한다.
외부 트랜잭션이 최초의 트랜잭션이기 때문에 결국 이 외부 트랜잭션이 커밋 또는 롤백을 해야 물리 트랜잭션이 커밋 또는 롤백을 한다.
내부 트랜잭션은 커밋이나 롤백을 해도 물리 트랜잭션은 아무런 작업을 하지 않는다. 왜냐? 외부 트랜잭션이 아직 남아있기 때문에.
1.내부 트랜잭션이 커밋되고 외부 트랜잭션이 롤백이 되면 그냥 롤백 처리가 된다.
2.내부 트랜잭션이 롤백되고 외부 트랜잭션이 커밋되면 UnexpectedRollbackException 예외가 터진다.
저 부분이 중요하다. 내부 트랜잭션이 롤백을 하면 물리 트랜잭션이 롤백을 바로 하지 않지만(물리 트랜잭션이 롤백 또는 커밋이 되면 데이터베이스에 실제 커밋 또는 롤백을 날리는데 아직 트랜잭션이 끝난게 아니니까) 물리 트랜잭션에 Rollback-Only 라는 마크를 달게 된다.
여기서 외부 트랜잭션이 커밋이 된다면 물리 트랜잭션에 커밋을 날리는데 커밋을 할 수 없는것이다. 왜냐? 내부 트랜잭션이 롤백을 했으니까. 그러나 개발자는 외부 트랜잭션을 커밋을 했는데 커밋이 되면 안되니까 UnexpectedRollbackException이 발생하는 것이다. 즉, 스프링 입장에서는 "너가 지금 물리 트랜잭션을 커밋하려고 시도했지만 내부 트랜잭션에서 롤백을 날렸기 때문에 넌 커밋을 할 수 없어"라고 친절하게 알려주는 것. 이것을 코드로 한번 봐보자!
@Test
void inner_rollback() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}
- 내부 트랜잭션은 롤백을 하고, 외부 트랜잭션은 커밋을 한다.
- 이 경우에 전체 트랜잭션인 물리 트랜잭션은 롤백을 하고 UnexpectedRollbackException 예외를 던진다.
실행 결과
- 실행 결과를 보면 UnexpectedRollbackException이 발생했음을 알 수 있다.
- 그리고, 내부 트랜잭션을 롤백할 때 찍히는 로그를 보면 `marking existing transaction as rollback-only` 라는 로그가 찍혀있다. 내부 트랜잭션은 물리 트랜잭션에 커밋이나 롤백을 할 수 없기 때문에 현재 이 물리 트랜잭션에 나 롤백했어! 라고 마킹을 하는 것이다. 그래야 외부 트랜잭션에서 커밋이든 롤백이든 뭔가 하려고 할 때 확인하고 롤백을 할 수 있을테니 말이다.
이게 기본 전파 속성 'REQUIRED'의 동작 흐름이다.
그래서 진짜 진짜 중요하게 이해해야 하는 부분이 있는데, 아래 그림을 보자.
위 그림처럼 트랜잭션에서 또 다른 트랜잭션을 가지는 내부 로직이 있고 이 세 개의 트랜잭션이 모두 REQUIRED 전파 타입인 경우가 있다고 가정하자.
REQUIRED 전파 속성은 모두 커밋이 되어야 물리 트랜잭션이 커밋되고 하나라도 롤백이라면 물리 트랜잭션은 롤백된다고 했다.
근데 만약, 런타임 예외가 발생한 지점에서 예외 처리를 하지 못하고 자신을 호출한 곳으로 예외를 던졌다고 가정하자. 당연히 이 로직의 트랜잭션은 롤백을 할 것이다. 그러나 이 트랜잭션이 가장 최초에 시작된 트랜잭션이 아니라면 롤백을 바로 데이터베이스에 날리는 게 아니라 Rollback Only 마킹을 하고 끝나는데, 그럼 이 예외를 넘겨받은 바깥쪽 트랜잭션 로직에서 이 예외를 복구했다면? 즉 예외를 잡아서 처리했다면? 이 전체 물리 트랜잭션은 커밋이 성공적으로 될까? 아니다. 그렇지 않다. 왜냐하면 결국 이 REQUIRED 전파 타입은 사용중인 트랜잭션에 참여하기 때문에 내부의 논리 트랜잭션에서 하나라도 rollback only에 마킹을 했다면 무조건 물리 트랜잭션은 롤백을 한다. 근데 예외를 잡아버리니까 정상 흐름을 유지하고 트랜잭션을 커밋을 할 거라고 기대하는 경우가 종종 있을 수 있다. 충분히 그럴수 있다는 생각이든다. 하지만 아니라는 것을 꼭 염두하자.
코드를 통해서 직접 확인해보자!
MemberService
package cwchoiit.tx.propagation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final LogRepository logRepository;
@Transactional
public void joinV1(String username) {
Member member = new Member(username);
Log logEntity = new Log(username);
log.info("MemberRepository 호출 시작");
memberRepository.save(member);
log.info("MemberRepository 호출 종료");
log.info("LogRepository 호출 시작");
logRepository.save(logEntity);
log.info("LogRepository 호출 종료");
}
@Transactional
public void joinV2(String username) {
Member member = new Member(username);
Log logEntity = new Log(username);
log.info("MemberRepository 호출 시작");
memberRepository.save(member);
log.info("MemberRepository 호출 종료");
log.info("LogRepository 호출 시작");
try {
logRepository.save(logEntity);
} catch (RuntimeException e) {
log.info("로그 저장에 실패했습니다. logMessage = {}", logEntity.getMessage());
}
log.info("LogRepository 호출 종료");
}
}
- 간단한 Member 관련 서비스 코드이다. joinV1, joinV2 메서드가 있다.
- 각 메서드에 @Transactional 애노테이션을 달았고, 아무런 옵션도 주지 않았으니 전파 옵션은 기본값인 REQUIRED이다.
MemberRepository
package cwchoiit.tx.propagation;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Slf4j
@Repository
@RequiredArgsConstructor
public class MemberRepository {
private final EntityManager em;
@Transactional
public void save(Member member) {
log.info("Saving member {}", member);
em.persist(member);
}
public Optional<Member> findById(String username) {
return em.createQuery("SELECT m FROM Member m WHERE m.username = :username", Member.class)
.setParameter("username", username)
.getResultList()
.stream()
.findFirst();
}
}
- MemberRepository에서 save() 메서드를 보면 이 메서드에도 @Transactional 애노테이션이 달려있다.
LogRepository
package cwchoiit.tx.propagation;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {
private final EntityManager em;
@Transactional
public void save(Log logMessage) {
log.info("log 저장");
em.persist(logMessage);
if (logMessage.getMessage().contains("로그예외")) {
log.info("Log 저장 시 예외 발생");
throw new RuntimeException("예외 발생");
}
}
public Optional<Log> find(String message) {
return em.createQuery("SELECT l FROM Log l WHERE l.message = :message", Log.class)
.setParameter("message", message)
.getResultList()
.stream()
.findFirst();
}
}
- LogRepository에도 save() 메서드에 @Transactional 애노테이션이 달려있다.
이런 상태에서 MemberService.joinV1(), joinV2()를 호출하면, MemberRepository.save()도 호출하고, LogRepository.save()도 호출한다. 그리고 joinV1, joinV2에 @Transactional이 붙어있기 때문에 트랜잭션을 최초로 시작하는 지점은 이 곳이고, MemberRepository.save()와 LogRepository.save()에 붙어있는 @Transactional은 전파 옵션이 REQUIRED이므로 기존 트랜잭션에 참여한다.
즉, 외부 트랜잭션, 내부 트랜잭션, 내부 트랜잭션 이렇게 총 세개의 논리 트랜잭션이 있게 되고, 모두가 커밋해야 물리 트랜잭션이 커밋이 되고 하나라도 롤백이면 물리 트랜잭션은 롤백이다. 이게 REQUIRED 옵션이니까. 그런데 이 코드는 사용자가 회원가입을 할때 호출되는 코드인데 회원가입 시 비즈니스 규칙으로 Log라는 엔티티에 해당 유저를 저장하는 부가 기능이 있을뿐이다. LogRepository.save()에서 어떤 예외가 발생했다고 해서 사용자 회원가입까지 못시키게 해버리면 사용자 입장에서는 원인도 모르고 그냥 회원가입이 안되는 상황을 마주하게 된다. 그게 싫어서 MemberService.joinV2()에서 LogRepository.save()의 예외가 올라오면 그 예외를 잡아버린다고 코드를 작성한 것이다. 이런다고 롤백이 안되는게 아니다! 이미 LogRepository.save()에서 실행된 논리 트랜잭션은 Rollback-only 마크를 찍었다. 그럼 외부 트랜잭션에서 정상흐름으로 커밋을 하더라도 절대 물리 트랜잭션은 커밋되지 않는다. 테스트 코드로 테스트를 해보자.
/**
* memberService @Transactional: ON
* memberRepository @Transactional: ON
* logRepository @Transactional: ON Exception
*/
@Test
void recover_exception_fail() {
String username = "로그예외_recover_exception_fail";
assertThatThrownBy(() -> memberService.joinV2(username)).isInstanceOf(UnexpectedRollbackException.class);
assertThat(memberRepository.findById(username)).isEmpty();
assertThat(logRepository.find(username)).isEmpty();
}
- joinV2를 호출하는데 이 메서드는 분명히 UnexpectedRollbackException을 던질 것이다.
- 그리고 데이터베이스에 Log 엔티티 뿐 아니라, Member 엔티티 역시 저장되지 않았을 것이다. 물리 트랜잭션은 롤백이 되기 때문에.
실행 결과
실행 결과를 보면 테스트는 통과하고 로그는 커밋을 시도했는데 트랜잭션에 rollback-only 마크가 찍혀있기 때문에 롤백을 한다라는 로그가 뚜렷히 보인다. 이 경우, 어떻게 해결할 수 있을까? 가장 간단한 건 바로 다음에 소개할 REQUIRES_NEW 전파 옵션을 사용하면 된다.
또다른 방법으로는 MemberRepository.save(), LogRepository.save() 이 두 개가 각각의 트랜잭션을 사용하면 된다. 아 물론 REQUIRES_NEW도 각각의 트랜잭션인데 MemberRepository.save()는 기존 트랜잭션에 참여하는 상태이고(왜냐하면, MemberService.joinV2()가 @Transactional이 있기 때문에 여기가 최초의 트랜잭션 시작점이라 그렇다) LogRepository.save()는 새로운 트랜잭션을 열어버리는게 REQUIRES_NEW인데, 아예 각각의 트랜잭션이 둘 다 물리 트랜잭션이 되게끔 MemberService.joinV2()에 있는 @Transactional을 없애버리면, 각각 트랜잭션이 둘 다 물리 트랜잭션이 되는 그런 경우로도 해결할 수 있다.
근데 이 방법의 문제는, 트랜잭션의 가장 중요한 특성인 원자성을 해친다는 점이다. 결국은 멤버를 저장하고, 로그를 저장하는 작업은 MemberService.joinV2()안에서 모두 일어나는 건데 이 joinV2()가 트랜잭션을 시작해야 이 안에서 일어난 모든 작업이 성공하거나 모든 작업이 실패하게 할 수 있다. 그런데 여기에 트랜잭션을 거는게 아니라, 각각의 save() 메서드에 걸어버리면 어떤건 성공하고 어떤건 실패하는 경우가 되는 것이다. 이게 만약 출금과 입금 작업이라면 큰 문제가 된다. 그러나 지금같은 비즈니스 규칙이 생겨서 로그를 저장하는데 실패하더라도 멤버는 회원가입 시키게 하고 싶다라는 룰이 프로젝트에 만들어졌으면 이런 방식으로도 해결을 할 수 있다는 말이다. 그래서 정답이 딱 있는게 아니라는 말을 하고 싶었다.
propagation - REQUIRES_NEW
REQUIRES_NEW는 위에서 설명한 REQUIRED와 다르게 새 트랜잭션을 사용하는 내부 로직이 있으면, 이전 트랜잭션과 완전히 분리된 새로운 트랜잭션에서 동작하는 방식이다. 즉, 또다른 커넥션이 사용된다.
기존 트랜잭션(A)이 있는 경우 해당 트랜잭션을 잠시 보류한 뒤 새로운 트랜잭션(B)을 만들어서 그 트랜잭션(B)을 사용하고 내부 로직이 모두 종료되는 시점에 트랜잭션(B)을 커밋 또는 롤백한 후 기존 트랜잭션(A)이 다시 사용된다.
그림으로 보면 다음으로 이해할 수 있다.
즉, 완전히 분리된 물리 트랜잭션을 가지며 어느 한쪽이 다른 한쪽에게 영향을 주지 않는다. 다시 말해 기존 물리 트랜잭션이 커밋이나 롤백을 하던, 새로운 물리 트랜잭션이 커밋이나 롤백을 하던 아무런 영향을 서로에게 주지 않는다. 자기 할 것을 한다. 이것도 코드로 한번 봐보자.
@Test
void required_new() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("outer.isNewTransaction() = {}", outer.isNewTransaction());
log.info("내부 트랜잭션 시작");
DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); // 새 트랜잭션을 만듦.
TransactionStatus inner = txManager.getTransaction(definition);
log.info("inner.isNewTransaction() = {}", inner.isNewTransaction());
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}
- 내부 트랜잭션(inner)을 시작할 때 전파 옵션인 propagationBehavior에 PROPAGATION_REQUIRES_NEW 옵션을 주었다.
- 이렇게 하면 내부 트랜잭션을 시작할 때 기존 트랜잭션에 참여하는 게 아니라 새로운 물리 트랜잭션을 만들어서 시작하게 된다.
- 당연히, 각각의 커밋과 롤백이 따로 수행된다.
실행 결과
- 실행 결과를 보면 외부 트랜잭션, 내부 트랜잭션의 각각 커넥션이 conn0, conn1로 다른 것을 알 수 있고, 내부 트랜잭션을 롤백할때 바로 트랜잭션 롤백 로그가 남는 것을 확인할 수 있다.
- 외부 트랜잭션을 커밋할때도 커밋 로그가 따로 남는 것을 확인할 수 있다.
주의
REQUIRES_NEW를 사용할 때 주의할 점은 커넥션이 쉽게 고갈될 수 있다. 만약 고객이 10명 접속했다고 하면 이 전파 옵션을 사용할 경우 쉽게 말해 20개의 커넥션을 사용할 수 있다. 내부 트랜잭션을 모두 이 옵션으로 사용한 경우에 해당하는 말이지만. 그래서, 이 옵션은 주의하면서 사용해야 한다.
문제 해결
아까 위에서 겪었던 MemberService, MemberRepository, LogRepository 이 문제를 REQUIRES_NEW로 해결해보자! 건드릴 부분은 딱 한 부분이다. LogRepository의 save() 메서드에 붙어있는 @Transactional에 전파 옵션을 REQUIRES_NEW로 변경만 해주면 된다. 아래 코드를 보자.
package cwchoiit.tx.propagation;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {
private final EntityManager em;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log logMessage) {
log.info("log 저장");
em.persist(logMessage);
if (logMessage.getMessage().contains("로그예외")) {
log.info("Log 저장 시 예외 발생");
throw new RuntimeException("예외 발생");
}
}
...
}
- @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용하므로써, 이 메서드가 호출되면 아예 새로운 커넥션을 만든다. 즉, MemberService.joinV2()에서 만든 커넥션과는 별개의 커넥션으로 동작하므로, 이 LogRepository.save()에서 롤백을 하더라도 MemberService.joinV2()에는 아무런 영향을 끼치지 않는다.
- 테스트 코드로 실행해보자.
/**
* memberService @Transactional: ON
* memberRepository @Transactional: ON
* logRepository @Transactional: ON (REQUIRES_NEW)
*/
@Test
void recover_exception_success() {
String username = "로그예외_recover_exception_success";
memberService.joinV2(username);
assertThat(memberRepository.findById(username)).isNotNull();
assertThat(logRepository.find(username)).isEmpty();
}
- joinV2()는 이제 어떠한 에러도 던지지 않을 것이다. 별개의 트랜잭션을 사용하기 때문에 LogRepository.save()가 롤백을 하더라도 아무런 영향을 받지 않는다.
- 그렇게 되면, 멤버는 잘 저장이 되고 로그는 남지 않은 그런 상태가 된다.
실행 결과
isolation
트랜잭션 격리 수준을 지정할 수 있다. 기본값은 데이터베이스에서 설정한 트랜잭션 격리 수준을 사용하는 'DEFAULT'이다. 대부분 데이터베이스에서 설정한 기준을 따른다. 애플리케이션 개발자가 트랜잭션 격리 수준을 직접 지정하는 경우는 드물다.
- DEFAULT: 데이터베이스에서 설정한 격리 수준을 따른다.
- READ_UNCOMMITTED: 커밋되지 않은 읽기 (특정 레코드를 변경 후 커밋하지 않았을 시점에서도 변경된 값을 적용해서 데이터베이스를 통해 읽어올 수 있음을 의미)
- READ_COMMITED: 커밋된 읽기 (특정 레코드를 변경 후 커밋하지 않으면 변경된 값이 아닌 직전 커밋 데이터를 읽음)
- REPEATABLE_READ: 반복 가능한 읽기
- SERIALIZABLE: 직렬화 가능
timeout
트랜잭션 수행 시간에 대한 타임아웃을 초 단위로 지정. 기본값은 트랜잭션 시스템의 타임아웃을 사용한다. 운영 환경에 따라 동작하는 경우도 있고 그렇지 않은 경우도 있기 때문에 꼭 확인하고 사용해야 한다.
label
트랜잭션 애노테이션에 있는 값을 직접 읽어서 어떤 동작을 하고 싶을 때 사용할 수 있다. 일반적으로 사용하지 않는다.
readOnly
트랜잭션은 기본적으로 읽기 쓰기가 모두 가능한 트랜잭션이 생성된다. `readOnly=true` 옵션을 사용하면 읽기 전용 트랜잭션이 생성된다. 이 경우 등록, 수정, 삭제가 안되고 읽기 기능만 동작한다. (드라이버나 데이터베이스에 따라 정상 동작하지 않는 경우도 있다) 그리고 이 옵션을 사용하면 읽기에서 다양한 성능 최적화가 발생할 수 있다.
readOnly 옵션은 크게 3가지에 적용된다.
- 프레임워크
- JdbcTemplate은 읽기 전용 트랜잭션 안에서 변경 기능을 실행하면 예외를 던진다.
- JPA는 읽기 전용 트랜잭션의 경우 커밋 시점에 `flush()`를 호출하지 않는다. 읽기 전용이니 변경에 사용되는 플러시를 호출할 필요가 없다. 추가로 변경이 필요 없으니 변경 감지를 위한 스냅샷 객체도 생성하지 않는다. 이렇게 JPA에서는 다양한 최적화가 발생
- JDBC 드라이버 (DB와 드라이버 버전에 따라 다르게 동작하기 때문에 사전 확인 필요)
- 읽기 전용 트랜잭션에서 변경 쿼리가 발생하면 예외를 던진다.
- 읽기, 쓰기 데이터베이스를 구분해서 요청한다. 읽기 전용 트랜잭션의 경우 읽기 데이터베이스의 커넥션을 획득해서 사용한다. 읽기 전용 트랜잭션의 경우 읽기 데이터베이스의 커넥션을 획득해서 사용한다.
- 데이터베이스
- 데이터베이스에 따라 읽기 전용 트랜잭션의 경우 읽기만 하면 되므로, 내부에서 성능 최적화가 발생한다.
체크 예외 / 언체크 예외(런타임 예외) 발생 시 커밋 또는 롤백
위 속성을 설명하면서 스프링 트랜잭션의 기본 설정으로 체크 예외가 발생하면 커밋, 언체크 예외가 발생하면 롤백한다고 했다.
실제 그렇게 동작하는지 확인해보자.
package com.example.springtx.exception;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.*;
@Slf4j
@SpringBootTest
public class RollbackTest {
@Autowired RollbackService rollbackService;
@Test
void runtimeException() {
assertThatThrownBy(rollbackService::runtimeException)
.isInstanceOf(RuntimeException.class);
}
@Test
void checkedException() {
assertThatThrownBy(rollbackService::checkedException)
.isInstanceOf(MyException.class);
}
@Test
void checkedRollbackFor() {
assertThatThrownBy(rollbackService::rollbackFor)
.isInstanceOf(MyException.class);
}
@TestConfiguration
static class RollbackTestConfig {
@Bean
RollbackService rollbackService() {
return new RollbackService();
}
}
static class RollbackService {
/**
* 런타임 예외(언체크 예외)시 롤백 확인
* */
@Transactional
public void runtimeException() {
log.info("call runtimeException");
throw new RuntimeException();
}
/**
* 체크 예외 발생 시 커밋 확인
* */
@Transactional
public void checkedException() throws MyException {
log.info("call checkedException");
throw new MyException();
}
/**
* 체크 예외 rollbackFor 지정 시 롤백 확인
* */
@Transactional(rollbackFor = MyException.class)
public void rollbackFor() throws MyException {
log.info("call checkedException rollbackFor");
throw new MyException();
}
}
static class MyException extends Exception {
}
}
RollbackService 클래스에는 세 개의 메소드가 있다. runtimeException(), checkedException(), rollbackFor().
runtimeException() 메소드를 실행하면 언체크 예외를 발생시키고 이는 롤백을 하게 된다.
그 결과를 확인하는 테스트 코드는 RollbackTest.runtimeException()이다.
그리고 실제 커밋을 하는지 롤백을 하는지 로그로 확인해보기 위해서는 다음 설정이 application.yml 파일에 필요하다.
logging:
level:
org.springframework.transaction.interceptor: TRACE
org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
org.springframework.orm.jpa.JpaTransactionManager: DEBUG
org.hibernate.resource.transaction: DEBUG
이제 실행해보자.
RollbackTest.runtimeException() 결과
로그를 살펴보면 rollback이 실행됐음을 알 수 있다.
그 다음, 체크 예외를 발생시키면 커밋을 하는 것을 확인하는 테스트 코드는 RollbackTest.checkedException()이다.
RollbackTest.checkedException() 결과
커밋 로그가 찍힌다.
이제 체크 예외를 발생시켰지만 rollbackFor로 해당 체크예외를 지정해 놓으면 커밋이 아닌 롤백을 하는지 확인해보는 테스트 코드는 RollbackTest.checkedRollbackFor()이다.
RollbackTest.checkedRollbackFor()결과
롤백 로그가 찍힌다.
그럼 왜 체크 예외는 커밋이고 언체크 예외는 롤백일까?
예외와 트랜잭션 커밋, 롤백
스프링은 왜 체크 예외는 커밋, 언체크 예외는 롤백할까?
스프링은 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임 예외는 복구가 불가능한 예외로 가정한다.
체크 예외 자체를 개발자가 이 예외가 터지는 것 자체가 비즈니스적으로 의미가 있다라고 판단한 예외라고 스프링은 가정하는 것.
비즈니스적으로 의미가 있다라고 판단한 예외란 또 어떤걸까? 아래와 같은 비즈니스 요구사항이 있다고 가정해보자.
비즈니스 요구사항
- 정상 처리: 주문시 결제를 성공하면 주문 데이터를 저장하고 결제 상태를 '완료'로 처리한다.
- 시스템 예외: 주문시 내부 복구 불가능한 예외(SQL 에러, 네트워크 에러, 시스템 장애 등)가 발생하면 전체 데이터를 롤백한다.
- 비즈니스 예외: 주문시 결제 잔고가 부족하면 주문 데이터를 저장하고 결제 상태를 '대기'로 처리한다. 이 경우, "고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내한다."
이 때, 결제 잔고가 부족하면 NotEnoughMoneyException 이라는 체크 예외가 발생한다고 가정해보면, 이 예외는 시스템에 문제가 있어서 발생하는 예외가 아니다. 시스템은 정상 동작해도 비즈니스 상황적으로 문제가 되기 때문에 발생한 예외이다. 이런 예외를 비즈니스 예외라고 한다. 위 요구사항에서 결제 상태를 대기로 처리하는 것 자체가 롤백될 이유가 없다는 소리다.
중요한 건 예외는 시스템 예외와 비즈니스 예외로 항상 분기되고 비즈니스 예외는 시스템적으로 문제가 생긴게 아닌것임을 인지하는것이 중요하다.
'Spring + Database' 카테고리의 다른 글
[Renewal] @Transactional 내부 호출 주의 (2) | 2024.12.11 |
---|---|
[Renewal] 스프링 트랜잭션 이해 (0) | 2024.12.08 |
[Renewal] 데이터 접근 기술에 대한 고민 두가지 (2) | 2024.12.08 |
[Renewal] Spring Data JPA (4) | 2024.12.07 |
[Renewal] MyBatis (4) | 2024.12.06 |