예제 프로젝트 시작
//Member.java
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
private Long id;
private String username;
public Member() { //JPA 스펙상 있어야함.
}
public Member(String username) {
this.username = username;
}
}
//Log.java
@Entity
@Getter @Setter
public class Log {
@Id
@GeneratedValue
private Long id;
private String message;
public Log() {
}
public Log(String message) {
this.message = message;
}
}
//MemberRepository.java
@Slf4j
@Repository
@RequiredArgsConstructor
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.java
@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 호출 종료 ==");
}
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
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());
}
/**
* memberService @Transactional:OFF
* memberRepository @Transactional:ON
* logRepository @Transactional:ON
*/
@Test
void outerTxOff_fail() {
//given
String username = "로그예외_outerTxOff_success"; //->LogRepository에서 롤백
//when
assertThatThrownBy(() -> memberService.joinV1(username))
.isInstanceOf(RuntimeException.class);
//then : 모든 데이터가 정상 저장된다.
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
}
- 서비스 계층에 트랜잭션이 없을 때
- outerTxOff_success - 모두 커밋
- 상황
- 서비스 계층엔 트랜잭션이 없고, 회원과 로그 리포지토리에 각각 트랜잭션을 가지고 있다.
- 회원과 로그 리포지토리 둘다 커밋에 성공한다.
- 회원과 로그 리포지토리는 각각 다른 커넥션을 사용한다.
- 회원 리포지토리의 트랜잭션이 시작하고 정상 커밋되어서 트랜잭션이 종료되면 로그 리포지토리의 트랜잭션이 시작되고, 정상 커밋된다.
- 두 트랜잭션 모두 커밋되었으므로 Member와 Log 모두 안전하게 저장된다.
- 상황
- outerTxOff_fail - LogRepository 롤백
- 사용자 이름에 로그예외가 있으면 LogRepository에서 런타임 예외가 발생한다.
- 트랜잭션 AOP는 런타임 예외를 확인하고 롤백 처리한다.
- MemberRepository는 정상 커밋되고, LogRepository는 롤백된다.
- outerTxOff_success - 모두 커밋
단일 트랜잭션
- 회원 리포지토리와 로그 리포지토리를 하나의 트랜잭션으로 묶는다.
- 가장 간단한 방법은 이 둘을 호출하는 회원 서비스에만 트랜잭션을 사용하는 것이다.
- => MemberRepository와 LogRepository의 @Transactional을 주석처리하고, MemberService의 join메서드에 @Transactional을 붙인다.
/**
* memberService @Transactional:ON
* memberRepository @Transactional:OFF
* logRepository @Transactional:OFF
*/
@Test
void singleTx() {
//given
String username = "outerTxOff_success";
//when
memberService.joinV1(username);
//then : 모든 데이터가 정상 저장된다.
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isPresent());
}
- 위와 같이 설정하고 테스트를 돌리면 모두가 정상적으로 커밋된 후에 트랜잭션이 종료된다.
- 이렇게하면 MemberService를 시작할 때부터 종료할 때까지의 모든 로직을 하나의 트랜잭션으로 묶을 수 있다.
- MemberService가 MemberRepository, LogRepository를 호출하므로 이 로직들은 같은 트랜잭션을 사용한다.
- MemberService만 트랜잭션을 처리하기 때문에 물리, 외부, 내부 트랜잭션 등을 고려할 필요가 없다.
전파 커밋
- MemberService, MemberRepository, LogRepository 세개 모두에 @Transactional을 붙인다.
- 그러면 MemberService가 신규 트랜잭션, 외부 트랜잭션이 된다.
- 나머지 MemberRepository와 LogRepository는 신규 트랜잭션이 될 수 없고, 내부 트랜잭션이 된다.
- MemberRepository의 save()와 LogRepository의 save()가 모두 성공하고, MemberService의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출되어 신규 트랜잭션이 커밋 요청한 것이라서 물리 커밋을 호출한다.
전파 롤백
- 논리 트랜잭션 중 하나라도 롤백이 되면 전체가 롤백이 된다.
- MemberRepository에서 정상 수행되어 커밋되고, LogRepository에서 런타임 예외가 터져서 롤백된다.
- LogRepository는 신규 트랜잭션이 아니라서 물리 롤백을 할 수 없고, rollbackOnly를 표시해야 한다.
- LogRepository의 런타임 예외가 MemberService로 올라가서 MemberService에서도 런타임 예외가 터지게 된다.
- MemberService에서도 롤백을 요청하고, 신규 트랜잭션이라서 물리 롤백을 호출한다. 어차피 롤백하는거라서 rollbackOnly를 참고하지 않는다.
/**
* 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());
}
/**
* memberService @Transactional:ON
* memberRepository @Transactional:ON
* logRepository @Transactional:ON Exception
*/
@Test
void outerTxOn_fail() {
//given
String username = "로그예외_outerTxOn_fail"; //->LogRepository에서 롤백
//when
assertThatThrownBy(() -> memberService.joinV1(username))
.isInstanceOf(RuntimeException.class);
//then : 모든 데이터가 정상 저장된다.
assertTrue(memberRepository.find(username).isEmpty());
assertTrue(logRepository.find(username).isEmpty());
}
복구 REQUIRED
- 회원과 로그를 하나의 트랜잭션으로 묶어서 데이터 정합성 문제를 해결했다.
- 하지만, 회원 이력 로그를 DB에 남기는 작업이 실패하면 회원가입도 실패하는 문제가 발생할 수도 있다.
- 회원 가입을 시도한 로그를 남기는데 실패해도 회원 가입은 유지되어야 한다.
- joinV2를 사용해서 MemberService에서 런타임 예외를 잡아서 정상흐름으로 만들어도 LogRepository에서 rollbackOnly를 표시한 것 때문에 모두 롤백이 된다.
복구 REQUIRES_NEW
REQUIRES_NEW를 사용해서 물리 트랜잭션을 별도로 분리한다.
//LogRepository의 save()
@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("예외 발생");
}
}
//테스트
/**
* memberService @Transactional:ON
* memberRepository @Transactional:ON
* logRepository @Transactional:ON(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());
}
- 항상 신규 트랜잭션을 생성하는 REQUIRES_NEW를 LogRepository의 save()에 붙여두었다.
- 예외를 복구하는 joinV2를 사용해서 MemberService가 정상 흐름이 되도록 해야 한다.
- MemberRepository는 기본 설정인 REQUIRED를 사용해서 MemberService와 같은 물리 트랜잭션으로 묶인다.
- REQUIRES_NEW를 사용하는 LogRepository는 다른 물리 트랜잭션으로 묶인다.
- 그래서 DB 커넥션도 별도로 사용하게 된다.
- LogRepository가 실패하면 신규 트랜잭션이라서 물리 트랜잭션을 롤백한다. rollbackOnly를 표시하지 않는다. 이렇게 아예 트랜잭션이 끝나버린다.
- 그리고 MemberService는 예외를 복구하고 MemberService와 MemberRepository가 속한 물리 트랜잭션은 정상적으로 커밋된다.
728x90
'Spring' 카테고리의 다른 글
[Spring boot & JPA 1] 회원 도메인 개발 (0) | 2024.05.01 |
---|---|
[Spring boot& JPA 1] 도메인 분석 설계 (3) | 2024.04.28 |
[Spring DB2] 스프링 트랜잭션 전파1 - 기본 (0) | 2024.03.31 |
[Spring DB2] 스프링 트랜잭션 이해 - 2 (0) | 2024.03.28 |
[Spring DB2] 스프링 트랜잭션 이해 - 1 (2) | 2024.03.27 |