⬜ 섹션11 스프링 트랜잭션 전파2 - 활용
◼️ 예제 프로젝트 시작
▪️비즈니스 요구사항
- 회원을 등록하고 조회
- 회원 변경 이력 추적을 위해 회원 데이터 변경시 변경 이력을 DB LOG 테이블에 남긴다.
- (여기서는 예제 단순화를 위해 회원 등록시에만 DB LOG에 남김)
▪️베이스 실습 코드
- 실습을 위해 Member,MemberRepository,Log,LogRepository,MemberService를 만들어주었다.
- 각 레포지토리에는 save(), find() 함수를 만들었다.
- 이때, 실습을 위해 아래 코드처럼 로그레포지토리에는 "로그예외" 단어를 포함하면 런타임예외를 발생시킨다.
🔵CASE 1 : 서비스 계층에 트랜잭션 없을 때 - 커밋
@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().findAny();
}
}
@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final LogRepository logRepository;
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 호출 종료 ==");
}
// DB 로그 저장시 예외 발생 -> 예외 복구
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 호출 종료 ==");
}
}
@Slf4j
@SpringBootTest
public class MemberServiceTest {
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Autowired
LogRepository logRepository;
/**
* MemberService @Transactional:OFF
* MemberRepository @Transactional:ON
* LogRepository @Transactional:ON
* */
@Test
void outerTxOff_success(){
//given
String username = "outerTxOff_success";
//when
memberService.joinV1(username);
//then: 모든 데이터가 정상 저장된다.
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isPresent());
}
}
🪄메모리 DB를 사용하기 때문에, 스프링 테스트는 모든 테스트가 완료되어야 DB가 사라진다.
JPA를 통한 모든 데이터 변경은 트랜잭션이 필요하다. (현재는 리포지토리에 트랜잭션이 있다.)
◼️ 커밋, 롤백
위의 코드 상황을 자세히 살펴보자. 서비스는 트랜잭션이 없고, 각 레포지토리에 트랜잭션이 있다.
🔶동작 순서
- @Transactional 어노테이션이 있으면 트랜잭션 AOP가 작동한다.
- 트랜잭션 매니저에 트랜잭션 요청시, 트랜잭션 동기화 매니저를 통해 커넥션을 보관한다.
- 이때 커밋시 신규 트랜잭션 여부, rollbackOnly 여부를 모두체크한다.
- 트랜잭션 매니저가 con1을 통해 물리 트랜잭션을 커밋한다.
🔵CASE 2 : 서비스 계층에 트랜잭션이 없을 때 - 롤백
/**
* MemberService @Transactional:OFF
* MemberRepository @Transactional:ON
* LogRepository @Transactional:ON Exception
* */
@Test
void outerTxOff_fail() {
//given
String username = "로그예외_outerTxOff_fail";
//when
assertThatThrownBy(() -> memberService.joinV1(username))
.isInstanceOf(RuntimeException.class);
//then: 완전히 롤백되지 않고, member 데이터가 남아서 저장된다.
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
🔶동작 순서
- LogRepository는 예외를 밖으로 던지고, 이 경우 트랜잭션 AOP가 예외를 받게된다.
- 런타임 예외가 발생해 트랜잭션 AOP는 트랜잭션 매니저에 롤백을 호출한다.
- 트랜잭션 매니저는 신규 트랜잭션이므로 물리 롤백을 호출한다.
🔵CASE 3 : 단일 트랜잭션
회원 이력 로그 롤백시에도 회원은 저장된다. 데이터 정합성을 위해 둘을 하나의 트랜잭션으로 묶어 처리해보자.
/**
* MemberService @Transactional:ON
* MemberRepository @Transactional:OFF
* LogRepository @Transactional:OFF
* */
@Test
void singleTx() {
//given
String username = "singleTx";
//when
memberService.joinV1(username);
//then: 모든 데이터가 정상 저장된다.
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isPresent());
}
좀더 자세히보면, @Transactional이 있는 MemberService에서 트랜잭션AOP가 적용되고, 시작~끝까지 각 레포지토리는 같은 커넥션을 공유한다. (+참고로, 같은 쓰레드 사용시 트랜잭션 동기화 매니저는 같은 커넥션을 반환한다.)
🔵CASE 4 : 각각 트랜잭션이 필요한 상황
다음과 같이 클라이언트 A,B,C가 있고 각각 사용하려는 트랜잭션 적용 범위가 다르다고 가정해보자.
클라이언트 B,C는 각 레포지토리에만 트랜잭션을 사용하고싶어한다.
이런 문제 해결을 위해!!! 트랜잭션 전파가 존재하는 것이다!!!
◼️ 트랜잭션 전파 활용 - 전파 커밋,전파 롤백
잠시 배웠던 내용 복습을 해보자!!
🟢트랜잭션 규칙
- 같은 물리 트랜잭션 사용 == 같은 동기화 커넥션 사용
- 둘이상의 트랜잭션이 하나의 물리트랜잭션에 묶이게되면, 논리&물리 트랜잭션으로 구분한다.
- 외부 신규 트랜잭션만 실제 물리 트랜잭션을 시작하고 커밋한다.
- 모든 논리 트랜잭션을 커밋해야 물리 트랜잭션도 커밋된다. 하나라도 롤백되면 물리도 롤백된다.
/**
* 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());
}
각 레포지토리 호출시, 트랜잭션 AOP가 호출된다. 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여하게된다.
그리고 트랜잭션 매니저에 커밋시, 신규 트랜잭션이 아니므로 실제 커밋을 호출하지않는다.
트랜잭션AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이므로 물리 커밋을 호출한다.
이제 로그 리포지토리에서 예외가 발생해 전체 트랜잭션이 롤백되는 경우를 보자.
/**
* MemberService @Transactional:ON
* MemberRepository @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());
}
- LogRepository 로직에서 런타임 예외 발생 시, 예외를 던지면 트랜잭션 AOP가 해당 예외를 받는다.
- 트랜잭션 AOP는 런타임 예외 발생시, 트랜잭션 매니저에 롤백을 요청한다.
- 이때 신규 트랜잭션이 아니므로 롤백 호출을 하지않고 rollbackOnly를 설정한다.
- 트랜잭션 AOP도 예외를 밖으로 던진다.
- MemberService는 런타임예외를 받고, 트랜잭션 매니저에 롤백을 요청한다.
- 트랜잭션 매니저는 신규 트랜잭션이므로 물리 롤백을 호출한다.
◼️ 트랜잭션 전파 활용 - 복구 REQUIRED
현재는 회원가입 로그에 문제가 발생하면 회원가입이 되지 않는다.
회원가입을 시도한 로그를 남기는데 실패해도 회원 가입은 유지되게 해보자.
단순히 생각하면 멤버서비스에서 로그리포지토리 발생예외를 잡아서 처리하면 될것같지만, 논리 트랜잭션 일부가 실패하면 물리 트랜잭션도 롤백되기때문에 이는 해결되지 않는다.
위 그림에서 볼 수 있듯, 내부 트랜잭션에서 rollbackOnly를 설정하기 때문에 물리 트랜잭션은 롤백된다.
내부 트랜잭션이 롤백 되었는데(rollbackOnly), 외부 트랜잭션이 커밋되면 UnexpectedRollbackException 예외가 발생한다.
◼️ 트랜잭션 전파 활용 - 복구 REQUIRES_NEW
그렇다면 '회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 한다.'는 조건은 어떻게 만족시킬까.
REQUIRES_NEW는 항상 새로운 트랜잭션을 만들기 때문에, 해당 트랜잭션 안에서는 DB 커넥션도 별도 사용하게 된다.
위처럼 REQUIRES_NEW를 사용하면 물리 트랜잭션이 분리되기때문에, rollbackOnly 표시가 되지않는다. 그저 해당 트랜잭션이 물리 롤백되고 끝날 뿐이다.
🔶동작 순서
- REQUIRES_NEW를 사용한 신규트랜잭션에서 예외가 발생하면 물리 롤백한다.
- 이후 트랜잭션 AOP는 전달받은 예외를 밖으로 던진다.
- 그리고 MemberService는 해당 예외 복구 후 정상흐름을 반환한다.
- 커밋을 호출할때 rollbackOnly를 체크하는데, rollbackOnly가 없으므로 물리 트랜잭션을 커밋한다.
/**
* 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());
}
//LogRepository
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Member member){
log.info("member 저장");
em.persist(member);
}
🟢정리
- 논리 트랜잭션은 하나라도 롤백되면 관련된 물리 트랜잭션은 롤백되어 버린다.
- 이 문제를 해결하려면 REQUIRES_NEW 를 사용해서 트랜잭션을 분리해야 한다.
🟢주의
- REQUIRES_NEW를 사용하면 하나의 HTTP 요청에 동시에 2개의 데이터베이스 커넥션을 사용하게 된다. 따라서 성능이 중요한 곳에서는 이런 부분을 주의해서 사용해야 한다.
- REQUIRES_NEW 를 사용하지 않고 문제를 해결할 수 있는 단순한 방법이 있다면, 그 방법을 선택하는 것이 더 좋다.
🤔 심화 개별 학습: MemberFacade??
교안에 코드는없었지만 위 상황이 궁금해서 추가로 코드를 작성 해봤다...
@Slf4j
@Component
@RequiredArgsConstructor
public class MemberFacade {
private final MemberService memberService;
private final LogRepository logRepository;
public void registerMemberV2(String username) {
log.info("== registerMemberV3 시작 ==");
memberService.joinV3(username);
log.info("== registerMemberV3 종료 ==");
log.info("== logRepository 호출 시작 ==");
Log logMessage = new Log(username);
try{
logRepository.save(logMessage);
} catch (RuntimeException e){
log.info("log 저장에 실패했습니다. logMessage={}",logMessage.getMessage());
log.info("정상 흐름 변환");
}
log.info("== logRepository 호출 종료 ==");
}
}
/**
* MemberService @Transactional:ON
* MemberRepository @Transactional:ON
* LogRepository @Transactional:ON
*/
@Test
void MemberFacade_test() {
//given
String username = "로그예외_facade";
//when
memberFacade.registerMemberV2(username);
//then: member 저장, log 롤백
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
각각 물리 트랜잭션이 따로 작동함으로써 REQUIRES_NEW 사용없이 조건을 만족시킬 수 있었다.
뭐가나은지는 적용상황에서의 장단점을 잘 따져봐야할것같다.
'Spring' 카테고리의 다른 글
[Spring] 스프링 핵심 원리 - 고급편 섹션2 쓰레드 로컬 - ThreadLocal (0) | 2024.07.19 |
---|---|
[Spring] 스프링 핵심 원리 - 고급편 섹션1 예제 만들기 (0) | 2024.07.17 |
[Spring] 스프링 DB 2편 - 데이터 접근 핵심 원리 섹션10 스프링 트랜잭션 전파1 - 기본 (0) | 2024.07.10 |
[Spring] 스프링 DB 2편 - 데이터 접근 핵심 원리 섹션9 스프링 트랜잭션 이해 (0) | 2024.07.08 |
[Spring] 스프링 DB 2편 - 데이터 접근 핵심 원리 섹션8 데이터 접근 기술 - 활용 방안 (0) | 2024.07.07 |