@Transactional 동작 원리

들어가며

 

해당 포스팅은 Spring boot 2.2.0.RELEASE 환경에서 진행됐다.

 

 

@Transactional이란?

 

비즈니스로직이 트랜잭션 처리를 필요로할 때 트랜잭션 처리 코드가 비즈니스 로직과 공존한다면 코드 중복이 발생하고 비지니스 로직에 집중 또한 힘들어질 수 있다.

 

@Transactional은 이러한 문제를 해결해주는 Spring이 제공하는 어노테이션으로 @Transactional을 메서드 또는 클래스에 명시하게 되면 특정 메서드 또는 클래스가 제공하는 모든 메서드에 대해 내부적으로 AOP를 통해 트랜잭션 처리코드가 전 후 로 수행된다.

 

 

AOP(Aspect Oriented Programming)이란?

 

AOP는 핵심기능 코드에 존재하는 공통된 부가기능 코드를 독립적으로 분리해주는 기술이다.

부가기능을 어드바이스(Advice)라 부르고 부가기능이 부여될 타깃을 선정하는 룰을 포인트컷(Point Cut)이라 부른다.

Spring 진영에서는 어드바이스, 포인트컷을 통틀어 어드바이저라 부르며 어드바이저는 아주 단순한 형태의 에스펙트(Aspect)라 부를 수 있다.

에스펙트란 핵심기능에 부가되는 특별한 모듈을 뜻하며 이 에스펙트를 통해 애플리케이션을 설계하여 부가기능을 분리하며 개발하는 방법을 관점 지향 프로그래밍(AOP) 이라 부른다.

 

즉, OOP를 돕는 보조적인 기술이다.

 

AOP에 대한 추가적인 용어 및 자세한내용은 생략한다.

 

AOP는 일반적으로 두가지 방식이 있다.

  • JDK Dynamic Proxy 방식
  • CGLib 방식

 

 

JDK Dynamic Proxy 방식이란?

 

 

왼쪽은 @Transactional을 적용하기전 상태이며, 오른쪽은 @Trnasactional이 적용되고 JDK Dynamic Proxy 방식 AOP로 동작했을 때의 모습이다.

트랜잭션 처리를 다이나믹 프록시 객체에 대신 위임한다.

다이나믹 프록시 객체는 타깃이 상속하고있는 인터페이스를 상속 후 추상메서드를 구현하며 내부적으로 타깃 메서드 호출 전 후로 트랜잭션 처리를 수행한다. Controller는 타깃의 메서드를 호출하는것으로 생각하지만 실제로는 프록시의 메서드를 호출하게된다. 

 

 

트랜잭션 처리 이외에도 로깅 처리를 비즈니스 로직을 가진 타깃에 부여하고싶으면 위의 그림과 같이 제공하면 될 것이다.

트랜잭션과 로깅 처리는 부가기능일 뿐이며 이러한 부가기능을 독립적으로 추출하고 핵심 기능을 가진 타깃에 부여하는 기술이 앞서 말했던 AOP다.

즉 AOP는 프록시 패턴으로 Controller가 타깃 메서드에 대한 접근을 컨트롤하고 부가기능을 입맛에 맞게 데코레이션 할 수 있는 데코레이터 패턴을 적용했다고 볼 수 있다.

 

 

그런데 왜 프록시 앞에 다이나믹이란 단어가 붙을까?

 

부가 기능을 핵심 기능과 독립적으로 분리하는것 까지는 좋지만 부가 기능을 가진 프록시 객체를 개발자가 일일이 생성한다면 비효율적일것이다.

이러한 프록시 객체를 개발자 대신 런타임 시점에 동적으로 만들어주기 때문에 프록시 앞에 다이나믹이란 단어가 붙는다.

 

JDK Dynamic Proxy 방식은 Java의 리플렉션 패키지에 존재하는 Proxy 클래스 통해 동적으로 다이나믹 프록시 객체를 생성한다.

 

 

타깃이 인터페이스를 상속하고 있지 않다면 어떻게 프록시 객체를 만들까?

 

CGLib 방식은 런타임 시점에 JDK Dynamic Proxy와 다른 방법을 사용해 인터페이스가 없으면 다이나믹 프록시 객체를 생성하지 못하는 문제를 해결한다.

 

 

CGLib 방식이란?

 

CGLib는 Java 리플랙션 대신 바이트 코드 생성 프레임워크를 사용하여 런타임 시점에 프록시 객체를 만드는 방식이다.

타깃오브젝트가 인터페이스를 상속하고 있지 않는 다면 CGLib를 사용하여 인터페이스 대신 타깃오브젝트를 상속하는 프록시 객체를 만든다.

 

@Trnasactional이 두가지 방식의 AOP로 처리될 수 있다는것을 알았으니 실제 내부적으로 어떻게 다이나믹 프록시 객체가 생성되나 살펴보자.

 

 

실제 코드로 보는 다이나믹 프록시 객체 생성 과정

 

트랜잭션 관련 로그를 보기위해 application 파일에 트랜잭션 패키지 로그 레벨을 아래와 같이 설정했다.

 

logging:
    level:
        org.springframework.transaction: TRACE

 

예를 들어 아래와 같이 @Transactional을 선언한 타깃이 있을때

 

public class PickMe {
    
    @Transactional
    void pick() {
    	//핵심 로직
    }
}

 

런타임 시점에 transactional method가 추가 됐다는 다음과 같은 로그를 볼 수 있다.

 

[2021-05-26 14:20:37,732][TRACE][org.springframework.transaction.annotation.AnnotationTransactionAttributeSource] : Adding transactional method 'com.test.my.service.PickMe.pick' with attribute: PROPAGATION_REQUIRED,ISOLATION_DEFAULT

 

위의 로그는 아래에서 로깅되며

 

public abstract class AbstractFallbackTransactionAttributeSource implements TransactionAttributeSource {

...

    @Nullable
    public TransactionAttribute getTransactionAttribute(Method method, @Nullable Class<?> targetClass) {
        ...
        
        TransactionAttribute txAttr = this.computeTransactionAttribute(method, targetClass);
        
        ...
        
        if (this.logger.isTraceEnabled()) {
            this.logger.trace("Adding transactional method '" + methodIdentification + "' with attribute: " + txAttr);
        }
		
        ...
        
        return txAttr;
    }
    
 ...
 }

 

getTransactionAttribute 메서드를 누가 호출하나 쭉 따라가보자.

 

abstract class TransactionAttributeSourcePointcut extends StaticMethodMatcherPointcut implements Serializable {
   
    ...

    public boolean matches(Method method, Class<?> targetClass) {
        TransactionAttributeSource tas = this.getTransactionAttributeSource();
        return tas == null || tas.getTransactionAttribute(method, targetClass) != null;
    }
    
    ...
 }

 

 

matches 라는 메서드 명에서 targetClass가 어드바이스가 적용될 수 있는 포인트컷에 해당되는 타깃인지 검증해보는것을 유추할 수 있다.

 

public abstract class AopUtils {
    
    ...
    
    public static List<Advisor> findAdvisorsThatCanApply(List<Advisor> candidateAdvisors, Class<?> clazz) {
        if (candidateAdvisors.isEmpty()) {
            return candidateAdvisors;
        } else {
            List<Advisor> eligibleAdvisors = new ArrayList();
            Iterator var3 = candidateAdvisors.iterator();

            while(var3.hasNext()) {
                Advisor candidate = (Advisor)var3.next();
                if (candidate instanceof IntroductionAdvisor && canApply(candidate, clazz)) {
                    eligibleAdvisors.add(candidate);
                }
            }

            boolean hasIntroductions = !eligibleAdvisors.isEmpty();
            Iterator var7 = candidateAdvisors.iterator();

            while(var7.hasNext()) {
                Advisor candidate = (Advisor)var7.next();
                if (!(candidate instanceof IntroductionAdvisor) && canApply(candidate, clazz, hasIntroductions)) {
                    eligibleAdvisors.add(candidate);
                }
            }

            return eligibleAdvisors;
        }
    }
    
    
    public static boolean canApply(Advisor advisor, Class<?> targetClass, boolean hasIntroductions) {
        if (advisor instanceof IntroductionAdvisor) {
            return ((IntroductionAdvisor)advisor).getClassFilter().matches(targetClass);
        } else if (advisor instanceof PointcutAdvisor) {
            PointcutAdvisor pca = (PointcutAdvisor)advisor;
            return canApply(pca.getPointcut(), targetClass, hasIntroductions);
        } else {
            return true;
        }
    }
    
    
    public static boolean canApply(Pointcut pc, Class<?> targetClass, boolean hasIntroductions) {
        Assert.notNull(pc, "Pointcut must not be null");
        ...
        MethodMatcher methodMatcher = pc.getMethodMatcher();
        ...
        
        else if (methodMatcher.matches(method, targetClass)) {
           return true;
        }
        
        ...
    }
    
    ...
}

 

matches 메서드는 AopUtils에서 호출하고 있고 AopUtils의 canApply 메서드는 matches에 어드바이저가 포함하고있는 포인트컷의 정보를 넘기고 있다.

 

 findAdvisorsThatCanApply 메서드는 canApply 메서드를 호출하며 넘겨받은 candidateAdvisors 중에 적용가능한 타깃이 있는지 검증한다.

 

public abstract class AbstractAdvisorAutoProxyCreator extends AbstractAutoProxyCreator {

    ...
    
    @Nullable
    protected Object[] getAdvicesAndAdvisorsForBean(Class<?> beanClass, String beanName, @Nullable TargetSource targetSource) {
        List<Advisor> advisors = this.findEligibleAdvisors(beanClass, beanName);
        return advisors.isEmpty() ? DO_NOT_PROXY : advisors.toArray();
    }
    
    protected List<Advisor> findCandidateAdvisors() {
        Assert.state(this.advisorRetrievalHelper != null, "No BeanFactoryAdvisorRetrievalHelper available");
        return this.advisorRetrievalHelper.findAdvisorBeans();
    }
    
    protected List<Advisor> findEligibleAdvisors(Class<?> beanClass, String beanName) {
        List<Advisor> candidateAdvisors = this.findCandidateAdvisors();
        List<Advisor> eligibleAdvisors = this.findAdvisorsThatCanApply(candidateAdvisors, beanClass, beanName);
        this.extendAdvisors(eligibleAdvisors);
        if (!eligibleAdvisors.isEmpty()) {
            eligibleAdvisors = this.sortAdvisors(eligibleAdvisors);
        }

        return eligibleAdvisors;
    }

    protected List<Advisor> findAdvisorsThatCanApply(List<Advisor> candidateAdvisors, Class<?> beanClass, String beanName) {
        ProxyCreationContext.setCurrentProxiedBeanName(beanName);

        List var4;
        try {
            var4 = AopUtils.findAdvisorsThatCanApply(candidateAdvisors, beanClass);
        } finally {
            ProxyCreationContext.setCurrentProxiedBeanName((String)null);
        }

        return var4;
    }

    ...

}

findEligibleAdvisors 메서드에서 findCandidateAdvisors 메서드를 호출하여 findCandidateAdvisors 메서드는 BeanFactoryAdvisorRetrievalHelper를 통해 Advisor.class 타입으로 등록된 Bean 을 모두 조회한 후 이 Bean 들은 Advisor가 적용될 수 있는 후보들로써 findAdvisorsThatCanApply 메서드 호출시 인수로 넘겨준다.

 

public abstract class AbstractAutoProxyCreator extends ProxyProcessorSupport implements SmartInstantiationAwareBeanPostProcessor, BeanFactoryAware {

   ...
   
   public Object postProcessAfterInitialization(@Nullable Object bean, String beanName) {
        if (bean != null) {
            Object cacheKey = this.getCacheKey(bean.getClass(), beanName);
            if (this.earlyProxyReferences.remove(cacheKey) != bean) {
                return this.wrapIfNecessary(bean, beanName, cacheKey);
            }
        }

        return bean;
   }
    

   protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
        if (StringUtils.hasLength(beanName) && this.targetSourcedBeans.contains(beanName)) {
            return bean;
        } else if (Boolean.FALSE.equals(this.advisedBeans.get(cacheKey))) {
            return bean;
        } else if (!this.isInfrastructureClass(bean.getClass()) && !this.shouldSkip(bean.getClass(), beanName)) {
            Object[] specificInterceptors = this.getAdvicesAndAdvisorsForBean(bean.getClass(), beanName, (TargetSource)null);
            if (specificInterceptors != DO_NOT_PROXY) {
                this.advisedBeans.put(cacheKey, Boolean.TRUE);
                Object proxy = this.createProxy(bean.getClass(), beanName, specificInterceptors, new SingletonTargetSource(bean));
                this.proxyTypes.put(cacheKey, proxy.getClass());
                return proxy;
            } else {
                this.advisedBeans.put(cacheKey, Boolean.FALSE);
                return bean;
            }
        } else {
            this.advisedBeans.put(cacheKey, Boolean.FALSE);
            return bean;
        }
    }

   ...
   
}

 

getAdvicesAndAdvisorsForBean 메서드는 wrapIfNecessary 메서드에서 호출되며 getAdvicesAndAdvisorsForBean의 응답값이 DO_NOT_PROXY가 아니라면  프록시 객체를 만들어서 반환한다.

 

여기서 만들어지는 프록시 객체는 JDK Dynamic Proxy 또는 CGLib 방식 중 설정된 한 방식으로 만들어진다. Spring boot는 기본적으로 proxy-target의 값이 true이므로 CGLib를 사용하여 타깃이 인터페이스를 상속하건 말건 모두 수용하여 타깃을 상속하는 프록시 객체를 생성한다. 인터페이스가 없을때만 CGLib를 사용하고 이외에 JDK Dynamic Proxy 방식을 사용하고 싶다면 아래와 같이 application 파일에 proxy-target 값을 false로 주면된다.

 

spring:
    aop:
      proxy-target-class: false

 

postProcessAfterInitialization 메서드를 호출하는 더 상위 코드를 따라가보면 Bean을 관리하는 스프링 컨테이너는 핵심 기능을 가지고있는 타깃 클래스를 Bean 으로 생성하고나서 Bean 후 처리기라는 곳에서 생성된 Bean 마다 어드바이스를 부여할 수 있는지 포인트컷으로 판단하며 부여가 가능하다면 다이나믹 프록시 객체를 생성한다.

최종적으로 Bean 후 처리 까지 이뤄진 타깃 빈을 반환할 때는 실제 타깃이 아닌 프록시 객체를 반환하여 흔히 말해 바꿔치기하여 클라언트를 속인다.

 

 

요약

 

기본적으로 트랜잭션기능이 담긴 어드바이스는 이미 등록되어있으며 @Transactional을 타깃에 명시하면 포인트컷 정보로 등록된다. 그리고 이 어드바이스와 포인트컷을 가지는 어드바이저는 Bean으로 등록된다. Bean 후처리기는 타깃 Bean이 생성된 직후 어드바이저 Bean을 조회 후 생성된 타깃 Bean에 어드바이스가 적용될지 포인트컷으로 판단하고 판단결과에 따라 타깃 Bean에 프록시 객체로 대신 치환된다.

 

즉 클라이언트는 타깃 Bean을 주입 받고 타깃의 메서드를 호출하는것 처럼 보이겠지만 실제는 프록시 객체를 주입받고 프록시 객체 메서드를 먼저 호출하게 되는것이다.

 

 

JDK Dynamic Proxy, CGLib의 다이나믹 프록시 객체 생성 방식 이외에 다른 방식은 없을까?

 

 

AspectJ

 

AOP 기술의 원조 프레임워크인 AspectJ 존재한다. Spring AOP 포인트컷 표현식 사용시 AspectJ AspectHExpressionPointcut 차용해서 사용할만큼 매우성숙하고 발전한 AOP 기술이다.

AspectJ 프록시처럼 간접적인방법이 아닌 컴파일 시점에 컴파일된 타깃 클래스파일 자체를 수정하거나 클래스가 JVM 로딩되는 시점을 가로채서 바이트코드를 수정하여 부가기능이 비즈니스 로직과 같이 있는것처럼 만들어버린다.

 

AspectJ는 다음과 같은 장, 단점이 존재한다.

 

장점 

  • Spring 컨테이너의 DI 도움을 받아 다이나믹 프록시 객체를 생성하지 않아도 되므로 스프링과 같은 컨테이너가 없는 환경에서도 적용이 가능하다.
  • 다이나믹 프록시 방식은 타깃 오브젝트의 메서드에 한해 부가기능을 부여 있지만 AspectJ 바이트를 직접 조작하므로 필드 , 스태틱 초기화, 오브젝트 생성, 필드 값의조회 등에 부가기능을 부여할 수있다.

 

단점

 

AspectJ 같은 고급 AOP기술은 바이트 코드 조작을 위해 JVM 실행 옵션을 변경하거나, 별도의 바이트코드 컴파일러를 사용하는 번거로운 작업이 따르며, 일반적으로 AOP 적용하는 데는 다이나믹 프록시 방식의 스프링 AOP로도 충분하다.

 

 

참고

 

토비의 스프링 3.1 Vol 1, 2