FireDrago
[Spring] 스프링 트랜잭션 전파 본문
서비스 계층과 리포지토리 계층이 있을때, 서비스 계층에만 트랜잭션을 적용하면 어떻게 될까?
서비스 계층에서 사용하는 여러 로직들이 하나의 트랜잭션으로 묶이고 그 중 하나라도 처리되지 않는 경우,
모든 로직들이 롤백 될 것이다.
만약 각각의 트랜잭션이 필요한 상황이라면 어떻게 해야할까?

클라이언트 A는 MemberService 를 통해 MemberRepository, LogRepository 를 하나의 트랜잭션으로 사용한다
반면, 클라이언트 B는 MemberRepository만 하나의 트랜잭션으로 사용하고 싶다.
또 클리아인트 C는 LogRepository만 하나의 트랜잭션으로 사용하고 싶어한다.
이런 상황을 위해서 스프링은 트랜잭션 전파 기능을 제공한다.
트랜잭션 전파 기능은 여러 트랜잭션이 적용되어 있는 경우, 최초 트랜잭션이 ( 외부 트랜잭션) 커밋과 롤백을 담당하고
이후의 트랜잭션 (내부 트랜잭션)은 직접 롤백, 커밋을 하지 않고, 롤백시 rollbackOnly 기록을 넘기는 것을 말한다.
트랜잭션 전파 기능이 적용되는 여러 상황별 코드들을 살펴보자
트랜잭션 전파 - 커밋
public class MemberService {
private final MemberRepository memberRepository;
private final LogRepository logRepository;
@Transactional
public void joinV1(String username) {
Member member = new Member(username);
Log logMessage = new Log(username);
log.info("== memberRepository 호출 시작 ==");
memberRepository.save(member);
log.info("== memberRepository 호출 종료 ==");
log.info("== logRepository 호출 시작 ==");
logRepository.save(logMessage);
log.info("== logRepository 호출 종료 ==");
}
}
/**
* MemberService @Transactional:ON
* MemberRepository @Transactional:ON
* LogRepository @Transactional:ON
*/
@Test
void outerTxOn_success() {
//given
String username = "outerTxOn_success";
//when
memberService.joinV1(username);
//then : 모든 트랜잭션이 커밋
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isPresent());
}
MemeberService , MemberRepository, LogRepository 모두 트랜잭션을 적용하고
MemberService의 joinV1 메서드를 호출했다.

1. @Transactional 애너테이션이 있으므로 각각의 트랜잭션은 모두 트랜잭션 프록시 객체가 생성된다.
2. 최초의 트랜잭션인 MemberService 트랜잭션이 외부트랜잭션, 나머지 트랜잭션은 내부트랜잭션이 된다.
3. 이렇게 두개이상의 트랜잭션이 존재하는경우, 모든 트랜잭션은 논리트랜잭션이 되고, 하나의 물리트랜잭션으로 묶인다.
4. 내부 트랜잭션인 MemberRepository와 LogicRepository 는 커밋상황에서도 신규트랜잭션이 아니므로, 커밋을 호출하지 않는다.
5. 이 로직의 커밋 요청은 오로지 최초트랜잭션인 MemberService 프록시가 요청한다.
트랜잭션 전파 - 롤백
그렇다면, 내부 트랜잭션(나중에 나오는 트랜잭션)중 하나가 롤백되는 상황도 살펴보자
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("예외발생");
}
}
}
/**
* MemberService @Transactional:ON
* MemberReposioty @Transactional:ON
* LogRepository @Transactional:ON Exception
*/
@Test
void outerTxOn_fail() {
//given : 예외를 유발하는 유저네임으로 예외발생
String username = "로그예외_outerTxOn_fail";
//when
assertThatThrownBy(() -> memberService.joinV1(username))
.isInstanceOf(RuntimeException.class);
//then: 모든 데이터가 롤백된다.
assertTrue(memberRepository.find(username).isEmpty());
assertTrue(logRepository.find(username).isEmpty());
}

MemberRepository 에서는 커밋요청이 되었으나, LogRepository에서 예외가 발생하여 롤백이 요청되는 상황이다.
1. LogRepository 에서 롤백을 호출하는 것이 아니라, rollbackOnly 설정을 한다.
2. 외부트랜잭션 (최초의 트랜잭션)인 MemberService 프록시객체는 항상 rollbackOnly 설정이 있는지 검사한다.
3. rollbackOnly 설정이 있으므로, 롤백을 실행하고, MemberRepository, LogRepository 모두 롤백된다.
사실 위 코드의 경우 MemberService 까지 전달된 예외를 처리하지 않으므로, rollbackOnly 설정 보기전에 롤백요청이 넘어간다.
트랜잭션 전파 - 트랜잭션 복구 REQUIRED, REQUIRES_NEW
@Transactional 을 사용하게 되면, 우리는 기본값으로 REQUIRED 설정을 사용하게된다.
REQUIRED 설정은 기존 트랜잭션이 없으면 트랜잭션을 생성하고, 기존 트랜잭션이 있으면 기존 트랜잭션에 참여한다.
같은 동기화 커넥션을 사용한다는 의미이다.
그런데 이런 상황을 상상해보자 로직기능에 문제가 생겨, 고객들이 회원가입 기능 전체를 사용할 수 없는 상황이 발생했다
로직기능을 하나의 물리트랜잭션에서 분리하여 따로 분리하되, 고객들이 회원가입기능을 사용할 수 있게 하고싶은 상황이
라면 어떻게 로직기능의 트랜잭션을 분리할 수 있을까? 로그를 남기지 않더라도 회원가입이 가능하게 하고 싶은 것이다.
<예외처리?>
MemberService에서 LogRepository 에서 발생한 예외를 처리해준다면 가능할까?
결론부터 말하면 그렇지 않다. 바로 rollbackOnly 설정때문이다.

서비스 계층에서 예외를 처리했더라도, 트랜잭션 매니저는 rollbackOnly 설정을 확인하고, rollbackOnly 설정을 없앤것은
아니기에 여전히 외부 트랜잭션은 롤백을 수행한다. 추가적으로 예외처리를 하여 예외발생이 없는데 rollbackOnly 설정만
있으므로 UnexpectedRollbackException 까지 발생시킨다.
<REQUIRES_NEW>

이런 특수한 상황에서 @Transactional(propagation = Propagation.REQUIRES_NEW) 설정을 사용한다.
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("예외발생");
}
}
}
이렇게 LogRepository의 로직을 별도의 트랜잭션을 사용하도록 분리했다.
/**
* MemberService @Transactional:ON
* MemberRepository @Transactional:ON
* LogRepository @Transactional(REQUIRES_NEW) Exception
*/
@Test
void recoverException_success() {
//given
String username = "로그예외_recoverException_success";
//when
memberService.joinV2(username);
//then: member 저장, log 롤백 따로 커밋,롤백되었다.
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
테스트 결과 MemberRepository 의 저장 로직은 커밋되었고, LogRepository의 로그 저장기능은 롤백되었다.
별도의 트랜잭션을 사용한 것이다.
---주의--
REQUIRES_NEW 를 사용하면 하나의 HTTP 요청에 동시에 2개의 데이터베이스 커넥션을 사용하게 된다.
성능이 중요한 곳에서는 이런 부분을 주의해서 사용해야 한다.
REQUIRES_NEW 를 사용하지 않고 문제를 해결할 수 있는 방법이 있다면, 그 방법을 선택하는 것이 더 좋다
'프로그래밍 > Spring' 카테고리의 다른 글
| [Spring] 동적 프록시 (JDK 동적 프록시, CGLIB) (0) | 2024.05.14 |
|---|---|
| [Spring] 동시성 문제와 쓰레드 로컬 (Thread Local) (0) | 2024.05.07 |
| [Spring] 스프링 트랜잭션 이해 (0) | 2024.03.15 |
| [Spring] 스프링 데이터 JPA (0) | 2024.03.11 |
| [Spring] MyBatis 사용하기 (0) | 2024.03.07 |
