[Spring]트랜잭션 예외복구 시 주의사항

2개 이상의 트랜잭션을 1개의 트랜잭션으로 수행 시, 어느 한 개의 트랜잭션에서 롤백이 발생하면 모든 트랜잭션이 rollback 되는 것이 스프링 트랜잭션의 대원칙이다. 그런데 경우에 따라 어느 트랜잭션에서는 try… catch로 예외를 처리할 수 있다. 이 경우 개발자들은 대개 예외 처리한 트랜잭션에서는 rollback 되더라도, 다른 트랜잭션은 commit 될 것이라고 생각한다. 하지만 앞서 언급한대로 어느 하나의 트랜잭션에서 롤백이 발생하면 전체 트랜잭션은 롤백되는 것이 기본이다. 이를 해결하기 위한 가장 손쉬운 방법은 try…catch로 예외처리 할 트랜잭션을 신규 트랜잭션으로 분리하는 것이다. 이럴경우 기존 트랜잭션과 별도 commit, rollback이 이뤄지므로, 기존 트랜잭션에는 영향을 주지 않는다. 전파옵션은 REQUIRES_NEW를 사용한다.

아래 예제를 살펴보자.

@Entity
@Getter
@Setter
public class Member {

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

    public Member() {
    }

    public Member(String username) {
        this.username = username;
    }
}


@Entity
@Getter
@Setter
public class Log {
    @Id
    @GeneratedValue
    private Long id;

    private String message;

    public Log() {
    }

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


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

@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {

    private final EntityManager em;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void save(Log message) {
        log.info("log 저장");
        em.persist(message);

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

    public Optional<Log> find(String logMessage) {
        return em.createQuery("select l from Log l where l.message = :message", Log.class)
                .setParameter("message", logMessage)
                .getResultList().stream()
                .findAny();
    }
}

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

2개의 엔터티 Member, Log가 있다. 이를 저장하는 2개의 Repository도 있다.

2개의 엔터티를 DB에 저장하는 MemberService.joinV2() 메서드가 있다. 여기서 Log 저장에 실패하여도 전체 트랜잭션은 commit 하고 싶은 경우 LogRepository.save() 트랜잭션의 전파옵션에 REQUIRES_NEW 를 사용하면 된다.

@Slf4j
@SpringBootTest
class MemberServiceTest {

    @Autowired
    private MemberService memberService;

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private LogRepository logRepository;

    @Test
    @DisplayName("memberService, memberRepository, logRepository 모두 tx가 있는 경우, 예외가 발생하는 경우")
    void recoverExceptionAndSuccess() {
        // given
        String username = "로그예외 test3";

        // when
        memberService.joinV2(username);

        // then
        assertThat(memberRepository.find(username).isPresent()).isTrue();
        assertThat(logRepository.find(username).isPresent()).isFalse();
    }
}

만약 LogRepository.save()의 전파옵션 REQUIRES_NEW를 제거하고 위 테스트 코드를 실행해보면 테스트가 실패하는 것을 알 수 있다. 또한 UnexpectedRollbackException.class 가 발생함을 알 수 있다. 이는 2개 이상의 트랜잭션이 1개의 트랜잭션으로 묶인 경우 논리 트랜잭션은 rollback이 발생하면 트랜잭션 동기화 매니저의 rollbackOnly=true로 플래그 설정을 하기 때문이다. 마지막 단계의 물리트랜잭션에서 커밋을 하기전 위 rollbackOnley 플래그값을 참조하여 해당 값이 true인 경우 모든 트랜잭션을 rollback 한다. 그러므로 특정 트랜잭션에 try…catch하여 예외를 처리했다고, 해당 트랜잭션만 rollback 되고 전체 트랜잭션이 commit 되지 않음에 유의한다. 특정 트랜잭션의 commit/rollback에 상관없이 전체트랜잭션을 commit 처리하고 싶다면 해당 트랜잭션의 전파옵션에 REQUIRES_NEW를 써주면 된다. 물론 예외처리(try… catch)를 해주는 것도 잊지 말아야 한다.

You may also like...

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다