Programming/Spring

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

잇(IT) 2023. 7. 8. 15:49

- 트랜잭션 전파 활용1 - 예제 프로젝트 시작

 

- 비즈니스 요구사항

1. 회원을 등록하고 조회한다.

2. 회원에 대한 변경 이력을 추적할 수 있도록 회원 데이터가 변경될 때 변경 이력을 DB LOG 테이블에 남겨야 한다.

   2.1. 여기서는 예제를 단순화 하기 위해 회원 등록시에만 DB LOG 테이블에 남긴다.

 

- Member

@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

@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) {
        //id가 pk이기 때문에 id로 찾으면 find JPA에서 제공하는 find로 찾으면 되는데
        //이름으로 찾기 때문에 jpql을 사용한다.
        return em.createQuery("select m from Member m where m.username = :username", Member.class)
                .setParameter("username", username)
                .getResultList().stream().findAny();
    }
}

- JPA를 사용하는 회원 리포지토리이다. 저장과 조회 기능을 제공한다

 

- Log

@Entity
@Getter
@Setter
public class Log {

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

    public Log() {
    }

    public Log(String message) {
        this.message = message;
    }
}

- JPA를 통해 관리하는 로그 엔티티이다.

 

- LogRepository

@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를 사용하는 로그 리포지토리이다. 저장과 조회 기능을 제공한다.

- 중간에 예외 상황을 재현하기 위해 로그예외 라고 입력하는 경우 예외를 발생시킨다.

 

- MemberService

@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 호출 종료 ==");
    }
}

- 회원을 등록하면서 동시에 회원 등록에 대한 DB 로그도 함께 남긴다.

- joinV1()

1. 회원과 DB로그를 함께 남기는 비즈니스 로직이다.

2. 현재 별도의 트랜잭션은 설정하지 않는다.

- joinV2()

1. joinV1() 과 같은 기능을 수행한다.

2. DB로그 저장시 예외가 발생하면 예외를 복구한다.

3. 현재 별도의 트랜잭션은 설정하지 않는다.

 

- MemberServiceTest

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

- 참고

1. JPA의 구현체인 하이버네이트가 테이블을 자동으로 생성해준다.

2. 메모리 DB이기 때문에 모든 테스트가 완료된 이후에 DB는 사라진다.

3. 여기서는 각각의 테스트가 완료된 시점에 데이터를 삭제하지 않는다. 따라서 username 은 테스트별로 각각 다르게 설정해야 한다. 그렇지 않으면 다음 테스트에 영향을 준다. (모든 테스트가 완료되어야 DB가 사라진다.)

 

- JPA와 데이터 변경

1. JPA를 통한 모든 데이터 변경(등록, 수정, 삭제)에는 트랜잭션이 필요하다. (조회는 트랜잭션 없이 가능하다.)

   1.1. 현재 코드에서 서비스 계층에 트랜잭션이 없기 때문에 리포지토리에 트랜잭션이 있다.


- 트랜잭션 전파 활용2 - 커밋, 롤백

 

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

1. MemberService 에서 MemberRepository 를 호출한다. MemberRepository 에는 @Transactional 애노테이션이 있으므로 트랜잭션 AOP가 작동한다. 여기서 트랜잭션 매니저를 통해 트랜잭션을 시작한다. 이렇게 시작한 트랜잭션을 트랜잭션B라 하자.

   1.1. 그림에서는 생략했지만, 트랜잭션 매니저에 트랜잭션을 요청하면 데이터소스를 통해 커넥션 con1 을 획득하고, 해당 커넥션을 수동 커밋 모드로 변경해서 트랜잭션을 시작한다.

   1.2. 그리고 트랜잭션 동기화 매니저를 통해 트랜잭션을 시작한 커넥션을 보관한다.

   1.3. 트랜잭션 매니저의 호출 결과로 status 를 반환한다. 여기서는 신규 트랜잭션 여부가 참이 된다.

2. MemberRepository 는 JPA를 통해 회원을 저장하는데, 이때 JPA는 트랜잭션이 시작된 con1 을 사용해서 회원을 저장한다.

3. MemberRepository 가 정상 응답을 반환했기 때문에 트랜잭션 AOP는 트랜잭션 매니저에 커밋을 요청한다.

4. 트랜잭션 매니저는 con1 을 통해 물리 트랜잭션을 커밋한다.

   4.1. 물론 이 시점에 앞서 설명한 신규 트랜잭션 여부, rollbackOnly 여부를 모두 체크한다.

 

- 이렇게 해서 MemberRepository 와 관련된 모든 데이터는 정상 커밋되고, 트랜잭션B는 완전히 종료된다. 이후에 LogRepository 를 통해 트랜잭션C를 시작하고, 정상 커밋한다. 결과적으로 둘다 커밋되었으므로 Member , Log 모두 안전하게 저장된다.


- @Transactional과 REQUIRED

1. 트랜잭션 전파의 기본 값은 REQUIRED 이다. 따라서 다음 둘은 같다.

   1.1. @Transactional(propagation = Propagation.REQUIRED)

   1.2. @Transactional

2. REQUIRED 는 기존 트랜잭션이 없으면 새로운 트랜잭션을 만들고, 기존 트랜잭션이 있으면 참여한다


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

 

- 상황

1. 서비스 계층에 트랜잭션이 없다.

2. 회원, 로그 리포지토리가 각각 트랜잭션을 가지고 있다.

3. 회원 리포지토리는 정상 동작하지만 로그 리포지토리에서 예외가 발생한다.

 

- outerTxOff_fail

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

- memberrepository에는 정상적으로 커밋된 것을 확인 할 수 있다.

 

- logRepository에는 '로그예외'가 포함되어 있기 때문에 예외 조건에 걸려 롤백된 것을 확인 할 수 있다.

- LogRepository 응답 로직

1. LogRepository 는 트랜잭션C와 관련된 con2 를 사용한다.

2. 로그예외 라는 이름을 전달해서 LogRepository 에 런타임 예외가 발생한다.

3. LogRepository 는 해당 예외를 밖으로 던진다. 이 경우 트랜잭션 AOP가 예외를 받게된다.

4. 런타임 예외가 발생해서 트랜잭션 AOP는 트랜잭션 매니저에 롤백을 호출한다.

5. 트랜잭션 매니저는 신규 트랜잭션이므로 물리 롤백을 호출한다.


- 트랜잭션 전파 활용3 - 단일 트랜잭션

 

- 트랜잭션 하나만 사용하기

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

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

@Transactional
    public void joinV1(String username) {
        Member member = new Member(username);
        Log logMessage = new Log(username);

 

- MemberRepository - save()

//    @Transactional
    public void save(Member member) {
        log.info("member 저장");
        em.persist(member);
    }

 

- LogRepository - save()

//    @Transactional
    public void save(Log logMessage) {
        log.info("log 저장");
        em.persist(logMessage);

        if (logMessage.getMessage().contains("로그예외")) {
            log.info("log 저장시 예외 발생");
            throw new RuntimeException("예외 발생");
        }
    }

1. 이렇게 하면 MemberService 를 시작할 때 부터 종료할 때 까지의 모든 로직을 하나의 트랜잭션으로 묶을 수 있다.

   1.1. 물론 MemberService 가 MemberRepository , LogRepository 를 호출하므로 이 로직들은 같은 트랜잭션을 사용한다.

2. MemberService 만 트랜잭션을 처리하기 때문에 앞서 배운 논리 트랜잭션, 물리 트랜잭션, 외부 트랜잭션, 내부 트랜잭션, rollbackOnly , 신규 트랜잭션, 트랜잭션 전파와 같은 복잡한 것을 고민할 필요가 없다. 아주 단순하고 깔끔하게 트랜잭션을 묶을 수 있다.

1. @Transactional 이 MemberService 에만 붙어있기 때문에 여기에만 트랜잭션 AOP가 적용된다.

   1.1. MemberRepository , LogRepository 는 트랜잭션 AOP가 적용되지 않는다.

2. MemberService 의 시작부터 끝까지, 관련 로직은 해당 트랜잭션이 생성한 커넥션을 사용하게 된다.

   2.1. MemberService 가 호출하는 MemberRepository , LogRepository 도 같은 커넥션을 사용하면서 자연스럽게 트랜잭션 범위에 포함된다.

- 하나의 물리 트랜잭션이 실행되었다가 커밋 후 종료되는 것을 확인 할 수 있다.


- 트랜잭션 전파 활용4 - 전파 커밋

 

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

1. 이 경우 외부에 있는 신규 트랜잭션만 실제 물리 트랜잭션을 시작하고 커밋한다.

2. 내부에 있는 트랜잭션은 물리 트랜잭션 시작하거나 커밋하지 않는다

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

- member, log Repository에 전부 @Transactional이 붙을 경우 로그에서 보는 것과 같이 가장 먼저 생성된 Service 트랜잭션에 Repository의 트랜잭션들이 참여하는 것을 확인 할 수 있다.

- 내부 트랜잭션에서 전부 정상적으로 Commit이 되고 신규 트랜잭션 또한 정상적으로 Commit이 된 경우 물리 트랜잭션이 정상적으로 Commit되는 것을 확인 할 수 있다.


- 트랜잭션 전파 활용5 - 전파 롤백

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

- 내부 트랜잭션에서 예외가 발생하여 rollback이 발생한 경우 rollback-only=true를 트랜잭션 매니저에 전달한다.

- 신규 트랜잭션에서 Commit을 할 때 rollback-only를 확인하고 rollback-only=true일 경우 물리 트랜잭션은 Commit을 하지 않고 rollback하게 된다.

 


- 트랜잭션 전파 활용6 - 복구 REQUIRED

 

- 조건

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

 

- MemberService

@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 호출 종료 ==");
    }

- 위의 MemberService의 코드를 확인해보면 내부 트랜잭션에서 예외가 발생하여 신규 트랜잭션으로 전달 되었을 때 예외를 잡아서 처리하는 코드를 작성하였다.

- 이론상 예외를 처리했기 떄문에 신규 트랜잭션은 아무런 예외를 위로 던지지 않고, 그렇다면 신규 트랜잭션이 Commit을 하게 되면 물리 트랜잭션 또한 아무 이상 없이 Commit이 되어야 한다.

- 하지만 결과 로그를 확인해보면 물리 트랜잭션이 rollback 한 것을 확인 할 수 있다.

 

- 물리 트랜잭션이 rollback한 이유는 내부 트랜잭션에서 rollback이 발생할 경우 트랜잭션 매니저에 rollback-only=true가 전달되기 때문에 예외 처리와 관계 없이 물리 트랜잭션은 rollback을 실행하게 된다.

1. 논리 트랜잭션 중 하나라도 롤백되면 전체 트랜잭션은 롤백된다.

2. 내부 트랜잭션이 롤백 되었는데, 외부 트랜잭션이 커밋되면 UnexpectedRollbackException 예외가 발생한다.

3. rollbackOnly 상황에서 커밋이 발생하면 UnexpectedRollbackException 예외가 발생한다.


- 트랜잭션 전파 활용7 - 복구 REQUIRES_NEW

 

- 회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 한다.

- 이 요구사항을 만족하기 위해서 로그와 관련된 물리 트랜잭션을 별도로 분리해보자. 바로 REQUIRES_NEW 를 사용하는 것이다.

 

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

 

- LogRepository

@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("예외 발생");
        }
    }

- 내부 트랜잭션(memberRepository)가 신규 트랜잭션에 참여하여 정상적으로 commit 한 것을 확인 할 수 있다.

@Transactional(propagation = Propagation.REQUIRES_NEW)

- REQUIRES_NEW가 적용된 (logRepository)의 경우 새롭게 신규 트랜잭션을 생성한다.

- 예외가 발생하여 밖으로 던지고, rollback을 실행하지만 새롭게 생성된 트랜잭션이기 때문에 rollbackonly를 트랜잭션 매니저에 전달하지 않는다.

- 신규 트랜잭션은 넘어온 예외를 처리하고 정상적으로 Commit을 실행한다.

- 물리 트랜잭션 또한 예외가 전부 처리되고 Commit을 하려고 트랜잭션 매니저의 rollback-only를 확인해도 아무 문제가 없는 것을 확인했기 때문에 Commit을 실행한다.

1. MemberRepository 는 REQUIRED 옵션을 사용한다. 따라서 기존 트랜잭션에 참여한다.

2. LogRepository 의 트랜잭션 옵션에 REQUIRES_NEW 를 사용했다.

3. REQUIRES_NEW 는 항상 새로운 트랜잭션을 만든다. 따라서 해당 트랜잭션 안에서는 DB 커넥션도 별도로 사용하게 된다.

1. REQUIRES_NEW 를 사용하게 되면 물리 트랜잭션 자체가 완전히 분리되어 버린다.

2. 그리고 REQUIRES_NEW 는 신규 트랜잭션이므로 rollbackOnly 표시가 되지 않는다. 그냥 해당 트랜잭션이 물리 롤백되고 끝난다.

 

1. LogRepository 에서 예외가 발생한다. 예외를 던지면 LogRepository 의 트랜잭션 AOP가 해당 예외를 받는다.

2. REQUIRES_NEW 를 사용한 신규 트랜잭션이므로 물리 트랜잭션을 롤백한다. 물리 트랜잭션을 롤백했으므로 rollbackOnly 를 표시하지 않는다. 여기서 REQUIRES_NEW 를 사용한 물리 트랜잭션은 롤백되고 완전히 끝이 나버린다.

3. 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던진다.

4. 예외가 MemberService 에 던져지고, MemberService 는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다.

5. 정상 흐름이 되었으므로 MemberService 의 트랜잭션 AOP는 커밋을 호출한다.

6. 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 한다. 이때 rollbackOnly 를 체크한다.

7. rollbackOnly 가 없으므로 물리 트랜잭션을 커밋한다.

8. 이후 정상 흐름이 반환된다.

 

- 결과적으로 회원 데이터는 저장되고, 로그 데이터만 롤백 되는 것을 확인할 수 있다

 

- 정리

1. 논리 트랜잭션은 하나라도 롤백되면 관련된 물리 트랜잭션은 롤백되어 버린다.

2. 이 문제를 해결하려면 REQUIRES_NEW 를 사용해서 트랜잭션을 분리해야 한다.

3. 참고로 예제를 단순화 하기 위해 MemberService 가 MemberRepository , LogRepository 만 호출하지만 실제로는 더 많은 리포지토리들을 호출하고 그 중에 LogRepository 만 트랜잭션을 분리한다고 생각해보면 이해하는데 도움이 될 것이다.

 

- 주의

1. REQUIRES_NEW 를 사용하면 하나의 HTTP 요청에 동시에 2개의 데이터베이스 커넥션을 사용하게 된다. 따라서 성능이 중요한 곳에서는 이런 부분을 주의해서 사용해야 한다.

2. REQUIRES_NEW 를 사용하지 않고 문제를 해결할 수 있는 단순한 방법이 있다면, 그 방법을 선택하는 것이 더 좋다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

출처 : 인프런 - 우아한 형제들 기술이사 김영한의 스프링 완전 정복 (스프링 DB 2편 - 데이터 접근 활용 기술)

728x90