스프링에서 트랜잭션을 사용중일때 추가로 트랜잭션을 수행하는 경우 어떻게 동작할지를 결정하는 것을 트랜잭션 전파(Propagation)이라고 한다.
트랜잭션 전파에 대한 개념을 알기 전에 외부 트랜잭션과 내부 트랜잭션에 대한 개념을 알아야 한다.
외부 트랜잭션이 수행중이고 아직 끝나지 않았는데 트랜잭션이 추가로 수행되면 이 트랜잭션을 내부 트랜잭션이라고 한다.
내부 트랜잭션은 외부에 트랜잭션이 수행되고 있는 도중에 호출되기 때문에 마치 내부에 있는 것 처럼 보여 내부 트랜잭션이라 한다.
스프링에서는 외부 트랜잭션과 내부 트랜잭션을 아래 그림과 같이 하나의 트랜잭션으로 묶어서 만들어준다.
이러한 개념을 바탕으로 스프링은 논리 트랜잭션과 물리 트랜잭션이라는 개념을 사용한다.
이러한 개념을 도입했을때 지켜야 하는 원칙이 존재한다.
- 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.
- 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.
각각의 상황에 대한 예시를 통해 알아보자.
외부 트랜잭션 도중 내부 트랜잭션 커밋
@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);
}
위 코드를 그림으로 나타내면 다음과 같다.
--- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 외부 트랜잭션 시작
--- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
--- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Acquired Connection [HikariProxyConnection@134963352 wrapping conn0: url=jdbc:h2:mem:665fcc57-8816-49c1-b034-05c41543e70f user=SA] for JDBC transaction
--- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Switching JDBC Connection [HikariProxyConnection@134963352 wrapping conn0: url=jdbc:h2:mem:665fcc57-8816-49c1-b034-05c41543e70f user=SA] to manual commit
--- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : outer.isNewTransaction()=true
--- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 내부 트랜잭션 시작
--- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Participating in existing transaction
--- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : inner.isNewTransaction()=false
--- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 내부 트랜잭션 커밋
--- [springtx] [ Test worker] hello.springtx.propagation.BasicTxTest : 외부 트랜잭션 커밋
--- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Initiating transaction commit
--- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Committing JDBC transaction on Connection [HikariProxyConnection@134963352 wrapping conn0: url=jdbc:h2:mem:665fcc57-8816-49c1-b034-05c41543e70f user=SA]
--- [springtx] [ Test worker] o.s.j.d.DataSourceTransactionManager : Releasing JDBC Connection [HikariProxyConnection@134963352 wrapping conn0: url=jdbc:h2:mem:665fcc57-8816-49c1-b034-05c41543e70f user=SA] after transaction
로그를 보면 내부 트랜잭션이 시작될때는 트랜잭션을 새롭게 생성하는 것이 아니라
먼저 생성된 외부 트랜잭션에 참여하기 때문에 Participating in existing transaction 이라는 로그가 보인다.
그리고 내부 트랜잭션이 커밋을 수행할때는 아무 일이 일어나지 않고 외부 트랜잭션을 커밋할때 물리 트랜잭션이 정상적으로 커밋되는 것을 확인할 수 있다.
만약 내부 트랜잭션이 실제 물리 트랜잭션을 커밋하면 트랜잭션이 끝나버리기 때문에 트랜잭션을 처음 시작한 외부 트랜잭션까지 이어갈 수 없는 문제 가 발생한다.
따라서 스프링은 처음 트랜잭션을 시작한 외부 트랜잭션이 실제 물리 트랜잭션을 관리하도록 함으로써,
트랜잭션 중복 커밋 문제를 해결한다.
※내부 트랜잭션에서 로직 수행 후 커밋을 해도 커밋이 호출되지 않고, 외부 트랜잭션이 커밋될때 같이 커밋된다고 생각하면 쉽다
내부 트랜잭션이나 외부 트랜잭션중 한곳에서 롤백을 하는 경우
이러한 경우는 위에서 말했듯이 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다는 원칙때문에 한곳에서 커밋을 하더라도 같이 롤백이 수행된다.
<내부 트랜잭션 커밋 후 외부 트랜잭션 롤백>
@Test
void outer_rollback(){
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); //커밋 수행 X
log.info("외부 트랜잭션 롤백");
txManager.rollback(outer);
}
<실행 결과>
외부 트랜잭션 시작
.
.
내부 트랜잭션 시작
Participating in existing transaction
inner.isNewTransaction()=false
내부 트랜잭션 커밋
외부 트랜잭션 롤백
Initiating transaction rollback
Rolling back JDBC transaction on Connection [HikariProxyConnection@134963352 wrapping conn0: url=jdbc:h2:mem:d6143f3c-2b01-4f7a-acb6-e456d745fbfa user=SA]
Releasing JDBC Connection [HikariProxyConnection@134963352 wrapping conn0: url=jdbc:h2:mem:d6143f3c-2b01-4f7a-acb6-e456d745fbfa user=SA] after transaction
내부 트랜잭션에서 커밋을 해도 외부 트랜잭션에서 롤백을 수행하여 트랜잭션이 롤백되는 것을 확인 할 수 있다.
내부에서 롤백을 수행할때도 마찬가지이다.
<내부에서 롤백, 외부에서 커밋>
@Test
void inner_rollback(){
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.rollback(inner); //rollbackOnly 마킹
log.info("외부 트랜잭션 커밋");
//txManager.commit(outer); //커밋 안됨, 런타임 예외를 던짐
assertThatThrownBy(()->txManager.commit(outer))
.isInstanceOf(UnexpectedRollbackException.class);
}
<실행결과 로그>
로그를 보면 내부 트랜잭션에서 롤백을 수행하면 marking existing transaction as rollback-only 이러한 로그가 출력되는 것을 확인할 수 있다.
만약 내부 트랜잭션에서 롤백이 수행되면 rollbackOnly=true로 설정하여 외부 트랜잭션에서 커밋을 해도 롤백이 수행된다.
스프링은 이러한 상황에 UnexpectedRollbackException 런타임 예외를 던져서 커밋이 수행되지 않고 롤백이 발생했다는 것을 명확하게 알려준다.
스프링 트랜잭션 전파 - REQUIRES_NEW
위에서는 한 곳에서 롤백이 발생하면 모두 롤백이 되어버리는 문제가 있었는데,
한곳에서 롤백이 일어나도 다른 곳에서 커밋을 할 수 있는 방법이 있다.
그것은 바로 외부 트랜잭션과 내부 트랜잭션을 완전히 분리하여 사용하는 방법이다!!
<REQUIRES_NEW 테스트>
@Test
void inner_rollback_requires_new(){
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("outer.isNewTransaction()={}", outer.isNewTransaction());
log.info("내부 트랜잭션 시작");
DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
definition.setPropagationBehavior(DefaultTransactionAttribute.PROPAGATION_REQUIRES_NEW); //신규 트랜잭션을 만들어버림
TransactionStatus inner = txManager.getTransaction(definition);
log.info("inner.isNewTransaction()={}", inner.isNewTransaction()); //새로운 트랜잭션
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner); //롤백
log.info("외부 트랜잭션 커밋");
txManager.commit(outer); //커밋
}
결과를 보면 실제로 내부 트랜잭션은 롤백이 수행되고 그 뒤 외부 트랜잭션은 커밋이 되는 것을 확인할 수 있다.
@Transactional(propagation=Propagation.REQUIRES_NEW) 사용해보기
<LogRepository>
@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("예외 발생");
}
}
LogRepository의 save에서는 REQUIRES_NEW를 이용해서 새로운 트랜잭션에서 수행되도록함.
<MemberRepository>
@Transactional
public void save(Member member){
log.info("member 저장");
em.persist(member);
}
MemberRepository에서는 기본값인 REQUIRES를 적용하여 외부 트랜잭션과 동일한 트랜잭션을 사용
<MemberService>
@Transactional
public void joinV2(String username) {
Member member = new Member(username);
Log logMessage = new Log(username);
log.info("== memberRepository 호출 시작 ==");
memberRepository.save(member);
log.info("== memberRepository 호출 종료 ==");
log.info("== logRepository 호출 시작 ==");
try {
logRepository.save(logMessage);
}
catch (RuntimeException e) {
log.info("log 저장에 실패했습니다. logMessage={}",
logMessage.getMessage());
log.info("정상 흐름 변환");
}
log.info("== logRepository 호출 종료 ==");
}
MemberService의 join은 내부 트랜잭션의 역할을 한다.
<테스트 코드>
/**
* MemberService @Transactional:ON
* MemberRepository @Transactional:ON
* LogRepository @Transactional:ON(REQUIRES_NEW) Exception
*/
@Test
void recoverException_success() {
//given
String username = "로그예외_outerTxOff_fail";
//when
memberService.joinV2(username);
//then: member 저장, log 롤백
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
이 테스트를 수행하면 memberRepository에서의 save는 정상적으로 커밋이 수행되었지만 logRepository의 save는 롤백된것을 확인할 수 있다.
왜냐하면 예외가 발생한 LogRepository의 save는 REQUIRES_NEW옵션을 통해 물리적으로 완전히 다른 트랜잭션으로 분리가 되었기 때문에 LogRepository의 save만 롤백이 일어나고 MemberRepository의 save는 정상적으로 수행되고 커밋이 되었다.
'BackEnd > Database' 카테고리의 다른 글
Spring 트랜잭션[@Transactional] (0) | 2025.02.12 |
---|---|
QueryDSL (1) | 2025.02.10 |
Spring Data JPA (0) | 2025.02.10 |
MyBatis (0) | 2025.02.08 |
DB Test[@Transactional, 임베디드 모드 DB] (0) | 2025.02.07 |