글 작성자: beaniejoy

이번 게시글은 Spring에서 자주 사용하는 @Transactional이 예외를 마주하게 되었을 때 발생하는 롤백에 대해서 정리해보고자 합니다. 

@Transactional에서 여러 propagation 옵션이 있는데 이번 게시글에서는 REQUIRED, REQUIRES_NEW에 대해서만 다룰 예정입니다. 두 개의 propagation 상황에서 어떤 Exception이 언제 발생하는지에 따라 롤백이 일어나는지 아닌지에 대해서도 다루려고 합니다. 그리고 또한 Custom AOP를 적용했을 때 그 안에서 발생하는 예외는 어떻게 처리가 되는지 정리해보고자 합니다.

지금부터 여러 케이스를 통해 롤백 발생여부를 알아보려 하는데요. 기본적인 테스트를 위한 로직 틀은 다음과 같습니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class ParentService {
    private final ChildService childService;
    private final ChildRepository childRepository;
    
    @Transactional
    public void justCallChildService() {
        Cafe cafe = Cafe.builder()
                .name("joy's cafe")
                .description("joy cafe desc")
                .phoneNumber("01033334444")
                .address("joy cafe's address")
                .build();

        cafeRepository.save(cafe);

        childService.justSave();
    }
}

@Slf4j
@Service
@RequiredArgsConstructor
public class ChildService {
    private final ChildRepository childRepository;
    
    @Transactional
    public void justSave() {
        Cafe cafe = Cafe.builder()
                .name("beanie's cafe")
                .description("beanie cafe desc")
                .phoneNumber("01023450981")
                .address("beanie cafe's address")
                .build();

        cafeRepository.save(cafe);
    }
}

ParentService에서 joy cafe를 JPA Repository를 통해 저장하고, 이후에 ChildService의 메소드를 호출합니다. 그 안에서는 마찬가지로 beanie cafe를 JPA Repository를 통해 저장하는 아주 단순한 로직입니다.

이 로직을 기반으로 해서 한 번 여러 케이스를 통해 롤백여부를 체크해봅시다.

 

📌 1. Method 내부에 또 다른 Method 호출하는 상황

 

1-1. parent, child 둘 다 @Transactional 기본 propagation

// ParentService
@Transactional
public void justCallChildService() {
    Cafe cafe = Cafe.builder()
            .name("joy's cafe")
            .description("joy cafe desc")
            .phoneNumber("01033334444")
            .address("joy cafe's address")
            .build();

    cafeRepository.save(cafe);

    childService.justSave();
}

//ChildService
@Transactional
public void justSave() {
    Cafe cafe = Cafe.builder()
            .name("beanie's cafe")
            .description("beanie cafe desc")
            .phoneNumber("01023450981")
            .address("beanie cafe's address")
            .build();

    cafeRepository.save(cafe);
    throw new RuntimeException("child exception")
}

Parent와 Child에 있는 Method 모두 기본 @Transactional 입니다. 해당 어노테이션 안에는 propagation을 지정할 수 있는데요. 기본 값은 "REQUIRED" 입니다.

REQUIRED
이전에 트랜잭션이 시작해서 현재 트랜잭션이 활성화된 상태면 그대로 사용하고 만약 트랜잭션이 없다면 새로 시작하는 전략입니다. 위의 상황에서는 ParentService에서 트랜잭션을 시작했고 이후 ChildService를 호출하고 있기 때문에 Parent, Child 모두 하나의 트랜잭션으로 묶여있다고 보면 됩니다.

Child에서 RuntimeException을 의도적으로 발생시키면 모두가 예상한 대로 해당 트랜잭션은 롤백이 되면서 Parent, Child 모두 롤백됩니다.(하나의 트랜잭션으로 묶여 있었기 때문에)

// ParentService
@Transactional
public void justCallChildService() {
    Cafe cafe = Cafe.builder()
        .name("joy's cafe")
        .description("joy cafe desc")
        .phoneNumber("01033334444")
        .address("joy cafe's address")
        .build();

    cafeRepository.save(cafe);

    try {	
        childService.justSave();
    } catch (Exception e) {
        log.error(e.getMessage());
    }
}

하지만 RuntimeException도 해당 메소드에서 try ~ catch로 처리해주면 롤백되지 않고 괜찮지 않을까 생각할 수 있는데요. 그래도 Parent, Child 모두 롤백됩니다. Child에서 RuntimeException이 발생한 상황에서 Transaction은 따로 롤백마크를 지정해두었다가 Parent 메소드가 끝나면서 롤백마크를 근거로 전체 롤백을 하게 됩니다.

이 때 발생하는 에러가 UnexpectedRollbackException 입니다.
(메소드는 끝났는데 말 그대로 정말 예기치 않은 롤백을 마주하게 됐다는 늬앙스가 있는 것 같네요.)

만약 ParentService에서 호출하고 있는 ChildSerivce method("justSave()")에서도 RuntimeException 발생하는 부분을 try ~ catch로 처리한다면 이 때는 전체 커밋이 될 것입니다.

뒤에서 다시 언급드리겠지만 노파심에서 먼저 말씀드리자면 Transaction과 java의 checked, unchecked Exception(특히 RuntimeException)는 아무런 관련이 없습니다.

Spring의 @Transactional 어노테이션 코드를 보면 rollback 마크를 default로 RuntimeException이 설정된 것을 확인할 수 있습니다. 우리는 이를 그대로 별생각 없이 사용했었기 때문에 RuntimeException 발생하면 롤백되는구나라고 생각해왔을 수 있습니다.

즉 RuntimeException은 Spring Transactional에서 기본적으로 롤백마크 설정한 예외이지, 둘 사이 관계(트랜잭션과 RuntimeException)는 전혀 관련 없다는 것을 인지하시고 이어서 게시글을 보시면 좋을 것 같습니다.

 

1-2. parent: "REQUIRED" / child: "REQUIRES_NEW" 상황

// ParentService
@Transactional
public void callChildServiceWithNewTx() {
    Cafe cafe = Cafe.builder()
            .name("joy's cafe")
            .description("joy cafe desc")
            .phoneNumber("01033334444")
            .address("joy cafe's address")
            .build();

    cafeRepository.save(cafe);

    childService.saveAndThrowRuntimeExceptionWithNewTx();
}

// ChildService
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAndThrowRuntimeExceptionWithNewTx() {
    Cafe cafe = Cafe.builder()
            .name("beanie's cafe")
            .description("beanie cafe desc")
            .phoneNumber("01023450981")
            .address("beanie cafe's address")
            .build();

    cafeRepository.save(cafe);
    throw new RuntimeException("test");
}

이전 케이스와 다른 점은 ChildService의 메소드에 @Transactional 전파(propagation)전략을 "REQUIRES_NEW"로 설정한 점입니다.

REQUIRES_NEW이전에 생성된 트랜잭션이 있어도 해당 메소드 안에서는 새로운 트랜잭션을 만들어 사용한다는 전파 전략입니다.
즉, A에서 B로 호출했을 때 A에 이미 트랜잭션 시작을 한 상황이라면 B로 들어가기 전에 기존의 트랜잭션을 잠시 멈추고, 새로운 트랜잭션을 생성해 B에 적용하는 것 같습니다.
물론, B가 끝나면 새로운 트랜잭션은 commit 되고 기존의 트랜잭션에서 수행하고 있던 로직을 다시 진행할 것입니다.

이 상황에서 기대하는 결과는 Parent와 Child가 서로 다른 트랜젹션으로 움직이고 있기 때문에 Child에서 예외가 발생하면 Child 내용만 롤백되고 Parent 내용은 커밋될 것이라고 생각할 수 있습니다.

하지만 예상은 보기 좋게 빗나가는데요. 둘 다 롤백 됩니다. 이유는 단순합니다. Exception은 throw 하는 순간 try ~ catch로 처리하지 않는 이상 호출한 상위 메소드로 예외가 전가되기 때문입니다.
즉, Child에서 발생한 예외를 따로 처리하지 않았기 때문에 Child를 호출했던 Parent의 메소드로 예외가 전가가 되는 것이고 Parent에도 예외를 따로 처리하지 않았기 때문에 전체 롤백이 되는 것이라 생각할 수 있습이다.

// ParentService
try {
    childService.saveAndThrowRuntimeExceptionWithNewTx();
} catch (Exception e) {
    log.error(e.getMessage());
}

그러면 똑같은 상황에서 ParentService에서 Child 메소드 호출한 부분을 try ~ catch로 예외처리하게 되면 어떻게 될까요?
이 때는 우리가 예상한 대로 Parent는 커밋이 되고, Child는 롤백이 됩니다.
REQUIRES_NEW
전략을 사용해서 새로운 트랜잭션을 제대로 사용하고 싶다면 예외 처리에 대해서 잘 생각을 해야할 것 같습니다.

 

📌 2. Checked Exception

지금까지 RuntimeException, 다시 말하면 Unchecked Exception에 대해서 테스트를 해보았는데요. 이번에는 Checked Exception에 대해서 트랜잭션 롤백이 발생하는지 다른 케이스를 통해 알아보겠습니다.

// ParentService
@Transactional
public void callChildServiceThrowChecked() throws IOException {
    Cafe cafe = Cafe.builder()
            .name("joy's cafe")
            .description("joy cafe desc")
            .phoneNumber("01033334444")
            .address("joy cafe's address")
            .build();

    cafeRepository.save(cafe);

    childService.justSave();

    throw new IOException("test");
}

// ChildService
@Transactional
public void justSave() {
    Cafe cafe = Cafe.builder()
            .name("beanie's cafe")
            .description("beanie cafe desc")
            .phoneNumber("01023450981")
            .address("beanie cafe's address")
            .build();

    cafeRepository.save(cafe);
}

이번에는 Parent method 마지막에 IOException을 발생시켜보았습니다. Child는 정상 동작하도록 했습니다. 여기서 전파 전략은 둘 다 기본 전략(REQUIRED)으로 설정했습니다.

예상하는 결과는 Parent 마지막에 예외가 발생했고 Parent, Child 둘 다 동일한 트랜잭션 안에 묶여있기 때문에, 둘 다 롤백이 될 것이라 생각하는데요. 실제로 실행하면 둘 다 커밋이 됩니다.

오잉 예외가 발생했는데 둘 다 커밋이라니 이상할 수 있는데요. RuntimeException, Error과 다르게 Checked Exception은 롤백 마킹 대상이 아니기 때문입니다.이것은 Spring에서의 @Transactional에서 해당하는 내용입니다.

@Transactional에서 rollback 관련된 설정을 할 수 있는데요. rollbackFor 옵션에 다음과 같은 설명이 있습니다.

By default, a transaction will be rolling back on RuntimeException and Error but not on checked exceptions (business exceptions)

스프링 docs에도 이에 대한 설명이 있는데요. (링크 참고)

즉, Spring에서의 @Transactional은 rollback 타겟에 대해 default로 RuntimeException, Error에 대해서만 적용한 것을 확인할 수 있습니다. 그래서 방금의 IOException이 발생하는 상황에서 모두 커밋이 된 것입니다.

 

📌 3. AOP에서 발생한 Exception

만약 Custom한 AOP를 ChildService 메소드에 적용했을 때 AOP에서 발생한 Exception은 어떻게 처리가 될까요? 상황을 통해 한 번 알아봅시다.

 

3-1. AOP에서 발생한 Checked, Unchecked Exception

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CustomAnnotation {
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CustomAnnotation2 {
}

@Slf4j
@Aspect
@Component
public class CustomAspect {
    @Around("@annotation(io.beaniejoy.springdatajpa.common.CustomAnnotation)")
    public Object testAround(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info(">>> TestAspect START");
        Object result = joinPoint.proceed();
        log.info(">>> TestAspect END");

        throw new RuntimeException("aop unchecked exception");

//        return result;
    }
    
    @Around("@annotation(io.beaniejoy.springdatajpa.common.CustomAnnotation2)")
    public Object testAround2(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info(">>> TestAspect START");
        Object result = joinPoint.proceed();
        log.info(">>> TestAspect END");

        throw new IOException("aop checked exception");

//        return result;
    }
}

위와 같이 간단하게 2개의 CustomAnnotation을 만들고 해당 어노테이션들에 Aspect를 설정했습니다.
그리고 AOP에는 실제 메소드가 처리된 이후에 Exception을 의도적으로 발생하게 했는데요. 하나는 RuntimeException으로 다른 하나는 IOException으로 설정했습니다.

// ChildService.java
@Transactional
@CustomAnnotation
public void saveWithCustomAspectThrowRuntimeException() {
    Cafe cafe = Cafe.builder()
            .name("beanie's cafe")
            .description("beanie cafe desc")
            .phoneNumber("01023450981")
            .address("beanie cafe's address")
            .build();

    cafeRepository.save(cafe);
}

@Transactional
@CustomAnnotation2
public void saveWithCustomAspectThrowCheckedException() {
    Cafe cafe = Cafe.builder()
            .name("beanie's cafe")
            .description("beanie cafe desc")
            .phoneNumber("01023450981")
            .address("beanie cafe's address")
            .build();

    cafeRepository.save(cafe);
}

CustomAnnotation 두 개를 각각 적용한 메소드를 ChildService에 만들었습니다. 먼저 해당 메소드들을 직접 호출하면 어떻게 되는지 테스트코드로 실행해보겠습니다.

@Test
public void child_with_tx_REQUIRED_and_custom_aspect_exception_test() {
    // 둘 다 RuntimeException 으로 떨어짐
    
    // RuntimeException
    assertThrows(RuntimeException.class, () -> {
        childService.saveWithCustomAspectThrowRuntimeException();
    });

    // checked exception > UndeclaredThrowableException로 던져짐
    assertThrows(UndeclaredThrowableException.class, () -> {
        childService.saveWithCustomAspectThrowCheckedException();
    });
}

AOP에서 RuntimeException, IOException 예외가 발생하는 상황에서 두 개의 메소드를 직접 호출하면 위와 같이 RuntimeException 예외가 발생하게 됩니다.

RuntimeException을 던진 @CustomAnnotation에서는 납득이 되는데 IOException을 던지는 @CustomAnnotation2에서도 RuntimeException이 발생한다는 것은 납득이 안가는데요. 이부분에 대해서 잘 설명해주는 글이 있는데요.

If the proxy itself throws a checked exception, from the caller's perspective, the save method throws that checked exception. The caller probably doesn't know anything about that proxy and will blame the save for this exception.
In such circumstances, Java will wrap the actual checked exception inside an UndeclaredThrowableException and throw the UndeclaredThrowableException instead. It's worth mentioning that the UndeclaredThrowableException itself is an unchecked exception.

# 출처 Baeldung's Post [When Does Java Throw UndeclaredThrowableException?]

제가 예시로 들었던 상황으로 해석하자면 @CustomAnnotation2이 적용된 ChildService의 saveWithCustomAspectThrowCheckedException 메소드를 호출하는 쪽(caller)에서는 AOP에서 checked exception이 발생했다는 것을 모르기 때문에 caller 입장에서는 saveWithCustomAspectThrowCheckedException 메소드에서 checked exception을 던진 것이라 책임을 전가할 수 있습니다. 사실 해당 메소드에서는 아무런 에러가 발생하지 않았는데 말이죠.

즉, 쉽게 말해 실제 호출한 메소드는 잘못한 것이 없고 뒤에 숨어있는 AOP라는 친구가 잘못한 것인데 호출한 쪽에서는 호출한 메소드한테 뭐라고 하는 억울한 상황(?)이라고 할 수 있을 것 같네요.

Java에서는 이것을 명확하게 구분하기 위해 위의 설명에 나와있듯이 UndeclaredThrowableException으로 AOP에서 발생한 checked exception을 한 번 감싸서(wrapping) 던집니다. 그리고 UndeclaredThrowableException는 RuntimeException을 상속받은 클래스입니다. 테스트코드에서 실행결과 둘 다 RuntimeException으로 떨어진다는 것이 이제야 납득이 되네요.

 

3-2. AOP 순서(Order)에 따른 롤백여부

그리고 @Transactional하고 같이 사용된다면 AOP에서 발생한 exception(unchecked, checked Exception 둘 다)의 종류와 상관없이 모두 롤백이 됩니다. 본래 checked exception은 롤백 마크 대상이 아니지만 위에서 확인했듯이 checked exception은 RuntimeException으로 감싸져서 throw되기 때문입니다. 

하지만 특이한 점이 있습니다. AOP의 우선순위를 최상위로 설정하고 롤백테스트를 하면 어떻게 되는지 보겠습니다.

@Slf4j
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CustomAspect {

    @Around("@annotation(io.beaniejoy.springdatajpa.common.CustomAnnotation2)")
    public Object testAround2(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info(">>> TestAspect START");
        Object result = joinPoint.proceed();
        log.info(">>> TestAspect END");

        throw new IOException("aop exception");

//        return result;
    }
}

먼저 Aspect 클래스 차원에서 @Order(Ordered.HIGHEST_PRECEDENCE)를 적용합니다. 말 그대로 최우선 순위로 설정하겠다는 것인데 이렇게 되면 @Transactional 보다 먼저 해당 AOP가 수행하게 됩니다. (@Transactional도 AOP)

이 상황에서 한 번 롤백 테스트를 해보겠습니다.

@Test
public void child_with_tx_REQUIRED_and_custom_aspect_exception_test() {
    // checked exception 에도 롤백이 되어야 할 것 같은데 커밋이 된다??
    assertThrows(UndeclaredThrowableException.class, () -> {
        childService.saveWithCustomAspectThrowCheckedException();
    });

    List<Cafe> cafes = cafeService.getAllCafes();
    
    // 테스트를 위해 기본으로 insert한 3개의 데이터 + childService에서 insert 커밋된 1개의 데이터
    // = 4개
    assertEquals(4, cafes.size());
}

예상대로 UndeclaredThrowableException이 발생했습니다. 그런데 결과를 보면 테스트 수행 전에 DB에 저장했던 3개의 테스트 데이터를 제외하고 AOP가 적용된 ChildService의 메소드에서 insert가 커밋된 것을 확인할 수 있습니다.
RuntimeException이기 때문에 본래 롤백이 되어야 하는데 커밋이 된 것입니다. 어떻게 된 것일까요. 해당 메소드에 적용된 AOP 실행 순서를 고려하면 납득이 됩니다.

CustomAOP > @Transactional(tx 시작) > childService save(insert 수행) > @Transactional(commit) > CustomAOP(exception 발생)

CustomAOP를 가장 먼저 실행되도록 설정했기 때문에 @Transactional 보다 먼저 수행됩니다. childService에서 insert를 수행하고 메소드가 끝났을 때는 @Transactional이 먼저 수행됩니다.(끝마치고 나갈 때는 Filter와 같이 역순으로 수행됩니다.)
이 때 commit이 먼저 이루어지게 되고 그 다음 CustomAOP에서 exception이 발생하게 됩니다. 커밋이 이루어지고 난 다음에 RuntimeException이 발생했기 때문에 롤백이 되지 않고 커밋이 되었다고 볼 수 있습니다.

CustomAOP > @Order(Ordered.HIGHEST_PRECEDENCE) 적용하지 않았을 때
CustomAOP > @Order(Ordered.HIGHEST_PRECEDENCE) 적용

 

📌 4. 마무리

실무에서도 CustomAOP 사용을 아주 많이 하게 됩니다. 주로 Redis 관련한 내용이 많은데요. CustomAOP에서 예외가 발생했을 때 @Transactional하고 같이 사용하는 상황에서는 트랜잭션이 어떻게 처리가 되는지에 대한 궁금증에서 시작이 되었습니다.

위의 정리한 내용 말고도 아주 다양한 상황들이 존재하는데요. 핵심만 잘 기억한다면 CustomAOP와 트랜잭션 처리에 대해 어려움을 겪지 않으실 거라 생각합니다.

  • @Transactional에는 다양한 propagation 설정들이 존재(우선 두 개만 기억)
    - REQUIRED: 이전에 트랜잭션 존재시 그대로 사용, 없다면 새로 시작하는 전략
    - REQUIRES_NEW: 이전의 트랜잭션 존재 유무 상관없이 새로운 트랜잭션 생성하는 전략
  • REQUIRES_NEW에도 호출하는 쪽(ParentService)에서 예외 처리(try catch)하지 않으면 같이 롤백이 되기에 주의
  • Spring의 @Transactional rollback 대상은 default로 RuntimeException이다.
    즉 @Transactional를 기본으로 사용하면 checked exception에 대해서는 롤백이 되지 않고 커밋됨
  • CustomAOP에서 발생한 Exception은 종류 상관없이 RuntimeException으로 throwing된다.
    (UndeclaredThrowableException 참고)
  • CustomAOP의 Order에 따라 @Transactional하고 같이 사용하는 상황에서 커밋이 될 수도, 안 될 수도 있다.
    (게시글 참고)

요정도로 요약할 수 있겠네요.

 

틀린 내용이 있을 수 있습니다. 피드백 언제나 환영합니다. 감사합니다.