Spring Transaction propagation Advanced

강정우·2024년 2월 21일
0

Spring-boot

목록 보기
71/73

트랜잭션 전파 활용

요구사항

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

예제 코드

도메인

Member.java

@Entity
@Getter @Setter
public class Member {

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

    public Member() {}

    public Member(String username) {
        this.username = username;
    }
}
  • jpa 스펙상 빈 생성자 함수는 반드시 있어야한다.

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

DB에 로그를 남기기 위해 도메인을 선언

Repository

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) {
        return em.createQuery("select m from Member m where m.username = :username", Member.class)
                .setParameter("username", username)
                .getResultList().stream().findAny();
    }
}

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();
    }
}
  • 여기 save 부분이 이제 뒤에 test 코드를 작성하며 주로 사용될 곳이다.
    일부로 runTimeException을 던지지만 로그 저장은 서버를 중단시킬정도로 크게 중요한 부분이 아니기 때문에 그냥 넘어가도록 할 것이다.

Service

MemberService

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;
    private final LogRepository logRepository;

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

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

문제

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

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

        //when: 모든 데이터가 정상 저장된다.
        assertTrue(memberRepository.find(username).isPresent());
        assertTrue(logRepository.find(username).isPresent());
    }
    
    ...
  • @Transactional(propagation = Propagation.REQUIRED) == @Transactional

case 2. 서비스 계층에 트랜잭션이 없는데 한 곳에서 롤백될 때

	... 

    /**
     * memberService    @Transactional:OFF
     * memberRepository @Transactional:ON
     * logRepository    @Transactional:ON RunTimeException
     */
    @Test
    void outerTxOff_fail() {
        //given
        String username = "로그예외_outerTxOff_fail";

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

        //when: log 데이터는 롤백된다.
        assertTrue(memberRepository.find(username).isPresent());
        assertTrue(logRepository.find(username).isEmpty());
    }
    
    ...

이제 이렇게 되면 MemberService 에서 MemberRepository 를 호출하는 부분은 트랜잭션이 정상 커밋되고, 회원 데이터도 DB에 정상 반영된다.
반면 LogRepository에서의 호출은 역시 새로운 트랜잭션이기 때문에 물리 롤백이 진행된다.

하지만 위와 같은 케이스는 데이터 정합성의 문제가 생길 수 있다.
그럼 이 경운는 어떻게 해야할까?

case 3. 하나의 트랜잭션으로 묶기

사실 가장 간단한 방법은 이 둘을 호출하는 회원 서비스에만 트랜잭션을 사용하는 것이다.
그리고 이 조건에 맞추려면 앞서 추가했던 Repository 단에 있는 @Transactional을 모두 제거하고 Service 단에 @Transactional을 하나 추가해준다.

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

	... 

    /**
     * memberService    @Transactional:ON
     * memberRepository @Transactional:OFF
     * logRepository    @Transactional:OFF
     */
    @Test
    void singleTx() {
        //given
        String username = "singleTx";

        //when
        memberService.joinV1(username);

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

case 4.각각 트랜잭션이 필요한 경우

사실 이는 그래서 Transaction의 전파가 반드시 필요한 부분이다.

물론 Propagation 없이 해결할 순 있다. 바로 Transaction이 있는 매서드와 없는 매서드를 생성하면 된다. 그런데 이는 Jonna bullshit이다.
또 다른 Serivce에서 Transaction이 시작한 상태로 해당 Service로 넘어올 수도 있다.

그래서 우리는 Transaction Propagation을 사용할 것이다. (사실 이미 앞서 포스팅한 내용과 같음)

REQUIRED

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

	... 

    /**
     * memberService    @Transactional:ON
     * memberRepository @Transactional:ON
     * logRepository    @Transactional:ON
     */
    @Test
    void outerTxOn_success() {
        //given
        String username = "outerTxOn_success";

        //when
        memberService.joinV1(username);

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

case 5.Transaction Propagation Rollback

	... 

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

        //when: 모든 데이터가 롤백된다.
        assertTrue(memberRepository.find(username).isEmpty());
        assertTrue(logRepository.find(username).isEmpty());
    }
    
    ...

저기 Log에서 터진 예외가 Rollback이라고 마킹되고 쭈우욱 Service까지 날라와서 그래도 예외가 안 잡히니까 결국은 신규 트랜잭션임을 확인하고 물리적으로 롤백을 하는 것이다.
이것까지도 굉장히 당연한 소리이다.

그럼 여기서 로그때문에 로그인이 안 되면 개 억울하니까 로그고 자시고 다시 정상흐름으로 반환하는 법은 무엇일까?

case 6.Transaction Propagation RECOVER

사실 이거 배우려고 앞선 복습을 한 것이다.
요구사항은 아래 그림과 같다.

즉, 중간에 로그를 찍는 로직에서 예외가 터져도 사용자의 정보 입력에는 문제가 없기 때문에 그냥 트랜잭션 자체를 Rollback이 아니라 정상흐름대로 처리하자는 것이다.

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

        //when: 모든 데이터가 롤백된다.
        assertTrue(memberRepository.find(username).isEmpty());
        assertTrue(logRepository.find(username).isEmpty());
    }

보면 joinV2 로 작성했는데 뭐 나름대로 Exception을 잡아서 정상흐름으로 바꾸려는 시도는 보이나 애초에 AOP에서 rollbackOnly으로 마킹이 되어버렸기 때문에 사실 그냥 case 5 의 흐름으로 가는 것이다.

즉, 내부 트랜잭션에서 rollbackOnly 를 설정(마크)하기 때문에 결과적으로 정상 흐름 처리를 해서 외부 트랜잭션에서 커밋을 호출해도 물리 트랜잭션은 롤백된다. 그리고 UnexpectedRollbackException 이 던져진다.
이는 내부 트랜잭션이 롤백 되었는데, 외부 트랜잭션이 커밋되면 발생하는 Exception이다.

정답. Transaction Propagation RECOVERY [REQUIRES_NEW]

정답은 바로 @Transactional(propagation = Propagation.REQUIRES_NEW) 를 사용하여 물리 트랜잭션을 별도로 분리해야한다.

LogRepository

@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {

    private final EntityManager em;

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

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

그림으로 설명하자면 이렇게 동작하는 것이다. 전혀 새로운 신규 트랜잭션을 만들어서 해당 트랜잭션 내부에서 롤백이 일어나면 해당 로직만 롤백이 일어나도록 구성한 것이다.
또한 con1, con2 이기 때문에 별도의 db connection이다.

논리 트랜잭션은 하나라도 롤백되면 관련된 물리 트랜잭션은 롤백되어 버린다.
이 문제를 해결하려면 REQUIRES_NEW 를 사용해서 트랜잭션을 분리해야 한다.
다만,

주의사항

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

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

위 그림과 같이 구성하면 HTTP 요청에 동시에 2개의 커넥션을 사용하지는 않는다. 순차적으로 사용하고 반환하게 된다.
물론 구조상 REQUIRES_NEW 를 사용하는 것이 더 깔끔한 경우도 있으므로 각각의 장단점을 이해하고 적절하게 선택
해서 사용하면 된다.

profile
智(지)! 德(덕)! 體(체)!

0개의 댓글