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
  • 순차적으로 사용, 반환