Spring DB - 스프링 트랜잭션 전파(2)

Kwon Yongho·2023년 5월 27일
0

Spring-DB

목록 보기
16/16
post-thumbnail

스프링 트랜잭션 전파

  1. 예제 프로젝트 시작
  2. 커밋, 롤백
  3. 단일 트랜잭션
  4. 전파 커밋
  5. 전파 롤백
  6. 복구 REQUIRED
  7. 복구 REQUIRES_NEW

1. 예제 프로젝트 시작

지금까지 배운 트랜잭션 전파에 대한 내용을 실제 예제를 통해서 이해해보도록 하겠습니다.

비즈니스 요구사항

  • 회원을 등록하고 조회한다.
  • 회원에 대한 변경 이력을 추적할 수 있도록 회원 데이터가 변경될 때 변경 이력을 DB LOG 테이블에 남겨야 한다.
    • 여기서는 예제를 단순화 하기 위해 회원 등록시에만 DB LOG 테이블에 남긴다.

Member

package com.example.springtrans
- JPA를 통해 관리하는 회원 엔티티actional.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를 통해 관리하는 회원 엔티티

MemberRepository

package com.example.springtransactional.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();
    }
}

Log

package com.example.springtransactional.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;
    }
}

LogRepository

package com.example.springtransactional.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 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

package com.example.springtransactional.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("------- memberRepositroy 호출 시작 -------------");
        memberRepository.save(member);
        log.info("------- memberRepositroy 호출 종료 -------------");

        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("------- memberRepositroy 호출 시작 -------------");
        memberRepository.save(member);
        log.info("------- memberRepositroy 호출 종료 -------------");

        log.info("------- logRepository 호출 시작 -------------");
        try {
            logRepository.save(logMessage);
        } catch (RuntimeException e) { 
            // 예외가 올라오면 잡아서 정상 흐름으로 바꾼다.
            log.info("log 저장에 실패했습니다. logMessage={}", logMessage.getMessage());
            log.info("정상 흐름 변환");
        }
        log.info("------- logRepository 호출 종료 -------------");
    }
}
  • 회원을 등록하면서 동시에 회원 등록에 대한 DB 로그도 함께 남긴다.
  • joinV1()
    • 회원과 DB로그를 함께 남기는 비즈니스 로직이다.
    • 현재 별도의 트랜잭션은 설정하지 않는다.
  • joinV2()
    • joinV1()과 같은 기능을 수행한다.
    • DB로그 저장시 예외가 발생하면 예외를 복구한다.
    • 현재 별도의 트랜잭션은 설정하지 않는다.

MemberServiceTest

package com.example.springtransactional.propagation;

import lombok.extern.slf4j.Slf4j;
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.assertTrue;

@Slf4j
@SpringBootTest
public 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());
    }

}


일단 성공하는 것을 확인 할 수 있다.

2. 커밋, 롤백

서비스 계층에 트랜잭션이 없을 때 - 커밋

상황

  • 서비스 계층에 트랜잭션이 없다.
  • 회원, 로그 리포지토리가 각각 트랜잭션을 가지고 있다.
  • 회원, 로그 리포지토리 둘다 커밋에 성공한다.

조금 더 자세히 보자

1. MemberService에서 MemberRepository를 호출한다. MemberRepository에는 @Transactional애노테이션이 있으므로 트랜잭션 AOP가 작동한다. 여기서 트랜잭션 매니저를 통해 트랜잭션을 시작한다. 이렇게 시작한 트랜잭션을 트랜잭션B라 하자.
2. MemberRepository는 JPA를 통해 회원을 저장하는데, 이때 JPA는 트랜잭션이 시작된 con1을 사용해서 회원을 저장한다.
3. MemberRepository가 정상 응답을 반환했기 때문에 트랜잭션 AOP는 트랜잭션 매니저에 커밋을 요청한다.
4. 트랜잭션 매니저는 con1을 통해 물리 트랜잭션을 커밋한다. 물론 이 시점에 앞서 설명한 신규 트랜잭션 여부, rollbackOnly여부를 모두 체크한다.

서비스 계층에 트랜잭션이 없을 때 - 롤백

상황

  • 서비스 계층에 트랜잭션이 없다.
  • 회원, 로그 리포지토리가 각각 트랜잭션을 가지고 있다.
  • 회원 리포지토리는 정상 동작하지만 로그 리포지토리에서 예외가 발생한다.

MemberServiceTest 수정

package com.example.springtransactional.propagation;

import lombok.extern.slf4j.Slf4j;
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.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertTrue;

@Slf4j
@SpringBootTest
public 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";
        String username = "로그예외_outerTxOff_fail";

        //when
        // memberService.joinV1(username);
        assertThatThrownBy(() -> memberService.joinV1(username)).isInstanceOf(RuntimeException.class);

        //then: 모든 데이터가 정상 저장된다.
        assertTrue(memberRepository.find(username).isPresent());
        
        // assertTrue(logRepository.find(username).isPresent());
        assertTrue(logRepository.find(username).isEmpty());
    }

}
  • 사용자 이름에 로그예외 라는 단어가 포함되어 있으면 LogRepository에서 런타임 예외가 발생한다.
  • 트랜잭션 AOP는 해당 런타임 예외를 확인하고 롤백 처리한다.


  • MemberService에서 MemberRepository를 호출하는 부분은 앞서 설명한 내용과 같다. 트랜잭션이 정상 커밋되고, 회원 데이터도 DB에 정상 반영된다.
  • LogRepository에서는 Log를 롤백 시킨다.

LogRepository 응답 로직
1. LogRepository는 트랜잭션C와 관련된 con2 를 사용한다.
2. 로그예외 라는 이름을 전달해서 LogRepository에 런타임 예외가 발생한다.
3. LogRepository는 해당 예외를 밖으로 던진다. 이 경우 트랜잭션 AOP가 예외를 받게된다.
4. 런타임 예외가 발생해서 트랜잭션 AOP는 트랜잭션 매니저에 롤백을 호출한다.
5. 트랜잭션 매니저는 신규 트랜잭션이므로 물리 롤백을 호출한다.

이 경우 회원은 저장되지만, 회원 이력 로그는 롤백된다. 따라서 데이터 정합성에 문제가 발생할 수 있다. 둘을 하나의 트랜잭션으로 묶어서 처리해보자.

3. 단일 트랜잭션

회원 리포지토리와 로그 리포지토리를 하나의 트랜잭션으로 묶는 가장 간단한 방법은 이 둘을 호출하는 회원 서비스에만 트랜잭션을 사용하는 것이다.

  • MemberService - joinV1() @Transactional 추가
  • MemberRepository - save() @Transactional 제거
  • LogRepository - save() @Transactional 제거
    --> 각각 Repository에서 사용하던 @Transactional를 제거하고 Service로 묶었다.

TEST 코드 추가

    @Test
    void singleTx() {
        //given
        String username = "singleTx";

        //when
        memberService.joinV1(username);

        //then: 모든 데이터가 정상 저장된다.
        assertTrue(memberRepository.find(username).isPresent());
        assertTrue(logRepository.find(username).isPresent());
    }

  • 이렇게 하면 MemberService를 시작할 때 부터 종료할 때 까지의 모든 로직을 하나의 트랜잭션으로 묶을 수 있다.
  • MemberService만 트랜잭션을 처리하기 때문에 앞서 배운 논리 트랜잭션, 물리 트랜잭션, 외부 트랜잭션, 내부 트랜잭션, rollbackOnly , 신규 트랜잭션, 트랜잭션 전파와 같은 복잡한 것을 고민할 필요가 없다. 아주 단순하고 깔끔하게 트랜잭션을 묶을 수 있다.

  • @TransactionalMemberService에만 붙어있기 때문에 여기에만 트랜잭션 AOP가 적용된다.
    • MemberRepository, LogRepository는 트랜잭션 AOP가 적용되지 않는다.
  • MemberService의 시작부터 끝까지, 관련 로직은 해당 트랜잭션이 생성한 커넥션을 사용하게 된다.
    • MemberService가 호출하는 MemberRepository, LogRepository도 같은 커넥션을 사용하면서 자연스럽게 트랜잭션 범위에 포함된다.

각각 트랜잭션이 필요한 상황

다음과 같은 상황은 어떻게 할까? (각각 다 필요)

  • 클라이언트 A는 MemberService부터 MemberRepository, LogRepository를 모두 하나의 트랜잭션으로 묶고 싶다.
  • 클라이언트 B는 MemberRepository만 호출하고 여기에만 트랜잭션을 사용하고 싶다.
  • 클라이언트 C는 LogRepository만 호출하고 여기에만 트랜잭션을 사용하고 싶다.

트랜잭션 전파 없이 이런 문제를 해결하려면 아마도 트랜잭션이 있는 메서드와 트랜잭션이 없는 메서드를 각각 만들어야 할 것이다.

4. 전파 커밋

  • 스프링은 @Transactional이 적용되어 있으면 기본으로 REQUIRED라는 전파 옵션을 사용한다.
  • 이 옵션은 기존 트랜잭션이 없으면 트랜잭션을 생성하고, 기존 트랜잭션이 있으면 기존 트랜잭션에 참여한다.
  • 참여한다는 뜻은 해당 트랜잭션을 그대로 따른다는 뜻이고, 동시에 같은 동기화 커넥션을 사용한다는 뜻이다.

  • 외부에 있는 신규 트랜잭션만 실제 물리 트랜잭션을 시작하고 커밋한다.
  • 내부에 있는 트랜잭션은 물리 트랜잭션 시작하거나 커밋하지 않는다.
  • 모든 논리 트랜잭션을 커밋해야 물리 트랜잭션도 커밋된다. 하나라도 롤백되면 물리 트랜잭션은 롤백된다.

클래스 위의 주석을 확인해서 모든 곳에 트랜잭션을 적용하자.

  • MemberService @Transactional:ON
  • MemberRepository @Transactional:ON
  • LogRepository @Transactional:ON

Test 코드 추가

    @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가 호출
    • 트랜잭션이 있으므로 기존 트랜잭션에 참여
  • MemberRepository의 로직 호출이 끝
    • 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않는다.
  • LogRepository를 호출하면서 트랜잭션 AOP가 호출
    • 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여
  • LogRepository의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출
    • 신규 트랜잭션이 아니므로 실제 커밋(물리 커밋)을 호출하지 않는다.
  • MemberService의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출
    • 이 경우 신규 트랜잭션이므로 물리 커밋을 호출한다.

5. 전파 롤백

LogRepository에서 오류 발생으로 롤백되는 경우를 알아보자

TEST 코드 추가

    /**
     * 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());
    }

  • LogRepository 예외 발생

  • 예외 로그를 남겨 줌

  • All 롤백 됨

  • 순서는 위에와 거의 비슷하다
  • LogRepository로직에서 런타임 예외가 발생한다. 예외를 던지면 트랜잭션 AOP가 해당 예외를 받게 된다.
    • 트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백을 요청한다. 이 경우 신규 트랜잭션이 아니므로 물리 롤백을 호출하지는 않는다. 대신에 rollbackOnly를 설정한다.
    • LogRepository가 예외를 던졌기 때문에 트랜잭션 AOP도 해당 예외를 그대로 밖으로 던진다.
  • MemberService에서도 런타임 예외를 받게 되는데, 여기 로직에서는 해당 런타임 예외를 처리하지 않고 밖으로 던진다.
    • 트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백을 요청한다. 이 경우 신규 트랜잭션이므로 물리 롤백을 호출한다.
    • MemberService가 예외를 던졌기 때문에 트랜잭션 AOP도 해당 예외를 그대로 밖으로 던진다.
  • 클라이언트A는 LogRepository부터 넘어온 런타임 예외를 받게 된다.

6. 복구 REQUIRED

  • 회원 이력 로그를 DB에 남기는 작업에 가끔 문제가 발생해서 회원 가입 자체가 안되는 경우가 가끔 발생하게 되었다. 그래서 사용자들이 회원 가입에 실패해서 이탈하는 문제가 발생하기 시작했다.
  • 회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 한다.

Test 코드 추가

    @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()에는 예외를 잡아서 정상 흐름으로 변환하는 로직이 추가되어 있다.
    public void joinV2(String username) {

        Member member = new Member(username);
        Log logMessage = new Log(username);

        log.info("------- memberRepositroy 호출 시작 -------------");
        memberRepository.save(member);
        log.info("------- memberRepositroy 호출 종료 -------------");

        log.info("------- logRepository 호출 시작 -------------");
        try {
            logRepository.save(logMessage);
        } catch (RuntimeException e) {
            // 예외가 올라오면 잡아서 정상 흐름으로 바꾼다.
            log.info("log 저장에 실패했습니다. logMessage={}", logMessage.getMessage());
            log.info("정상 흐름 변환");
        }
        log.info("------- logRepository 호출 종료 -------------");
    }

  • 하지만 역시 전체 롤백된다.

  • 내부 트랜잭션에서 rollbackOnly를 설정하기 때문에 결과적으로 정상 흐름 처리를 해서 외부 트랜잭션에서 커밋을 호출해도 물리 트랜잭션은 롤백된다.
  • 그리고 UnexpectedRollbackException이 던져진다.

더 세세하게 알아본다면

  • LogRepository에서 예외가 발생한다.
    • 신규 트랜잭션이 아니므로 물리 트랜잭션을 롤백하지는 않고, 트랜잭션 동기화 매니저에 rollbackOnly를 표시한다.
  • 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던진다.
  • 예외가 MemberService에 던져지고, MemberService는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다.
  • 정상 흐름이 되었으므로 MemberService의 트랜잭션 AOP는 커밋을 호출한다.
  • 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 한다. 이때 rollbackOnly를 체크한다.
  • rollbackOnly가 체크 되어 있으므로 물리 트랜잭션을 롤백한다.
  • 트랜잭션 매니저는 UnexpectedRollbackException예외를 던진다.
  • 트랜잭션 AOP도 전달받은 UnexpectedRollbackException을 클라이언트에 던진다.

저번 게시물에 논리 트랜잭션의 원칙이 있었다.

원칙

  • 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다.
  • 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다.

7. 복구 REQUIRES_NEW

그렇다면 어떻게 해야 다음 요구사항을 만족할 수 있을까?
회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 한다.

  • MemberService @Transactional:ON
  • MemberRepository @Transactional:ON
  • LogRepository @Transactional(REQUIRES_NEW)

LogRepository - save()

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void save(Log logMessage){
  • 이렇게 해서 기존 트랜잭션에 참여하는 REQUIRED대신에, 항상 신규 트랜잭션을 생성하는 REQUIRES_NEW를 적용하자

TEST 코드 추가

    @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());
    }

  • MemberRepositoryREQUIRED옵션을 사용한다. 따라서 기존 트랜잭션에 참여한다.
  • LogRepository의 트랜잭션 옵션에 REQUIRES_NEW를 사용했다.
  • REQUIRES_NEW는 항상 새로운 트랜잭션을 만든다. 따라서 해당 트랜잭션 안에서는 DB 커넥션도 별도로 사용하게 된다.

조금 더 세세히 살펴보자

  • LogRepository에서 예외가 발생한다. 예외를 던지면 LogRepository의 트랜잭션 AOP가 해당 예외를 받는다.
  • REQUIRES_NEW를 사용한 신규 트랜잭션이므로 물리 트랜잭션을 롤백한다. 물리 트랜잭션을 롤백했으므로
  • rollbackOnly를 표시하지 않는다. 여기서 REQUIRES_NEW를 사용한 물리 트랜잭션은 롤백되고 완전히 끝이 나버린다.
  • 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던진다.
  • 예외가 MemberService에 던져지고, MemberService는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다.
  • 정상 흐름이 되었으므로 MemberService의 트랜잭션 AOP는 커밋을 호출한다.
  • 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 한다. 이때 rollbackOnly를 체크한다.
  • rollbackOnly가 없으므로 물리 트랜잭션을 커밋한다. 이후 정상 흐름이 반환된다

참고
김영한: 스프링 DB 2편 - 데이터 접근 활용 기술(인프런)
Github - https://github.com/b2b2004/Spring_DB

0개의 댓글