Spring Transaction의 Self Invocation

하루히즘·2021년 9월 12일
1

Spring Framework

목록 보기
9/15

서론

최근 프로그래머스 데브코스에서 강의를 듣고 있는데 지난 면접에서 물어봤던 트랜잭션의 전파와 고립 레벨을 다루는 내용이 있었다. 그래서 좀 더 확실하게 알아보고자 @Transactional 어노테이션의 propagation 속성을 활용하여 트랜잭션의 전파를 실습해보는데 문제가 있었다.

본론

정확히는 트랜잭션의 전파 레벨에서 caller와 callee의 트랜잭션 전파 상태가 다를 때(REQUIRED / REQUIRES_NEW) 기존 트랜잭션이 중단(suspend)된다는 것이 어떤 것인지 알아보고자 했다.

@Override
@Transactional
public void transactionTest() {
    customerRepository.save(new Customer("username" + new Random().nextInt(), "alias"));
    transactionTestNested();
    throw new RuntimeException("Rollback customer");
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void transactionTestNested() {
    customerRepository.save(new Customer("nested-username" + new Random().nextInt(), "alias"));
}

그래서 위처럼 transactionTest 메서드에서는 기본값인 REQUIRED 전파 트랜잭션에서 "usernameXXX"라는 이름의 Customer 객체를 생성해서 데이터베이스에 저장하도록 구현했다. 그리고 이 메서드에서 호출하는 transactionTestNested 메서드에서는 기존 트랜잭션을 중단하고 새로운 트랜잭션을 생성하는 REQUIRES_NEW 전파 트랜잭션을 생성하여 "nested-usernameXXX"라는 이름의 Customer 객체를 저장하도록 구현했다.

만약 생각한 것이 맞다면 transactionTest 메서드에서 RuntimeException이 발생하더라도 transactionTestNested 메서드는 기존 트랜잭션이 아닌 새로운 트랜잭션에서 진행되기 때문에 RuntimeException으로 인한 rollback의 영향을 받지 않을 것이라고 생각했다.

하지만 실행 결과는 둘 다 rollback 되었다. 비슷한 동작의 NOT_SUPPORTED 전파도 마찬가지였는데 무엇 때문에 트랜잭션이 나뉘지 않고 하나로 묶이는지 알 수가 없었다. 그래서 데브코스 슬랙에 질문을 올려보니 self-invocation이라는 키워드를 얻을 수 있었다.
이를 기반으로 검색해보니 스택오버플로우에서 다음과 같은 정보를 얻을 수 있었다.

In proxy mode (which is the default), only external method calls coming in through the proxy are intercepted.

위 내용은 Spring Framework Documentation - Data Access - Declarative transaction management - Using @Transactional 문단에서 찾을 수 있다. 좀 더 자세히 읽어보면 target object, 즉 트랜잭션이 적용되는 메서드가 동일한 클래스의 다른 메서드를 호출하는 것을 self-invocation이라 하는 것을 알 수 있다. 그리고 이 경우에는 @Transactional 어노테이션이 적용되어 있더라도 실제로 트랜잭션이 적용되진 않는다.

이는 @Transactional 트랜잭션의 기본 모드인 proxy가 프록시를 통한 접근만 잡아서 처리할 수 있기 때문이다. 스프링의 트랜잭션은 기본적으로 AOP를 활용하여 트랜잭션 대상 객체를 프록시 객체로 생성하고 외부 클라이언트, 위의 코드에서는 transactionTest 메서드를 호출하는 쪽의 접근을 제어하는 방식으로 적용한다.

그렇지만 transactionTestNested 메서드처럼 같은 클래스 내의 메서드를 호출(self-invocation)한다면 클라이언트가 프록시 객체를 통해서 호출한 것이 아니기 때문에 제어할 수 없다. 그렇기 때문에 트랜잭션의 전파가 제대로 반영되지 않는 것이며 이 경우 AspectJ를 활용하거나 TransactionTemplate을 사용하여 직접 트랜잭션을 적용해야 한다.

둘 중 쉬운 예시인 TransactionTemplate을 활용한다면 다음처럼 작성할 수 있다.

public void transactionTest() {
    TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
    transactionTemplate.execute(status -> {
        customerRepository.save(new Customer("do-not-exist-username" + new Random().nextInt(), "alias"));
        transactionTestNested();
        throw new RuntimeException("Rollback customer");
    });
}

public void transactionTestNested() {
    TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
    transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    transactionTemplate.execute(status -> customerRepository.save(new Customer("nested-username" + new Random().nextInt(), "alias")));
}

현재 환경은 스프링 부트기 때문에 트랜잭션을 관리하는 PlatformTransactionManager Bean 객체가 자동으로 등록되어 있다. 이를 주입받아서 TransactionTemplate을 생성하고 execute 메서드로 트랜잭션 내에서 수행할 작업을 지정하는 방식으로 사용할 수 있다.

transactionTestNested 메서드는 REQUIRES_NEW를 사용했기 때문에 TransactionTemplate 객체의 setPropagationBehavior에 enum으로 정의된 전파 유형을 TransactionDefinition.PROPAGATION_REQUIRES_NEW로 전달해서 지정할 수 있다.

실행 결과 의도된대로 transactionTest 메서드에서 등록하는 do-not-exist-username은 RuntimeException에 의해 롤백되지만 transactionTestNested 메서드에서 등록하는 nested-username은 이와 관계없이 잘 등록된 것을 볼 수 있다.

결론

스프링에서 트랜잭션이 어떤 기술(AOP)로 구현되어 있고 해당 기술의 특징(프록시 패턴)이 무엇인지 정확하게 알고 있었다면 금방 원인을 알 수 있지 않았을까 생각한다. 기반 지식이 무엇보다 중요하고 궁금증의 끝은 공식 문서와 소스코드라는 것을 느끼게 되는 계기였다.

profile
YUKI.N > READY?

3개의 댓글

comment-user-thumbnail
2023년 5월 9일

spring Aop의 다이내믹 프록시(혹은 CGLIB)를 통해 호출되는 것이 프록시 참조를 통해서 invocationHandler의 리플렉션 api를 통해서 타깃 오브젝트의 부가행동을 더하는 것으로 알고있는데요

흑구님의 Spring Aop로 동작하기 때문에 프록시 객체 참조형태가 아니라는 말은 혹시 어떤 말일까요? 잘못 말씀하신걸까요? 아니면 제가 잘못 알고 있는 걸까요

2개의 답글