이 글은 배민 기술이사 김영한 이사님의 인프런 강의 "스프링 DB 2편 - 데이터 접근 활용 기술" 을 기반으로 작성되었습니다. 문제 시 삭제 조치하겠습니다.
아래와 같은 순서로 공부를 진행합니다.
- 전파 커밋
- 전파 롤백
- 복구 REQUIRED
- 복구 REQUIRES_NEW
3. 전파 커밋
스프링은 @Transactional
이 적용되어 있으면 기본으로 REQUIRED
라는 전파 옵션을 사용합니다.
이 옵션은 이전 글에서도 설명했듯 기존 트랜잭션이 없으면 트랜잭션을 생성하고, 기존 트랜잭션이 있으면 기존 트랜잭션에 참여합니다. 참여한다는 뜻은 해당 트랜잭션을 그대로 따른다는 뜻이고, 동시에 같은 동기화 커넥션을 사용한다는 의미입니다.
이렇게 둘 이상의 트랜잭션이 하나의 물리 트랜잭션에 묶이게 되면 둘을 구분하기 위해 논리 트랜잭션과 물리 트랜잭션으로 구분합니다.
신규 트랜잭션
이 경우 외부에 있는 신규 트랜잭션만 실제 물리 트랜잭션을 시작하고 커밋합니다.
내부에 있는 트랜잭션은 물리 트랜잭션 시작하거나 커밋하지 않습니다.
모든 논리 트랜잭션 커밋
모든 논리 트랜잭션을 커밋해야 물리 트랜잭션도 커밋됩니다. 하나라도 롤백되면 물리 트랜잭션은 롤백됩니다.
모든 논리 트랜잭션이 정상 커밋되는 경우
outerTxOn_success
/**
* 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());
}
클라이언트A(여기서는 테스트 코드)가 MemberService
를 호출하면서 트랜잭션 AOP 을 호출합니다.
여기서 신규 트랜잭션이 생성되고, 물리 트랜잭션도 시작됩니다.
MemberRepository
를 호출하면서 트랜잭션 AOP 호출
- 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여합니다.
MemberRepository
의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP 을 호출합니다.- 트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청합니다. 이 경우 신규 트랜잭션이 아니므로 실제 커밋은 호출하지 않습니다.
LogRepository
를 호출하면서 트랜잭션 AOP 호출
- 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여합니다.
LogRepository
의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP 을 호출합니다.- 트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청합니다. 이 경우 신규 트랜잭션이 아니므로 실제 커밋(물리 커밋)을 호출하지 않습니다.
MemberService
의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP 을 호출합니다.
- 트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청합니다. 이 경우 신규 트랜잭션이므로 물리 커밋을 호출합니다.
4. 전파 롤백
로그 리포지토리에서 예외가 발생해서 전체 트랜잭션이 롤백되는 경우
outerTxOn_fail
/**
* 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
에서 런타임 예외가 발생합니다.
흐름
클라이언트A가 MemberService
를 호출하면서 트랜잭션 AOP 호출
- 여기서 신규 트랜잭션이 생성되고, 물리 트랜잭션도 시작
MemberRepository
를 호출하면서 트랜잭션 AOP 호출
- 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여
MemberRepository
의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP 호출
- 트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청. 이 경우 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않음
LogRepository
를 호출하면서 트랜잭션 AOP 호출
- 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여
LogRepository
로직에서 런타임 예외가 발생. 예외를 던지면 트랜잭션 AOP가 해당 예외를 받음- 트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백을 요청. 이 경우 신규 트랜잭션이 아니므로 물리 롤백을 호출하지는 않고
rollbackOnly
를 설정 LogRepository
가 예외를 던졌기 때문에 트랜잭션 AOP도 해당 예외를 그대로 밖으로 던짐
- 트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백을 요청. 이 경우 신규 트랜잭션이 아니므로 물리 롤백을 호출하지는 않고
MemberService
에서도 런타임 예외를 받게 되는데, 여기 로직에서는 해당 런타임 예외를 처리하지 않고 밖으로 던짐.
- 트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백을 요청. 이 경우 신규 트랜잭션이므로 물리 롤백을 호출
- 참고로 이 경우 어차피 롤백이 되었기 때문에,
rollbackOnly
설정은 참고하지 않음
MemberService
가 예외를 던졌기 때문에 트랜잭션 AOP도 해당 예외를 그대로 밖으로 던짐
- 이렇게 클라이언트A는
LogRepository
부터 넘어온 런타임 예외를 받게 됩니다.
정리
회원과 회원 이력 로그를 처리하는 부분을 하나의 트랜잭션으로 묶은 덕분에 문제가 발생했을 때 회원과 회원 이력 로그가 모두 함께 롤백됩니다. 따라서 데이터 정합성에 문제가 발생하지 않습니다.
5. 복구 REQUIRED
앞서 회원과 로그를 하나의 트랜잭션으로 묶어서 데이터 정합성 문제를 깔끔하게 해결하였습니다.
그런데 회원 이력 로그를 DB에 남기는 작업에 가끔 문제가 발생해서 회원 가입 자체가 안되는 경우가 가끔 발생합니다. 그래서 사용자들이 회원 가입에 실패해서 이탈하는 문제가 발생할 수 있죠.
회원 이력 로그의 경우 여러가지 방법으로 추후에 복구가 가능할 것으로 보입니다. 그래서 비즈니스 요구사항이 변경되었습니다. 회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 합니다.
단순하게 생각해보면 LogRepository
에서 예외가 발생하면 그것을 MemberService
에서 예외를 잡아서 처리하면 될 것 같습니다.
이렇게 하면 MemberService
에서 정상 흐름으로 바꿀 수 있기 때문에 MemberService
의 트랜잭션 AOP에서 커밋을 수행할 수 있을 것으로 보입니다.
그런데 이 방법은 실패합니다. 이 방법이 왜 실패하는지 예제를 통해서 확인해봅시다.
recoverException_fail
/**
* MemberService @Transactional:ON
* MemberRepository @Transactional:ON
* LogRepository @Transactional:ON Exception
*/
@Test
void recoverException_fail() {
//given
String username = "로그예외_recoverException_fail";
//when
assertThatThrownBy(() -> memberService.joinV2(username))
.isInstanceOf(UnexpectedRollbackException.class);
//then: 모든 데이터가 롤백된다.
assertTrue(memberRepository.find(username).isEmpty());
assertTrue(logRepository.find(username).isEmpty());
}
여기서 memberService.joinV2()
를 호출하는 부분을 주의해야 합니다. joinV2()
에는 예외를 잡아서 정상 흐름으로 변환하는 로직이 추가됩니다.
try {
logRepository.save(logMessage);
} catch (RuntimeException e) {
log.info("log 저장에 실패했습니다. logMessage={}", logMessage);
log.info("정상 흐름 변환");
}
내부 트랜잭션에서 rollbackOnly
를 설정하기 때문에 결과적으로 정상 흐름 처리를 해서 외부 트랜잭션에서 커밋을 호출해도 물리 트랜잭션은 롤백됩니다.
그리고 UnexpectedRollbackException
이 던져집니다.
전체 흐름
LogRepository
에서 예외가 발생합니다. 예외를 던지면 LogRepository
의 트랜잭션 AOP가 해당 예외를 받습니다.
- 신규 트랜잭션이 아니므로 물리 트랜잭션을 롤백하지는 않고, 트랜잭션 동기화 매니저에
rollbackOnly
를 표시
이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던집니다.
- 예외가
MemberService
에 던져지고,MemberService
는 해당 예외를 복구한다. 그리고 정상적으로 리턴.
정상 흐름이 되었으므로 MemberService
의 트랜잭션 AOP는 커밋을 호출합니다.
- 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 함. 이때
rollbackOnly
를 체크 rollbackOnly
가 체크 되어 있으므로 물리 트랜잭션을 롤백- 트랜잭션 매니저는
UnexpectedRollbackException
예외를 던짐 - 트랜잭션 AOP도 전달받은
UnexpectedRollbackException
을 클라이언트에 던짐
정리
논리 트랜잭션 중 하나라도 롤백되면 전체 트랜잭션은 롤백됩니다.
내부 트랜잭션이 롤백 되었는데, 외부 트랜잭션이 커밋되면 UnexpectedRollbackException
예외가 발생합니다.
rollbackOnly
상황에서 커밋이 발생하면 UnexpectedRollbackException
예외가 발생한다는 것과 같은 의미입니다.
6. 복구 REQUIRES_NEW
바뀐 요구사항에 따르면 회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 합니다!
이 요구사항을 만족하기 위해서 REQUIRES_NEW
를 사용하여 로그와 관련된 물리 트랜잭션을 별도로 분리합시다.
recoverException_success
/**
* 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
- save()
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log logMessage)
이렇게 해서 기존 트랜잭션에 참여하는 REQUIRED
대신에, 항상 신규 트랜잭션을 생성하는 REQUIRES_NEW
를 적용합니다.
이 때 예외를 복구하는 memberService.joinV2()
를 사용한다는 점도 주의합시다.
REQUIRES_NEW - 물리 트랜잭션 분리
MemberRepository
는REQUIRED
옵션을 사용. 따라서 기존 트랜잭션에 참여LogRepository
의 트랜잭션 옵션에REQUIRES_NEW
를 사용REQUIRES_NEW
는 항상 새로운 트랜잭션을 생성. 따라서 해당 트랜잭션 안에서는 DB 커넥션도 별도로 사용
REQUIRES_NEW - 복구
REQUIRES_NEW
를 사용하게 되면 물리 트랜잭션 자체가 완전히 분리됩니다.
REQUIRES_NEW
는 신규 트랜잭션이므로 rollbackOnly
표시가 되지 않습니다. 그냥 해당 트랜잭션이 물리 롤백되고 종료되는 것이지요
REQUIRES_NEW - 자세히
LogRepository
에서 예외가 발생. 예외를 던지면 LogRepository
의 트랜잭션 AOP가 해당 예외를 받습니다.
REQUIRES_NEW
를 사용한 신규 트랜잭션이므로 물리 트랜잭션을 롤백합니다. 물리 트랜잭션을 롤백했으므로rollbackOnly
를 표시하지 않습니다. 여기서REQUIRES_NEW
를 사용한 물리 트랜잭션은 롤백되고 완전히 종료됩니다.
이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던집니다.
- 예외가
MemberService
에 던져지고,MemberService
는 해당 예외를 복구. 그리고 정상적으로 리턴
정상 흐름이 되었으므로 MemberService
의 트랜잭션 AOP는 커밋을 호출합니다.
- 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 함. 이때
rollbackOnly
를 체크 rollbackOnly
가 없으므로 물리 트랜잭션을 커밋- 이후 정상 흐름이 반환
결과적으로 회원 데이터는 저장되고, 로그 데이터만 롤백됩니다!!
정리
- 논리 트랜잭션은 하나라도 롤백되면 관련된 물리 트랜잭션은 롤백됩니다.
- 이 문제를 해결하려면
REQUIRES_NEW
를 사용해서 트랜잭션을 분리해야 합니다.
주의
REQUIRES_NEW
를 사용하면 하나의 HTTP 요청에 동시에 2개의 데이터베이스 커넥션을 사용하게 됩니다.
따라서 성능이 중요한 곳에서는 이런 부분을 주의해서 사용해야 합니다.
REQUIRES_NEW
를 사용하지 않고 문제를 해결할 수 있는 단순한 방법이 있다면, 그 방법을 선택하는 것이 더 좋습니다.
예를 들면 다음과 같이 REQUIRES_NEW
를 사용하지 않고 구조를 변경할 수 있습니다.
MemberService
에 의존하는 한 계층의 MemberFacade
계층을 만드는 것이죠.
MemberFacade
는 회원 가입 로그를 제외한 회원 정보에 대한 일을 처리할 때는 트랜잭션을 시작하며 MemberService
을 사용합니다. 그리고 회원 가입 로그를 처리하는 일은 다른 트랜잭션을 시작하며 LogRepository
을 사용합니다.
이렇게 하면 HTTP 요청에 동시에 2개의 커넥션을 사용하지 않고 순차적으로 사용하고 반환될 수 있습니다.
물론 구조상 REQUIRES_NEW
를 사용하는 것이 더 깔끔한 경우도 있으므로 각각의 장단점을 이해하고 적절하게 선택해서 사용하면 됩니다!
'spring > 스프링 DB 2편' 카테고리의 다른 글
10. 스프링 트랜잭션 전파 - 활용(1) (0) | 2023.02.24 |
---|---|
9. 스프링 트랜잭션 전파 - 기본 (0) | 2023.02.24 |
8. 스프링 트랜잭션 이해(3) (0) | 2023.02.23 |
8. 스프링 트랜잭션 이해(2) 주의사항, 프록시, 초기화 시점 (0) | 2023.02.23 |
8. 스프링 트랜잭션 이해(1) - 소개, 적용 (0) | 2023.02.23 |