멈재

[Spring] How to use @Transactional well? (부제: Spring Transactional 주의점) 본문

JAVA & Spring & JPA

[Spring] How to use @Transactional well? (부제: Spring Transactional 주의점)

멈재 2023. 6. 25. 04:02
728x90

스프링에서는 트랜잭션 처리를 하는 선언적 트랜잭션 방식인 Transactional 애너테이션을 제공해주고 있고, 해당 애너테이션이 있다면 트랜잭션 AOP의 대상이 되어 트랜잭션 프록시가 적용된다.

트랜잭션이 무엇인지는 [DB] MySQL 트랜잭션 격리 수준 살펴보기 포스팅을 참고해주세요

 
본 포스팅에서는 (나도 그랬고) 스프링을 처음 접하거나 익숙하지 않을 때 Transactional 애너테이션으로 인해 마주할 수 있는 두 가지 문제를 소개하려고 한다.
 
 


 
 
주요 내용은 크게 다음과 같다.

  • 트랜잭션 프록시의 적용 대상과 접근제어자
  • 트랜잭션 AOP 프록시의 내부 호출

 

트랜잭션 프록시의 적용 대상과 접근제어자

자바에는 외부로부터 함부로 접근하지 못하도록 접근제어자가 존재하고 있고 접근제어자의 종류와 허용 범위는 다음과 같다.

이미지 출처: https://blog.uniony.me/java/object-terms/

 
접근 제어자를 설명한 이유는 스프링의 Transactional 애너테이션은 접근 제어자와 영향을 받기 때문이다.
 
아래는 공식 문서에서 일부 발췌한 내용이다.

Method visibility and @Transactional
When you use transactional proxies with Spring’s standard configuration, you should apply the @Transactional annotation only to methods with public visibility. If you do annotate protected, private, or package-visible methods with the @Transactional annotation, no error is raised, but the annotated method does not exhibit the configured transactional settings.
출처: 공식문서 - data-access/transaction/declarative/annotations

요약하면 Transactional 애너테이션은 오직 public 한 메서드에 적용해야 하고, 다른 접근 제어자에 적용할 경우 에러를 발생시키지는 않지만 트랜잭션 설정이 적용되지 않는다고 설명하고 있다.
 
코드로 바로 알아보자.

실행 환경
spring: 5.3.27
spring-boot: 2.7.12

 
트랜잭션 동기화 매니저의 도움을 받아 현재 트랜잭션의 활성화 상태(active)를 응답받아 확인하였다.

@Transactional
public void publicTx() {
    boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
    System.out.println("public: isActive = " + isActive);
}

@Transactional
protected void protectedTx() {
    boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
    System.out.println("protected: isActive = " + isActive);
}

@Transactional
void defaultTx() {
    boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
    System.out.println("default: isActive = " + isActive);
}

@Transactional
private void privateTx() {
    boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
    System.out.println("private: isActive = " + isActive);
}

 
위 예시 코드는 선언적 트랜잭션을 적용하고 접근 제어자를 달리 한 메서드들로 각각의 메서드가 수행이 될 때 트랜잭션이 적용되었는지 확인하는 코드이다.

[실행 결과]
public: isActive = true
protected: isActive = false
default: isActive = false
private: isActive = false

실행 결과를 보게 되면 공식 문서에 설명되어 있던 것처럼 접근 제어자가 public인 메서드만 트랜잭션 활성화가 된 것을 볼 수 있다.
 
 
접근제어자가 private인 메서드는 왜 정상적으로 동작되지 않는 걸까
스프링에서 동적 프록시를 적용하는 방법에는 JDK 기반의 동적 프록시와 CGLib 방식 기반의 동적 프록시를 이용해 AOP를 지원하고 있다.

이미지 출처: https://www.baeldung.com/spring-aop-vs-aspectj

 
 
JDK Dynamic Proxy

  • JDK에서 제공하는 동적 프록시는 인터페이스를 기반으로 한 동적 프록시 기술로 대상(target)이 되는 클래스는 인터페이스를 필수적으로 가져야 함
  • 클래스 정보(필드, 메서드 등)를 런타임에 가져올 수 있는 Reflection API를 활용해 프록시를 생성

 
CGLib 

  • 인터페이스 없이 대상 클래스가 구현 클래스여도 동적 프록시 생성 가능
  • 바이트 코드를 조작해서 프록시를 생성

 
Spring Boot 2.0.0부터 동적 프록시 방식으로 CGLib가 디폴트로 설정되어 적용되고 있다.
그러나 CGLib 방식은 위 이미지처럼 바이트 코드를 조작해 상속을 통한 프록시 생성 방식이기 때문에 메서드의 접근 제어자가 private이라면 상속이 불가능해져서 프록시 생성이 막혀 원하는 대로 동작하지 않은 것이다.
 

Spring Boot에서 Spring AOP를 자동 구성 하기 위한 AopAutoConfiguration에 해당 설정이 적용되어 있고 자세한 내용은 아래 링크를 참고해주세요
 

AOP in Spring Boot, is it a JDK dynamic proxy or a Cglib dynamic proxy?

As we all know, the underlying AOP is dynamic proxies, and there are two ways to implement dynamic proxies in Java: JDK-based dynamic proxy Dynamic proxy based on Cglib The biggest difference between these two is that JDK-based dynamic proxies require the

www.springcloud.io

 
 
그럼 접근제어자가 protected인 메서드는 왜 정상적으로 동작되지 않는 걸까
마찬가지로 공식 문서에 이러한 설명이 존재하고 있다.

For JDK proxies, only public interface method calls on the proxy can be intercepted. With CGLIB, public and protected method calls on the proxy are intercepted (and even package-visible methods, if necessary). However, common interactions through proxies should always be designed through public signatures.
출처: 공식문서 - /core/aop/ataspectj/pointcuts.html#aop-pointcuts-designators
Due to the proxy-based nature of Spring's AOP framework, protected methods are by definition not intercepted, neither for JDK proxies (where this isn't applicable) nor for CGLIB proxies (where this is technically possible but not recommendable for AOP purposes). As a consequence, any given pointcut will be matched against public methods only!
If your interception needs include protected/private methods or even constructors, consider the use of Spring-driven native AspectJ weaving instead of Spring's proxy-based AOP framework.
출처: 공식문서 - 6.2.3.1. Supported Pointcut Designators

두 문단을 요약해보면 다음과 같다.
 
문단 1
JDK Dynamic Proxy는 public interface method만 가로챌 수 있는 반면 CGLib를 사용할 때에는 접근제어자가 public이거나 protected인 메서드(심지어 package-visible 한 메서드도)를 가로챌 수 있다.
하지만 프록시를 통한 상호 작용은 public 한 형태를 통해서만 이뤄지게끔 설계(designed)되어있다.
 
문단 2
Spring AOP Proxy 특성으로 인해 접근 제어자가 protected인 메서드는 정의상 프록시로 가로채지지 않는다.(by definition not intercepted)
자세히는 JDK Dynamic Proxy는 protected인 메서드가 어디에 있든 적용되지 않고 CGLib Proxy는 기술적으로는 가능하지만 AOP 목적상 권장되지 않기 때문이다.
만약 이러한 조건에서도 사용하길 원한다면 AspectJ을 사용하는 걸 고려하라.
 
이러한 이유들로 스프링 트랜잭션은 접근 제어자가 public이 아닌 메서드에는 적용되지 않았던 것이다.
 
 

트랜잭션 AOP 프록시의 내부 호출

Transactional 애너테이션은 클래스 레벨이나 메서드 레벨에 개별적으로 사용할 수 있고 트랜잭션 AOP의 대상이 되어 프록시 객체를 생성하게 된다.
 
인터페이스 기반의 클래스와 구현 클래스가 프록시 객체로 생성되었는지를 확인하는 예시이다.

@Test
void serviceTest() {
    System.out.println("service.getClass() = " + service.getClass());
    System.out.println("targetService.getClass() = " + targetService.getClass());
}

================================================

@TestConfiguration
static class Config {

    @Bean
    public Service service() {
        return new ServiceImpl();
    }

    @Bean
    public TargetService targetService() {
        return new TargetService();
    }
}

@Transactional
static class TargetService {

    public void save() {
        System.out.println("TargetService.save");
    }
}

@Transactional
static class ServiceImpl implements Service {

    @Override
    public void save() {
        System.out.println("ServiceImpl.save");
    }
}

interface Service {
    void save();
}

두 클래스 모두 프록시 객체로 생성된 것을 확인할 수 있다.
Spring AOP는 JDK Dynamic Proxy 또는 CGLIB를 사용하여 지정된 대상 객체에 대한 프록시를 생성하게 된다.
 
트랜잭션 프록시에서 메서드를 호출하는 과정은 다음과 같다.

이미지 출처: https://docs.spring.io/spring-framework/docs/4.2.x/spring-framework-reference/html/transaction.html#tx-decl-explained

 
이러한 호출 과정에서 한 가지 문제가 발생할 수 있다. 그 원인이 바로 내부 호출(self-invocation)로 인한 문제이다.
쉽게 설명하면, 프록시 객체를 거치지 않고 대상 객체의 메서드를 호출할 경우 트랜잭션이 적용되지 않는 문제가 발생할 수 있다.
 
아래 코드로 자세히 알아보자. 코드 블럭에 두 가지 예시가 존재한다.

  1. method1(Transactional O)에서 method3(Transactional O)을 호출
  2. method2(Transactional X)에서 method3(Transactional O)을 호출
static class Service {

    @Transactional
    public String method1() {
        System.out.println("call method1");

        boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
        System.out.println("isActive = " + isActive);

        method3();

        return "ok - method1";
    }

    public String method2() {
        System.out.println("call method2");

        boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
        System.out.println("isActive = " + isActive);

        method3();

        return "ok - method2";
    }

    @Transactional
    public String method3() {
        System.out.println("call method3");

        boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
        System.out.println("isActive = " + isActive);

        return "ok - method3";
    }
}

 
먼저 method1이 method3을 호출하는 예시의 경우 다음과 같은 결과를 얻게 된다.

 
반면 method2가 method3을 호출하는 예시의 경우 다음의 결과를 얻게 된다.

 
두 번째 예시의 경우 트랜잭션이 적용되지 않은 결과가 나온 것을 확인할 수 있다.
 
Service#method2를 실행하면 다음의 과정을 거치게 된다.

  1. 클라이언트가 Service클래스의 method2를 호출
  2. 대상 메서드에 Transactional 애너테이션이 적용되어 있는지 확인하고 없는 경우 실제(target) 메서드 호출
  3. 실제 객체 메서드의 실행 흐름에 따라 실행되므로 method2와 method3은 트랜잭션이 적용되지 않은 채 동작

즉 프록시 객체가 되어도 대상 메서드가 트랜잭션이 적용되지 않았다면 실제 객체에서 내부 호출을 하기 때문에 트랜잭션이 동작되지 않게 된다.
 
공식 문서에는 다음과 같이 설명한다.

In proxy mode (which is the default), only external method calls coming in through the proxy are intercepted. This means that self-invocation, in effect, a method within the target object calling another method of the target object, will not lead to an actual transaction at runtime even if the invoked method is marked with @Transactional.

프록시 모드가 기본값이라면 프록시를 통해 들어오는 외부 메서드 호출만을 가로채게 된다. 이 말은 실제(target) 객체의 메서드가 다른 메서드를 내부 호출할 때 그 대상이 @Transactional이 적용되어 있어도 트랜잭션이 적용되지 않는다는 의미이다.
 
 

이 문제는 어떻게 해야 할 수 있을까?

가장 좋은 방법은 내부 호출이 일어나지 않는 코드로 리펙터링 하는 것이다.
 
그 외에 세 가지 방법이 있는 것 같다.
 
1. Self Injection
다소 생소한 방법인 자기 자신을 주입하는 방법이다.

@Service
public class MyService {

    private MyService self;

    @Autowired
    public MyService(MyService self) {
        this.self = self;
    }

    public void selfMethod() {
        self.method();
    }

    @Transactional
    public void method() {
    	// called
    }
}

 
그러나 Self Injection는 순환 참조 문제가 발생할 수 있어 문제 될 수 있다.

 
 
2. 클래스 내 로직을 Spring AOP로 연결하는 방법
도큐먼트에 끔찍한(horrendous) 방법이라고 설명할 정도로 권장하지 않는 방법이니 이런 방법도 있다는 것만 알아두면 될 것 같다. 해당 링크로 이동
 
 
3. 또 다른 추상화 계층을 만들어 메서드를 분리하고 외부 호출로 변경
이 방법이 어쩔 수 없이 내부 호출이 일어나야 하는 경우에 선택하기 가장 적절한 방법이라고 생각한다.
 
한 가지 예시 상황을 만들어봤다.
유료 강의를 구매하는 기능이 있고, 각각의 세부 기능은 다음과 같다.

  • 기능 1. 강의 구매자 수 증가, 구매 내역 생성(OrderService)
  • 기능 2. 데이터 통계 용도로 사용하기 위해 구매 요청 내역을 로그에 기록 (LogService)
  • 기능 3. 아직 사용자가 적고 초기 서비스라 구매 요청 발생 정보를 슬랙 알림(Notification)이 오도록 설정 (NotificationService)

 

  • 조건 1. 각각의 기능에 대해서는 실행되거나 실행되지 않아야 하고, 모든 기능이 Atomic하게 묶이지 않아도 된다.
  • 조건 2. '강의 구매'에 세 개의 서비스를 호출해야 하기 때문에 퍼사드 패턴을 적용하여 하나의 클래스로 관리한다.

 
세 가지 기능과 두 가지의 조건에 따라 다음과 같은 코드를 작성할 수 있다.

import static org.springframework.transaction.support.TransactionSynchronizationManager.*;

@RequiredArgsConstructor
static class Facade {

    private final OrderService orderService;
    private final LogService logService;
    private final NotificationService notificationService;

    void logic() {
        System.out.println("Facade.isActive = " + isActualTransactionActive());

        orderService.create();
        logService.log();
        notificationService.send();
    }

}

static class OrderService {

    @Transactional
    public void create() {
        System.out.println("OrderService.isActive = " + isActualTransactionActive());

        // do something...
        System.out.println("강의 구매자 수 증가");

        // do something...
        System.out.println("구매 내역 생성");
    }
}

static class LogService {

    @Transactional
    public void log() {
        System.out.println("LogService.isActive = " + isActualTransactionActive());
        System.out.println("[Log] 구매 요청 내역");
    }
}

static class NotificationService {

    @Transactional
    public void send() {
        System.out.println("NotificationService.isActive = " + isActualTransactionActive());

        // do something...
        System.out.println("슬랙 알림 전송");
    }
}

 
해당 코드를 실행하게 되면 다음의 실행 결과를 확인할 수 있다.

@Test
void facadeTest() {
    facade.logic();
}
[실행 결과]
Facade.isActive = false
=========================
OrderService.isActive = true
강의 구매자 수 증가
구매 내역 생성
=========================
LogService.isActive = true
[Log] 구매 요청 내역
=========================
NotificationService.isActive = true
슬랙알림 전송

 
실제(target) 객체의 메서드에서 호출했음에도 불구하고 각각의 기능에 대해서 트랜잭션이 적용된 것을 볼 수 있다.
즉 메서드의 책임을 별도의 클래스를 만들어 분리하여 내부 호출이 아닌 외부 호출로 동작하게 만들어 정상적인 과정이 일어나도록 한다.
 
 

잘못 번역되거나 틀린 내용이 있는 경우 댓글에 남겨주세요 :)