@Transactional 과 PROXY

신명철·2022년 4월 17일
10

JPA

목록 보기
8/14

들어가며

@Transactional에는 Spring AOPProxy방식이 사용된다. 그렇기 때문에 우선 Proxy에 대해서 알아보자.

프록시 패턴을 사용하는 이유 ?

프록시 객체는 원래 객체를 감싸고 있는 객체로, 원래 객체와 타입은 동일하다. 프록시 객체가 원래 객체를 감싸서 client의 요청을 처리하게 하는 패턴이다.

  • 접근 권한을 부여할 수 있다
  • 부가 기능을 추가할 수 있다

IoC 컨테이너와 AOP Proxy

Spring에는 크게 두 가지 프록시 구현체를 사용한다. JDK PROXY(=Dynamic PROXY)CGLib이다. Spring AOP는 PROXY의 매커니즘을 기반으로 AOP PROXY를 제공하고 있다.

위 그림처럼 Spring AOP는 사용자의 특정 호출 시점에 IoC 컨테이너에 의해 AOP를 할 수 있는 Proxy Bean을 생성해준다. 동적으로 생성된 Proxy Bean은 타깃의 메서드가 호출되는 시점에 부가기능을 추가할 메서드를 자체적으로 판단해 가로채어 부가기능을 주입한다. 이를 호출시점에 동적으로 위빙을 한다해 런타임 위빙(Runtime Weaving)이라고 한다.

Spring AOP는 런타임 위빙 방식을 기반으로 하고 있고, Spring 에서는 상황에 따라 JDK ProxyCGLib방식을 통해 Proxy Bean을 생성해준다.

JDK PROXY와 CGLib를 선택하는 기준

이 두 가지AOP Proxy를 생성하는 기준은 자체 검증 로직을 통해 타깃(Target)의 인터페이스 유무를 판단하는 것이다.

Spring AOP와 JDK Dynamic Proxy

JDK ProxySpring AOP의 근간이 된다.JDK Proxy는 JAVA의 reflectionProxy클래스가 동적으로 Proxy를 생성해준다. 이 클래스를 사용하기 위한 몇 가지 조건이 있지만 핵심은 타겟의 인터페이스를 기준으로 Proxy를 생성해준다는 점이다.

JDK Proxy의 Proxy

JDK Proxy가 Proxy 객체를 생성하는 방식은 다음과 같다.

  1. 타겟의 인터페이스를 검증해 ProxyFactory에 의해 타겟의 인터페이스를 상속한 Proxy객체를 생성한다.
  2. Proxy객체에 InvocationHandler를 포함시켜서 하나의 객체로 반환한다.

다음과 같이 Proxy를 생성하는 과정에서 핵심적인 부분은 무엇보다 인터페이스를 기준으로 Proxy객체를 생성한다는 점이다. 따라서 구현체는 인터페이스를 상속해야 하고, @Autowired를 통해, 생성된 Proxy Bean을 사용하기 위해서는 반드시 인터페이스의 타입으로 지정해줘야 한다.

이런 Proxy의 구조를 이해하지 못한다면 다음과 같은 상황이 벌어질 수 있다.

@Controller
public class UserController{
  @Autowired
  private MemberService memberService; // <- Runtime Error 발생...(Interface가 아닌 Class 타입으로 DI를 헀다)
  ...
}

@Service
public class MemberService implements UserService{
  @Override
  public Map<String, Object> findUserId(Map<String, Object> params){
    ...isLogic
    return params;
  }
}

MemberService 클래스는 인터페이스를 상속받고 있기 때문에 SpringJDK Proxy방식으로 Proxy Bean을 생성한다. 그렇기 때문에 위 코드를 실행하면 RuntimeException이 발생한다.

@Autowired MemberService memberService가 인터페이스 타입이 아니기 때문이다. 즉, UserService userService로 형식을 변경해줘야 한다.

인터페이스 기준과 내부 검증 코드

Proxy패턴은 접근제어의 목적으로 Proxy를 구성한다는 점도 중요하지만, 무엇보다 사용자의 요청이 기존의 타겟을 그대로 바라볼 수 있도록 타겟에 대한 위임코드를 Proxy객체에 작성해줘야 한다. 이 위임코드를 InvocationHandler에 작성해준다.

사용자의 요청이 최종적으로 생성된 Proxy의 메서드를 통해서 호출될 때 내부적으로 invoke에 대한 검증과정이 이뤄진다. 다음 코드를 참조하자.

public Object invoke(Object proxy, Method proxyMethod, Object[] args) throws Throwable {
  Method targetMethod = null;
  // 주입된 타깃 객체에 대한 검증 코드
  if (!cachedMethodMap.containsKey(proxyMethod)) {
    targetMethod = target.getClass().getMethod(proxyMethod.getName(), proxyMethod.getParameterTypes());
    cachedMethodMap.put(proxyMethod, targetMethod);
  } else {
    targetMethod = cachedMethodMap.get(proxyMethod);
  }

  // 타깃의 메소드 실행
  Ojbect retVal = targetMethod.invoke(target, args);
  return retVal;
}

이러한 검증과정이 필요한 까닭은 Proxy가 인터페이스에 대해서만 Proxy를 생성하기 때문이다. 따라서 타겟에 대한 정보가 잘못 주입된 경우를 대비해 JDK Proxy는 내부적으로 주입된 타겟에 대해서 검증코드를 형성한다.

CGLib(Code Generator Library)

CGLib는 클래스의 바이트 코드를 조작해 Proxy객체를 생성해주는 라이브러리이다.

SpringCGLib를 사용해 인터페이스가 아닌 클래스에 대해서도 Proxy를 생성해준다. CGLibEnhancer라는 클래스를 통해 Proxy를 생성할 수 있다.

Enhancer enhancer = new Enhancer();
         enhancer.setSuperclass(MemberService.class); // 타깃 클래스
         enhancer.setCallback(MethodInterceptor);     // Handler
Object proxy = enhancer.create(); // Proxy 생성

이 과정에서 CGLib는 타겟 클래스에 포함된 모든 메서드를 재정의해서 Proxy를 생성해준다. 이 때문에 CGLibfinal 메서드 또는 클래스에 대해서 재정의를 할 수 없기 때문에 Proxy 를 생성할 수 없다는 단점이 있지만, CGLib는 바이트 코드를 조작해서 Proxy를 생성하기 때문에 성능적으로 JDK Proxy보다 좋다.

성능 차이의 근본적인 이유는 CGLib는 타겟에 대한 정보를 제공받기 때문이다. 따라서, CGLib는 제공받은 타겟 클래스에 대한 바이트 코드를 조작해 Proxy를 생성하기 때문에 Handler안에서 타겟의 메서드를 호출할 때 다음과 같은 코드가 형성된다.

public Object invoke(Object proxy, Method proxyMethod, Object[] args) throws Throwable {
  Method targetMethod = target.getClass().getMethod(proxyMethod.getName(), proxyMethod.getParameterTypes());
  Ojbect retVal = targetMethod.invoke(target, args);
  return retVal;
}
  1. 메서드가 처음 호출되었을 때 동적으로 타겟의 클래스의 바이트 코드를 조작한다.
  2. 이후 호출시엔 조작된 바이트 코드를 재사용한다.

Spring의 Proxy 객체 생성 방법 정리
1. 인터페이스를 구현하고 있는지 확인
2. 인터페이스 구현 -> JDK Proxy
3. 인터페이스 미구현 -> CGLib


@Transactional 의 동작 원리

@Transactional은 AOP를 사용하여 구현된다. transaction 의 begincommit을 메인 로직 앞 뒤로 수행해주는 기능을 담당한다.

@Transactional가 붙은 메서드가 호출되기 전 begin을 호출하고, 메서드가 종료되고 commit을 호출한다. 이 때 Spring AOP는 기본적으로 PROXY 패턴을 사용한다.

Spring AOP, CGLib와 JDK Proxy

Spring boot는 프록시 객체를 생성할 때 기본적으로 CGLib를 사용한다. 그 이유는 JDK Proxy는 내부적으로 Reflection을 사용하기 때문이다. Reflection은 비용이 비싼 효율성이 떨어지는 API이고, JDK Proxy는 타겟으로 인터페이스만을 허용하기 때문이다.

의무적으로 서비스 계층에서 인터페이스를 xxxxImpl클래스로 작성하던 관례도 다 JDK Proxy의 특성 때문이기도 하다.

JDK ProxyInvocationHandlerinvoke메서드를 오버라이딩 해서 Proxy위임 기능을 수행하는데, 이 때 메서드에 대한 명세를 가져올 때 Refelction이 사용된다.

@Transactional 사용 주의점

@Transactional은 Proxy 형태로 동작한다.

1. private은 @Transactional이 적용되지 않는다.

@Transactional // -> 오류 발생
private void createUser(){
	// createUser 로직
}

@Transactional은 Proxy 형태로 동작하기 때문에 외부에서 접근이 가능한 메서드만 설정할 수 있다.

2. 같은 클래스 내 여러 @Transactional method 호출

@Transactional
public void createUserListWithTrans(){
    for (int i = 0; i < 10; i++) {
        createUser(i);
    }
}

@Transactional
public User createUser(int index){
    User user = User.builder()
            .name("testname::"+index)
            .email("testemail::"+index)
            .build();
    
    userRepository.save(user);
    return user;
}

위 코드는 실행하면 10번의 createUser가 실행되지만 User는 생성되지 않는다. 그 이유는 @Transactional이 Proxy 형태로 동작하기 때문이다. JPA가 AOP를 사용해서 생성한 Proxy 객체는 다음과 같은 코드의 형태를 가질 것이다.

public void createUserListWithTrans(){
        EntityTransaction tx = em.getTransaction();
        tx.begin();
        
        super.createUserListWithTrans();
        
        tx.commit();
}

public User createUser(int index){
        EntityTransaction tx = em.getTransaction();
        tx.begin();
        
        User user = super.createUser(index);
        
        tx.commit();    
        
        return user;
}

Proxy 객체에서 UserServicecreateUserListWithTrans를 호출하고, createUserListWithTrans는 그 안에서 같은 클래스의 createUser를 호출하기 때문에 createUserListWithTransTransactio만 동작하게 된다.

Proxy 형태로 동작하게 되면 위 과정대로 동작하기 때문에 최초 진입점인 createUserListWithTransTransaction만 동작하게 되는 것이다.

만약 진입점의 @Transactional이 없다면 ?

// UserService.java

// No Transaction
public void createUserListWithoutTrans(){
    for (int i = 0; i < 10; i++) {
        createUser(i);
    }
    throw new RuntimeException();
}
    
@Transactional
public User createUser(int index){
    User user = User.builder()
            .name("testname::"+index)
            .email("testemail::"+index)
            .build();
    
    userRepository.save(user);
    return user;
}


// AopApplication.java
userService.createUserListWithoutTrans();

위 코드는 아래와 같이 동작할 것이다.

// UserService.java
public void createUserListWithoutTrans(){
    for (int i = 0; i < 10; i++) {
        this.createUser(i);
    }

    throw new RuntimeException();
}

public User createUser(int index){
    User user = User.builder()
            .name("testname::"+index)
            .email("testemail::"+index)
            .build();
    
    userRepository.save(user);
    return user;
}




// UserService359caca0.java (proxy객체)
public void createUserListWithoutTrans(){
    super.createUserListWithTrans();
    // 진입 시점에 @Transactional이 없기 때문에 트랜잭션없이 동작
}

실행 시 10개의 user가 생성된다. 오히려 @Transactional이 없기 때문에 createUser가 각각 insert하면서 DB의 설정대로 auto commit 까지 동작한 결과다.

@Transactional 사용시 주의사항
1. private method 에 사용할 수 없음
2. 서로 다른 @Transactional method 는 서로를 호출해서 사용할 수 없음


출처

profile
내 머릿속 지우개

1개의 댓글

comment-user-thumbnail
2023년 6월 18일

안녕하세요, 정리해주신 글 너무 잘 읽었습니다. 많은 도움이 되었어요!
다름이 아니라 중간에 Spring AOP 의 프록시 객체 생성에 사용되는 기본 기술이 CGLib 라고 기재되어 있는데, 공식 문서를 참고해보니 Dynamic Proxy 를 사용한다고 나와 있어서요.
한번 참고 부탁드립니다!
https://docs.spring.io/spring-framework/reference/core/aop/introduction-proxies.html

답글 달기