Spring
[인프런/스프링DB 2편] 6. 스프링 트랜잭션 전파 (2)
주니어주니
2023. 7. 28. 00:21
1. 스프링 트랜잭션 전파 활용
비즈니스 요구사항
- 회원을 등록하고 조회한다.
- 회원에 대한 변경 이력을 추적할 수 있도록 데이터 변경(등록) 이력을 DB LOG 테이블에 남긴다.
1-1. 객체 세팅
* Member
package hello.springtx.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;
}
}
- JPA를 통해 관리하는 회원 엔티티
- JPA는 기본 생성자 필수
- 기본키인 id에 대한 생성자가 아닌, username으로 Member 객체 생성
* MemberRepository
package hello.springtx.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();
}
}
- JPA를 사용하는 리포지토리. (저장, 조회 기능만)
- 기본키인 id가 아닌, username으로 조회 -> 쿼리 작성
- 리포지토리의 회원 저장 메소드에 @Transactional 적용
* Log
package hello.springtx.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;
}
}
- JPA를 통해 관리하는 로그 엔티티
- JPA는 기본 생성자 필수
* LogRepository
package hello.springtx.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 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();
}
}
- JPA를 사용하는 로그 리포지토리 (저장, 조회 기능)
- 기본키인 id가 아닌 message로 조회 -> 쿼리 작성
- 로그 리포지토리의 저장 메소드에 @Transactional 적용
- message에 "로그예외"가 포함 -> 런타임 예외 던짐 -> 롤백 시키기 위함
* MemberService
package hello.springtx.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("== memberRepository 호출 시작 ==");
memberRepository.save(member);
log.info("== memberRepository 호출 종료 ==");
log.info("== logRepository 호출 시작 ==");
logRepository.save(logMessage);
log.info("== logRepository 호출 종료 ==");
}
}
- 회원 등록 + 회원 등록에 대한 DB 로그 남기기
- joinV1()
- 회원과 DB 로그를 함께 남기는 비즈니스 로직
- 서비스에서 별도의 트랜잭션 설정 X
1-2. 트랜잭션 전파 활용 테스트
* MemberServiceTest
- JPA의 구현체인 하이버네이트가 테이블 자동 생성
- 메모리 DB이기 때문에 모든 테스트가 완료된 이후에 DB는 사라짐 (각 테스트 X, 모든 테스트 O)
- Test에는 @Transactional을 안걸고있음 (트랜잭션 테스트 중이니까) -> 각 테스트 완료 후 사라지지 않음
- 그래서 각 테스트별 username을 각각 다르게 설정해줌 (중복 오류 방지)
1) 서비스 계층 트랜잭션 X 일 때
(1) 커밋
- 서비스 : 트랜잭션 X
- 회원 리포지토리 : 트랜잭션 O
- 로그 리포지토리 : 트랜잭션 O
- 회원, 로그 리포지토리 커밋 성공 일 때
package hello.springtx.propagation;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Assertions;
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.*;
import static org.junit.jupiter.api.Assertions.*;
@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 (junit의 assertTrue 사용) -> 모든 데이터 정상 저장
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isPresent());
}
}
📌 insert 쿼리가 커밋보다 나중에 실행되는 이유
JPA는 커밋/롤백 시점에 쿼리 실행 -> 커밋/롤백 하자마자 바로 쿼리 실행됨
- @Transactional 어노테이션이 있는 Repository 에서 트랜잭션 AOP 작동
- 커넥션 획득 -> 커넥션 수동 모드 -> 트랜잭션 시작
- 저장 메소드 실행 후 커밋 요청
- 신규 트랜잭션 여부, rollbackOnly 여부 확인
- 물리 트랜잭션 커밋 -> 트랜잭션 종료
(2) 롤백
- 서비스 : 트랜잭션 X
- 회원 리포지토리 : 트랜잭션 O
- 로그 리포지토리 : 트랜잭션 O
- 회원 리포지토리는 정상 동작, 로그 리포지토리는 예외 발생
/**
* MemberService @Transactional: OFF
* MemberRepository @Transactional: ON
* LogRepository @Transactional: ON Exception 발생
*/
@Test
void outerTxOff_fail() {
// given
String username = "로그예외_outerTxOff_fail";
// when
assertThatThrownBy(() -> memberService.joinV1(username))
.isInstanceOf(RuntimeException.class);
// then
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
- 로그 예외 -> 런타임 예외 발생 -> 롤백 -> logRepository에는 저장 X
- 로그 리포지토리는 런타임 예외 -> 롤백 -> 신규 트랜잭션 확인 -> 물리 롤백 호출
- 회원은 저장 O, 로그는 롤백 -> 데이터 정합성에 문제 발생할 수 있음
2) 서비스 계층에 트랜잭션 O
- 서비스 : 트랜잭션 O
- 회원 리포지토리 : 트랜잭션 X
- 로그 리포지토리 : 트랜잭션 X
/**
* MemberService @Transactional: ON
* MemberRepository @Transactional: OFF
* LogRepository @Transactional: OFF
*/
@Test
void singleTx() {
// given
String username = "singleTx";
// when
memberService.joinV1(username);
// then
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isPresent());
}
- MemberService를 시작할 때부터 종료할 때까지의 모든 로직을 하나의 트랜잭션으로 묶을 수 있음
- 서비스에만 트랜잭션 AOP 적용, 리포지토리에는 트랜잭션 AOP 적용 X
- Service가 Repository 호출 -> 같은 트랜잭션, 커넥션 사용
- * 같은 쓰레드를 사용하면 트랜잭션 동기화 매니저는 같은 커넥션 반환
** 근데, 각각 트랜잭션이 필요하다면 ?
- 클라이언트 A : Service부터 Repository까지 모두 하나의 트랜잭션으로 묶고 싶음
- 클라이언트 B : MemberRepository만 호출하고 여기에만 트랜잭션 적용하고 싶음
- 클라이언트 C : LogRepository만 호출하고 여기에만 트랜잭션 적용하고 싶음
--> 트랜잭션 전파
3) 트랜잭션 전파
@Transactional 적용 -> REQUIRED 전파 옵션 기본
: 기존 트랜잭션 X -> 트랜잭션 생성
기존 트랜잭션 O -> 기존 트랜잭션에 참여
(1) 커밋
- 서비스 : 트랜잭션 O
- 회원 리포지토리 : 트랜잭션 O
- 로그 리포지토리 : 트랜잭션 O
/**
* 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 호출 -> 트랜잭션 AOP 호출
- 신규 트랜잭션 생성, 물리 트랜잭션 시작
- 신규 트랜잭션 생성, 물리 트랜잭션 시작
- MemberRepository 호출 -> 트랜잭션 AOP 호출
- 이미 트랜잭션 있음 -> 기존 트랜잭션에 참여
- 로직 수행 후 트랜잭션 AOP 호출 -> 커밋 요청
-> 신규 트랜잭션 X -> 실제 커밋 호출 X
- LogRepository 호출 -> 트랜잭션 AOP 호출
- 이미 트랜잭션 있음 -> 기존 트랜잭션에 참여
- 로직 수행 후 트랜잭션 AOP 호출 -> 커밋 요청
-> 신규 트랜잭션 X -> 실제 커밋 호출 X
- MemberService 로직 수행 후 트랜잭션 AOP 호출 -> 커밋 요청
- 신규 트랜잭션 O -> 실제 커밋 호출 O
(2) 롤백
- 서비스 : 트랜잭션 O
- 회원 리포지토리 : 트랜잭션 O
- 로그 리포지토리 : 트랜잭션 O
/**
* 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());
}
- MemberService 호출 -> 트랜잭션 AOP 호출
- 신규 트랜잭션 생성, 물리 트랜잭션 시작
- 신규 트랜잭션 생성, 물리 트랜잭션 시작
- MemberRepository 호출 -> 트랜잭션 AOP 호출
- 이미 트랜잭션 있음 -> 기존 트랜잭션에 참여
- 로직 수행 후 트랜잭션 AOP 호출 -> 커밋 요청
-> 신규 트랜잭션 X -> 실제 커밋 호출 X
- LogRepository 호출 -> 트랜잭션 AOP 호출
- 이미 트랜잭션 있음 -> 기존 트랜잭션에 참여
- 런타임 예외 발생 -> 롤백 요청
-> 신규 트랜잭션 X -> 실제 롤백 호출 X -> rollbackOnly 설정(표시) - LogRepository가 던진 예외를 트랜잭션 AOP도 그대로 던짐
- MemberService 에서 런타임 예외를 받음 -> 예외처리 로직 X -> 트랜잭션 AOP한테 던짐
- 트랜잭션 AOP는 런타임 예외 발생했으므로 트랜잭션 매니저에게 롤백 요청
- 신규 트랜잭션 O -> 실제 롤백 호출 O
- 어차피 롤백이므로 rollbackOnly 설정 참고 X
- MemberService가 예외를 던졌기 때문에 트랜잭션 AOP도 그대로 클라이언트한테 던짐
- 하나의 트랜잭션 -> 하나 롤백되면 전부 롤백 -> 회원과 회원 이력 로그 일치 -> 데이터 정합성 O
4) 트랜잭션 전파 복구
비즈니스 요구사항
회원 가입 로그는 실패하더라도, 회원가입은 유지되어야 함
= MemberRepository는 커밋, LogRepository는 롤백 상태로 저장해라
- LogRepository에서 예외 발생 + 롤백 -> MemberService에서 예외 잡아서 정상흐름으로 변환
- MemberService에서 정상흐름 커밋 수행
- ↑ 이 방법이 안되는 이유 !!! (실수 많이함 주의)
- 런타임 예외 발생 -> 논리 트랜잭션 롤백 요청 -> rollbackOnly 자동 표시 !!
- 물리 트랜잭션에서 rollbackOnly 확인 -> 롤백 요청 -> 전체 롤백 !!
(0) MemberService - 복구 코드 (예외 잡아서 정상흐름으로 바꿈)
package hello.springtx.propagation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
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("== logRepository 호출 시작 ==");
try {
logRepository.save(logMessage);
} catch (RuntimeException e) {
log.info("log 저장에 실패했습니다. logMessage={}", logMessage.getMessage());
log.info("정상 흐름 변환");
}
log.info("== logRepository 호출 종료 ==");
}
}
(1) 전체 롤백 (복구 실패)
/**
* 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());
}
- joinV2 (복구 코드) 호출 !
- LogRepository에서 런타임 예외 발생 -> LogRepository의 트랜잭션 AOP가 예외 받음 -> 런타임 예외니까 트랜잭션 매니저한테 롤백 요청
- 신규 트랜잭션 X -> 롤백 호출 X + 근데 rollbackOnly 자동 표시
- 트랜잭션 AOP는 전달받은 예외를 밖으로 던짐
- MemberService에서 예외를 잡아서 복구 -> 정상흐름으로 처리 -> 정상흐름이니까 MemberService의 트랜잭션 AOP가 커밋 호출
- 신규 트랜잭션 O -> 커밋 호출 O -> 근데 rollbackOnly가 있네 ? -> 물리 트랜잭션 롤백
- 내부 트랜잭션은 롤백 + 외부 트랜잭션은 커밋 -> UnexpectedRollbackException 예외 던짐
📌 정리
- 논리 트랜잭션 중 하나라도 롤백되면 전체 트랜잭션 롤백 (rollbackOnly = true 자동 표시)
- rollbackOnly 상황에서 커밋 발생 -> UnexpectedRollbackException 예외 발생
(2) 🚨 복구 성공 (REQUIRES_NEW) - 회원 가입 로그 실패 + 회원 가입 유지
/**
* MemberService @Transactional: ON
* MemberRepository @Transactional: ON
* LogRepository @Transactional(REQUIRES_NEW): ON 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());
}
@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("예외 발생"); // 롤백
}
}
- LogRepository에서 런타임 예외 발생 -> LogRepository의 트랜잭션 AOP가 예외 받음
- REQUIRES_NEW를 사용한 신규 트랜잭션 O -> 물리 트랜잭션 롤백 O -> rollbackOnly 표시하지 않고, 롤백하고 끝남
- 트랜잭션 AOP는 예외는 잡아서 던짐
- MemberService에서 해당 예외 복구 -> 정상흐름으로 처리
- 정상흐름이므로 MemberService의 트랜잭션 AOP는 커밋 호출
- 신규 트랜잭션 O -> 물리 트랜잭션 커밋 O
- 정상흐름 반환 (회원 데이터는 저장, 로그 데이터만 롤백)
📌 정리
- 논리 트랜잭션은 하나라도 롤백 -> 물리 트랜잭션 롤백
- REQUIRES_NEW 사용해서 트랜잭션 분리 -> 커밋하고 싶은 것 커밋, 롤백하고 싶은 것 롤백
✔ 주의
- REQUIRES_NEW 사용 -> 동시에 2개의 데이터베이스 커넥션 사용 -> 성능 주의
- REQUIRES_NEW를 사용하지 않는 방법이 있다면 그걸로 사용
예) REQUIRES_NEW 사용하지 않고, 구조 변경
- 동시에 2개의 커넥션 사용 X
- 순차적으로 사용, 반환