지금까지 배운 트랜잭션 전파에 대한 내용을 실제 예제를 통해서 이해해보도록 하겠습니다.
비즈니스 요구사항
Member
package com.example.springtrans
- JPA를 통해 관리하는 회원 엔티티actional.propagation;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
public class Member {
@Id
@GeneratedValue
private Long id;
private String username;
public Member() {
}
public Member(String username) {
this.username = username;
}
}
MemberRepository
package com.example.springtransactional.propagation;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@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();
}
}
Log
package com.example.springtransactional.propagation;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
public class Log {
@Id
@GeneratedValue
private Long id;
private String message;
public Log() {
}
public Log(String message) {
this.message = message;
}
}
LogRepository
package com.example.springtransactional.propagation;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {
private 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();
}
}
MemberService
package com.example.springtransactional.propagation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@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("------- memberRepositroy 호출 시작 -------------");
memberRepository.save(member);
log.info("------- memberRepositroy 호출 종료 -------------");
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("------- memberRepositroy 호출 시작 -------------");
memberRepository.save(member);
log.info("------- memberRepositroy 호출 종료 -------------");
log.info("------- logRepository 호출 시작 -------------");
try {
logRepository.save(logMessage);
} catch (RuntimeException e) {
// 예외가 올라오면 잡아서 정상 흐름으로 바꾼다.
log.info("log 저장에 실패했습니다. logMessage={}", logMessage.getMessage());
log.info("정상 흐름 변환");
}
log.info("------- logRepository 호출 종료 -------------");
}
}
joinV1()
joinV2()
joinV1()
과 같은 기능을 수행한다.MemberServiceTest
package com.example.springtransactional.propagation;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertTrue;
@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());
}
}
일단 성공하는 것을 확인 할 수 있다.
상황
조금 더 자세히 보자
1. MemberService
에서 MemberRepository
를 호출한다. MemberRepository
에는 @Transactional
애노테이션이 있으므로 트랜잭션 AOP가 작동한다. 여기서 트랜잭션 매니저를 통해 트랜잭션을 시작한다. 이렇게 시작한 트랜잭션을 트랜잭션B라 하자.
2. MemberRepository
는 JPA를 통해 회원을 저장하는데, 이때 JPA는 트랜잭션이 시작된 con1
을 사용해서 회원을 저장한다.
3. MemberRepository
가 정상 응답을 반환했기 때문에 트랜잭션 AOP는 트랜잭션 매니저에 커밋을 요청한다.
4. 트랜잭션 매니저는 con1
을 통해 물리 트랜잭션을 커밋한다. 물론 이 시점에 앞서 설명한 신규 트랜잭션 여부, rollbackOnly
여부를 모두 체크한다.
상황
MemberServiceTest 수정
package com.example.springtransactional.propagation;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertTrue;
@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";
String username = "로그예외_outerTxOff_fail";
//when
// memberService.joinV1(username);
assertThatThrownBy(() -> memberService.joinV1(username)).isInstanceOf(RuntimeException.class);
//then: 모든 데이터가 정상 저장된다.
assertTrue(memberRepository.find(username).isPresent());
// assertTrue(logRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
}
LogRepository
에서 런타임 예외가 발생한다.
MemberService
에서 MemberRepository
를 호출하는 부분은 앞서 설명한 내용과 같다. 트랜잭션이 정상 커밋되고, 회원 데이터도 DB에 정상 반영된다.LogRepository
에서는 Log를 롤백 시킨다.LogRepository 응답 로직
1. LogRepository
는 트랜잭션C와 관련된 con2 를 사용한다.
2. 로그예외 라는 이름을 전달해서 LogRepository
에 런타임 예외가 발생한다.
3. LogRepository
는 해당 예외를 밖으로 던진다. 이 경우 트랜잭션 AOP가 예외를 받게된다.
4. 런타임 예외가 발생해서 트랜잭션 AOP는 트랜잭션 매니저에 롤백을 호출한다.
5. 트랜잭션 매니저는 신규 트랜잭션이므로 물리 롤백을 호출한다.
이 경우 회원은 저장되지만, 회원 이력 로그는 롤백된다. 따라서 데이터 정합성에 문제가 발생할 수 있다. 둘을 하나의 트랜잭션으로 묶어서 처리해보자.
회원 리포지토리와 로그 리포지토리를 하나의 트랜잭션으로 묶는 가장 간단한 방법은 이 둘을 호출하는 회원 서비스에만 트랜잭션을 사용하는 것이다.
MemberService - joinV1()
@Transactional
추가MemberRepository - save()
@Transactional
제거LogRepository - save()
@Transactional
제거Repository
에서 사용하던 @Transactional
를 제거하고 Service
로 묶었다.TEST 코드 추가
@Test
void singleTx() {
//given
String username = "singleTx";
//when
memberService.joinV1(username);
//then: 모든 데이터가 정상 저장된다.
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isPresent());
}
MemberService
를 시작할 때 부터 종료할 때 까지의 모든 로직을 하나의 트랜잭션으로 묶을 수 있다.MemberService
만 트랜잭션을 처리하기 때문에 앞서 배운 논리 트랜잭션, 물리 트랜잭션, 외부 트랜잭션, 내부 트랜잭션, rollbackOnly , 신규 트랜잭션, 트랜잭션 전파와 같은 복잡한 것을 고민할 필요가 없다. 아주 단순하고 깔끔하게 트랜잭션을 묶을 수 있다.@Transactional
이 MemberService
에만 붙어있기 때문에 여기에만 트랜잭션 AOP가 적용된다.MemberRepository
, LogRepository
는 트랜잭션 AOP가 적용되지 않는다.MemberService
의 시작부터 끝까지, 관련 로직은 해당 트랜잭션이 생성한 커넥션을 사용하게 된다.MemberService
가 호출하는 MemberRepository
, LogRepository
도 같은 커넥션을 사용하면서 자연스럽게 트랜잭션 범위에 포함된다.다음과 같은 상황은 어떻게 할까? (각각 다 필요)
MemberService
부터 MemberRepository
, LogRepository
를 모두 하나의 트랜잭션으로 묶고 싶다.MemberRepository
만 호출하고 여기에만 트랜잭션을 사용하고 싶다.LogRepository
만 호출하고 여기에만 트랜잭션을 사용하고 싶다.트랜잭션 전파 없이 이런 문제를 해결하려면 아마도 트랜잭션이 있는 메서드와 트랜잭션이 없는 메서드를 각각 만들어야 할 것이다.
@Transactional
이 적용되어 있으면 기본으로 REQUIRED
라는 전파 옵션을 사용한다.클래스 위의 주석을 확인해서 모든 곳에 트랜잭션을 적용하자.
MemberService
@Transactional:ON
MemberRepository
@Transactional:ON
LogRepository
@Transactional:ON
Test 코드 추가
@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
를 호출하면서 트랜잭션 AOP가 호출된다.MemberRepository
를 호출하면서 트랜잭션 AOP가 호출MemberRepository
의 로직 호출이 끝LogRepository
를 호출하면서 트랜잭션 AOP가 호출LogRepository
의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출MemberService
의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출LogRepository
에서 오류 발생으로 롤백되는 경우를 알아보자
TEST 코드 추가
/**
* 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
예외 발생LogRepository
로직에서 런타임 예외가 발생한다. 예외를 던지면 트랜잭션 AOP가 해당 예외를 받게 된다.rollbackOnly
를 설정한다.LogRepository
가 예외를 던졌기 때문에 트랜잭션 AOP도 해당 예외를 그대로 밖으로 던진다.MemberService
에서도 런타임 예외를 받게 되는데, 여기 로직에서는 해당 런타임 예외를 처리하지 않고 밖으로 던진다.MemberService
가 예외를 던졌기 때문에 트랜잭션 AOP도 해당 예외를 그대로 밖으로 던진다.LogRepository
부터 넘어온 런타임 예외를 받게 된다.Test 코드 추가
@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());
}
public void joinV2(String username) {
Member member = new Member(username);
Log logMessage = new Log(username);
log.info("------- memberRepositroy 호출 시작 -------------");
memberRepository.save(member);
log.info("------- memberRepositroy 호출 종료 -------------");
log.info("------- logRepository 호출 시작 -------------");
try {
logRepository.save(logMessage);
} catch (RuntimeException e) {
// 예외가 올라오면 잡아서 정상 흐름으로 바꾼다.
log.info("log 저장에 실패했습니다. logMessage={}", logMessage.getMessage());
log.info("정상 흐름 변환");
}
log.info("------- logRepository 호출 종료 -------------");
}
rollbackOnly
를 설정하기 때문에 결과적으로 정상 흐름 처리를 해서 외부 트랜잭션에서 커밋을 호출해도 물리 트랜잭션은 롤백된다.UnexpectedRollbackException
이 던져진다.더 세세하게 알아본다면
LogRepository
에서 예외가 발생한다.rollbackOnly
를 표시한다.MemberService
에 던져지고, MemberService
는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다.MemberService
의 트랜잭션 AOP는 커밋을 호출한다.rollbackOnly
를 체크한다.rollbackOnly
가 체크 되어 있으므로 물리 트랜잭션을 롤백한다.UnexpectedRollbackException
예외를 던진다.UnexpectedRollbackException
을 클라이언트에 던진다.저번 게시물에 논리 트랜잭션의 원칙이 있었다.
원칙
- 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.
- 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.
그렇다면 어떻게 해야 다음 요구사항을 만족할 수 있을까?
회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 한다.
MemberService
@Transactional:ON
MemberRepository
@Transactional:ON
LogRepository
@Transactional(REQUIRES_NEW)
LogRepository - save()
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log logMessage){
REQUIRED
대신에, 항상 신규 트랜잭션을 생성하는 REQUIRES_NEW
를 적용하자TEST 코드 추가
@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());
}
MemberRepository
는 REQUIRED
옵션을 사용한다. 따라서 기존 트랜잭션에 참여한다.LogRepository
의 트랜잭션 옵션에 REQUIRES_NEW
를 사용했다.REQUIRES_NEW
는 항상 새로운 트랜잭션을 만든다. 따라서 해당 트랜잭션 안에서는 DB 커넥션도 별도로 사용하게 된다.조금 더 세세히 살펴보자
LogRepository
에서 예외가 발생한다. 예외를 던지면 LogRepository
의 트랜잭션 AOP가 해당 예외를 받는다.REQUIRES_NEW
를 사용한 신규 트랜잭션이므로 물리 트랜잭션을 롤백한다. 물리 트랜잭션을 롤백했으므로rollbackOnly
를 표시하지 않는다. 여기서 REQUIRES_NEW
를 사용한 물리 트랜잭션은 롤백되고 완전히 끝이 나버린다.MemberService
에 던져지고, MemberService
는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다.MemberService
의 트랜잭션 AOP는 커밋을 호출한다.rollbackOnly
를 체크한다.rollbackOnly
가 없으므로 물리 트랜잭션을 커밋한다. 이후 정상 흐름이 반환된다참고
김영한: 스프링 DB 2편 - 데이터 접근 활용 기술(인프런)
Github - https://github.com/b2b2004/Spring_DB