Spring Transaction 관리에 대한 메모
A Short Memo on Spring Framework Transaction
스프링 프레임워크의 트랜잭션 추상화
트랜잭션을 언제 사용해야 하나?
예를 들어, 결제 시스템을 생각해볼 수 있다. 여러 테이블이 존재하고, 해당 테이블에서 데이터를 입출력한다. 어느 하나의 과정에서 에러 및 예외 사항이 발생했을 때, 모든 전처리 데이터를 rollback해야 한다.
결제는 완료되었지만 결제한 카드의 정보를 볼 수 없는 시스템 오류가 있다고 생각해보자. 이런 시스템 오류는 수행 과정 중에서 단 하나라도 존재할 수 있다면, 트랜잭션이 상황에 따라 rollback하여 발생 가능한 오류를 막아준다.
Transaction의 사용
JDBC 라이브러리에서 트랜잭션을 시작하고, try 코드 구문에서 결제 관련 비즈니스 로직을 수행하고, 해당 변경을 커밋하거나 롤백하고, 최종적으로 DB 커넥션을 종료하는 트랜잭션 코드를 살펴보자.
아래 코드에서는 결제금액의 저장과 결제 정보의 저장을 하나의 단위로 바라보았다. 그리고, 이를 하나의 트랜잭션으로 묶어주었다. 어느 하나의 부분에라도 에러가 발생한다면, 롤백하도록 설정되었다.
구체적으로, 결제 기능 안에서 ‘결제금액 저장’과 ‘결제정보 저장’을 하나의 단위로 보고 트랜잭션으로 묶어줌으로써 어느 한 부분에서라도 에러가 발생한다면 롤백 할 수 있도록 설정했다.
하기 JDBC Connection 트랜잭션 코드의 단점은 분명하다. 트랜잭션을 발생시켜야 하는 경우, Data Access 기능에서 결코 자유롭지 않다는 것이다. JDBC Transaction은 로컬 트랜잭션이다.
즉, 두 개 이상의 DB에 접근해야 하는 작업을 하나의 트랜잭션으로 만들 수 없다. Java Transaction API(JTA)를 이용할 수도 있지만, API를 직접 다루는 것은 의도하지 않은 에러 발생 가능성을 높인다. 이 외에도, 기술환경이 변화함에 따라 코드가 달라질 수 있다는 가능성을 내포한다.
public void payment() throws Exception {
Connection c = dataSource.getConnection(); // DB 커넥션 생성
c.setAutoCommit(false); // 트랜잭션 시작
// setAutoCommit 메소드는 Connection이 하나의 SQL문을 실행할 때마다 자동적으로 커밋처리 해주는 것을 방지해줌으로써 에러 발생 시 모든 SQL문을 롤백 할 수 있도록 처리
try {
paymentDao.account(account); // 결제금액 저장
paymentDao.paymentType(paymentType); // 결제정보 저장(ex. 카드, 계좌이체 정보 등)
c.commit(); // 트랜잭션 커밋
}
catch(Exception e) {
c.rollback(); // 트랜잭션 롤백
}
c.close(); // DB 커넥션 종료
}
How Spring Framework solves this problem?
스프링에서는 PlatformTransactionManager 인터페이스 추상화를 통해, 인터페이스를 스프링 설정 파일을 통해 Bean으로 등록하고 DI를 받아 사용하는 과정을 거친다. PlatformTransactionManager는 TransactionManager의 최상위 인터페이스로, 인터페이스에 각자의 환경에 맞는 TransactionManager 클래스를 Dependency Injection 한다.
- 예를 들어, DataSourceTransactionManager : JDBC 및 마이바티스 등의 JDBC 기반 라이브러리로 데이터베이스에 접근하는 경우에 이용된다.
- HibernateTransactionManager : 하이버네이트를 이용해 데이터베이스에 접근하는 경우에 이용된다.
- JpaTransactionManager : JPA로 데이터베이스에 접근하는 경우에 이용된다.
이를 통해, 데이터 액세스 기술이 변경될 경우 설정 파일을 변경함으로서 일괄 적용이 가능하다. Service 클래스에서는 비즈니스 로직이 변경되는 경우에만 수정되도록 설계한다.
// PaymentService.javapublic class PaymentService {
...
private PlatformTransactionManager transactionManager;
public void setTransactionManager(PlatformTransactionManager transactionManager){
this.transactionManager = transactionManager;
}
public void transactionCode() throws Exception {
TransactionStatus status =
this.transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
paymentDao.account(account); // 결제금액 저장
paymentDao.paymentType(paymentType); // 결제정보 저장(ex. 카드, 계좌이체 정보 등)
this.transactionManager.commit(status);
} catch(RuntimeException e) {
this.transactionManager.rollback(status);
throw e;
}
}
}// applicationContext.xml<bean id="paymentService" class="spring.test.service.PaymentService">
<property name="paymentDao" ref="paymentDao" />
<property name="transactionManager" ref="transactionManager" />
</bean>
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionMager">
<property name="dataSource" ref="dataSource" />
</bean>
결국, 트랜잭션의 추상화는 애플리케이션의 비즈니스 로직과 그 하위에서 동작하는 로우레벨의 트랜잭션 기술이라는 아예 다른 계층의 특성을 갖는 코드를 분리한 것이다.
Deep dive into PlatformTransactionManager…
Spring 프레임워크의 Transaction 전략은 PlatformTransactionManager에 명시되어 있다. 하기 인터페이스 명세를 보면 다음과 같다.
public interface PlatformTransactionManager { TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException; void commit(TransactionStatus status) throws TransactionException; void rollback(TransactionStatus status) throws TransactionException;
}
PlatformTransactionManager는 Transaction의 경계를 정하는 과정에서 사용된다. 트랜잭션이 어디에서 시작하고, 또 종료하는가? 종료할 때 정상적인 종료인가(commit), 아니면 비정상적인 종료인가(rollback)를 결정한다.
getTransaction 메소드를 보면 TransactionDefinition을 인자로 받아 TransactionStatus를 반환하고 있다. TransactionDefinition은 인터페이스로서 다음의 정보를 갖고 있다.
- Isolation : 트랜잭션 Isolation level을 나타내는 정보이다. 해당 트랜잭션이 다른 트랜잭션의 작업과 격리되는 정도를 지정한다.
- Propagation : 트랜잭션 propagation을 나타내는 정보이다. 트랜잭션 경계의 시작 지점에서 트랜잭션 전파 속성을 참조해서 해당 범위의 트랜잭션을 어떤 식으로 진행시킬지 정할 수 있다.
- Timeout : 트랜잭션 실행 시간으로, 이 시간이 지나게 되면 롤백된다. 시간이 만료되기 전에 해당 트랜잭션이 얼마나 오랫동안 실행되고 의존 트랜잭션 인프라스트럭처가 자동으로 롤백하는 지를 나타낸다.
- read-only 여부 : 읽기 전용인지에 대해 나타낸다. 이 옵션은 Hibernate에서 읽기 전용 트랜잭션인지 확인하는 용도로 쓰인다.
TransactionStatus는 현재 참여하고 있는 트랜잭션의 ID와 구분정보를 담고 있다. 커밋 commit() 또는 롤백 rollback() 시에 이 TransactionStatus 를 사용한다.
getTransaction 메소드는 begin() 처럼 트랜잭션이 시작되는 것을 의미한다. 스프링에서는 시작과 종료를 트랜잭션 전파 기법을 이용해 자유롭게 조합하고 확장할 수 있다. 그래서 트랜잭션을 시작한다는 의미의 begin()이 아니라, 적절한 트랜잭션을 가져온다는 의미의 getTransaction() 메소드를 사용한다.
getTransaction()은 트랜잭션 속성에 따라서 새로 시작하거나 진행 중인 트랜잭션에 join하거나, 진행 중인 트랜잭션을 무시하고 새로운 트랜잭션을 만드는 식으로 상황에 따라 다르게 동작한다.
public interface TransactionStatus extends SavepointManager { boolean isNewTransaction(); // 새로운 트랜잭션이 존재하는가? boolean hasSavepoint(); // 현재 일치하는 트랜잭션이 존재하는가? void setRollbackOnly(); // rollback이 가능한가? boolean isRollbackOnly(); // rollback이 되었는가? void flush(); // 실제 DB에 동기화 boolean isCompleted(); // 트랜잭션이 완료되었는가?}
Transaction 경계설정 전략
일반적으로 Transaction의 시작과 종료는 Service Layer 내부의 메소드에 달려있다. 트랜잭션의 경계를 설정하는 방법으로는 1) 코드를 통해 임의적으로 지정하는 방법 2) AOP를 이용하여 지정하는 방법으로 크게 나뉠 수 있다. 이 중에서도 AOP를 활용한 @Transactional 어노테이션 활용이 주가 된다.
선언적으로 트랜잭션을 선언하기 위해 @Transactional 어노테이션을 쓰고 싶다면, 설정에서 @EnableTransactionManagement를 추가한 뒤, 트랜잭션을 사용하고 싶은 클래스 및 메소드에 @Transactional을 추가하면 된다. 스프링부트에서는 TransactionAutoConfiguration을 통해서 해당 어노테이션이 자동 설정된다.
선언적 트랜잭션에 경계를 설정할 수 있는 이유는 Transaction Proxy Bean 덕분이다. Transaction은 대부분 그 성격이 비슷하기 때문에, 적용 대상 별로 일일이 선언하지 않고 일괄적으로 설정하는 편이 좋다. 따라서, 특정한 부가기능을 임의의 target object에 부여할 수 있는 Proxy AOP가 주로 이용된다.
다음은 MyBatis로 짜여진 코드다. 하기 코드의 출처는 다음 링크에 있다.
package tkstone.test.transaction;
public class TransactionInvoker {
private Mapper1 mapper1;
public void setMapper1(Mapper1 mapper1){
this.mapper1 = mapper1;
}
@Transactional
public String invoke() {
System.out.println("*** invoke start");
insert1();
insert2();
System.out.println("*** invoke end");
return "transaction invoked";
}public void insert1(){
A1 a1 = new A1();
a1.col1 = "col1";
a1.col2 = "col2";
mapper1.insertA1(a1);
}
public void insert2(){
A2 a2 = new A2();
a2.col1 = "col1";
a2.col2 = "col2";
mapper1.insertA2(a2);
}
} // End of TransactionInvoker
위의 코드를 보면 Transaction을 사용했음을 알 수 있다. 이 때 로그 출력값은 다음과 같다.
DEBUG: org.springframework.jdbc.datasource.DataSourceTransactionManager - Creating new transactionDEBUG: org.springframework.jdbc.datasource.DataSourceTransactionManager - Acquired Connection for JDBC transactionDEBUG: org.springframework.jdbc.datasource.DataSourceTransactionManager - Switching JDBC Connection to manual commit*** invoke startDEBUG: org.mybatis.spring.SqlSessionUtils - Creating a new SqlSessionDEBUG: org.mybatis.spring.SqlSessionUtils - Registering transaction synchronization for SqlSessionDEBUG: org.mybatis.spring.transaction.SpringManagedTransaction - JDBC Connection will be managed by SpringDEBUG: org.mybatis.spring.SqlSessionUtils - Releasing transactional SqlSessionDEBUG: org.mybatis.spring.SqlSessionUtils - Fetched SqlSession from current transactionDEBUG: org.mybatis.spring.SqlSessionUtils - Releasing transactional SqlSession*** invoke endDEBUG: org.mybatis.spring.SqlSessionUtils - Transaction synchronization committing SqlSessionDEBUG: org.mybatis.spring.SqlSessionUtils - Transaction synchronization deregistering SqlSessionDEBUG: org.mybatis.spring.SqlSessionUtils - Transaction synchronization closing SqlSessionDEBUG: org.springframework.jdbc.datasource.DataSourceTransactionManager - Initiating transaction commitDEBUG: org.springframework.jdbc.datasource.DataSourceTransactionManager - Committing JDBC transaction on ConnectionDEBUG: org.springframework.jdbc.datasource.DataSourceTransactionManager - Releasing JDBC Connection after transactionDEBUG: org.springframework.jdbc.datasource.DataSourceUtils - Returning JDBC Connection to DataSource
위의 로그를 보면 TransactionManager가 Transaction 관리를 하는 것을 알 수 있다. 여기서 중요한 특징은 다음과 같다.
- invoke() method 가 시작되기 전에 DB Connection 을 얻어 와서 Autocommit = false 설정을 한다.
- invoke() method 내에서는 하나의 Mybatis SqlSession 객체를 사용해서 insert1() 및 insert2() 를 실행한다. 즉 2개의 insert 가 실행될 때 동일한 DB Connection 을 사용한다.
- invoke() method 가 실행된 이후 Transaction 을 commit 하고 connection 을 반환한다.
여기서 Transaction 이 시작하는 지점은 invoke() 메소드에 대한 Proxy method 내부이므로, 다음의 순서대로 호출 경계가 설정된다.
- 호출 Bean
- Proxy.invoke() 시작
- Transaction 시작
- TransactionInvoker.invoke() 처리 완료
- Transaction commit
- Proxy.invoke() 종료
즉, Spring AOP 방식의 Transaction의 경우 Transaction 은 method 단위로 관리 된다. 다시 말해서, method 가 끝날 때까지 commit 또는 connection 반환이 이루어지지 않는다.
Transaction 대상 method 내에서 발생하는 SQL 은 동일한 Connection 을 사용한다. 따라서 처리 시간이 긴 method 의 경우에는 Transaction 단위를 조정해서 DB Lock 지속시간이 지나치게 길어지거나 DB connection pool 이 모자라지 않도록 해야 한다.
주의해야 할 점
Spring AOP에서의 Transaction은 Proxy를 기반으로 이루어지기 때문에, boilercode를 작성하지 않아도 편리하게 기능을 구현할 수 있다고 했다.
그렇다면 다음 상황을 보자.
public class BooksImpl implements Books {
public void addBooks(List<String> bookNames) {
bookNames.forEach(bookName -> this.addBook(bookName));
}
@Transactional
public void addBook(String bookName) {
Book book = new Book(bookName);
bookRepository.save(book);
book.setFlag(true);
}
}
위 코드에서의 문제점은 addBooks 메소드에 @Transaction 어노테이션이 적용되지 않는다는 것이다. 해당 코드를 실행하더라도 DB에는 저장된 book 정보에 Flag 칼럼이 정상적으로 업데이트되지 않는다. 다시 말해서, addBooks가 실행되고 난 후 Books 객체의 변경이 자동으로 감지되지 않는다는 뜻이다.
앞에서도 설명했지만, Spring은 @Transaction 어노테이션을 선언한 메소드가 실행되기 전, transaction begin 코드를 삽입한다. 메서드가 실행된 후, transaction commit 코드를 삽입하여 트랜잭션이 수행되도록 한다. 이러한 방식은 Dynamic Weaving을 통해 이루어지며, 이는 후에 다시 언급할 것이다.
간단히 원리를 설명하자면 다음과 같다. 프록시 객체로 우리가 만든 객체(클래스 또는 인터페이스) 를 한 번 감싼 후, 메서드 위 아래로 코드를 삽입한다. 여기서는 우리가 일일이 해보자.
public class BooksProxy {
private final Books books;
private final TransactonManager manager = TransactionManager.getInstance();
public BooksProxy(Books books) {
this.books = books;
}
public void addBook(String bookName) {
try {
manager.begin();
books.addBook(bookName);
manager.commit();
} catch (Exception e) {
manager.rollback();
}
}
}출처: https://mommoo.tistory.com/92 [개발자로 홀로 서기]
우리는 BooksImpl 클래스를 사용할 때, Spring이 제공하는 BooksProxy 객체를 사용해야 하며, 그래야 BooksProxy 객체가 제공하는 addBooks를 사용하여 트랜잭션을 수행할 수 있게 된다.
public class BooksImpl implements Books {
public void addBooks(List<String> bookNames) {
bookNames.forEach(bookName -> this.addBook(bookName));
}
@Transactional
public void addBook(String bookName) {
Book book = new Book(bookName);
bookRepository.save(book);
book.setFlag(true);
}
}출처: https://mommoo.tistory.com/92 [개발자로 홀로 서기]
BooksProxy 가 addBooks 메서드를 수행하면, BooksProxy::addBooks -> BooksImpl::Book의 형태로 작동하게 된다.
즉, BooksImpl 내부의 코드(addBook) 가 수행 되기 때문에 해당 메서드는 프록시로 감싸진 메서드가 아니라는 점에서 @Transactonal 어노테이션 기능이 수행되지 않는다는 것이다.
이를 해결하기 위해서는 해당 메소드에 @Transactional 어노테이션을 붙여주거나, 의존성 주입을 활용하여 Proxy 인스턴스를 자체적으로 가져와 사용하는 방법을 고려해볼 수 있겠다.
@Service
public class BooksImpl implements Books {
@Autowired
private Books self;
public void addBooks(List<String> bookNames) {
bookNames.forEach(bookName -> self.addBook(bookName)); // this 가 아닌 변수 self 로
}
@Transactional
public void addBook(String bookName) {
Book book = new Book(bookName);
bookRepository.save(book);
book.setFlag(true);
}
}출처: https://mommoo.tistory.com/92 [개발자로 홀로 서기]
위 코드는 Books 인터페이스를 이용하여 BooksProxy 인스턴스를 주입할 수 있도록 한다. Spring Boot의 기본 방식인 CGLib 방식으로 인터페이스를 통한 프록시 주입을 구현하도록 선언한 것이다.
그 후, 자기 자신 즉 this 가 가지고 있는 순수한 addBook 메서드가 아니라 proxy 로 감싸진 addBook 메서드를 통해 @Transactional 어노테이션을 이용할 수 있게 된다.
Dynamic Weaving
일반적으로 Spring은 AOP 과정에서 Aspect를 진행할 때 핵심 관심 코드에 직접적으로 하지 않고, java.lang.reflect.proxy 를 통해 Proxy 객체를 생성, 이를 이용하여 동적으로 참조한다. 이를 통하여 핵심 관심 객체와 횡단 관심 객체 간의 결합도를 줄이고, 경우에 따라 부가기능을 탈부착하기 용이하게 만든다.
AOP는 특정 JoinPoint에 Advice하여 핵심 기능과 횡단 기능이 교차하여 새롭게 생성된 객체를 프로세스에 적용하는 일련의 모든 과정을 위빙(weaving)이라고 한다.
다음 그림을 살펴보자. Spring AOP는 사용자의 특정 호출 시점에 IoC 컨테이너에 의해 AOP를 할 수 있는 Proxy Bean을 생성한다. 동적으로 생성된 Proxy Bean은 target method가 호출되는 시점에 부가기능을 추가할 메소드를 자체적으로 판단하여 해당 메소드에 부가기능을 주입한다. 이를 호출 시점에 동적으로 작동한다고 하여 런타임 위빙(Runtime Weaving)이라고 부른다.
JDK Dynamic Proxy
Spring AOP는 Dynamic Weaving을 런타임 기반으로 구현하고 있는데, 상황에 따라 CGLib Proxy와 JDK Proxy를 번갈아가며 사용한다. AOP Proxy 생성 과정에서 target이 하나 이상의 interface를 구현하고 있는 class라면 JDK Dynamic Proxy 기반으로 구현하고, 그렇지 않다면 CGLib Proxy 기반으로 구현한다.
먼저 JDK Dynamic Proxy부터 살펴보자. Spring AOP의 default 값이다. java.lang에 포함되어 있는 Reflection의 Proxy 클래스가 말 그대로 동적으로 생성한다고 하여 Dynamic Proxy다. Target의 Interface를 기준으로 Proxy를 생성한다는 것이 Dynamic Proxy의 핵심이라고 할 수 있다.
JDK Dynamic Proxy는 Reflection Proxy 클래스의 newProxyInstance를 이용하여 생성된다. 먼저, ProxyFactory에 의해 target interface를 상속한 Proxy 객체를 생성한다. 그리고, Proxy 객체에 InvocationHandler를 포함시켜 하나의 객체로 반환한다.
Object proxy = Proxy.newProxyInstance(ClassLoader // 클래스로더
, Class<?>[] // 타깃의 인터페이스
, InvocationHandler // 타깃의 정보가 포함된 Handler
다음의 상황을 살펴보자. MemberService 클래스는 UserService 인터페이스를 상속받고 있기 때문에 Spring AOP은 proxy bean을 interface 기준으로 생성한다. 이 때 UserController는 MemberService를 Interface로 DI하지 않고, class로 DI하고 있다. 따라서 해당 코드는 RuntimeException이 발생할 것이다.
@Controller
public class UserController{
@Autowired
private MemberService memberService; // <- Runtime Error 발생하므로, @Autowired를 지워야 한다. 대신, private UserService userService로 받아야 한다.}@Service
public class MemberService implements UserService{
@Override
public Map<String, Object> findUserId(Map<String, Object> params){
...isLogic
return params;
}
}
JDK Dynamic Proxy는 Proxy 패턴의 구현체라고도 생각할 수 있다. 생성된 Proxy 객체의 위임코드는 InvocationHandler에 작성되어야 한다. 사용자의 요청이 최종적으로 생성된 Proxy 메소드를 통해 호출될 때 내부적으로 reflection이 일어나면서 검증과정을 거친다.
하기 코드는 실제로 InvocationHandler를 상속받아서 구현한 예시이다.
// 코드 출처: https://huisam.tistory.com/entry/springAOPpackage study.proxy;import core.aop.pointcut.MethodMatcher;
import lombok.RequiredArgsConstructor;import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;@RequiredArgsConstructor
public class UpperCaseHandler implements InvocationHandler {private final Car car;
private final MethodMatcher methodMatcher;@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
final String methodName = (String) method.invoke(car, args);
if (methodMatcher.matches(method)) {
return methodName.toUpperCase();
}
return methodName;
}
}
위 코드를 보면, Car라는 interface와 MethodMatcher라는 interface를 상속받아서 의존성 주입을 했다. invoke로 실행될 때 내부적으로 reflection이 일어나며, 타깃 객체 메소드에 대한 검증 작업이 이루어진다.
Proxy 객체가 interface 기준으로 생성되므로, 개발자가 target 정보를 잘못 주입했을 경우에 대비해 JDK Dynamic Proxy는 내부적으로 주입받은 target에 대한 검증 과정을 하기 코드처럼 진행하고 있다.
// 코드 출처: https://gmoon92.github.io/spring/aop/2019/04/20/jdk-dynamic-proxy-and-cglib.htmlpublic 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;
}
CGLib (Code Generator Library)
CGLib은 class의 bytecode를 조작하여 Proxy 객체를 생성하는 라이브러리다. CGLib을 사용하여 인터페이스가 아닌 target class에 대해서도 Proxy를 생성할 수 있다. 이 때, CGLib은 Enhancer라는 class를 통해 Proxy를 생성한다.
Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(MemberService.class); // 타깃 클래스 enhancer.setCallback(MethodInterceptor); // Handler
Object proxy = enhancer.create(); // Proxy 생성
아래 사진과 같이 CGLib은 target class를 상속받아 Proxy를 생성하는데, 이 과정에서 CGLib은 target class를 포함한 모든 method를 재정의한다. CGLib은 제공받은 타깃 클래스에 대한 바이트 코드를 조작하여 Proxy를 생성한다.
CGLib은 메소드가 처음 호출되었을 때 동적으로 target class의 bytecode를 조작하고, 이후에는 해당 bytecode를 재사용한다.
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;
}
단, final 메소드나 클래스에 대해서는 재정의할 수 없다. JDK Dynamic Proxy와 달리 reflection을 활용하지 않으므로 성능 면에서 CGLib이 우월하다. Spring 3.2 이후 버전에서는 CGLib을 default로 채택하여 사용할 수 있다.
CGLib Proxy를 사용하는 실제 코드를 살펴보자. 먼저, Proxy화를 진행할 Target Class를 생성한다. start와 stop 메소드를 통해 차가 출발하고 멈추는 기능을 구현했다.
// 코드 출처: https://huisam.tistory.com/entry/springAOP
package study.proxy;public class CarTarget implements Car {
@Override
public String start(String name) {
return "Car " + name + " started!";
}@Override
public String stop(String name) {
return "Car " + name + " stopped!";
}
}
여기서 start 메소드만 proxy로 만들고 싶다고 가정하자. MethodMatcher라는 interface를 만들고, 이를 상속해서 특정 조건(method 이름이 start이면)에 맞으면 필터링하도록 구현하자.
package study.proxy.matcher;import core.aop.pointcut.MethodMatcher;import java.lang.reflect.Method;public class StartMethodMatcher implements MethodMatcher {
private static final String TALK_PREFIX = "start";@Override
public boolean matches(Method method) {
final String methodName = method.getName();return methodName.startsWith(TALK_PREFIX);
}
}
Matcher로 Handling 시켜줄 Interceptor가 필요하다. CGLib에서는 MethodInterceptor라는 interface로 이를 구현할 수 있다.
Enhancer 객체는 반드시 SuperClass를 지정하고, callback을 통해서 어떠한 Handler 를 설정할 것인지 명시해야 한다.
package study.proxy;import core.aop.pointcut.MethodMatcher;
import lombok.RequiredArgsConstructor;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;import java.lang.reflect.Method;@RequiredArgsConstructor
public class UpperCaseInterceptor implements MethodInterceptor {private final MethodMatcher methodMatcher;@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
final String methodName = (String) proxy.invokeSuper(obj, args);
if (methodMatcher.matches(method)) {
return methodName.toUpperCase(); // to check proxied
}
return methodName;
}
}
실제로 테스트 해보면, Proxy 객체가 start 메소드에서 실행된 것을 알 수 있다.
@Test
@DisplayName("cglib Proxy 테스트")
void cglibProxyTest() {
/* given */
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(CarTarget.class);
enhancer.setCallback(new UpperCaseInterceptor(new StartMethodMatcher()));/* when */
final Car proxiedCar = (Car) enhancer.create();/* then */
assertThat(proxiedCar.start("huisam")).isEqualTo("CAR HUISAM STARTED!");
assertThat(proxiedCar.stop("huisam")).isEqualTo("Car huisam stopped!");
}
AOP와 연관지어 생각해보면, JDK Dynamic Proxy의 InvocationHandler와 CGlib 에서 MethodInterceptor는 Spring AOP의 JoinPoint이다. 특정 조건에 따라 필터링하는 MethodMatcher는 PointCut이다. Proxy 로직이 실행되는 invoke, intercept 메소드는 Advice이다.
Proxy AOP에 한계는 없을까?
앞서 알아보았듯, 프록시가 적용되면 클라이언트는 프록시를 target object라고 생각하고 proxy method를 호출하게 된다.
Proxy는 client로부터 요청을 받으면 target object의 method로 위임하고, 경우에 따라 부가작업을 추가한다. Transaction AOP에 의해 추가된 proxy라면, target object 메소드 호출 전에 트랜잭션을 시작하고, 호출 후에 트랜잭션을 commit하거나 rollback할 것이다.
이 과정에서, proxy는 client가 target object를 호출하는 과정에만 동작한다. target object의 method가 자기 자신의 다른 method를 호출할 때는 프록시가 동작하지 않는다. 자기 자신의 다른 메소드를 호출하는 경우에는, 프록시를 통하지 않고 직접 target object의 method로 호출이 일어난다. 즉, target object의 자기 호출에는 AOP가 동작하지 않는다는 점이 한계이다.
이러한 경우에는 AssertJ를 활용하여, Proxy 대신 class bytecode를 직접 변경해서 부가기능을 추가하는 방법을 고민해야 한다. AssertJ를 활용하면 target object의 자기 호출 중에도 transaction 부가기능이 적용된다.
즉, 따라서, Proxy AOP를 사용할 경우에는 public 메소드에만 @Transactional 어노테이션을 적용해야 한다. protected나 private에 해당 어노테이션을 적용할 경우, 부가기능이나 설정을 적용할 수 없다. public이 아닌 접근자에도 트랜잭션 부가기능을 지정하고 싶다면, AssertJ를 사용해야 한다.
그리고, 더욱이 중요한 점은 @Transactional 어노테이션은 클래스에만 붙여야 한다는 것이다. 이는 인터페이스를 구현하는 클래스인 경우에도 마찬가지인데, CGLib을 활용한 방식일 경우 인터페이스만 어노테이션을 붙이면 해당 정보는 구현 클래스로는 전달되지 않는다. 즉, 트랜잭션이 일어나지 않는다.
Transaction Proxy를 통한 메소드 호출 개념
- Caller에서 AOP Proxy를 탄다. 이 때 proxy를 invoke하지, target을 invoke하지는 않는다.
- AOP Proxy는 Transaction Advisor를 호출한다. 이 과정에서 commit이 되거나 rollback이 된다.
- Custom Advisor가 있다면, Transaction Advisor 실행 전후로 동작한다.
- Custom Advisor는 Target Method를 호출하여, 비즈니스 로직을 invoke한다.
- 후에 순서대로 리턴되는 구조이다.
TransactionInterceptor의 경우 TransactionAspectSupport를 상속받는다. TransactionAspectSupport은 내부에 구현된 invokeWithinTransaction 메소드를 통해서 프록시 객체의 메소드를 호출한다.
PlatformTransactionManager ptm = asPlatformTransactionManager(tm);
final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);if (txAttr == null || !(ptm instanceof CallbackPreferringPlatformTransactionManager)) {
// Standard transaction demarcation with getTransaction and commit/rollback calls.
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);Object retVal;
try {
// This is an around advice: Invoke the next interceptor in the chain.
// This will normally result in a target object being invoked.
retVal = invocation.proceedWithInvocation();
}
catch (Throwable ex) {
// target invocation exception
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}if (retVal != null && vavrPresent && VavrDelegate.isVavrTry(retVal)) {
// Set rollback-only in case of Vavr failure matching our rollback rules...
TransactionStatus status = txInfo.getTransactionStatus();
if (status != null && txAttr != null) {
retVal = VavrDelegate.evaluateTryFailure(retVal, txAttr, status);
}
}commitTransactionAfterReturning(txInfo);
return retVal;
}
invokeWithinTransaction의 메소드를 보면, 트랜잭션을 가져오거나 생성한 다음에 invocation.proceedWithInvocation() 메소드를 통해 비즈니스 메소드를 호출시키고 정상이라면 트랜잭션 커밋을, 예외가 발생하면 롤백처리를 한다.
Spring Transaction Propagation에 대해
트랜잭션 전파(Transaction Propagation)은 임의의 한 트랜잭션의 경계에서 이미 진행 중인 트랜잭션이 존재할 때, 혹은 존재하지 않을 때 어떻게 동작할 것인가를 결정하는 방식이다. A라는 트랜잭션이 시작되고, 트랜잭션 A가 끝나지 않은 시점에서 트랜잭션 B 메소드가 호출된다고 가정해보자. 그렇다면, B는 어느 트랜잭션에서 동작해야 할까?
- 여러 경우를 생각해볼 수 있다. 먼저, A라는 트랜잭션이 시작되어서 진행 중이라면 B의 코드는 새로운 트랜잭션을 만들지 않고 A에서 시작된 트랜잭션에 참여할 수 있다. 이러한 경우, B를 호출한 b.method까지 마치고, 이후 작업에서 예외가 발생한다면 A와 B가 모두 A의 트랜잭션에 하나로 묶여있으므로 전체 다 rollback될 것이다.
- 이와 달리, 트랜잭션 B가 트랜잭션 A와 별도의 트랜잭션을 만들 수 있다. 이 경우, 트랜잭션 B 경계를 빠져나가는 순간 (이 경우는 B 작업이 끝날 때이다) B 트랜잭션은 독립적으로 커밋되거나 롤백될 것이다. 트랜잭션 A는 그에 영향을 받지 않고 진행될 것이다. A의 (2) 에서 예외가 발생하더라도 트랜잭션 A만 롤백되지 트랜잭션 B에는 아무런 영향이 없을 것이다.
1. PROPAGATION_REQUIRED
가장 많이 사용되는 트랜잭션 전파 속성이다. 이미 진행 중인 트랜잭션이 없으면 새로 시작하고, 진행 중인 트랜잭션이 있다면 기존 트랜잭션에 참여한다.
2. PROPAGATION_REQUIRES_NEW
항상 새로운 트랜잭션을 시작하는 방식이다. 앞에 시작된 트랜잭션의 존재 유무와 상관 없이 새로운 트랜잭션을 만들어 독립적으로 동작시킨다. 즉, 독립적인 트랜잭션이 보장되어야 하는 코드에 적용할 수 있다.
3. PROPAGATION_NOT_SUPPORTED
해당 속성을 사용하면 트랜잭션 자체를 무시한다. 트랜잭션 없이 동작하는 것이다. 트랜잭션의 경계 설정 대부분은 AOP를 이용하여 여러 메소드를 일괄적으로 적용한다. 따라서 특별한 임의의 메소드 하나에만 트랜잭션을 적용하지 않도록 하기 위한 전파 속성이다.
4. PROPAGATION_NESTED
이미 실행 중인 트랜잭션이 존재한다면, 중첩 트랜잭션을 만든다. 중첩 트랜잭션이란 트랜잭션 내부에 다시 트랜잭션을 만드는 것이다. 즉, 부모 트랜잭션에서 새로운 트랜잭션을 내부에 만든다. 중첩 트랜잭션은 부모 트랜잭션의 커밋과 롤백에는 영향을 받지만, 중첩 트랜잭션 자기 자신은 부모 트랜잭션에 영향을 주지 않는다. REQUIRED와 마찬가지로 부모 트랜잭션이 존재하지 않으면 독립적으로 트랜잭션을 생성해서 사용한다.
5. PROPAGATION_MANDATORY
이미 진행중인 트랜잭션이 있으면 해당 트랜잭션에 합류한다. 만약 진행중인 트랜잭션이 없다면 예외를 발생시킨다는 것이 REQUIRED와 다른 점이다. 즉, 독립적인 트랜잭션을 생성하면 안되는 경우에 사용한다.
6. PROPAGATION_SUPPORT
이미 진행중인 트랜잭션이 있다면 해당 트랜잭션에 합류한다. 이미 진행중인 트랜잭션이 없다면, 트랜잭션이 없이 진행한다.
7. PROPAGATION_NEVER
트랜잭션을 사용하지 않도록 강제한다. 즉, 트랜잭션을 사용하지 않는다. NOT_SUPPORT와 다른점은 NOT_SUPPORT는 트랜잭션을 무시하고 보류하는 반면에, NEVER는 트랜잭션이 존재하면 예외를 발생 시킨다.
더 알아보려면 다음 링크에 국문으로 정리가 잘 되어 있으니 참고하면 좋다.
Spring TransactionTemplate 이용하기
해당 글을 페이스북에 올리고, 항상 도움을 주시는 Bona Lee님께서, 귀찮은 AOP를 이용한 내부의 transactionInterceptor 라는 어드바이스를 쓰는 것보다는 클래식하게 트랜잭션을 다루지만 boilerplate 하지 않은 그런 스타일을 좋아하는 편이라고 하시면서 TransactionTemplate 내용을 추가할 것을 제안주셨다. 이에 학습하여 추가해본다.
많은 Spring 서적을 보면 DB Transaction과 business level을 구분하라고 하고, 나도 그렇게 배웠다. 그런데 DB transaction이 매우 중요한 서비스에서는 business logic 구현 시 성능 이슈도 고려해야 한다. 즉, transaction 코드 자체를 개발자가 직접 신경쓰는 편이 낫다는 것이다.
특히 DBMS 종류에 따라 DB Lock 지속 시간이나 Read consistency의 차이, 그리고 이로 인한 서비스 동시성 문제 등을 생각하면 method 단위로 경계가 설정되는 AOP 방식의 트랜잭션이 비효율적일 수 있다. 예를 들어, 실행 시간이 상당한 method에 AOP로 트랜잭션을 붙였다고 생각해보자. 불필요하게 DB connection을 점유하거나 DB Lock이 유지되는 시간이 길어질 수 있을 것이다.
앞서 Spring AOP 를 이용한 Transaction 사용법을 설명 하였다. 특히 @Transactional 을 사용한 Transaction 선언이 편리하기는 하나 다음과 같은 경우에는 동작을 하지 않는다. 하기에서 사용되는 코드 출처는 다음과 같다.
public class TransactionInvoker2 {
private A1Dao a1dao;
private A2Dao a2dao;
public void setA1dao(A1Dao dao){
this.a1dao = dao;
}
public void setA2dao(A2Dao dao){
this.a2dao = dao;
}
// 외부에서 호출하는 method
public void invoke() throws Exception{
doInternalTransaction();
}
@Transactional
public void doInternalTransaction() throws Exception{
a1dao.insertA1();
a2dao.insertA2();
}
}
[출처] TransactionTemplate 을 이용한 Spring Transaction 사용|작성자 예영아빠
위의 코드에 Spring AOP 방식의 트랜잭션이 적용되지 않는 이유는 무엇일까? 바로 Proxy 방식으로 동작하기 때문이다. 여기서 invoke()가 호출하는 대상 method는 Proxy의 doInternalTransaction()이 아닌, 실제 doInternalTransaction()이다. Proxy는 클래스나 인터페이스 외부에서 객체가 주입되었을 때 호출하는 경우에만 동작한다는 것을 생각해보자.
물론 AOP 방식으로 invoke() 에 @Transactional을 설정할 수 있겠지만 앞서 언급했듯이 비효율이 발생할 수 있다. 이러한 경우 개발자가 직접 트랜잭션의 경계를 설정할 필요가 있고, 이 때 사용할 수 있는 것이 TransactionTemplate이다.
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionCallbackWithoutResult;
import org.springframework.transaction.TransactionStatus;
public class TransactionInvoker2 {
private A1Dao a1dao;
private A2Dao a2dao;
private TransactionTemplate transactionTemplate;public void setA1dao(A1Dao dao){
this.a1dao = dao;
}
public void setA2dao(A2Dao dao){
this.a2dao = dao;
}
public void setTransactionManager(PlatformTransactionManager transactionManager){
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
}
public void invoke() throws Exception{
doInternalTransaction();
}
private void doInternalTransaction() throws Exception{
transactionTemplate.execute(new TransactionCallbackWithoutResult(){
public void doInTransactionWithoutResult(TransactionStatus status){
try{
a1dao.insertA1();
a2dao.insertA2();
}
catch(Exception e){
status.setRollbackOnly();
\ }
return;
}
});
}
}[출처] TransactionTemplate 을 이용한 Spring Transaction 사용|작성자 예영아빠
21번째 라인에서 Spring에서 TransactionManager 를 주입 받는다. 이 때 TransactionTemplate 을 생성 및 Transaction 속성을 설정한다. 실제로 Transaction 을 실행하는 부분에서 처리해도 무방하다.
30번째 라인에서 43번쩨 라인에서는 TransactionTemplate.execute() 내에서 로직을 실행한다.