REQUIRES_NEW에 대한 오해
REQUIRES_NEW를 사용하면 물리적으로 트랜잭션이 분리되어 분리한 트랜잭션의 예외가
이 트랜잭션을 호출한 상위 트랜잭션에 전파되지 않는다고 이해하고 있었다.
결론부터 말하면 아니였다.
어떤식으로 트랜잭션 흐름이 진행되는지 한번 알아보자.
@Service
@RequiredArgsConstructor
public class MeetingService {
private final MeetingRepository meetingRepository;
private final MemberService memberService;
@Transactional
public void save() {
meetingRepository.save(new Meeting("스터디 모임", LocalDate.now(), LocalTime.now(), "AB3AS2EG"));
memberService.save();
}
@Transactional(readOnly = true)
public List<Meeting> findAll() {
return meetingRepository.findAll();
}
}
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save() {
memberRepository.save(new Member("카키"));
throw new IllegalArgumentException();
}
@Transactional(readOnly = true)
public List<Member> findAll() {
return memberRepository.findAll();
}
}
위 코드를 보면 MeetingService가 meeting을 저장하고
MemberService를 호출해 Member를 저장하고 있다.
이 MemberService의 save()는 MeetingService의 트랜잭션과 분리되어 있다.
그럼 정말 분리된 트랜잭션의 예외가 이를 호출한 상위 트랜잭션에 영향을 안주는지 확인해보자.
@Test
void test() {
assertThatThrownBy(() -> meetingService.save())
.isInstanceOf(IllegalArgumentException.class);
// 롤백 여부 확인
List<Meeting> meetings = meetingService.findAll();
List<Member> members = memberService2.findAll();
assertThat(meetings).isEmpty();
assertThat(members).isEmpty();
}
트랜잭션 흐름을 자세히 확인하기 위해 아래처럼 로깅 옵션을 추가했다.
logging:
level:
org.springframework.transaction: TRACE
org.springframework.orm.jpa: DEBUG
결과
출력된 로그를 분석해보았다.
// MeetingService 트랜잭션 시작 - PROPAGATION_REQUIRED
[o.s.orm.jpa.JpaTransactionManager] - Creating new transaction with name [com.ody.test.MeetingService.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[o.s.orm.jpa.JpaTransactionManager] - Opened new EntityManager [SessionImpl(223855256<open>)] for JPA transaction
// SimpleJpaRepository.save 호출 및 Meeting 저장 트랜잭션 실행
[o.s.t.i.TransactionInterceptor] - Getting transaction for [com.ody.test.MeetingService.save]
[o.s.orm.jpa.JpaTransactionManager] - Found thread-bound EntityManager [SessionImpl(223855256<open>)] for JPA transaction
[o.s.orm.jpa.JpaTransactionManager] - Participating in existing transaction
[o.s.t.i.TransactionInterceptor] - Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
[o.s.t.i.TransactionInterceptor] - Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
// MeetingService 트랜잭션 일시 중지
[o.s.orm.jpa.JpaTransactionManager] - Suspending current transaction, creating new transaction with name [com.ody.test.MemberService2.save]
// MemberService 트랜잭션 시작
[o.s.orm.jpa.JpaTransactionManager] - Opened new EntityManager [SessionImpl(1803331398<open>)] for JPA transaction
// SimpleJpaRepository.save 호출 및 Member 저장 트랜잭션 실행
[o.s.t.i.TransactionInterceptor] - Getting transaction for [com.ody.test.MemberService.save]
[o.s.orm.jpa.JpaTransactionManager] - Found thread-bound EntityManager [SessionImpl(1803331398<open>)] for JPA transaction
[o.s.orm.jpa.JpaTransactionManager] - Participating in existing transaction
[o.s.t.i.TransactionInterceptor] - Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
[o.s.t.i.TransactionInterceptor] - Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
// Member 저장 트랜잭션 수행 중 예외 발생 -> 롤백
[o.s.t.i.TransactionInterceptor] - Completing transaction for [com.ody.test.MemberService.save] after exception: java.lang.IllegalArgumentException
[o.s.orm.jpa.JpaTransactionManager] - Initiating transaction rollback
[o.s.orm.jpa.JpaTransactionManager] - Rolling back JPA transaction on EntityManager [SessionImpl(1803331398<open>)]
[o.s.orm.jpa.JpaTransactionManager] - Closing JPA EntityManager [SessionImpl(1803331398<open>)] after transaction
// 내부 트랜잭션 완료 후 MeetingService 트랜잭션 재개 및 롤백
[o.s.orm.jpa.JpaTransactionManager] - Resuming suspended transaction after completion of inner transaction
[o.s.t.i.TransactionInterceptor] - Completing transaction for [com.ody.test.MeetingService.save] after exception: java.lang.IllegalArgumentException
[o.s.orm.jpa.JpaTransactionManager] - Initiating transaction rollback
[o.s.orm.jpa.JpaTransactionManager] - Rolling back JPA transaction on EntityManager [SessionImpl(223855256<open>)]
[o.s.orm.jpa.JpaTransactionManager] - Closing JPA EntityManager [SessionImpl(223855256<open>)] after transaction
결과를 요약하면 다음과 같다.
- MemberService의 트랜잭션은 독립적으로 롤백
- 발생한 예외가 MeetingService로 전파
- 전파된 예외로 인해 MeetingService의 트랜잭션도 롤백
그렇다면 왜 독립된 트랜잭션의 영향을 받은 걸까?
바로 Java의 예외 전파 메커니즘 때문이다.
Spring은 Thread마다 독립적인 트랜잭션 컨텍스트를 유지하기 위해 트랜잭션을 쓰레드 로컬로 관리한다.
현재 위 트랜잭션은 분리되어 있지만 하나의 스레드에서 생긴 커넥션이다.
따라서 분리된 트랜잭션 MemberService.save()의 예외가 상위인 MeetingService.save()로 전파가 된 것이다.
그럼 예외가 전파되지 않게 하려면?
예외가 전파되지 않게 하려면 간단하다.
예외를 잡아 처리하면 된다.
@Transactional
public void save() {
meetingRepository.save(new Meeting("스터디 모임", LocalDate.now(), LocalTime.now(), "AB3AS2EG"));
try {
memberService.save();
} catch (IllegalArgumentException exception) {
// 예외 처리
}
}
REQUIRES_NEW 사용시 주의할 점
REQUIRES_NEW 옵션은 트랜잭션을 하나 더 열고, 이는 곧 2개의 커넥션을 사용한다는 의미이다.
여기서 문제가 발생할 수 있는데
Hikari 커넥션 풀이 스레드에 의해 모두 사용 중인 상태인데 다른 커넥션들이 요청을 하면 아래와 같은 문구를 볼 수 있다.
HikariPool-1 - Connection is not available, request timed out after 30013ms (total=10, active=10, idle=0, waiting=0)
Hikari의 기본 커넥션 풀 사이즈는 10개, 그리고 타임아웃은 30초다.
즉, 새로운 커넥션 요청이 들어왔지만 사용 가능한 커넥션이 없어서 타임아웃이 발생한 것이다.
기본적으로 타임아웃이 설정되어는 있지만 타임아웃이 없다면 교착 상태로 이어질 수도 있다.
이로 인해 서비스에는 예상치 못한 장애를 초래할 수 있다.
타임 아웃을 설정한다면 교착 상태까지는 이어지진 않겠지만, 커넥션을 2개씩 쓰기 때문에
현재 서비스에 맞게 커넥션 풀을 설정했다하더라도 예상치 못하게 커넥션 부족 현상으로 이어질 수 있다.
아래 글에서도 해당 옵션에 대해 열띤 토론이 이어져온 것을 볼 수 있다.
https://github.com/spring-projects/spring-framework/issues/26250
이런 REQRUIES_NEW의 잠재적인 문제를 피하면서 트랜잭션 분리가 필요하다면 어떤 방식을 적용할 수 있을까?
@Transactional + @Async
비동기로 처리될 수 있는 트랜잭션 로직이라면 @Transactional + @Async 조합으로 해결할 수 있다.
스프링에서 트랜잭션은 스레드 로컬로 관리되는데 @Async로 새로운 스레드에서 작업을 수행하도록 해
해당 스레드에서 따로 커넥션을 열도록 할 수 있다.
하지만 이 경우엔 이를 호출한 메서드가 결과가 필요하다면 적합하지 않다.
CompletableFuture 객체를 사용해 동기 방식으로 반환 값 전달할 순 있지만
어차피 동긴데 굳이 스레드를 두 개로 나눠 리소스를 소비하는 것은 @Async를 적용할 이유 까진 아닌 것 같다.
이벤트 리스너를 활용한 방식
이벤트 리스너 방식을 사용해 트랜잭션을 분리 할 수도 있다.
@TransactionalEventListener와 @Transactional을 조합해
TransactionalEventListener의 아래와 같은 옵션으로 분리될 트랜잭션의 시작 시점을 정할 수 있다.
AFTER_COMMIT: 메인 트랜잭션 커밋 후
AFTER_ROLLBACK: 롤백 후
AFTER_COMPLETION: 트랜잭션 완료 후
BEFORE_COMMIT: 커밋 전
옵션을 보면 알 수 있듯이 트랜잭션 커밋/롤백 전 후로만 트랜잭션을 분리해 실행할 수 있어
순차적인 로직 흐름이라면 적합하지 않다.
개발은 항상 최선의 선택지만 존재하진 않는 것 같다.
트랜잭션 분리가 필요하다면 어떤 방식을 선택할 것인지는 현재 서비스 상황에 맞게 트레이드 오프를 고려해 적절한 방법을 선택하자.
'◼ Spring' 카테고리의 다른 글
분산락을 적용해 동시성 문제 해결하기 (3) | 2024.11.20 |
---|---|
외부 API 의존성 분리 및 장애 대응 체계 구축하기 (0) | 2024.11.19 |
[Spring] TestContainers로 Redis 테스트하기 (2) | 2024.11.14 |
개발 운영 환경과 비슷한 로컬 환경 구축하기 (feat. TestContainer) (4) | 2024.10.16 |
[Spring] DB Replication으로 분리된 Read / Write 연결 적용하기 (5) | 2024.09.25 |