예제 프로젝트 만들기
* 회원 등록 및 조회
* 회원 변경 이력을 추적할 수 있게 회원 데이터 변경 시 이력을 DB LOG 테이블에 남겨야 함
Member.java
package hello.springtx.propagation;
import jakarta.persistence.*;
import lombok.AccessLevel;
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;
}
}
Log.java
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;
}
}
MemberRepository.java
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();
}
}
LogRepository.java
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();
}
}
MemberService.java
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); // DB 로그 남김
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 호출 종료 ==");
}
}
-> JPA의 구현체인 하이버네이트가 테이블을 자동으로 생성해줌
-> 메모리 DB이기 때문에 테스트 완료 후 DB는 사라짐
커밋 & 롤백 확인
1. 서비스 계층에 트랜잭션이 없을 때 : 커밋
* 서비스 계층에 트랜잭션이 없음
* 회원, 로그 리포지토리는 각각 트랜잭션을 가지고 있음
MemberServiceTest.java
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.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() {
String username = "outerTxOff_success";
memberService.joinv1(username); // 저장
// isPresent() : 존재하냐
Assertions.assertTrue(memberRepository.find(username).isPresent());
Assertions.assertTrue(logRepository.find(username).isPresent());
}
}
실행 결과

동작 과정

1. MemberService에서 MemberRepository호출 : MemberRepository에 @Transactional이 있으므로 트랜잭션 AOP가 작동
- 트랜잭션 매니저를 통해 트랜잭션 시작(이를 트랜잭션 B라고 하자)
- 트랜잭션 매니저는 데이터소스를 통해 con1을 획득하고 커넥션을 수동 커밋 모드로 변경해 트랜잭션을 시작함
- 트랜잭션 동기화 매니저를 통해 트랜잭션을 시작한 커넥션 보관, status를 반환, 신규 트랜잭션 여부가 참이 됨
2. MemberRepository는 JPA를 통해 회원 저장(con1 사용)
3. MemberRepository가 정상 응답 반환 -> 트랜잭션 AOP가 트랜잭션 매니저에 커밋 요청
4. 트랜잭션 매니저가 con1을 통해 물리 트랜잭션 커밋
...
LogRepository도 동일
2. 서비스 계층에 트랜잭션이 없을 때 : 롤백
MemberServiceTest.java
/**
* memberService : @Transactional OFF
* memberRepository : @Transactional ON
* logRepository : @Transactional ON -> Exception
*/
@Test
void outerTxOff_fail() {
String username = "로그예외_outerTxOff_fail";
memberService.joinv1(username); // 저장
// isPresent() : 존재하냐
Assertions.assertTrue(memberRepository.find(username).isPresent());
Assertions.assertTrue(logRepository.find(username).isPresent());
}
실행 결과

-> 회원은 저장되지만 회원 이력 로그는 롤백됨
-> 데이터 정합성에 문제가 발생할 수 있음
단일 트랜잭션 사용
회원 리포지토리와 로그 리포지토리를 하나의 트랜잭션을 묶는 가장 간단한 방법은 이 둘을 호출하는 회원 서비스에만 트랜잭션을 사용하는 것
* MemberService에 @Transactionl 추가 & LogRepository, MemberRepository에 @Transactional 제거

MemberServiceTest.java
/**
* memberService : @Transactional ON
* memberRepository : @Transactional OFF
* logRepository : @Transactional OFF
*/
@Test
void singleTx() {
String username = "outerTxOff_success";
memberService.joinv1(username); // 저장
// isPresent() : 존재하냐
Assertions.assertTrue(memberRepository.find(username).isPresent());
Assertions.assertTrue(logRepository.find(username).isPresent());
}
동작 과정

1. MemberService에만 @Transactional이 붙어있기 때문에 여기에만 트랜잭션 AOP가 적용된다.
- MemberRepository, LogRepository는 트랜잭션 AOP가 적용되지 않음, 순수 자바 코드
2. MemberService의 시작부터 끝까지 관련 로직은 해당 트랜잭션이 생성한 커넥션을 사용 : MemberRepository, LogRepository도 같은 커넥션을 사용하며 자연스럽게 트랜잭션 범위에 포함됨
* 같은 쓰레드를 사용하면 트랜잭션 동기화 매니저는 같은 커넥션을 반환함
트랜잭션 전파 커밋 & 롤백
스프링은 @Transactional이 적용되어 있으면 기본으로 REQUIRED 전파 옵션을 사용한다.

* MemberService, LogRepository, MemberRepository 모두 @Transanctional 추가
1. 전파 커밋
MemberServiceTest.java
/**
* memberService : @Transactional ON
* memberRepository : @Transactional ON
* logRepository : @Transactional ON
*/
@Test
void outerTxOn_success() {
String username = "outerTxOff_success";
memberService.joinv1(username); // 저장
Assertions.assertTrue(memberRepository.find(username).isPresent());
Assertions.assertTrue(logRepository.find(username).isPresent());
}
2. 전파 롤백
MemberServiceTest.java
/**
* memberService : @Transactional ON
* memberRepository : @Transactional ON
* logRepository : @Transactional ON -> EXCEPTION
*/
@Test
void outerTxOn_fail() {
String username = "로그예외_outerTxOn_fail";
memberService.joinv1(username); // 저장
Assertions.assertTrue(memberRepository.find(username).isPresent());
Assertions.assertTrue(logRepository.find(username).isPresent());
}
-> "로그예외"를 넘겼기 때문에 LogRepository에서 런타임 예외 발생
실행 결과

-> 모든 데이터가 롤백됨
동작 과정

1. MemberService가 호출되며 트랜잭션 AOP가 호출됨 : 신규 트랜잭션 생성, 물리 트랜잭션 시작
2. MemberRepository가 호출되며 트랜잭션 AOP가 호출됨 : 기존 트랜잭션에 참여
3. MemberRepository 로직 호출이 끝나고 정상 응답이므로 트랜잭션 매니저에 커밋 요청 but 신규 트랜잭션이 아니므로 실제 커밋은 호출 X
4. LogRepository가 호출되며 트랜잭션 AOP가 호출됨 : 기존 트랜잭션에 참여
5. LogRepository 로직에서 런타임 예외 발생 : 신규 트랜잭션이 아니므로 rollbackOnly를 설정함
6. MemberService가 런타임 예외를 받고 따로 예외를 처리하지 않은채 밖으로 던짐
7. 트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백 요청 : 신규 트랜잭션이므로 물리 롤백 호출 -> 어차피 롤백이 되었기 때문에 rollbackOnly 설정은 참고하지 X
트랜잭션 전파 복구 : REQUIRED
"회원 가입 시도 로그를 남기는데 실패하더라도 회원 가입은 유지되도록"
MemberServiceTest.java
/**
* memberService : @Transactional ON
* memberRepository : @Transactional ON
* logRepository : @Transactional ON -> EXCEPTION
*/
@Test
void recoverException_fail() {
String username = "로그예외_recoverException_fail";
memberService.joinv2(username); // 저장
Assertions.assertTrue(memberRepository.find(username).isPresent());
Assertions.assertTrue(logRepository.find(username).isPresent());
}
이때, joinv2를 호출로 예외가 복구됨(정상 커밋이 될까?)

실행 결과

-> 예외 발생
-> 논리 트랜잭션 중 하나라도 롤백되면 전체 트랜잭션이 롤백됨
-> rollbackOnly가 체크되어 있으므로..
동작 과정

트랜잭션 전파 복구 : REQUIRES_NEW
"REQUIRES_NEW"를 사용해 로그와 관련된 물리 트랜잭션을 별도로 분리
MemberServiceTest.java
/**
* memberService : @Transactional ON
* memberRepository : @Transactional ON
* logRepository : @Transactional ON(REQUIRES_NEW) -> EXCEPTION
*/
@Test
void recoverException_success() {
String username = "로그예외_recoverException_success";
memberService.joinv2(username); // 저장
Assertions.assertTrue(memberRepository.find(username).isPresent());
Assertions.assertTrue(logRepository.find(username).isPresent());
}
LogRepository.save에 REQUIRES_NEW 옵션 추가

실행 결과

-> MemberRepository는 커밋, LogRepository는 롤백
동작 과정

-> REQUIRES_NEW를 사용해 신규 트랜잭션을 생성한다.
-> rollbackOnly가 표시되지 않고 LogRepository의 트랜잭션이 물리 롤백되고 끝난다.
* REQUIRES_NEW 주의점
1. 하나의 HTTP 요청에 동시에 2개의 데이터베이스 커넥션을 사용하게 되므로 성능이 중요한 곳에서는 이 부분을 주의해 사용해야 한다.
2. REQUIRES_NEW를 사용하지 않고 문제를 해결할 수 있는 단순한 방법이 있다면 그 방법을 선택하는 것이 더 좋다.
'인프런 > 스프링 DB 2편 - 데이터 접근 활용 기술' 카테고리의 다른 글
| [인프런] 스프링 DB 2편 - 데이터 접근 기술 활용 / 10. 스프링 트랜잭션 전파1 - 기본 (2) | 2024.09.17 |
|---|---|
| [인프런] 스프링 DB 2편 - 데이터 접근 기술 활용 / 9. 스프링 트랜잭션 이해 (2) | 2024.09.09 |
| [인프런] 스프링 DB 2편 - 데이터 접근 기술 활용 / 8. 데이터 접근 기술 - 활용 방안 (2) | 2024.09.01 |
| [인프런] 스프링 DB 2편 - 데이터 접근 기술 활용 / 7. 데이터 접근 기술 - Querydsl (0) | 2024.08.22 |
| [인프런] 스프링 DB 2편 - 데이터 접근 기술 활용 / 6. 데이터 접근 기술 - 스프링 데이터 JPA (0) | 2024.08.11 |