티스토리 뷰
이번에는 REQUIRES_NEW를 사용해서 문제 상황을 가정하고 해결해보도록 하자.
문제 상황
회원가입에 성공하면 회원 DB와 회원 이력 로그 DB에 저장하는 비지니스 로직을 수행해야 한다.
회원가입과 이력 로그를 저장하는 로직을 하나의 트랜잭션으로 묶어서 처리하던 중, 만약 이력 로그 저장에서 문제가 발생하면 회원가입 자체가 안 되는 상황이 발생한다.
실제 비지니스에서 이런 상황이 발생하면 많은 회원들이 이탈하는 문제가 발생할 것이기 때문에 요구 사항이 변경 되었다.
"회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되게 해주세요."
단순하게 생각해보면 LogRepository
에서 예외가 발생하면 그것을 MemberService
에서 예외를 잡아서 처리하면 될 것 같다.
이렇게 하면 MemberService
에서 정상 흐름으로 바꿀 수 있기 때문에 MemberService
의 트랜잭션 AOP 에서 커밋을 수행할 수 있다.
직접 테스트 해보자!
MemberRepository
@Slf4j
@RequiredArgsConstructor
@Repository
public class MemberRepository {
private final EntityManager em;
@Transactional
public void save(Member member) {
log.info("member 저장");
em.persist(member);
}
public Optional<Member> find(String username) {
return em.createQuery("select m from Member m where m.username =:username", Member.class)
.setParameter("username", username)
.getResultList().stream()
.findAny();
}
}
LogRepository
@Slf4j
@RequiredArgsConstructor
@Repository
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();
}
}
save
중, message에 "로그예외"가 포함되어 있다면, 예외를 던진다.
MemberService
@Slf4j
@RequiredArgsConstructor
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final LogRepository logRepository;
@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("== logMessage 호출 시작 ==");
try {
logRepository.save(logMessage);
} catch (RuntimeException e) {
log.info("log 저장에 실패했습니다. logMessage={}", logMessage.getMessage());
log.info("정상 흐름 반환");
}
log.info("== logMessage 호출 종료 ==");
}
}
Test Code
@Test
void recoverException_fail() {
// given
String username = "로그예외 recoverException_fail";
// when
assertThatThrownBy(() -> memberService.joinV2(username))
.isInstanceOf(UnexpectedRollbackException.class);
// then
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
Test 결과
예상했던 결과는 달리 marking existing transaction as rollback-only
라는 내용과 함께 테스트에 실패한 것을 확인할 수 있다.
왜 실패했을까?
내부 트랜잭션에서 rollbackOnly
를 설정하기 때문에 결과적으로 정상 흐름 처리를 해서 외부 트랜잭션에서 커밋을 호출하더라도 물리 트랜잭션은 롤백되며, UnexpectedRollbackException
이 던져진다.
전체 흐름을 좀 더 자세하게 알아보자!
LogRepository
에서 예외가 발생한다. 예외를 던지면LogRepository
의 트랜잭션 AOP가 해당 예외를 받는다.- 신규 트랜잭션이 아니므로 물리 트랜잭션을 롤백하지는 않고, 트랜잭션 동기화 매니저에
rollbackOnly
를 표시한다. - 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던진다.
- 예외가
MemberService
에 던져지고,MemberService
는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다. - 정상 흐름이 되었으므로
MemberService
의 트랜잭션 AOP는 커밋을 호출한다. - 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 한다. 이때
rollbackOnly
설정을 확인한다. rollbackOnly
가 체크 되어 있으므로 물리 트랜잭션을 롤백한다.- 트랜잭션 매니저는
UnexpectedRollbackException
예외를 던진다. - 트랜잭션 AOP도 전달받은
UnexpectedRollbackException
을 클라이언트에 던진다.
위 테스트를 통해 논리 트랜잭션 중 하나라도 롤백되면 전체 트랜잭션은 롤백되고, rollbackOnly
상황에서 커밋이 발생하면 UnexpectedRollbackException
예외가 발생하는 것을 확인했다.
이 문제를 해결하기 위해 REQUIRES_NEW
를 사용하여 로그와 관련된 물리 트랜잭션을 별도로 분리해보자!
LogRepository
@Slf4j
@RequiredArgsConstructor
@Repository
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("예외 발생");
}
}
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();
}
}
save
에@Transactional(propagation = Propagation.REQUIRES_NEW)
를 설정하였다.
Test Code
@Test
void recoverException_success() {
// given
String username = "로그예외 recoverException_success";
// when
memberService.joinV2(username);
// then
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
Test 결과
테스트가 정상적으로 성공하였다.
어떻게 성공했을까?
REQUIRES_NEW - 물리 트랜잭션 분리
MemberRepository
는 REQUIRED
옵션을 사용하기 때문에 기존 트랜잭션에 참여하지만, LogRepository
의 트랜잭션 옵션에 REQUIRES_NEW
를 사용했다. REQUIRES_NEW
는 항상 새로운 트랜잭션을 만들기 때문에 해당 트랜잭션 안에서는 DB 커넥션도 별도로 사용하게 된다.
REQUIRES_NEW - 복구
REQUIRES_NEW
를 사용하게 되면 물리 트랜잭션 자체가 완전히 분리되며, 신규 트랜잭션이므로 rollbackOnly
표시가 되지 않고, 그냥 해당 트랜잭션이 물리 롤백되고 끝난다.
이번에도 전체 흐름을 좀 더 자세하게 알아보자!
LogRepository
에서 예외가 발생한다. 예외를 던지면LogRepository
의 트랜잭션 AOP가 해당 예외를 받는다.REQUIRES_NEW
를 사용한 신규 트랜잭션이므로 물리 트랜잭션을 롤백한다. 물리 트랜잭션을 롤백했으므로rollbackOnly
를 표시하지 않는다. 여기서REQUIRES_NEW
를 사용한 물리 트랜잭션은 롤백되고 완전히 끝이 나버린다.- 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던진다.
- 예외가
MemberService
에 던져지고,MemberService
는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다. - 정상 흐름이 되었으므로
MemberService
의 트랜잭션 AOP는 커밋을 호출한다. - 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 한다. 이때
rollbackOnly
설정을 확인한다. rollbackOnly
가 없으므로 물리 트랜잭션을 커밋한다. 이후 정상 흐름이 반환된다.
REQUIRES_NEW
를 사용해서 결과적으로 회원 데이터는 저장되고, 회원 로그 데이터만 롤백되는 것을 확인할 수 있다. 하지만 REQUIRES_NEW
를 사용하면 하나의 HTTP 요청에 2개의 데이터베이스 커넥션을 사용하게 되므로, 성능이 중요한 곳에서는 주의가 필요하다. 아무래도 REQUIRES_NEW
를 사용하지 않아도 문제를 해결할 수 있다면, 그 방법을 선택하는 것이 더 좋다.
'Spring' 카테고리의 다른 글
[Spring] 스프링 부트 내장 톰캣 (0) | 2023.03.12 |
---|---|
[Spring] WAR VS JAR (0) | 2023.03.10 |
[Spring] 스프링 트랜잭션 전파 옵션 REQUIRES_NEW에 대해 알아보자 (0) | 2022.11.22 |
[Spring] 프록시 내부 호출 문제점 (0) | 2022.10.28 |
[Spring] WebFlux란? (1) | 2022.09.17 |
- Total
- Today
- Yesterday
- 그리디
- 코테
- 문자열
- leetcode
- 알고리즘
- 정렬
- webflux
- 리팩토링
- 자료구조
- 데이터베이스
- 북클럽
- 노마드
- Real MySQL
- 인프런
- spring boot
- mysql 8.0
- Algorithm
- 백준
- 스프링
- Spring
- 스프링 부트
- Refactoring
- 김영한
- MySQL
- 릿코드
- 노마드코더
- 파이썬
- 코틀린
- kotlin
- 구현
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |