Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
Tags
- 자바의 정석 기초편 ch6
- 자바의 정석 기초편 ch13
- 코드로 시작하는 자바 첫걸음
- 타임리프 - 기본기능
- 자바의 정석 기초편 ch2
- 게시글 목록 api
- 자바의 정석 기초편 ch3
- jpa 활용2 - api 개발 고급
- 스프링 mvc2 - 검증
- 스프링 db2 - 데이터 접근 기술
- 자바의 정석 기초편 ch7
- 자바의 정석 기초편 ch12
- 자바의 정석 기초편 ch9
- jpa - 객체지향 쿼리 언어
- 자바의 정석 기초편 ch8
- 자바의 정석 기초편 ch1
- 스프링 mvc1 - 스프링 mvc
- 자바의 정석 기초편 ch4
- 스프링 db1 - 스프링과 문제 해결
- 2024 정보처리기사 수제비 실기
- 스프링 mvc2 - 로그인 처리
- 자바의 정석 기초편 ch14
- @Aspect
- 스프링 mvc1 - 서블릿
- 스프링 mvc2 - 타임리프
- 자바의 정석 기초편 ch5
- 스프링 입문(무료)
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch11
- 2024 정보처리기사 시나공 필기
Archives
- Today
- Total
나구리의 개발공부기록
스프링 트랜잭션 전파 - 활용, 예제 프로젝트 시작, 커밋/롤백, 단일 트랜잭션, 전파 커밋, 전파 롤백, 복구 REQUIRED, 복구 REQUIRES_NEW 본문
인프런 - 스프링 완전정복 코스 로드맵/스프링 DB 2편 - 데이터 접근 활용
스프링 트랜잭션 전파 - 활용, 예제 프로젝트 시작, 커밋/롤백, 단일 트랜잭션, 전파 커밋, 전파 롤백, 복구 REQUIRED, 복구 REQUIRES_NEW
소소한나구리 2024. 9. 26. 10:01 출처 : 인프런 - 스프링 DB 2편 데이터 접근 핵심 원리 (유료) / 김영한님
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-2
1. 트랜잭션 전파 활용1 - 예제 프로젝트 시작
- 지금 까지 배운 트랜잭션 전파에 대한 내용을 실제 예제를 통해 학습
1) 비즈니스 요구 사항
- 회원을 등록하고 조회
- 회원에 대한 변경이력을 추적할 수 있도록 회원 데이터가 변경될 때 변경 사유에 대한 이력을 DB LOG 테이블에 남겨야함
- 예제를 단순화 하기 위해서 회원 등록시에만 DB LOG 테이블에 남기는 것으로 진행
2) Member
- JPA를 통해 관리하는 회원 엔터티
package hello.springtx.propagation;
@Entity
@Getter
@Slf4j
public class Member {
@Id @GeneratedValue
private Long id;
private String username;
public Member(){}
public Member(String username) {
this.username = username;
}
}
3) MemberRepository
- 저장과 조회 기능을 제공하는 JPA를 사용한 회원 리포지토리
package hello.springtx.propagation;
@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(); // 결과가 여러개면 findAny()로 먼저 찾은 결과를 반환하고 값이 없으면 Optional 안에 empty()로 반환
}
}
4) Log
- JPA를 통해 관리하는 로그 엔터티
package hello.springtx.propagation;
@Entity
@Getter
@Setter
public class Log {
@Id
@GeneratedValue
private Long id;
private String message;
public Log() {}
public Log(String message) {
this.message = message;
}
}
5) LogRepository
- JPA를 사용하는 로그 리포지토리이며 저장과 조회 기능을 제공함
- 중간에 예외 상황을 재현하기 위해서 로그 예외라고 입력하는 경우 예외를 발생 시킴
package hello.springtx.propagation;
@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("예외 발생"); // runtime 예외 발생, 트랜잭션 롤백
}
}
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();
}
}
6) MemberService
- 회원을 등록하면서 동시에 회원 등록에 대한 DB 로그도 함께 남김
(1) joinV1()
- 회원과 DB로그를 함께 남기는 비즈니스 로직
- 별도의 트랜잭션은 설정하지 않고, 각 리포지토리에 설정된 트랜잭션이 동작
(2) joinV2()
- joinV1()과 같은 기능을 수행하지만 DB로그 저장시 예외가 발생하면 예외를 복구시켜 정상 흐름으로 변환함
- 마찬가지로 별도의 트랜잭션을 설정은 안함
package hello.springtx.propagation;
@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);
log.info("정상 흐름 반환");
}
log.info("== logRepository 호출 종료 ==");
}
}
7) MemberServiceTest
- outerTxOffd_success() 테스트 코드에 대한 설명은 바로 밑에서 설명, 지금은 해당 코드가 정상적으로 동작하는지만 테스트를 수행해보면 정상적으로 동작함
- JPA의 구현체인 하이버네이트가 테이블을 자동으로 생성해주고 메모리 DB이기 때문에 모든 테스트가 완료된 이후 DB는 사라짐
- 여기서는 각각의 테스트가 완료된 시점에 데이터를 삭제하지 않기 때문에 username의 리터럴을 테스트별로 다르게 설정해야 다음 테스트에 영향을 주지 않음(모든 테스트가 완료되어야 DB가 사라짐)
package hello.springtx.propagation;
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 - Assertions.assertTrue 사용
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isPresent());
}
}
** 참고
- JPA를 통한 모든 데이터 변경(등록, 수정, 삭제)에는 트랜잭션이 필요한데 현재 코드에서 서비스 계층에 트랜잭션이 없기 때문에 리포지토리에 트랜잭션이 있음
- 조회는 트랜잭션 없이 가능함
2. 트랜잭션 전파 활용2 - 커밋, 롤백
1) 서비스 계층에 트랜잭션이 없을 때 - 커밋
/**
* memberService @Transactional:OFF
* memberRepository @Transactional:ON
* logRepository @Transactional:ON
*/
@Test
void outerTxOff_success() {
// 코드 생략
}
(1) 흐름 설명
- 1. MemberService에서 MemberRepository를 호출하면 MemberRepository에 @Transactional 애노테이션이 있으므로 트랜잭션 AOP가 작동하고, 트랜잭션 매니저를 통해 트랜잭션을 시작함
- 그림에서는 생략 되어 있지만 트랜잭션 매니저에 트랜잭션을 요청하면 데이터소스를 통해 커넥션(여기에선 con1)을 획득하고 해당 커넥션을 수동 커밋모드로 변경해서 트랜잭션을 시작함
- 트랜잭션 동기화 매니저를 통해 트랜잭션을 시작한 커넥션을 보관하고 트랜잭션 매니저의 호출 결과로 신규 트랜잭션 여부가 true로 status를 반환함
- 2. MemberRepository는 JPA를 통해 회원을 저장, JPA는 트랜잭션이 시작된 con1을 사용해서 회원을 저장함
- 3. MemberRepository가 정상 응답을 반환했으므로 트랜잭션 AOP는 트랜잭션 매니저에 커밋을 요청
- 4. 트랜잭션 매니저는 con1을 통해 신규 트랜잭션 여부, rollbackOnly 여부를 모두 체크한뒤 이상이 없으면 물리 트랜잭션을 커밋함
- MemberRepository와 관련된 모든 데이터는 정상 커밋되고 트랜잭션은 완전히 종료됨
- 이후에 LogRepository를 통해 트랜잭션을 시작하고 동일한 과정을 거쳐서 정상 커밋을 하게되어 결과적으로 둘다 커밋되어 Member와 Log 모두 안전하게 저장됨
2) 서비스 계층에 트랜잭션이 없을 때 - 롤백
(1) outerTxOff_fail() 메서드 추가
- 동일한 상황이지만 LogRepository에서 런타임 예외가 발생한 상황
- username에 "로그예외"가 포함되면 joinV1 호출 시 LogRepository에서 save()를 호출할 때 RuntimeException이 발생함
- Member는 저장이되고 Log는 롤백이 되어 저장되지 않아서 빈값이 반환됨
/**
* memberService @Transactional:OFF
* memberRepository @Transactional:ON
* logRepository @Transactional:ON - RuntimeException
*/
@Test
void outerTxOff_fail() {
// given: username에 로그예외가 포함되도록 설정하면 Log 저장시 런타임 예외 발생
String username = "로그예외_outerTxOff_fail";
// when: joinV1에서 Log 저장시 예외가 발생하는것을 검증
assertThatThrownBy(() -> memberService.joinV1(username)).isInstanceOf(RuntimeException.class);
// then: Member는 저장되지만 Log는 저장되지 않아서 비어있음
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
(2) LogRepository 흐름 설명
- 1. LogRepository는 트랜잭션C와 관련된 con2 커넥션을 사용함
- 2. 로그예외라는 이름을 전달해서 LogRepository에 런타임 예외가 발생함
- 3. LogRepository는 해당 예외를 밖으로 밖으로 던지고 트랜잭션 AOP가 예외를 받게 됨
- 4. 런타임 예외가 발생해서 트랜잭션 AOP는 트랜잭션 매니저에 롤백을 호출함
- 5. 트랜잭션 매니저는 신규 트랜잭션이므로 물리 롤백을 호출함
** 참고
- 트랜잭션 AOP도 결국 내부에서는 트랜잭션 매니저를 사용함
- 결과적으로 회원은 저장되었지만 회원 이력 로그는 롤백이 되어서 데이터 정합성에 문제가 발생할 수 있음
- 둘을 하나의 트랜잭션으로 묶어서 처리할 필요가 있음
3. 트랜잭션 전파 활용3 - 단일 트랜잭션
1) 트랜잭션 하나만 사용하기
- 회원 리포지토리와 로그 리포지토리를 하나의 트랜잭션으로 묶는 가장 간단한 방법을 이 둘을 호출하는 회원 서비스에만 트랜잭션을 사용하면 됨
(1) singleTx() 추가
- 기능은 outerTxOff_success()와 동일하고 주석에서 memberService에민 @Transactional을 적용
/**
* memberService @Transactional:ON
* memberRepository @Transactional:OFF
* logRepository @Transactional:OFF
*/
@Test
void singleTx() {
// ... 코드 생략, username변경
// outerTxOff_success()와 검증 코드는 동일함
}
(2) MemberService -joinV1()
- MemberService의 joinV1 메소드에 @Transactional 적용
@Transactional
public void joinV1(String username) {
// ... 코드 생략
}
(3) MemberRepository와 LogRepository의 save()
- 각 저장소의 sava()메소드에 적용했던 @Transactional을 주석처리하여 제거
// @Transactional
public void save(Member member) {
// ... 코드 생략
}
// @Transactional
public void save(Log logMessage) {
// ... 코드 생략
}
2) 흐름 설명
- MemberService에만 @Transactional을 적용하여 MemberService를 시작할 때 부터 종료할 때까지의 모든 로직을 하나의 트랜잭션으로 묶을 수 있게 되었음
- MemberService만 트랜잭션을 처리하기 때문에 논리, 물리, 외부, 내부 트랜잭션의 개념과 여러 트랜잭션의 옵션에 대한 고민할 필요없이 단순하게 트랜잭션을 묶을 수 있음
- 트랜잭션이 적용되는 MemberService에만 트랜잭션 AOP가 적용되고(MemberRepository, LogRepository에는 트랜잭션 AOP 적용 X) MemberService의 시작부터 끝까지 관련 로직들은 해당 트랜잭션이 생성한 커넥션을 사용하게 되므로 MemberService가 호출하는 MemberRepository, LogRepository도 같은 커넥션을 사용하면서 자연스럽게 트랜잭션 범위에 포함됨
** 참고
- 같은 쓰레드를 사용하면 트랜잭션 동기화 매니저는 같은 커넥션을 반환함
3) 만일 각각 트랜잭션이 필요한 상황이라면?
- 클라이언트 A는 MemberService부터 MemberRepository, LogRespository를 모두 하나의 트랜잭션으로 묶어서 사용
- 클라이언트 B는 MemberRepository만 호출하고 여기에만 트랜잭션을 사용
- 클라이언트 C는 LogRepository만 호출하고 여기에만 트랜잭션을 사용
- 클라이언트 Z는 OerderService에서도 트랜잭션을 시작하고 모든 계층을 하나의 트랜잭션으로 묶어서 사용
- 이런식으로 무수한 클라이언트가 여러 트랜잭션을 사용하려고 할 수 있음
- 클라이언트 A만 생각하면 방금 예제에서 했던 것처럼 Repository 코드들에는 트랜잭션을 제거하고 MemberService에만 트랜잭션을 적용하면 모든 트랜잭션을 간단하게 묶을 수 있지만 클라인터 B, C가 호출하는 상황은 트랜잭션을 적용할 수 없게 됨
- 만약 트랜잭션 전파 없이 이런 문제를 해결하려면 내부 기능은 동일한 메서드를 트랜잭션이 달린 메서드와 트랜잭션이 없는 메서드를 만들어서 각각 구조를 설계해야 하는 복잡하고 번거로운 상황이 발생됨
- 이런 문제를 해결하기 위해 트랜잭션 전파가 필요한 것임
4. 트랜잭션 전파 활용4 - 전파 커밋
1) @Transactional - REQUIRED
- 앞에서 다뤘듯 스프링은 @Transactional이 적용되어있으면 기본으로 REQUIRED라는 전파 옵션을 사용함
- 기존 트랜잭션이 없으면 트랜잭션을 생성하고 기존 트랜잭션이 있으면 기존 트랜잭션에 참여함
- 참여한다는 것은 해당 트랜잭션을 그대로 따른다는 뜻이고 동시에 같은 동기화 커넥션을 사용한다는 뜻
- 둘 이상의 트랜잭션이 하나의 물리 트랜잭션에 묶이게 되면 둘을 구분하기 위해 논리 트랜잭션과 물리 트랜잭션으로 구분됨
- 신규 트랜잭션만 실제 물리 트랜잭션을 시작하고 커밋하고 내부에 있는 트랜잭션은 물리 트랜잭션을 시작하거나 커밋하지 않음
- 모든 논리 트랜잭션을 커밋해야 물리 트랜잭션도 커밋되고 하나라도 롤백되면 물리 트랜잭션은 롤백 됨
2) 코드 수정
(1) outerTxon_success() 추가
/**
* memberService @Transactional:ON
* memberRepository @Transactional:ON
* logRepository @Transactional:ON
*/
@Test
void outerTxOn_success() {
// ... 코드 생략, username변경
// outerTxOff_success()와 검증 코드는 동일함
}
(2) @Transactional 적용
- MemberService, MemberRepository, LogRepository에 트랜잭션을 모두 적용
3) 흐름 설명
- 클라이언트 A(테스트 코드)가 MemberService를 호출하면서 트랜잭션 AOP가 호출되며 신규 트랜잭션이 생성되고 물리 트랜잭션도 시작함
- MemberRepository를 호출하면서 트랜잭션 AOP가 호출되고 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여함
- MemberRepository의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출되고, 정상응답이므로 트랜잭션 매니저에 커밋을 요청함 -> 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않음
- LogRepository를 호출하면서 트랜잭션 AOP가 호출되고 마찬가지로 이미 트랜잭션이 있어 기존 트랜잭션으로 참여함
- LogRepository의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출되고 트랜잭션 매니저에 커밋을 요청함 -> 마찬가지로 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않음
- MemberService의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출되고 정상응답이므로 트랜잭션 매니저에 커밋을 요청함 -> 신규 트랜잭션이므로 물리 커밋을 호출해서 실제 커밋이 됨
5. 트랜잭션 전파 활용5 - 전파 롤백
- 로그 리포지토리에서 예외가 발생하여 전체 트랜잭션이 롤백되는 상황
- 클라이언트까지 예외가 올라오게됨
1) outerTxOn_fail 추가
- memberService, memberRepository,logRepository에 @Transactional이 적용된 상황에서 username에 로그예외라는 단어를 넣어서 LogRepository에서 save()될 때 예외가 발생되도록 설정
- 실제 물리 트랜잭션에서 롤백을 모든 트랜잭션이 롤백 되고 Member, Log의 조회값은 모두 빈 값이 되어야 함
/**
* memberService @Transactional:ON
* memberRepository @Transactional:ON
* logRepository @Transactional:ON - RuntimeException
*/
@Test
void outerTxOn_fail() {
// given: username에 로그예외가 포함되도록 설정하면 Log 저장시 런타임 예외 발생
String username = "로그예외_outerTxOn_fail";
// when: joinV1에서 Log 저장시 예외가 발생하는것을 검증
assertThatThrownBy(() -> memberService.joinV1(username)).isInstanceOf(RuntimeException.class);
// then: 전체가 다 롤백되어 Member, Log 모두 빈값이 반환되야함
assertTrue(memberRepository.find(username).isEmpty());
assertTrue(logRepository.find(username).isEmpty());
}
2) 흐름 설명
- 클라이언트 A가 MemberService를 호출하면서 트랜잭션 AOP를 호출하면 신규 트랜잭션이 생성되고 물리 트랜잭션도 시작함
- MemberRepository를 호출하면서 트랜잭션 AOP가 호출되고 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여함
- MemberRepository의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출되고, 정상응답이므로 트랜잭션 매니저에 커밋을 요청함 -> 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않음
- LogRepository를 호출하면서 트랜잭션 AOP가 호출되고 마찬가지로 이미 트랜잭션이 있어 기존 트랜잭션으로 참여함
- LogRepository의 로직에서 런타임 예외가 하여 트랜잭션 AOP가 해당 예외를 받게되면 트랜잭션 매니저에게 롤백을 요청함 -> 신규 트랜잭션이 아니므로 실제 롤백을 호출하진않지만 rollbackOnly을 설정함
- MemberService에서도 런타임 예외를 받게 되는데 여기 로직에서 해당 런타임 예외를 처리하지않고 예외를 밖으로 던짐
- 트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백을 요청하고 신규 트랜잭션이므로 물리 롤백을 호출하며 이경우에는 어차피 롤백이 되었기 때문에 rollbackOnly 설정은 참고하지 않음
- MemberService가 예외를 던졌기 때문에 트랜잭션 AOP도 해당 예외를 그대로 밖으로 던지고 클라이언트A는 LogRepository부터 넘어온 런타임 예외를 받게 됨
3) 정리
- 회원과 회원 이력 로그를 처리하는 부분을 하나의 트랜잭션으로 묶은 덕분에 문제가 발생했을 때 회원과 회원 이력 로그가 모두 함께 롤백되어 데이터 정합성에 문제가 발생하지 않음
6. 트랜잭션 전파 활용6 - 복구 REQUIRED
1) 비즈니스 요구사항이 변경
- 회원 이력 로그를 DB에 남기는 작업에 가끔 문제가 발생되서 회원 가입이 자체가 안되는 경우가 생겨 사용자들이 회원가입 실패로 이탈하는 문제를 확인되었다고 가정
- 회원 이력 로그의 경우 여러가지 방법으로 추후에 복구가 가능할 것으로 판단하여 회원가입을 시도한 로그를 남기는데 실패하더라도 회원가입은 유지되도록 요구사항이 변경된 상황으로 가정
2) 잘못된 접근
- 단순하게 접근하면 LogRepository에서 예외가 발생했으니 MemberService에서 예외를 잡아서 처리하면 MemberService에서 정상 흐름으로 바꿀 수 있기 때문에 MemberService의 트랜잭션 AOP에서 커밋을 수행할 수 있다고 생각할 수 있음
- 하지만 이방법은 무조건 실패함
- 많은 개발자가 이방법을 사용해서 실패한다고함.. 지금은 강의가 퍼져서 기본일듯?
3) 문제 해결 접근
(1) recoverException_fail 추가
- memberService에서 호출하는 메서드를 joinV2 메서드로 변경 후 inInstanceOf를 UnexpectedRollbackException.class로 설정하여 joinV2 메서드에서 예외가 발생할 것을 검증해야 테스트가 통과됨
- MemberService의 joinV2의 메서드에 @Transactional 적용
- Member가 커밋되었다고 생각하여 값을 반환할 것으로 기대하고 assertTrue(memberRepository.find(username).isPresent()); 코드로 테스트를하면 테스트가 실패함
- 트랜잭션 전체가 롤백이 되어버려서 둘다 isEmpty()로 검증해야 테스트가 통과함
- 이유는 내부 트랜잭션에서 rollbackOnly를 설정하기 때문에 결과적으로 정상 흐름 처리를 해서 외부 트랜잭션에서 커밋을 호출해도 물리 트랜잭션이 롤백되며 UnexpectedRollbackException이 던져지기 때문임
/**
* memberService @Transactional:ON
* memberRepository @Transactional:ON
* logRepository @Transactional:ON - RuntimeException
*/
@Test
void recoverException_fail() {
// given: username에 로그예외가 포함되도록 설정하면 Log 저장시 런타임 예외 발생
String username = "로그예외_recoverException_fail";
// when: joinV2로 메서드를 변경, v2메서드에는 runtimeException을 try-catch로 잡는 로직이 있음
// 하지만 트랜잭션이 rollbackOnly로 설정되어 runtimeException이 예외가 처리가되어 정상로직이 되었더라도 트랜잭션이 롤백이됨
// 스프링이 외부 트랜잭션이 정상처리되었지만 내부 롤백이 발생할 경우 UnexpectedRollbackException을 던짐
assertThatThrownBy(() -> memberService.joinV2(username)).isInstanceOf(UnexpectedRollbackException.class);
// then: try-catch로 runtimeException을 잡았기 때문에 Member는 commit되어 값이 있을 것이라고 생각할 수 있지만
// 트랜잭션이 rollbackOnly로 설정되어버렸기 때문에 전체가 롤백되어버림
// assertTrue(memberRepository.find(username).isPresent()); // 테스트 검증이 실패함
assertTrue(memberRepository.find(username).isEmpty());
assertTrue(logRepository.find(username).isEmpty());
}
// MemberService class - @Transactional 추가
@Transactional
public void joinV2(String username) {
// ... logRepository.save에서 runtimeException이 발생하면 처리하는 로직이 있음
}
(2) 흐름 설명
- LogRepository에서 예외가 발생되어 예외를 던지면 LogRepository의 트랜잭션 AOP가 해당 예외를 받음
- 신규 트랜잭션이 아니므로 물리 트랜잭션을 롤백하지는 않고 트랜잭션 동기화 매니저에 rollbackOnly 마크를 남김
- 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던짐
- 예외가 MemberService에 던져지고 Memberservice가 해당 예외를 복구하고 정상흐름으로 리턴함
- 정상 흐름이 되었으므로 MemberService의 트랜잭션 AOP는 커밋을 호출함
- 커밋을 호출할 때 해당 트랜잭션이 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋함 -> 이때 rollbackOnly 여부를 체크함
- rollbackOnly가 체크되어 있으므로 커밋이 되지않고 물리 트랜잭션은 롤백함
- 트랜잭션 매니저는 정상흐름으로 커밋을 시도했지만 전체가 롤백이 되었음을 명시적으로 알리기위해(스프링이 이렇게 설계함 - 좋은 설계의 예시임) UnexpectedRollbackException 예외를 던지고 트랜잭션 AOP도 전달받은 UnexpectedRollbackException을 클라이언트에 던짐
4) 정리
- 논리 트랜잭션 중 하나라도 롤백되면 전체 트랜잭션은 롤백됨
- 내부 트랜잭션이 롤백 되었는데, 외부 트랜잭션이 커밋되면 UnexpectedRollbackException 예외가 발생함
- rollbackOnly 상황에서 커밋이 발생하면 UnexpectedRollbackException 예외가 발생함
7. 트랜잭션 전파 활용7 - 복구 REQUIRES_NEW
1) 변경된 요구사항을 만족시키기 위한 솔루션
- 회원가입을 시도한 로그를 남기는데 실패하더라도 회원가입은 유지되어야한다
- 변경된 위 요구사항을 만족하기 위해서 로그와 관련된 물리 트랜잭션을 REQUIRES_NEW 옵션을 적용하여 분리 적용
- 물론 위의 방법을 해결하기위한 다른 방법도 여러가지가 존재하지만 지금은 REQUIRES_NEW를 사용해서 해결
2) 코드 수정
(1) LogRepository 수정
- save() 메소드의 @Transactional에 propagation = REQUIRES_NEW 옵션을 적용
// LogRepository 클래스
@Transactional(propagation = REQUIRES_NEW)
public void save(Log logMessage) {
// ... 코드 생략
}
(2) recoverException_success() 추가
- LogRepository에서 런타임 예외가 던져지면 joinV2에서는 해당 예외를 try-catch로 잡아서 정상흐름으로 반환을 기대
- MemberService에서 @Transactional에 REQUIRES_NEW 옵션을 적용했기 때문에 모든 트랜잭션은 신규 트랜잭션으로 적용되므로 MemberRepository에서의 트랜잭션은 커밋이 수행되고 LogRepository에서의 트랜잭션은 롤백이 수행될 것은 기대
/**
* memberService @Transactional:ON
* memberRepository @Transactional:ON
* logRepository @Transactional:ON(REQUIRES_NEW) - RuntimeException
*/
@Test
void recoverException_success() {
// given: username에 로그예외가 포함되도록 설정하면 Log 저장시 런타임 예외 발생
String username = "로그예외_recoverException_success";
// when: LogRepository에서 런타임 예외가 발생하지만 joinV2에서 try-catch로 예외를 잡아서 정상흐름으로 반환함
memberService.joinV2(username);
// then: Member는 저장 성공, Log는 롤백이 됨
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
3) 흐름 설명
(1) REQUIRES 설명
- MemberRepository는 REQUIRED옵션을 사용하여 기존 트랜잭션에 참여함(MemberService에서 시작한 신규 트랜잭션에 참여)
- LogRepository의 트랜잭션 옵션에 REQUIRES_NEW를 사용했기 때문에 항상 새로운 트랜잭션을 만들어서 해당 트랜잭션 안에서는 DB 커넥션도 별도로 사용하게 되고 물리 트랜잭션 자체가 완전히 분리 되어버림
- REQUIRES_NEW는 신규 트랜잭션이기 때문에 rollbackOnly를 표시하는 것이 아니라 해당 트랜잭션이 물리 롤백이 되고 끝남
(2) 응답 흐름 설명
- MemberService 트랜잭션 매니저가 생성한 커넥션(con1)은 잠시 미뤄두로 LogRepository에서 신규 트랜잭션이 시작하고 트랜잭션 매니저가 생성한 커넥션 con2가 사용됨
- LogRepository에서 예외가 발생하고 예외를 던지면 LogRepository의 트랜잭션 AOP가 해당 예외를 받고 REQUIRES_NEW를 사용한 신규 트랜잭션이므로 물리 트랜잭션을 롤백하고 완전히 끝나버림
- 이후 트랜잭션 AOP는 전달받은 예외를 밖으로 지면 MemberService가 받게되고 MemberService에서는 해당 예외를 복구하는 로직이 있으므로 정상적으로 리턴함
- 정상 흐름이 되었으므로 MemberService의 트랜잭션 AOP는 커밋을 호출함
- 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋하기 전 rollbackOnly 여부를 체크를 진행
- rollbackOnly가 없으므로 물리 트랜잭선이 커밋되어 정상흐름이 반환됨
- 결과적으로 회원 데이터는 저장되고 로그 데이터만 롤백 되는 요구사항이 잘 반영됨
4) 정리
- 논리 트랜잭션은 하나라도 롤백되면 관련된 물리 트랜잭션은 롤백되어 버림
- 이 문제를 해결을 위해 다양한 방법 중 트랜잭션에 REQUIRES_NEW 옵션을 사용하여 트랜잭션을 분리하여 해결했음
- 참고로 예제를 단순화 하기 위해 MemberService가 MemberRepository, LogRepository만 호출하지만 실제로를 더 많은 리포지토리들을 호출할 것이고 그 중에 LogRepository만 트랜잭션을 분리했다고 생각해보면 이러한 상황을 실무에 어떻게 적용해야할지 도움이 될 것임
** 주의
- 이론에서 배웠듯 REQUIRES_NEW를 사용하면 하나의 HTTP요청이 동시에 2개의 데이터베이스 커넥션을 사용하게 되기 때문에 성능이 중요한 곳에서는 이런 부분을 주의해서 사용해야함
- REQUIRES_NEW를 사용하지 않고 문제를 해결할 수 있는 단순한 방법이 있다면, 그 방법을 선택하는 것이 일반적으로는 더 좋을 수 있지만 상황에 따라서 어쩔수 없이 REQUIRES_NEW를 써야만 할 때도 있음
(1) 여러가지 해결방법 중 한가지 예시
- REQUIRES_NEW를 사용하지 않고 구조를 변경하는 방법
- 앞단에 MemberFacade라는 추가적인 구조를 만들어서 MemberService와 LogRepository를 MemberFacade에서 호출하도록하면 각각의 물리 트랜잭션으로 분리가 됨
- HTTP요청이 동시에 2개의 커넥션을 사용하지 않고 순차적으로 사용하고 반환하게 됨
- 무조상 REQUIRES_NEW를 사용하는 것이 더 깔끔한 경우도 있으므로 각각의 장단점을 이해하고 적절하게 선택해서 사용해야함