기술스택
- JDK 17
- Spring Boot 3.3.5
- JPA
1. 요구사항
- 회원을 등록하고 조회한다.
- 회원에 대한 변경 이력을 추적할 수 있도록 변경될 때 변경이력을 DB LOG 테이블에 남겨야한다.
- 이 예제는 단순화 하기 위해 회원 등록시에만 DB LOG 테이블에 남긴다.
테스트 전 기본 코드
아래는 기본 세팅 코드들이다.
Log.java ( Log Entity)
@Entity
@Getter @Setter
public class Log {
@Id @GeneratedValue
private Long id;
private String message;
public Log() {
}
public Log(String message) {
this.message = message;
}
}
LogRepository.java (로그를 저장하는 Repository)
@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();
}
}
Member.java (회원 Entity)
@Entity
@Getter
@Setter
public class Member {
@Id @GeneratedValue
private Long id;
private String username;
public Member() {
}
public Member(String username) {
this.username = username;
}
public Member(Long id, String username) {
this.id = id;
this.username = username;
}
}
MemberRepository.java (회원 저장 Repository)
@Slf4j
@Repository
@RequiredArgsConstructor
public class MemberRepository {
private final EntityManager em;
@Transactional
public void save(Member member) {
log.info("save 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();
}
}
MemberService.java (Repository 호출 서비스)
@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 호출 종료 =========");
}
@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 호출 종료 =========");
}
}
참고
JPA 구현체인 하이버네이트가 자동으로 테이블을 생성해준다. 메모리 DB이기 때문에 모든 테스트가 완료된 이후에 DB는 사라진다.
2. 전파 활용 - 커밋, 롤백
서비스 계층에 트랜잭션(@Transactional)이 없을 때 - 커밋
서비스 계층에 트랜잭션이 없고, 각각의 리포지토리에 트랜잭션을 가지고 있는 상황이다.
MemberServiceTest.java
/**
* 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());
}
각각의 트랜잭션이 따로 동작했기 때문에 둘다 정상적으로 성공하는 것을 확인할 것이다.
서비스 계층에 트랜잭션(@Transactional)이 없을 때 - 롤백
서비스 계층에 트랜잭션이 없고, 각각의 리포지토리에 트랜잭션을 가지고 있는 상황이다.
회원 리포지토리는 정상 동작하지만 로그 리포지트로에서 예외가 발생한다.
MemberServiceTest.java
/**
* 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: log 데이터는 롤백된다.
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
이것도 역시 각각의 트랜잭션이 따로 동작하기 때문에 회원은 커밋이 되나, 로그는 롤백이 된다.
3. 전파 활용 - 단일 트랜잭션
회원 리포지토리와 로그 리포지토리를 하나의 트랜잭션으로 묶는 가장 간단한 방법은 이 둘을 호출하는 회원 서비스에만 트랜잭션을 사용하는 것이다.
MemberService - joinV1()
@Transactional //추가
public void joinV1(String username)
MemberRepository- save()
//@Transactional //제거
public void save(Member member)
LogRepository- joinV1()
//@Transactional //제거
public void save(Log logMessage)
Repository의 메서드는 @Transactional 코드를 제거, Service 코드에는 @Transactional 추가 했다.
이렇게 하면 MemberService를 시작할 때 부터 종료할 때 까지의 모든 로직을 하나의 트랜잭션으로 묶을 수 있다.
하지만 다음과 같이 각각 트랜잭션이 필요하면 어떻게 해야할까?
클라이언트 A는 MemberService부터 MemberRepository, LogRepository를 모두 하나의 트랜잭션으로 묶고 싶다.
클라이언트 B는 MemberRepository만 호출하고 여기에만 트랜잭션을 사용하고 싶다.
클라이언트 C는 LogRepository만 호출하고 여기에만 트랜잭션을 사용하고 싶다.
더 복잡한 상황이 발생할 수도 있다.
이런 문제를 해결하기 위해 트랜잭션 전파가 필요하다.
4. 전파 활용 - 전파 커밋
스프링은 @Transactional이 적용되어 있으면 기본으로 REQUIRED라는 전파 옵션을 사용한다. 이 옵션은 기존 트랜잭션이 없으면 트랜잭션을 생성하고 기존 트랜잭션이 있으면 기존 트랜잭션에 참여한다.
"참여한다"라는 뜻은 같은 동기화 커넥션을 사용한다는 뜻이다.
앞서 위의 코드에서 리포지토리에 주석한 @Transactional의 주석을 푼다.
MemberServiceTest.java
/**
* MemberService @Transactional: ON
* MemberRepository @Transactional: ON
* LogRepository @Transactional: ON
*/
@Test
void outerTxOn(){
//given
String username = "outerTxOn";
//when
memberService.joinV1(username);
//then: 모든 데이터가 저장된다.
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isPresent());
}
테스트 코드가 MemberService를 호출하면서 트랜잭션 AOP가 호출된다. 여기서 신규 트랜잭션이 생성되고, 물리 트랜잭션도 시작한다.
MemberRepository를 호출하면서 트랜잭션 AOP가 호출되지만 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다. (신
규 트랜잭션이 아니므로 실제 커밋 호출 X)
LogRepository를 호출하면서 트랜잭션 AOP가 호출되지만 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다. (신규 트랜잭션이 아니므로 실제 커밋 호출 X)
MemberService의 로직 호출이 끝나고 정상응답시 트랜잭션 AOP가 호출된다.
롤백도 같은 로직으로 논리 트랜잭션이 하나라도 롤백시 물리 트랜잭션도 같이 롤백된다.
5. 전파활용 - 복구 REQUIRED NEW
근데 만약 요구사항이 바뀌어 회원가입을 시도한 로그를 남기는데 실패하더라도 회원가입은 유지되어야 한다면?
이 요구사항을 만족하기 위해서 로그와 관련된 물리 트랜잭션을 별도로 분리해보자. 바로 REQUIRED_NEW를 사용하는 것이다.
LogRepository - save()
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log logMessage)
이렇게 해서 기존 트랜잭션에 참여하는 REQUIRED 대신에, 항상 신규 트랜잭션을 생성하는 REQUIRES_NEW를 적용하자. 그러면 아래 사진과 같이 동작할 것이다.
MemberRepository는 REQUIRED 옵션을 사용한다. 따라서 기존 트랜잭션에 참여한다.
LogRepository의 트랜잭션 옵션에 REQUIRES_NEW를 사용했다.
따라서 항상 새로운 트랜잭션을 만들고 해당 트랜잭션 안에서 DB커넥션도 별도로 사용하게 된다.
MemberService.test
/**
* 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를 사용해서 트랜잭션을 분리해야한다.
주의점은 REQUIRES_NEW을 사용하면 하나의 HTTP 요청에 동시에 2개의 DB 커넥션을 사용하게 된다. 따라서 성능이 중요한 곳에서는 이런 부분을 주의해서 사용해야 한다.
REQUIRES_NEW를 사용하지 않고 문제를 해결할 수 있는 단순한 방법이 있다면 그 방법을 선택하는 것이 더 좋다.
'Spring Transaction' 카테고리의 다른 글
스프링 트랜잭션 전파 - 1 (1) | 2024.11.14 |
---|---|
스프링 트랜잭션 이해 (0) | 2024.11.13 |