티스토리 뷰

이번에는 REQUIRES_NEW를 사용해서 문제 상황을 가정하고 해결해보도록 하자.


문제 상황

회원가입에 성공하면 회원 DB와 회원 이력 로그 DB에 저장하는 비지니스 로직을 수행해야 한다.


회원가입과 이력 로그를 저장하는 로직을 하나의 트랜잭션으로 묶어서 처리하던 중, 만약 이력 로그 저장에서 문제가 발생하면 회원가입 자체가 안 되는 상황이 발생한다.


실제 비지니스에서 이런 상황이 발생하면 많은 회원들이 이탈하는 문제가 발생할 것이기 때문에 요구 사항이 변경 되었다.


"회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되게 해주세요."


단순하게 생각해보면 LogRepository에서 예외가 발생하면 그것을 MemberService에서 예외를 잡아서 처리하면 될 것 같다.
이렇게 하면 MemberService에서 정상 흐름으로 바꿀 수 있기 때문에 MemberService의 트랜잭션 AOP 에서 커밋을 수행할 수 있다.


직접 테스트 해보자!


MemberRepository

@Slf4j
@RequiredArgsConstructor
@Repository
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
@RequiredArgsConstructor
@Repository
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 중, message에 "로그예외"가 포함되어 있다면, 예외를 던진다.

MemberService

@Slf4j
@RequiredArgsConstructor
@Service
public class MemberService {

    private final MemberRepository memberRepository;

    private final LogRepository 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("== logMessage 호출 시작 ==");
        try {
            logRepository.save(logMessage);
        } catch (RuntimeException e) {
            log.info("log 저장에 실패했습니다. logMessage={}", logMessage.getMessage());
            log.info("정상 흐름 반환");
        }

        log.info("== logMessage 호출 종료 ==");
    }

}

Test Code

    @Test
    void recoverException_fail() {

        // given
        String username = "로그예외 recoverException_fail";

        // when
        assertThatThrownBy(() -> memberService.joinV2(username))
                .isInstanceOf(UnexpectedRollbackException.class);

        // then
        assertTrue(memberRepository.find(username).isPresent());
        assertTrue(logRepository.find(username).isEmpty());
    }

Test 결과

예상했던 결과는 달리 marking existing transaction as rollback-only라는 내용과 함께 테스트에 실패한 것을 확인할 수 있다.


왜 실패했을까?

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



전체 흐름을 좀 더 자세하게 알아보자!


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

위 테스트를 통해 논리 트랜잭션 중 하나라도 롤백되면 전체 트랜잭션은 롤백되고, rollbackOnly 상황에서 커밋이 발생하면 UnexpectedRollbackException 예외가 발생하는 것을 확인했다.


이 문제를 해결하기 위해 REQUIRES_NEW를 사용하여 로그와 관련된 물리 트랜잭션을 별도로 분리해보자!


LogRepository

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

}
  • save@Transactional(propagation = Propagation.REQUIRES_NEW)를 설정하였다.

Test Code

    @Test
    void recoverException_success() {

        // given
        String username = "로그예외 recoverException_success";

        // when
        memberService.joinV2(username);

        // then
        assertTrue(memberRepository.find(username).isPresent());
        assertTrue(logRepository.find(username).isEmpty());
    }

Test 결과

테스트가 정상적으로 성공하였다.


어떻게 성공했을까?

REQUIRES_NEW - 물리 트랜잭션 분리

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


REQUIRES_NEW - 복구

REQUIRES_NEW를 사용하게 되면 물리 트랜잭션 자체가 완전히 분리되며, 신규 트랜잭션이므로 rollbackOnly 표시가 되지 않고, 그냥 해당 트랜잭션이 물리 롤백되고 끝난다.



이번에도 전체 흐름을 좀 더 자세하게 알아보자!


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

REQUIRES_NEW를 사용해서 결과적으로 회원 데이터는 저장되고, 회원 로그 데이터만 롤백되는 것을 확인할 수 있다. 하지만 REQUIRES_NEW를 사용하면 하나의 HTTP 요청에 2개의 데이터베이스 커넥션을 사용하게 되므로, 성능이 중요한 곳에서는 주의가 필요하다. 아무래도 REQUIRES_NEW를 사용하지 않아도 문제를 해결할 수 있다면, 그 방법을 선택하는 것이 더 좋다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
글 보관함