스프링 @Transactional에서의 proxy

후니팍·2023년 11월 27일
0
post-thumbnail

데이터베이스 트랜잭션만 신경쓰고, 어플리케이션에서 트랜잭션은 깊게 생각해보지 못했습니다.
최근 큰 코를 다쳐 잘못 알고있었던 개념에 대해 정리해보고자 합니다.

직접 트랜잭션

    public void transaction() throws SQLException {
        Connection connection = null;
        try {
            connection = dataSource.getConnection();
            connection.setAutoCommit(false);
            PreparedStatement preparedStatement = connection.prepareStatement(
                    "INSERT INTO item (name, price, stock_quantity) VALUES (?, ?, ?)");
            preparedStatement.setString(1, "트랜잭션 상품");
            preparedStatement.setInt(2, 1000);
            preparedStatement.setInt(3, 1);
            preparedStatement.executeUpdate();
            connection.commit();
        } catch (SQLException e) {
            if (connection != null) {
                connection.rollback();
            }
        } finally {
            if (connection != null) {
                connection.close();
            }
        }
    }

위 코드와 같이 connection.setAutoCommit(false);를 설정하면 자동 커밋 옵션이 해제됩니다.
이렇게 item을 생성하는 것에 대해 직접 트랜잭션을 적용하게 되면, rollback과 commit을 직접 설정할 수 있습니다.

문제점

하지만 만약 하나의 트랜잭션이 조금 커지면 어떻게 될까요?
코드가 길어질거고, transaction 설정 부분을 service 계층에 두어야하는 상황이 발생합니다.
service 부분까지 dataSource가 침입하게 되죠.
(비즈니스 로직 + 트랜잭션 로직) 두 로직을 함께 처리해야하기 때문에 복잡해집니다.


선언적 트랜잭션

어노테이션을 이용하여 직접 트랜잭션을 적용하는 방식입니다.

    @Transactional
    public Long declarativeTransaction() {
        System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
        final Item item = itemRepository.save(new Item("트랜잭션 상품", 1000, 1));
        return item.getId();
    }

어노테이션 만으로 트랜잭션이 동작합니다. 롤백과 커밋 모두 어노테이션 하나로 동작할 수 있습니다.
그렇다면 스프링은 어떻게 어노테이션 만으로 트랜잭션 동작이 가능하게 한 것일까요?

@Transactional 동작 원리

@Transactional은 Spring AOP를 통해 프록시 객체를 생성하여 사용됩니다.
프록시 객체를 통해 트랜잭션의 로직을 처리하고, 비즈니스 로직은 target 객체에서 처리하는 방법을 선택했습니다.
스프링 @Transactional은 2가지 방법으로 프록시 패턴 사용했는데요. 각각 살펴보도록 하겠습니다.

JDK dynamic 프록시

JDK dynamic 프록시는 인터페이스를 기반으로 작동합니다. 이 방식에서는 InvocationHandler를 사용하여 메소드 호출을 가로챕니다. @Transactional이 붙은 메소드 호출이 프록시를 통해 이루어질 때, InvocationHandlerinvoke() 메소드를 통해 트랜잭션 시작, 커밋, 롤백이 실행됩니다.

public interface ProductService {
	void perform();
}
public class ProductServiceImpl implements ProductService {
	@Transactional
    @Override
    public void perform() {
        System.out.println("Performing a transactional action");
        // 여기에 비즈니스 로직 구현
    }
}

인터페이스 기반으로 동작한다고 했는데, 이는 반드시 필요합니다. 비즈니스 로직을 처리하는 Service 객체에 인터페이스가 존재해야 합니다.
인터페이스를 두고, target 객체를 구현체로 만들었습니다. target에는 비즈니스 로직을 작성했다고 가정하겠습니다.

스프링 어플리케이션이 시작되고 스프링 컨텍스트가 로드될 때, 스프링은 @Transactional이 붙은 어노테이션이 적용된 클래스 또는 인터페이스를 찾아 프록시 객체를 생성하는 작업을 수행합니다.
이 때 리플렉션 API의 Proxy 클래스를 사용하여 인터페이스를 구현하는 프록시 객체가 생성됩니다.
생성된 프록시 객체는 원본 객체의 메소드 호출을 가로채고, 필요한 추가적인 처리를 수행한 후 실제 메소드를 호출합니다. 이 호출을 가로챌 때 InvocationHandler가 사용된다고 했는데, 어떤 원리로 동작하는지 자세히 살펴보겠습니다.

public class TransactionalInvocationHandler implements InvocationHandler {
    private final Object target;

    public TransactionalInvocationHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (method.getName().startsWith("perform")) {
            beginTransaction();
            try {
                Object result = method.invoke(target, args);
                commitTransaction();
                return result;
            } catch (Exception e) {
                rollbackTransaction();
                throw e;
            }
        } else {
            return method.invoke(target, args);
        }
    }

    private void beginTransaction() {
        System.out.println("Transaction started");
        // 트랜잭션 시작 로직
    }

    private void commitTransaction() {
        System.out.println("Transaction committed");
        // 트랜잭션 커밋 로직
    }

    private void rollbackTransaction() {
        System.out.println("Transaction rolled back");
        // 트랜잭션 롤백 로직
    }
}

TransactionalInvocationHandler를 만들어서 invoke()메소드에 트랜잭션 로직을 모두 작성했습니다. 실제 코드는 훨씬 복잡하지만 간단하게만 보았을 때 위 코드와 같습니다.
동작 순서를 살펴보자면,

  1. 컨트롤러에서 ProductServiceperform() 메소드를 호출하면, 이 호출은 실제로 ProductService의 프록시 객체를 통해 메소드 호출이 이루어집니다.
  2. 프록시 객체에서 invoke() 메소드를 호출하여 트랜잭션 관리 로직을 실행한 후, 프록시는 원본 ProductServiceImpl 클래스의 perform() 메소드를 호출합니다.
  3. perform() 메소드의 실행이 완료되면, 그 결과는 invoke() 메소드를 통해 호출자에게 반환됩니다. 메소드 실행 중 예외가 발생하면, 이는 invoke() 메소드에서 상황에 따라 트랜잭션을 롤백할 수 있습니다.

JDK dynamic 프록시 방식의 경우 리플랙션을 활용하여 메소드를 호출하기 때문에 성능이 조금 아쉬울 수 있습니다. 하지만, 성능 차이가 미미하기 때문에 신경쓸 정도가 아니라고 하네요!


CGLIB 프록시

JDK dynamic 프록시 방식의 경우 interface가 필수적으로 필요합니다. interface가 없는 클래스에 @Transactional을 사용하는 경우 CGLIB 프록시 방식이 사용됩니다. CGLIB는 클래스를 상속하여 프록시 객체를 생성하는 방식을 사용합니다. 코드로 살펴보겠습니다.

public class ProductService {
    public void perform() {
        System.out.println("Performing a business logic");
		// 여기에 비즈니스 로직 구현
    }
}
public class ProductServiceProxyFactory {
    public static ProductService createProxy() {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(ProductService.class);
        enhancer.setCallback(new MethodInterceptor() {
            @Override
            public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
            	// 트랜잭션 시작 로직
                System.out.println("Transaction started");
                Object result = null;
                try {
                    result = proxy.invokeSuper(obj, args);
                    // 트랜잭션 커밋 로직
                    System.out.println("Transaction committed");
                } catch (Exception e) {
                	// 트랜잭션 롤백 로직
                    System.out.println("Transaction rolled back");
                    throw e;
                }
                return result;
            }
        });
        return (ProductService) enhancer.create();
    }
}

JDK dynamic 프록시 방식과 크게 다르지 않습니다. perform() 메소드가 호출되면 intercept()가 먼저 호출된 후에 내부에서 invokeSuper() 메소드를 통해 perform()이 호출됩니다. JDK dynamic 프록시는 리플랙션 방식을 선택한 반면에, CGLIB는 바이트 코드를 조작하는 방식을 선택했습니다.

CGLIB는 런타임에 클래스의 바이트코드를 조작하여 프록시 클래스를 생성합니다. 이 과정은 일반적인 JDK dynamic 프록시보다 더 복잡하고, 초기 프록시 생성 시에 약간 더 많은 리소스를 사용할 수 있습니다. 하지만 생성이 된 후에 실행 속도는 JDK dynamic 프록시보다 빠르다는 장점이 있습니다.

스프링은 기본 설정인 경우 인터페이스가 존재하지 않는 경우에만 CGLIB를 채택하고 있습니다. 만약 설정파일에 proxy-target-class 속성을 true로 둔다면 클래스 기반으로 프록시를 생성합니다. 즉, 인터페이스가 존재하더라도 CGLIB 방식을 채택합니다.

그렇다면 왜 스프링은 default로 JDK dynamic 프록시 방식을 선택했을까요?
CGLIB에는 아래와 같은 단점이 있기 때문입니다.

  • 클래스의 제약: CGLIB는 final 클래스나 메소드에 대해서는 프록시를 생성할 수 없습니다. 이는 final 클래스나 메소드가 상속 또는 오버라이딩되지 않기 때문입니다.

  • 복잡성: CGLIB는 JDK dynamic 프록시보다 복잡하게 구현되어 있고, 더 무겁습니다.


마무리

면접에서 transactional과 spring AOP 관련해서 깊이 있는 질문을 받았습니다. 제대로 대답하지 못해 아쉬워 정리해보았습니다.

profile
영차영차

0개의 댓글