데이터베이스 트랜잭션만 신경쓰고, 어플리케이션에서 트랜잭션은 깊게 생각해보지 못했습니다.
최근 큰 코를 다쳐 잘못 알고있었던 개념에 대해 정리해보고자 합니다.
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
은 Spring AOP를 통해 프록시 객체를 생성하여 사용됩니다.
프록시 객체를 통해 트랜잭션의 로직을 처리하고, 비즈니스 로직은 target 객체에서 처리하는 방법을 선택했습니다.
스프링 @Transactional
은 2가지 방법으로 프록시 패턴 사용했는데요. 각각 살펴보도록 하겠습니다.
JDK dynamic 프록시는 인터페이스를 기반으로 작동합니다. 이 방식에서는 InvocationHandler
를 사용하여 메소드 호출을 가로챕니다. @Transactional
이 붙은 메소드 호출이 프록시를 통해 이루어질 때, InvocationHandler
의 invoke()
메소드를 통해 트랜잭션 시작, 커밋, 롤백이 실행됩니다.
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()
메소드에 트랜잭션 로직을 모두 작성했습니다. 실제 코드는 훨씬 복잡하지만 간단하게만 보았을 때 위 코드와 같습니다.
동작 순서를 살펴보자면,
ProductService
의 perform()
메소드를 호출하면, 이 호출은 실제로 ProductService
의 프록시 객체를 통해 메소드 호출이 이루어집니다.invoke()
메소드를 호출하여 트랜잭션 관리 로직을 실행한 후, 프록시는 원본 ProductServiceImpl
클래스의 perform()
메소드를 호출합니다.perform()
메소드의 실행이 완료되면, 그 결과는 invoke()
메소드를 통해 호출자에게 반환됩니다. 메소드 실행 중 예외가 발생하면, 이는 invoke()
메소드에서 상황에 따라 트랜잭션을 롤백할 수 있습니다.JDK dynamic 프록시 방식의 경우 리플랙션을 활용하여 메소드를 호출하기 때문에 성능이 조금 아쉬울 수 있습니다. 하지만, 성능 차이가 미미하기 때문에 신경쓸 정도가 아니라고 하네요!
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 관련해서 깊이 있는 질문을 받았습니다. 제대로 대답하지 못해 아쉬워 정리해보았습니다.