본문 바로가기
ETC

당신은 트랜잭션에 대해 얼마나 알고 있는가

by dkswnkk 2023. 6. 6.

서론

최근 실무에서 작업하던 중 트랜잭션 때문에 이슈가 한번 있었다. 분명 롤백이 되지 않게끔 조치를 취해놓고 작업을 했지만 아래와 같은 오류가 발생하면서 롤백이 되어버렸다.

Caused by: org.springframework.transaction.UnexpectedRollbackException: 
    Transaction silently rolled back because it has been marked as rollback-only

전에 https://techblog.woowahan.com/2606 글도 보았고, https://dkswnkk.tistory.com/688 포스팅도 작성하면서 어느 정도 트랜잭션에 대해서 감을 잡았다고 생각했는데,, 실제로 문제가 발생하니 참 우울해졌다.. 마무리는 잘 되었지만.. 그래도 뭐 어쩌겠는가.... 공부하는 수밖에... 
 

예시

다양한 예시 코드들을 작성해서 실험해 보았으니 이 포스팅을 보시는 분들도 롤백이 될지, 안될지 한번 예상을 해보시기 바랍니다.

public class Domain {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private int count;

    public void plus() {
        this.count++;
    }
}

위와 같은 간단한 예시 도메인을 기준으로 작성해 보겠습니다. domain.plus() 작업이 롤백이 될지 안될지를 기준으로 작성했습니다.


 
예시 1) 롤백됨

    @Transactional
    public void test1(Long id) {
        Domain domain = findById(id);

        test2(domain);
    }

    private void test2(Domain domain) {
        domain.plus();
        
        throw new RuntimeException("throw error");
    }

다들 당연히 롤백될 거라 예상한 코드입니다. 

Spring의 @Transactional은 메서드를 호출하는 동안 데이터베이스 트랜잭션을 시작하고, 메서드 실행이 완료되면 트랜잭션을 커밋합니다. 만약 메서드 실행 도중에 unchecked exception (예를 들어, RuntimeException과 그 하위 클래스들)이 발생하면, 트랜잭션은 자동으로 롤백됩니다.

test2()는 test1()의 트랜잭션 컨텍스트에서 실행되므로, test1()test2()는 동일한 트랜잭션을 공유합니다. test2()에서 RuntimeException을 던지면, 이것은 test1()의 트랜잭션을 롤백시킵니다.

따라서 test2()에서 domain.plus();로 인한 변경사항은 롤백되게 되며, 이는 test2()에서 발생한 RuntimeException이 동일한 트랜잭션을 공유하는 test1()의 트랜잭션을 롤백시키기 때문입니다.


 
예시 2) 롤백됨

public class A {
    @Transactional
    public void test1(Long id) {
        Domain domain = findById(id);

        B.test2(domain);
    }  
}

public class B {
    @Transactional
    public void test2(Domain domain) {
        domain.plus();
        
        throw new RuntimeException("throw error");
    }
}

현재 A.test1()과 B.test2()가 전부 @Transactional이 붙어있습니다. 기본적으로 Spring의 @Transaction은 호출된 메서드가 이미 트랜잭션의 일부인지 확인하는데, 만약 일부라면 그 메서드는 기존 트랜잭션의 일부로 실행되며, 이를 트랜잭션 전파라고 부릅니다.

A.test1()에서 호출되는 B.test2()는 동일한 트랜잭션에서 실행되며 B.test2()에서 RuntimeException이 발생하면, 이 예외는 메서드 호출 스택을 통해 상위 메서드로 전파됩니다. 

결과적으로, B.test2()에서 발생한 예외로 인해 domain.plus()의 변경사항이 롤백됩니다.


 
예시 3) 롤백됨

    @Transactional
    public void test1(Long id) {
        Domain domain = findById(id);

        test2(domain);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void test2(Domain domain) {
        domain.plus();

        throw new RuntimeException("throw error");
    }

위 코드는 같은 클래스 내에 있는 메서드들입니다. 따라서 test2()는 Spring 프록시를 거치지 않고 호출됩니다. 이는 test2()에 설정된 @Transactional(propagation = Propagation.REQUIRES_NEW) 전파 정책이 무시되고, test1()의 트랜잭션을 사용하게 되며, test2() 메서드는 새로운 트랜잭션을 시작하지 않고 test1()의 트랜잭션 범위 내에서 실행된다는 것을 의미합니다.

따라서 test2()에서 발생한 RuntimeException은 test1()의 트랜잭션을 롤백시키게 되며, 최종적으로 test2()에서 예외가 발생하여 전체 트랜잭션이 롤백이 됩니다.

이러한 현상을 피하기 위해서는 test2()를 다른 클래스로 이동하거나, 같은 클래스 내에서도 프록시를 통해 메서드를 호출하도록 수정하는 방법으로 해결할 수 있습니다.


 
예시 4) 롤백됨

public class A {
    @Transactional
    public void test1(Long id) {
        Domain domain = findById(id);

        B.test2(domain);
    }
}

public class B {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void test2(Domain domain) {
        domain.plus();
        
        throw new RuntimeException("throw error");
    }
}

예시 3과 동일하지만, 다른 클래스에 존재하는 메서드들입니다.

B.test2()에서는 @Transactional(propagation = Propagation.REQUIRES_NEW)이 적용되어 A.test1()의 트랜잭션과는 별개의 새로운 트랜잭션을 시작합니다. 이렇게 설정하면 B.test2() 트랜잭션은 A.test1() 트랜잭션과 독립적이게 되며, 서로 다른 생명 주기를 가져 하나의 트랜잭션에서 발생한 문제가 다른 트랜잭션에 영향을 주지 않습니다.

실행 흐름은 다음과 같습니다.

  • A.test1()이 트랜잭션을 시작한다.
  • B.test2()가 새로운 트랜잭션을 시작하고, domain.plus()를 수행한 후, RuntimeException을 발생시킨다.
  • RuntimeException이 발생했기 때문에, Spring은 현재 트랜잭션(B.test2()의 트랜잭션)을 롤백한다.
  • 그러나 A.test1()에서 시작된 첫 번째 트랜잭션은 B.test2()의 실패와 관계없이 계속 진행된다.

즉, 이 경우 B.test2()에서 발생한 예외로 인해 B.test2()의 트랜잭션이 롤백됩니다. 하지만 별개의 트랜잭션 이기 때문에 A.test1()의 트랜잭션은 B.test2()의 실패로 인해 직접적으로 영향을 받지 않습니다. 다만 B.test2()에서 발생한 예외로 인해 B.test2()의 트랜잭션에서 롤백이 일어나고, B.test2()에서 발생한 예외는 호출자에게 전파되어 A.test1()에게 전파됩니다. 따라서 B.test2() 뿐만 아니라 A.test1()에  추가 작업이 있다면 이 작업도 롤백됩니다.

즉, 최종적으로 domain.plus()의 작업은 반영되지 않습니다.


 
예시 5) 롤백 안됨

    @Transactional
    public void test1(Long id) {
        try {
            Domain domain = findById(id);

            test2(domain);
        } catch (Exception e) {
            log.error(e.getMessage());
        }
    }

    private void test2(Domain domain) {
        domain.plus();
        throw new RuntimeException("throw error");
    }

현재 같은 클래스 내에 존재하는 메서드들입니다. 그리고, test1()이 @Transaction을 가지고 있기에 test2()는 test1()의 트랜잭션 컨텍스트에서 실행되므로 서로 동일한 트랜잭션을 공유합니다.

test2()에서는 domain.plus() 작업 후 RuntimeException을 던지는데 이 예외는 test2()를 호출하는 test1()의 try-catch 블록에서 잡힙니다. Spring의 @Transactionl 어노테이션이 트랜잭션을 롤백하도록 동작하는 방식은 RuntimeException이 발생했을 때인데, 이는 예외가 Spring 트랜잭션 관리자에게 전달되었을 때만 적용됩니다.

여기서는 test1()의 try-catch 블록이 test2()에서 발생한 RuntimeException을 잡아서 처리하고 있기에, 예외가 관리자에게 전달되지 않아 롤백이 발생하지 않고, domain.plus()의 변경사항은 커밋됩니다.


 
예시 6) 롤백 안됨

    @Transactional
    public void test1(Long id) {
        Domain domain = findById(id);

        test2(domain);
    }

    private void test2(Domain domain) {
        try {
            domain.plus();
            throw new RuntimeException("throw error");
        } catch (Exception e) {
            log.error(e.getMessage());
        }
    }

예시 5와 동일한 이유로 롤백이 수행되지 않고 커밋됩니다.


 
예시 7) 롤백 안됨

public class A {
    @Transactional
    public void test1(Long id) {
        Domain domain = findById(id);
        
        B.test2(domain);
    }
}

public class B {
    @Transactional
    public void test2(Domain domain) {
        try {
            domain.plus();
            throw new RuntimeException("throw error");
        } catch (Exception e) {
        }
    }
}

예시 5, 6번의 설명과 같으며, B.test()에서 domain.plus() 이후에 RuntimeException을 발생시키지만, 이 예외는 즉시 catch 블록에서 처리됩니다. catch 블록은 예외를 잡아서 처리하고 프로그램이 계속 실행되게 하기 때문에, B.test2()의 트랜잭션은 롤백되지 않고 domain.plus()의 변경은 커밋됩니다.

즉, domain.plus()의 작업은 롤백되지 않는데, 이는 B.test2()에서 발생한 RuntimeException이 catch블록에서 처리되어 트랜잭션 롤백을 발생시키지 않기 때문입니다.


 
예시 8) 롤백 안됨

public class A {
    @Transactional(noRollbackFor = {RuntimeException.class})
    public void test1(Long id) {
        Domain domain = findById(id);
        
        B.test2(domain);
    }
}

public class B {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void test2(Domain domain) {
        domain.plus();
        
        throw new RuntimeException("throw error");
    }
}

A.test1()을 호출하면 B.test2()가 실행되고, B.test2()에서 domain.plus()를 수행합니다.

B.test2()에는 @Transactional(propagation = Propagation.REQUIRES_NEW)가 적용되어 있어, 독립적인 트랜잭션이 생성됩니다. 그리고 B.test2()에서 RuntimeException이 발생하면 해당 트랜잭션은 롤백이 됩니다. 따라서 domain.plus()의 작업도 해당 트랜잭션 내에서는 롤백이 됩니다.

하지만 A.test1() 메서드의 트랜잭션은 B.test2()와 독립적입니다. A.test1()에는 @Transactional(noRollbackFor = {RuntimeException.class})를 통해 RuntimeException에 대해 롤백이 되지 않도록 설정이 되어있습니다.

따라서 최종적으로 A.test1()에서 B.test2()를 호출하더라도, B.test2()에서 발생한 RuntimeException으로 인한 롤백이 A.test1()의 트랜잭션에는 영향을 미치지 않으므로, 롤백이 발생하지 않습니다.

예시 8은 아무리 생각해도 모르겠네요... domain.plus()의 작업은 롤백이 되어야 한다고 생각하는데, 계속 반영이 되는걸 제가 확인했습니다. 혹시 이와 관련해서 아시는 분 있으시면 알려주시면 감사하겠습니다..!


 
예시 9) 롤백됨

public class A {
    @Transactional
    public void test1(Long id) {
        Domain domain = findById(id);
        
        B.test2(domain);
    }
}

public class B {
    @Transactional(noRollbackFor = {RuntimeException.class})
    public void test2(Domain domain) {   
        domain.plus();
        
        throw new RuntimeException("throw error");
    }
}

롤백이 되지 않을 거라 예상했지만 롤백이 발생했습니다. 이유는 A.test1()에서 B.test2()를 호출할 때 이미 A.test1()의 트랜잭션 내에서 실행되고 있기 때문입니다.

A.test1()는 트랜잭션을 시작하고, B.test2() 메서드를 호출하는데, B.test2 메서드는 별도의 트랜잭션을 시작하지 않고 A.test1()의 트랜잭션 내에서 실행되므로 B.test2()에서 발생하는 예외는 A.test1의 트랜잭션에 영향을 미치게 됩니다.

따라서 B.test2()에서 @Transactional(noRollbackFor = {RuntimeException.class})을 설정했더라도 B.test2() 메서드가 실행되는 트랜잭션은 A.test1()의 것이므로 B.test2()에서 발생한 예외가 메서드 호출 스택을 통해 상위 메서드(A.test1())로 전파되어 상위 메서드인 A.test1()에는@Transactional(noRollbackFor = {RuntimeException.class})이 걸려있지 않아 롤백이 발생하게 됩니다.

 
예시 10) 롤백됨

public class A {
    @Transactional(noRollbackFor = {RuntimeException.class})
    public void test1(Long id) {
        Domain domain = findById(id);

        B.test2(domain);
    }
}

public class B {
    @Transactional
    public void test2(Domain domain) {
        domain.plus();
        
        throw new RuntimeException("throw error");
    }
}

A.test1()에서 @Transactional(noRollbackFor = {RuntimeException.class})가 적용되어 있습니다. 하지만 이 설정은 A.test1()에서 발생하는 RuntimeException에만 적용됩니다. A.test1() 메서드 내에서 호출되는 다른 메서드에서 발생하는 RuntimeException에 대해서는 적용되지 않습니다.

따라서 B.test2()에서 발생하는 RuntimeException은 B.test2()의 트랜잭션이 롤백되도록 하며, A.test1()의 @Transaction 설정은 B.test2()의 트랜잭션에 영향을 주지 않습니다.

결국 최종적으로 B.test2() 메서드에서 수행한 domain.plus()의 작업은 B.test2()가 RuntimeException을 발생시키기 때문에 롤백되며, 이는 각 메서드가 별도의 트랜잭션 경계를 가지고, 하나의 메서드 내에서 발생하는 예외가 해당 메서드의 트랜잭션에만 영향을 주기 때문입니다.


 
예시 11) 롤백 안됨

    @Transactional(noRollbackFor = {RuntimeException.class})
    public void test1(Long id) {
        Domain domain = findById(id);

        test2(domain);
    }

    @Transactional
    public void test2(Domain domain) {
        domain.plus();
        
        throw new RuntimeException("throw error");
    }

예시는 8, 9, 10 과는 달리 같은 클래스 내에 존재하는 메서드들입니다.

따라서 test1()에서 test2()를 호출하면 test2()는 test1()의 트랜잭션 컨텍스트에서 실행되기 때문에, 이미 시작된 test1()의 트랜잭션을 사용합니다.

이 경우 test2()에서 발생하는 RuntimeException은 test1()에 영향을 주게 되며, test1()에서는 @Transactional(noRollbackFor = {RuntimeException.class})이 설정되어 있기 때문에 RuntimeException이 발생하더라도 롤백되지 않도록 설정되어 있습니다.

최종적으로, test1()를 호출하면 domain.plus()의 작업이 롤백되지 않으며, 이는 test1()의 트랜잭션 설정이 test2()를 포함하는 전체 트랜잭션에 적용되기 때문입니다.

참고로 위 코드에서 A.test1()과 B.test2()가 같은 트랜잭션에 참여하도록 하려면 B.test2()가 새로운 트랜잭션을 생성하지 않도록 @Transactional(propagation = Propagation.MANDATORY)등을 사용할 수 있습니다. 이러면 A.test1()에서 설정한 트랜잭션 롤백 설정이 B.test2()에도 적용됩니다.


 
예시 12) 롤백됨

public class A {
    @Transactional(noRollbackFor = {RuntimeException.class})
    public void test1(Long id) {
        try {
             Domain domain = findById(id);
             B.test2(domain);
        } catch (Exception e) {
        }
    }
}

public class B {
    @Transactional
    public void test2(Domain domain) {
        domain.plus();

        throw new RuntimeException("throw error");
    }
}

마지막으로 제가 실무에서 실수한 로직과 거의 유사한 코드입니다. 

A.test1()에서 RuntimeException에 대해 롤백을 하지 않도록 설정되어 있지만, 이 설정은 A.test1() 메서드 내에서 직접 발생하는 RuntimeException에 대해서만 적용됩니다. A.test1()에서 호출하는 다른 메서드인 B.test2()에서 발생하는 RuntimeException에 대해서는 적용되지 않기 때문에 B.test2()에서 발생한 RuntimeException에 대해 롤백이 발생하여 domain.plus() 로직은 커밋되지 않습니다. 

즉, 이미 롤백 돼버린 내용을 상위에서 cath를 통해 잡고, noRollbackFor = {RuntimeException.class}를 걸어봤자 소용이 없었다는 말입니다. ㅠㅠ

8번 예시와 동일하게 B.test2()에 @Transactional(propagation = Propagation.REQUIRES_NEW)를 걸었었지만, 업무 환경에서 DeadLock이 발생해서 어쩔 수 없이 이 부분은 제거했었습니다.


 

트랜잭션의 전파와 속성의 전파는 다르다

하지만 아무리 생각해도 궁금증이 풀리지 않은 게 있습니다. 예시 12번과 같이 최상위 메서드에 @Transactional(noRollbackFor = {RuntimeException.class})을 걸어두었는데 왜 하위 메서드에서는 noRollbackFor 옵션이 적용되지 않았을까요? 우리가 알기로는 자식 메서드는 부모 메서드의 트랜잭션에 참가하는 걸로 알고 있는데 말입니다.

이는 우리가 알고 있는 트랜잭션 전파와 트랜잭션의 속성 전파는 다르기 때문입니다. @Transactional의 속성 중 noRollbackFor과 rollbackFor 등의 롤백 관련 속성은 트랜잭션의 속성입니다. 이 트랜잭션 속성들은 메서드에 직접 적용된 @Transactional에서만 읽히고, 호출한 메서드의 트랜잭션 속성에는 영향을 미치지 않습니다.

따라서 마지막 예제가 롤백이 된 이유는 @Transactional의 롤백 관련 속성이 트랜잭션의 속성으로, 다른 메서드의 트랜잭션 속성에 영향을 주지 않기 때문이었습니다.

AbstractPlatformTransactionManager.java:821 부분부터 디버깅을 해보면 알 수 있습니다. completeTransactionAfterThrowing 메서드는 Spring 트랜잭션 관리의 핵심 부분으로, 예외 발생 시 롤백 여부를 결정합니다. 이 메서드는 TransactionAspectSupport 클래스의 메서드로, 예외가 발생했을 때 트랜잭션을 어떻게 처리할지 결정하는데, TransactionAspectSupport 클래스는 트랜잭션 어드바이스를 구현하는 Spring AOP의 핵심 클래스입니다.

Spring의 AOP 관련 클래스와 메서드에서는 join point라는 용어를 자주 사용하는데, joint point는 프로그램 실행 중에 어드바이스(advice)를 적용할 수 있는 위치를 나타냅니다. 따라서 @Transaction 어노테이션이 붙은 메서드는 join point에 해당합니다.

completeTransactionAfterThrowing  디버깅 중

여기서 joinPointIdentification을 잘 보면 package.class.method 형식을 가지고 있는 걸 알 수 있습니다. joinPointIdentification은 현재의 join point를 식별하는 문자열로, 대상 메서드의 이름, 대상 객체의 클래스 이름 등을 포함하고 있습니다.

프록시가 @Transactional 메서드를 호출할 때, 해당 메서드의 joinPointIdentification을 가져와 TransactionAspectSupport의 메서드들에게 제공합니다. 이를 통해 트랜잭션 관리 코드는 현재 처리 중인 트랜잭션에 대한 세부 정보를 알 수 있습니다.

따라서 트랜잭션 속성은 일반적으로 트랜잭션이 전파되는 것과 같이 부모 트랜잭션을 따라가는 것이 아닌, 본인의 메서드에만 적용되는 것을 알 수 있습니다.
 

정리

따라서 여러 개의 트랜잭션을 수행해야 하는데, noRollbackFor을 적용하고 싶다면

  • 그냥 안전하게 모든 메서드에 noRollbackFor 속성을 부여한다.
  • osiv를 꺼놓고 controller단에서 여러 개의 트랜잭션을 호출한다.

정도로 정리할 수 있을 것 같습니다.

 

같이 보면 좋은 글

댓글