로그 저장 로직에서 에러가 터지는데, 왜 회원 가입도 안되는거지?
로그 저장 로직의 성공 여부에 상관 없이 회원 가입을 하고 싶은데 어떻게 하지?
아래의 글은 김영한님의 Spring DB V2 강의를 참고해서 작성했습니다.
이번 글은 아래와 같은 상황을 가정하고, 해결하는 방법을 다루는 글입니다.
신입 개발자 A씨는 회원 가입과 회원 가입 이력 로그 저장을 구현하는 업무를 맡았습니다.A씨는 데이터 정합성을 보장하기 위해 두 작업을 하나의 트랜잭션으로 묶어 처리했습니다.
그러던 어느 날, 고객들로부터 회원 가입이 되지 않는다는 항의가 쏟아지기 시작했습니다.이상을 감지한 A씨가 원인을 확인해본 결과, 회원 가입 이력 로그 저장 과정에서 오류가 발생하면서,같은 트랜잭션 내에 포함된 회원 정보 저장 로직까지 롤백되는 문제가 발생하고 있었습니다.
이 문제를 해결하기 위해선 회원 가입 이력에 대한 로그를 남기는데 실패하더라도 회원 가입은 진행되어야 합니다.
가정한 상황에 대한 흐름을 그림으로 나타내면 다음과 같습니다.
어떻게 해결할 수 있을까요?
개발자 A씨는 LogRepository에서 예외가 발생한 것을 확인하고, MemberService에서 LogRepository로 부터 넘어온 예외를 잡아서 처리하면 된다고 생각을 하고 진행을 했습니다.
논리 트랜잭션 A에서 예외 처리하기
개발자 A씨는 아래와 같이 try-catch 문을 사용해서 logRepository로부터 발생할 수 있을 예외를 처리해주는 로직으로 수정했습니다.
@Transactional
public void joinV2(String username) { // DB 로그 저장시 예외 발생시 예외 복구
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("로그 저장에 실패했습니다. logMessage = {}", logMessage.getMessage());
log.info("정상 흐름 반환");
}
log.info("==================== logRepository 호출 종료");
}
아래와 같은 테스트 코드를 통해 로그 예외가 발생해도 회원 정보가 잘 저장이 되는지 확인합니다.
@Test
void recoverException_fail() {
String username = "로그예외_recoverException_fail";
Assertions.assertThatThrownBy(() -> memberService.joinV2(username)).isInstanceOf(
UnexpectedRollbackException.class);
// when, then
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
개발자 A씨는 테스트가 무조건 통과할 것이라고 확신을 했지만, 테스트 결과는 다음과 같았습니다.
예상한 결과와 다르게 테스트를 실패하게 됩니다.
왜, 이런 현상이 발생할까요?
내부 트랜잭션에서 예외가 발생하면 rollbackOnly를 설정하기 때문에, 예외를 잡아주고 정상 흐름으로 처리를 해주어도 물리 트랜잭션은 그것과 상관없이 내부 트랜잭션에서 rollbackOnly를 체크를 해주었기 때문에, 최종적으로 롤백처리가 됩니다.
이때, UnexpectedRollbackException오류가 발생합니다.
다시말해, UnexpectedRollbackException 오류가 발생하는 상황은 내부 트랜잭션이 롤백되었는데, 외부 트랜잭션이 커밋되는 경우에 발생하게 됩니다.
이 흐름을 나타내는 이미지는 다음과 같습니다.
- LogRepository에서 예외가 발생합니다. LogRepository의 트랜잭션 AOP가 예외를 전달 받습니다.
- LogRepository의 트랜잭션은 신규 트랜잭션이 아니므로, 롤백하지 않고 트랜잭션 동기화 매니저에 rollbackOnly를 표시합니다.
- 트랜잭션 AOP는 전달 받은 예외를 밖으로 던집니다.
- 이 예외는 MemberService에 던져지고, MemberService는 해당 예외를 try-catch를 사용해 복구하고, 정상적으로 다음 로직을 실행합니다.
- 정상적으로 흐름이 복구되어 MemberService 트랜잭션 AOP는 커밋을 호출합니다.
- 커밋 호출시, MemberService는 신규 트랜잭션이므로 물리 트랜잭션을 커밋합니다.
- 커밋할때, 트랜잭션 매니저는 rollbackOnly를 체크합니다. rollbackOnly가 체크되어 있으므로 물리 트랜잭션을 롤백합니다.
- 트랜잭션 매니저는 UnexpectedRollbackException 예외를 던집니다.
- 트랜잭션 AOP도 전달받은 UnexpectedRollbackException을 클라이언트에 던집니다.
어떻게, 동일 트랜잭션 내에서 두 내부 트랜잭션인 회원가입 로직과 로그 저장로직을 분리할 수 있을까요?
트랜잭션 전파 옵션인 REQUIRES_NEW 옵션을 사용하면 됩니다.
논리 트랜잭션 REQUIRES_NEW 옵션 사용하기
로그를 저장하는 로직에 다음과 같이 REQUIRES_NEW 옵션을 붙여줍니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log message) {
log.info("로그 저장");
em.persist(message);
if(message.getMessage().contains("로그예외")) {
log.info("log 저장시 예외 발생");
throw new RuntimeException(); // 롤백 발생
}
}
아래와 같은 테스트 코드를 통해, 로그 저장이 실패해도 회원 정보가 저장되는지 확인합니다.
@Test
void recoverException_success() {
String username = "로그예외_recoverException_success";
memberService.joinV2(username);
// when, then
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
출력 결과는 다음과 같습니다.
로그 저장이 실패했음에도 불구하고, 회원 정보가 저장되었습니다.
어떻게 해결할 수 있었을까요?
REQUIRES_NEW 옵션을 사용하게 되면, 항상 새로운 트랜잭션을 만들기 때문에 DB 커넥션도 별도로 사용할 뿐만 아니라, 물리 트랜잭션 자체가 완전히 분리되게 됩니다. 또한, 로그를 저장하는 논리 트랜잭션은 신규 트랜잭션이기 때문에 rollbackOnly 표시도 되지 않습니다. 즉, 단순히 로그를 저장하는 로직이 있는 해당 트랜잭션에서 롤백이되고 끝나게 됩니다.
이 흐름을 나타내는 이미지는 다음과 같습니다.
- LogRepository에서 예외가 발생합니다. LogRepository의 트랜잭션 AOP가 예외를 전달 받습니다.
- REQUIRES_NEW옵션을 사용한 신규 트랜잭션이므로 물리 트랜잭션을 롤백합니다. 롤백후 log와 관련된 트랜잭션은 완전히 끝나게 됩니다. (rollbackOnly를 표시하지 않습니다.)
- 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던집니다.
- 이 예외는 MemberService에 던져지고, MemberService는 해당 예외를 try-catch를 사용해 복구하고, 정상적으로 다음 로직을 실행합니다.
- 정상적으로 흐름이 복구되어 MemberService 트랜잭션 AOP는 커밋을 호출합니다.
- 커밋 호출시, MemberService는 신규 트랜잭션이므로 물리 트랜잭션을 커밋합니다.
- 커밋할때, 트랜잭션 매니저는 rollbackOnly를 체크합니다. rollbackOnly가 체크되어 있지 않으므로 트랜잭션을 커밋합니다.
정리
- 논리 트랜잭션은 하나라도 롤백이 되는 경우 물리 트랜잭션은 롤백이 됩니다.
- REQUIRES_NEW 옵션을 사용하면 트랜잭션을 분리하기 때문에 물리 트랜잭션이 롤백되는 문제를 해결할 수 있습니다.
- REQUIRES_NEW를 사용하면 하나의 HTTP 요청에 동시에 2개의 데이터베이스 커넥션을 사용하게 되므로, 성능이 중요한 곳에서는 이러한 부분을 주의해서 사용해야 합니다.
- REQUIRES_NEW를 사용하지 않고 문제를 해결할 수 있는 단순한 방법이 있는 경우, 그 방법을 선택하는 것이 더 좋습니다
- Facade구조를 사용해서 구조 자체를 아래와 같이 분리해서 사용하는 방법이 있습니다.
'Spring' 카테고리의 다른 글
트랜잭션 전파 다양한 옵션 (0) | 2025.02.17 |
---|---|
싱글톤 컨테이너 (0) | 2025.02.17 |
트랜잭션 전파(REQUIRES_NEW) (0) | 2025.02.14 |
트랜잭션 전파(REQUIRED) ROLLBACK 편 (0) | 2025.02.13 |
트랜잭션 전파(REQUIRED) COMMIT편 (0) | 2025.02.12 |