Spring

[Spring DB2] 스프링 트랜잭션 전파2 - 활용

gogi masidda 2024. 3. 31. 21:48

예제 프로젝트 시작

//Member.java
@Entity
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String username;

    public Member() { //JPA 스펙상 있어야함.
    }

    public Member(String username) {
        this.username = username;
    }
}

//Log.java
@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
@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
@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();
    }
}
@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.getMessage());
            log.info("정상 흐름 반환");
        }
        log.info("== logRepository 호출 종료 ==");
    }
}
@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 : 모든 데이터가 정상 저장된다.
        assertTrue(memberRepository.find(username).isPresent());
        assertTrue(logRepository.find(username).isPresent());
    }
    
    /**
     * memberService    @Transactional:OFF
     * memberRepository @Transactional:ON
     * logRepository    @Transactional:ON
     */
    @Test
    void outerTxOff_fail() {
        //given
        String username = "로그예외_outerTxOff_success"; //->LogRepository에서 롤백
        //when
        assertThatThrownBy(() -> memberService.joinV1(username))
                .isInstanceOf(RuntimeException.class);
        //then : 모든 데이터가 정상 저장된다.
        assertTrue(memberRepository.find(username).isPresent());
        assertTrue(logRepository.find(username).isEmpty());
    }
}
  • 서비스 계층에 트랜잭션이 없을 때
    • outerTxOff_success  - 모두 커밋
      • 상황
        • 서비스 계층엔 트랜잭션이 없고, 회원과 로그 리포지토리에 각각 트랜잭션을 가지고 있다.
        • 회원과 로그 리포지토리 둘다 커밋에 성공한다. 
      • 회원과 로그 리포지토리는 각각 다른 커넥션을 사용한다.
      • 회원 리포지토리의 트랜잭션이 시작하고 정상 커밋되어서 트랜잭션이 종료되면 로그 리포지토리의 트랜잭션이 시작되고, 정상 커밋된다.
      • 두 트랜잭션 모두 커밋되었으므로 Member와 Log 모두 안전하게 저장된다. 
    • outerTxOff_fail - LogRepository 롤백
      • 사용자 이름에 로그예외가 있으면 LogRepository에서 런타임 예외가 발생한다. 
      • 트랜잭션 AOP는 런타임 예외를 확인하고 롤백 처리한다. 
      • MemberRepository는 정상 커밋되고, LogRepository는 롤백된다. 

단일 트랜잭션

  • 회원 리포지토리와 로그 리포지토리를 하나의 트랜잭션으로 묶는다.
  • 가장 간단한 방법은 이 둘을 호출하는 회원 서비스에만 트랜잭션을 사용하는 것이다. 
  • => MemberRepository와 LogRepository의 @Transactional을 주석처리하고, MemberService의 join메서드에 @Transactional을 붙인다. 
/**
     * memberService    @Transactional:ON
     * memberRepository @Transactional:OFF
     * logRepository    @Transactional:OFF
     */
    @Test
    void singleTx() {
        //given
        String username = "outerTxOff_success";
        //when
        memberService.joinV1(username);
        //then : 모든 데이터가 정상 저장된다.
        assertTrue(memberRepository.find(username).isPresent());
        assertTrue(logRepository.find(username).isPresent());
    }
  • 위와 같이 설정하고 테스트를 돌리면 모두가 정상적으로 커밋된 후에 트랜잭션이 종료된다.
  • 이렇게하면 MemberService를 시작할 때부터 종료할 때까지의 모든 로직을 하나의 트랜잭션으로 묶을 수 있다. 
  • MemberService가 MemberRepository, LogRepository를 호출하므로 이 로직들은 같은 트랜잭션을 사용한다. 
  • MemberService만 트랜잭션을 처리하기 때문에 물리, 외부, 내부 트랜잭션 등을 고려할 필요가 없다. 

전파 커밋

  • MemberService, MemberRepository, LogRepository 세개 모두에 @Transactional을 붙인다.
  • 그러면 MemberService가 신규 트랜잭션, 외부 트랜잭션이 된다.
  • 나머지 MemberRepository와 LogRepository는 신규 트랜잭션이 될 수 없고, 내부 트랜잭션이 된다.
  • MemberRepository의 save()와 LogRepository의 save()가 모두 성공하고, MemberService의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출되어 신규 트랜잭션이 커밋 요청한 것이라서 물리 커밋을 호출한다. 

전파 롤백

  • 논리 트랜잭션 중 하나라도 롤백이 되면 전체가 롤백이 된다. 
  • MemberRepository에서 정상 수행되어 커밋되고, LogRepository에서 런타임 예외가 터져서 롤백된다.
  • LogRepository는 신규 트랜잭션이 아니라서 물리 롤백을 할 수 없고, rollbackOnly를 표시해야 한다. 
  • LogRepository의 런타임 예외가 MemberService로 올라가서 MemberService에서도 런타임 예외가 터지게 된다. 
  • MemberService에서도 롤백을 요청하고, 신규 트랜잭션이라서 물리 롤백을 호출한다. 어차피 롤백하는거라서 rollbackOnly를 참고하지 않는다.
/**
     * 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    @Transactional:ON
     * memberRepository @Transactional:ON
     * logRepository    @Transactional:ON Exception
     */
    @Test
    void outerTxOn_fail() {
        //given
        String username = "로그예외_outerTxOn_fail"; //->LogRepository에서 롤백
        //when
        assertThatThrownBy(() -> memberService.joinV1(username))
                .isInstanceOf(RuntimeException.class);
        //then : 모든 데이터가 정상 저장된다.
        assertTrue(memberRepository.find(username).isEmpty());
        assertTrue(logRepository.find(username).isEmpty());
    }

복구 REQUIRED

  • 회원과 로그를 하나의 트랜잭션으로 묶어서 데이터 정합성 문제를 해결했다.
  • 하지만, 회원 이력 로그를 DB에 남기는 작업이 실패하면 회원가입도 실패하는 문제가 발생할 수도 있다. 
  • 회원 가입을 시도한 로그를 남기는데 실패해도 회원 가입은 유지되어야 한다. 
  • joinV2를 사용해서 MemberService에서 런타임 예외를 잡아서 정상흐름으로 만들어도 LogRepository에서 rollbackOnly를 표시한 것 때문에 모두 롤백이 된다. 

복구 REQUIRES_NEW

REQUIRES_NEW를 사용해서 물리 트랜잭션을 별도로 분리한다.

 

//LogRepository의 save()
@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("예외 발생");
        }
    }
    
//테스트
/**
     * 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를 LogRepository의 save()에 붙여두었다. 
  • 예외를 복구하는 joinV2를 사용해서 MemberService가 정상 흐름이 되도록 해야 한다.
  • MemberRepository는 기본 설정인 REQUIRED를 사용해서 MemberService와 같은 물리 트랜잭션으로 묶인다.
  • REQUIRES_NEW를 사용하는 LogRepository는 다른 물리 트랜잭션으로 묶인다. 
  • 그래서 DB 커넥션도 별도로 사용하게 된다. 
  • LogRepository가 실패하면 신규 트랜잭션이라서 물리 트랜잭션을 롤백한다. rollbackOnly를 표시하지 않는다. 이렇게 아예 트랜잭션이 끝나버린다.
  • 그리고 MemberService는 예외를 복구하고 MemberService와 MemberRepository가 속한 물리 트랜잭션은 정상적으로 커밋된다. 
728x90