본문 바로가기

Spring

[Spring] 트랜잭션 전파(Transaction Propagation)

트랜잭션 전파

트랜잭션이란 데이터베이스의 상태를 바꾸는 작업의 단위로 @Transactional을 메서드나 클래스에 선언하여 작업의 범위를 지정한다. 트랜잭션은 설정값에 따라 하나의 트랜잭션이 실행중일 때 다른 트랜잭션을 실행하면 기존의 실행중이던 트랜잭션에 종속되기도 하고 아예 새로운 트랜잭션이 실행되기도 하는데 이를 트랜잭션 전파(Transaction Propagation)라고 한다. 이번 글에서는 트랜잭션 전파 옵션의 종류와 의미, 그리고 적용예시에 대해서 정리해 보려고 한다.

트랜잭션 전파 옵션

REQUIRED

트랜잭션 전파의 기본 설정이다. 실무에서 가장 많이 사용되는 옵션으로 기존 트랜잭션이 없으면 새로 생성하고 있으면 참여한다. 기존 트랜잭션이나 신규 트랜잭션 중 하나라도 롤백이 발생하면 전부 롤백된다.

 

REQUIRES_NEW

항상 새로운 트랜잭션을 생성한다. REQUIRES_NEW 속성을 부여하면 기존에 실행중이던 트랜잭션이 있어도 새로 생성된 트랜잭션과 서로 영향을 미치지 않는다.

 

SUPPORT

기존 트랜잭션이 없으면 없는대로 진행하고 있으면 기존 트랜잭션에 참여한다.

 

NOT_SUPPORT

기존 트랜잭션이 있든 없든 트랜잭션 없이 진행한다.

 

MANDATORY

트랜잭션이 반드시 있어야 한다. 기존 트랜잭션이 없으면 IllegalTransactionStateException 예외가 발생하고 있으면 기존 트랜잭션에 참여한다.

  • 기존 트랜잭션 있음 : 기존 트랜잭션에 참여
  • 기존 트랜잭션 없음 : IllegalTransactionStateException 예외 발생

NEVER

MANDATORY 속성과 반대되는 개념으로 기존 트랜잭션이 없으면 없는대로 진행하고 있으면 IllegalTransactionStateException 예외가 발생한다.

  • 기존 트랜잭션 있음 : IllegalTransactionStateException 예외 발생
  • 기존 트랜잭션 없음 : 트랜잭션 없이 진행

NESTED

기존 트랜잭션이 없으면 새로운 트랜잭션을 생성하고 있으면 중첩 트랜잭션을 생성한다.

중첩 트랜잭션은 외부 트랜잭션에는 영향을 받기만하고 주지는 않는다. 즉 외부 트랜잭션에서 롤백이 발생하면 중첩 트랜잭션도 롤백되지만 중첩 트랜잭션에서 롤백이 발생하면 외부 트랜잭션은 롤백되지 않는다.

 

NESTED 속성은 JDBC의 savepoint 기능을 사용하므로 사용하기 전에 데이터베이스 드라이버에서 해당 기능을 지원하는지 확인이 필요하다. 참고로 JPA에서는 사용이 불가능하다.

REQUIRES_NEW 적용예시

REQUIRED 옵션은 외부 트랜잭션이 롤백되거나 내부 트랜잭션이 롤백되면 모든 연산이 롤백되므로 트랜잭션 원자성을 지켜 데이터 정합성을 보장해주기 때문에 실무에서 가장 많이 사용하는 옵션이다. 하지만 특정상황에 REQUIRES_NEW 옵션을 사용해야 하는 경우가 있는데 아래와 같은 경우이다.

MemberService

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    private final LogRepository logRepository;

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

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("로그 저장");
        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();
    }
}

 

회원을 등록하는 로직으로 회원등록과 함께 로그도 DB에 저장되도록 구현했다. MemberService, MemberRepository, LogRepository 각각 트랜잭션이 적용되어 있고 세 트랜잭션 모두 디폴트 옵션인 REQUIRED 속성으로 설정되어 있다. REQUIRED 속성으로 설정되어 있으므로 MemerService에서 시작한 트랜잭션에 MemberReopository와 LogRepository의 트랜잭션이 참여하고 있다.

 

로그 데이터를 저장하는 것은 비즈니스 로직에 영향을 미치지 않기 때문에 LogRepository에서 예외가 발생해도 회원은 등록되도록 MemberService에서 try ~ catch문으로 예외를 처리하고 정상로직으로 반환하도록 처리해주었다. 이제 LogRepository에서 예외가 발생해도 회원은 등록될 것으로 예상했지만 아래 코드로 테스트 해보니 예상과 다르게 전부 롤백되어 회원데이터도 사라지는 것을 확인할 수 있었다.

 

/**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON Exception
 */
@Test
void outerTxOn_fail() {
    // given
    String username = "로그예외_outerTxOn_fail";

    // when
    org.assertj.core.api.Assertions.assertThatThrownBy(() -> memberService.join(username))
            .isInstanceOf(RuntimeException.class);

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

 

 

REQUIRED 속성을 사용하는 경우 내부 트랜잭션에서 예외가 발생하면 트랜잭션 동기화 매니저에 rollbackOnly=true가 표시되고 외부트랜잭션을 커밋할 때 rollbackOnly=true가 표시되어 있으면 UnexpectedRollbackException 에외가 발생하면서 종속된 트랜잭션을 전부 롤백시켜버린다.

 

이와 같은 상황을 REQUIRES_NEW 속성을 사용하여 해결할 수 있다.

REQUIRES_NEW 속성은 아래와 같이 @Transactional 어노테이션에 propagation = Propagation.REQUIRES_NEW를 사용하여 적용할 수 있다.

 

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

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

 

REQUIRES_NEW 속성은 항상 새로운 트랜잭션을 생성하기 때문에 외부 트랜잭션과는 독립되어 있다. 그렇기 때문에 Log 데이터를 저장하는데 실패해도 외부 트랜잭션에 영향을 미치지 않으며 내부 트랜잭션이 롤백되어도 회원정보는 DB에 잘 저장되는 것을 확인할 수 있다.

 

 

/**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON(REQUIRES_NEW) Exception
 */
@Test
void recoverException_success() {
    // given
    String username = "로그예외_recoverException_success";

    // when
    memberService.join(username);

    // then: member 저장, log 롤백
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isEmpty());
}

 

하지만 REQUIRES_NEW 옵션은 데이터베이스 커넥션을 2개 사용하기 때문에 성능이 중요한 곳에서는 적합하지 않다. 대신 아래와 같이 구조를 변경하는 방법을 사용할 수 있다. 여러 해결방법이 있으니 각각의 장단점을 파악하고 적재적소에 알맞는 해결법을 사용해야 겠다.