본문 바로가기
인프런/스프링 DB 2편 - 데이터 접근 활용 기술

[인프런] 스프링 DB 2편 - 데이터 접근 기술 활용 / 11. 스프링 트랜잭션 전파2 - 활용

by hxxyeoniii 2024. 9. 22.

예제 프로젝트 만들기

* 회원 등록 및 조회

* 회원 변경 이력을 추적할 수 있게 회원 데이터 변경 시 이력을 DB LOG 테이블에 남겨야 함

 

Member.java

package hello.springtx.propagation;

import jakarta.persistence.*;
import lombok.AccessLevel;
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;
    }

}

 

 

Log.java

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

 

 

MemberRepository.java

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

 

 

LogRepository.java

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

 

MemberService.java

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); // DB 로그 남김
        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 호출 종료 ==");
    }
}

 

-> JPA의 구현체인 하이버네이트가 테이블을 자동으로 생성해줌

-> 메모리 DB이기 때문에 테스트 완료 후 DB는 사라짐


커밋 & 롤백 확인

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

 

* 서비스 계층에 트랜잭션이 없음

* 회원, 로그 리포지토리는 각각 트랜잭션을 가지고 있음

 

 

MemberServiceTest.java

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.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() {
        String username = "outerTxOff_success";
        memberService.joinv1(username); // 저장

        // isPresent() : 존재하냐
        Assertions.assertTrue(memberRepository.find(username).isPresent());
        Assertions.assertTrue(logRepository.find(username).isPresent());
    }
}

 

실행 결과

 

동작 과정

 

1. MemberService에서 MemberRepository호출 : MemberRepository에 @Transactional이 있으므로 트랜잭션 AOP가 작동

   - 트랜잭션 매니저를 통해 트랜잭션 시작(이를 트랜잭션 B라고 하자)

   -  트랜잭션 매니저는 데이터소스를 통해 con1을 획득하고 커넥션을 수동 커밋 모드로 변경해 트랜잭션을 시작함

   - 트랜잭션 동기화 매니저를 통해 트랜잭션을 시작한 커넥션 보관, status를 반환, 신규 트랜잭션 여부가 참이 됨

2. MemberRepository는 JPA를 통해 회원 저장(con1 사용)

3. MemberRepository가 정상 응답 반환 -> 트랜잭션 AOP가 트랜잭션 매니저에 커밋 요청

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

...

LogRepository도 동일

 

 

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

 

MemberServiceTest.java

    /**
     * memberService : @Transactional OFF
     * memberRepository : @Transactional ON
     * logRepository : @Transactional ON -> Exception
     */
    @Test
    void outerTxOff_fail() {
        String username = "로그예외_outerTxOff_fail";
        memberService.joinv1(username); // 저장

        // isPresent() : 존재하냐
        Assertions.assertTrue(memberRepository.find(username).isPresent());
        Assertions.assertTrue(logRepository.find(username).isPresent());
    }

 

실행 결과

-> 회원은 저장되지만 회원 이력 로그는 롤백됨

-> 데이터 정합성에 문제가 발생할 수 있음


단일 트랜잭션 사용

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

 

 

* MemberService에 @Transactionl 추가 & LogRepository, MemberRepository에 @Transactional 제거

joinV1에 @Transactional 추가

 

 

MemberServiceTest.java

    /**
     * memberService : @Transactional ON
     * memberRepository : @Transactional OFF
     * logRepository : @Transactional OFF
     */
    @Test
    void singleTx() {
        String username = "outerTxOff_success";
        memberService.joinv1(username); // 저장

        // isPresent() : 존재하냐
        Assertions.assertTrue(memberRepository.find(username).isPresent());
        Assertions.assertTrue(logRepository.find(username).isPresent());
    }

 

 

동작 과정 

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

   - MemberRepository, LogRepository는 트랜잭션 AOP가 적용되지 않음, 순수 자바 코드

2. MemberService의 시작부터 끝까지 관련 로직은 해당 트랜잭션이 생성한 커넥션을 사용 : MemberRepository, LogRepository도 같은 커넥션을 사용하며 자연스럽게 트랜잭션 범위에 포함됨

 

* 같은 쓰레드를 사용하면 트랜잭션 동기화 매니저는 같은 커넥션을 반환함


트랜잭션 전파 커밋 & 롤백

스프링은 @Transactional이 적용되어 있으면 기본으로 REQUIRED 전파 옵션을 사용한다.

 

 

* MemberService, LogRepository, MemberRepository 모두 @Transanctional 추가

 

 

1. 전파 커밋

MemberServiceTest.java

    /**
     * memberService : @Transactional ON
     * memberRepository : @Transactional ON
     * logRepository : @Transactional ON
     */
    @Test
    void outerTxOn_success() {
        String username = "outerTxOff_success";
        memberService.joinv1(username); // 저장

        Assertions.assertTrue(memberRepository.find(username).isPresent());
        Assertions.assertTrue(logRepository.find(username).isPresent());
    }

 

 

2. 전파 롤백

MemberServiceTest.java

    /**
     * memberService : @Transactional ON
     * memberRepository : @Transactional ON
     * logRepository : @Transactional ON -> EXCEPTION
     */
    @Test
    void outerTxOn_fail() {
        String username = "로그예외_outerTxOn_fail";
        memberService.joinv1(username); // 저장

        Assertions.assertTrue(memberRepository.find(username).isPresent());
        Assertions.assertTrue(logRepository.find(username).isPresent());
    }

-> "로그예외"를 넘겼기 때문에 LogRepository에서 런타임 예외 발생

 

실행 결과

-> 모든 데이터가 롤백됨

 

동작 과정

1. MemberService가 호출되며 트랜잭션 AOP가 호출됨 : 신규 트랜잭션 생성, 물리 트랜잭션 시작

2. MemberRepository가 호출되며 트랜잭션 AOP가 호출됨 : 기존 트랜잭션에 참여

3. MemberRepository 로직 호출이 끝나고 정상 응답이므로 트랜잭션 매니저에 커밋 요청 but 신규 트랜잭션이 아니므로 실제 커밋은 호출 X

4. LogRepository가 호출되며 트랜잭션 AOP가 호출됨 : 기존 트랜잭션에 참여

5. LogRepository 로직에서 런타임 예외 발생 : 신규 트랜잭션이 아니므로 rollbackOnly를 설정함

6. MemberService가 런타임 예외를 받고 따로 예외를 처리하지 않은채 밖으로 던짐

7. 트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백 요청 : 신규 트랜잭션이므로 물리 롤백 호출 -> 어차피 롤백이 되었기 때문에 rollbackOnly 설정은 참고하지 X


트랜잭션 전파 복구 : REQUIRED

"회원 가입 시도 로그를 남기는데 실패하더라도 회원 가입은 유지되도록"

 

 

MemberServiceTest.java

    /**
     * memberService : @Transactional ON
     * memberRepository : @Transactional ON
     * logRepository : @Transactional ON -> EXCEPTION
     */
    @Test
    void recoverException_fail() {
        String username = "로그예외_recoverException_fail";
        memberService.joinv2(username); // 저장

        Assertions.assertTrue(memberRepository.find(username).isPresent());
        Assertions.assertTrue(logRepository.find(username).isPresent());
    }

 

이때, joinv2를 호출로 예외가 복구됨(정상 커밋이 될까?)

 

실행 결과

-> 예외 발생

-> 논리 트랜잭션 중 하나라도 롤백되면 전체 트랜잭션이 롤백됨

-> rollbackOnly가 체크되어 있으므로..

 

동작 과정


트랜잭션 전파 복구 : REQUIRES_NEW

"REQUIRES_NEW"를 사용해 로그와 관련된 물리 트랜잭션을 별도로 분리

 

 

MemberServiceTest.java

    /**
     * memberService : @Transactional ON
     * memberRepository : @Transactional ON
     * logRepository : @Transactional ON(REQUIRES_NEW) -> EXCEPTION
     */
    @Test
    void recoverException_success() {
        String username = "로그예외_recoverException_success";
        memberService.joinv2(username); // 저장

        Assertions.assertTrue(memberRepository.find(username).isPresent());
        Assertions.assertTrue(logRepository.find(username).isPresent());
    }

 

LogRepository.save에 REQUIRES_NEW 옵션 추가

 

실행 결과

-> MemberRepository는 커밋, LogRepository는 롤백

 

동작 과정

-> REQUIRES_NEW를 사용해 신규 트랜잭션을 생성한다.

-> rollbackOnly가 표시되지 않고 LogRepository의 트랜잭션이 물리 롤백되고 끝난다.

 

 

* REQUIRES_NEW 주의점

1. 하나의 HTTP 요청에 동시에 2개의 데이터베이스 커넥션을 사용하게 되므로 성능이 중요한 곳에서는 이 부분을 주의해 사용해야 한다.

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