오늘은 @Transactional의 동작 원리를 AOP와 함께 좀 더 자세하게 조사해보려고 한다.
여기서 다루는 내용은 다음과 같다.
AOP란 Aspect Oriented Programming의 약자로 관점 지향 프로그래밍이라고 한다. 여기서 Aspect(관점)이란 흩어진 관심사들을 하나로 모듈화 한 것을 의미한다.
객체 지항 프로그래밍(OOP)에서는 주요 관심사에 따라 클래스를 분할한다. 이 클래스들은 보통 SRP(Single Responsibility Principle)에 따라 하나의 책임만을 갖게 설계된다. 하지만 클래스를 설계하다보면 로깅, 보안, 트랜잭션 등 여러 클래스에서 공통적으로 사용하는 부가 기능들이 생긴다. 이들은 주요 비즈니스 로직은 아니지만, 반복적으로 여러 곳에서 쓰이는 데 이를 흩어진 관심사(Cross Cutting Concerns)라고 한다.
AOP 없이 흩어진 관심사를 처리하면 다음과 같은 문제가 발생한다.
따라서 흩어진 관심사를 별도의 클래스로 모듈화하여 위의 문제들을 해결하고, 결과적으로 OOP를 더욱 잘 지킬 수 있도록 도움을 주는 것이 AOP이다.
Spring AOP는 기본적으로 프록시 방식으로 동작한다. 프록시 패턴이란 어떤 객체를 사용하고자 할 때, 객체를 직접적으로 참조 하는 것이 아니라, 해당 객체를 대행(대리, proxy)하는 객체를 통해 대상객체에 접근하는 방식을 말한다.
그렇다면 Spring은 왜 Target 객체를 직접 참조하지 않고 프록시 객체를 사용할까?
프록시 객체 없이 Target 객체를 사용하고 있다고 생각해보자. Aspect 클래스에 정의된 부가 기능을 사용하기 위해서, 우리는 원하는 위치에서 직접 Aspect 클래스를 호출해야 한다. 이 경우 Target 클래스 안에 부가 기능을 호출하는 로직이 포함되기 때문에, AOP를 적용하지 않았을 때와 동일한 문제가 발생한다. 여러 곳에서 반복적으로 Aspect를 호출해야 하고, 그로 인해 유지보수성이 크게 떨어진다.
그래서 Spring에서는 Target 클래스 혹은 그의 상위 인터페이스를 상속하는 프록시 클래스를 생성하고, 프록시 클래스에서 부가 기능에 관련된 처리를 한다. 이렇게 하면 Target에서 Aspect을 알 필요 없이 순수한 비즈니스 로직에 집중할 수 있다.
예를 들어 다음 코드의 logic()
메서드가 Target이라면,
public interface TargetService{
void logic();
}
@Service
public class TargetServiceImpl implements TargetService{
@Override
public void logic() {
...
}}
Proxy에서 Target 전/후에 부가 기능을 처리하고 Target을 호출한다.
@Service
public class TargetServiceProxy implements TargetService{
// 지금은 구현체를 직접 생성했지만, 외부에서 의존성을 주입 받도록 할 수 있다.
TargetService targetService = new TargetServiceImpl();
...
@Override
public void logic() {
// Target 호출 이전에 처리해야하는 부가 기능
// Target 호출
targetService.logic();
// Target 호출 이후에 처리해야하는 부가 기능
}
}
사용하는 입장에서는 Target 객체를 사용하는 것처럼 Proxy 객체를 사용할 수 있다.
@Service
public class UseService{
// 지금은 구현체를 직접 생성했지만, 외부에서 의존성을 주입 받도록 할 수 있다.
TargetService targetService = new TargetServiceProxy();
...
public void useLogic() {
// Target 호출하는 것처럼 부가 기능이 추가된 Proxy를 호출한다.
targetService.logic();
}
}
Spring에서는 몇 가지 설정을 하면 자동으로 Target의 프록시 객체를 생성해주는데, JDK Proxy(Dynamic Proxy)와 CGLib Proxy를 만들 수 있다.
두 방식의 가장 큰 차이점은 Target의 어떤 부분을 상속 받아서 프록시를 구현하느냐에 있다.
JDK Proxy는 Target의 상위 인터페이스를 상속 받아 프록시를 만든다. 따라서 인터페이스를 구현한 클래스가 아니면 의존할 수 없다. Target에서 다른 구체 클래스에 의존하고 있다면, JDK 방식에서는 그 클래스(빈)를 찾을 수 없어 런타임 에러가 발생한다.
CGLib Proxy는 Target 클래스를 상속 받아 프록시를 만든다. JDK 방식과는 달리 인터페이스를 구현하지 않아도 되고, 구체 클래스에 의존하기 때문에 런타임 에러가 발생할 확률도 상대적으로 적다. 또한 JDK Proxy는 내부적으로 Reflection을 사용해서 추가적인 비용이 들지만, CGLib는 그렇지 않다고 한다. 여러 성능 상 이점으로 인해 Spring Boot에서는 CGLib를 사용한 방식을 기본으로 채택하고 있다.
트랜잭션 처리를 위한 @Transactional
애노테이션은 Spring AOP의 대표적인 예이다. @Transactional
역시 Proxy 형태로 동작한다. (Spring은 JDK Proxy, Spring Boot는 CGLIb Proxy를 기본으로 하기 때문에, 사용하는 것에 따라 생성된 프록시 객체 형태는 다를 수 있다.)
코드 레벨로 보자면 아래와 유사한 작업이 이루어진다.
public class TransactionProxy{
private final TransactonManager manager = TransactionManager.getInstance();
...
public void transactionLogic() {
try {
// 트랜잭션 전처리(트랜잭션 시작, autoCommit(false) 등)
manager.begin();
// 다음 처리 로직(타겟 비스니스 로직, 다른 부가 기능 처리 등)
target.logic();
// 트랜잭션 후처리(트랜잭션 커밋 등)
manager.commit();
} catch ( Exception e ) {
// 트랜잭션 오류 발생 시 롤백
manager.rollback();
}
}
@Transactional
이 프록시 방식으로 동작하는 것을 모른다면 실수하기 쉬운 부분들이 있다. 여기서는 몇 가지 예제를 통해 그 부분을 짚고 넘어가고자 한다.
앞서 트랜잭션이 코드 레벨에서 어떻게 동작하는지 대락적으로 살펴봤다. 프록시 객체는 타겟 객체/인터페이스를 상속 받아서 구현하는데, private으로 되어 있으면 자식인 프록시 객체에서 호출할 수 없다. 따라서 @Transactional
이 붙는 메서드, 클래스는 프록시 객체에서 접근 가능한 레벨로 지정해야 한다.
다음과 같이 A, B, C의 메서드가 있다고 가정하자.
@Service
public class TestService {
@Autowired
CouponGroupMapper couponGroupMapper;
@Transactional
public void A(CouponGroupParam param) {
param.setStatus(CouponGroupStatus.CREATED); // 상태 변경
couponGroupMapper.insertCouponGroup(param);
}
public void B() {
for(int i=0; i<3; i++) {
CouponGroupParam param = new CouponGroupParam();
param.setName("1000포인트 쿠폰");
param.setAmount(1000);
param.setMaxCount(100);
param.setValidFromDt(new Date());
param.setValidToDt(new Date());
param.setIssuerId("0101");
param.setCode("B000" + i);
A(param);
}
throw new RuntimeException(); // 오류 발생!
}
@Transactional
public void C() {
for(int i=0; i<3; i++) {
CouponGroupParam param = new CouponGroupParam();
param.setName("1000 포인트 쿠폰");
param.setAmount(1000);
param.setMaxCount(100);
param.setValidFromDt(new Date());
param.setValidToDt(new Date());
param.setIssuerId("0101");
param.setCode("C000" + i);
A(param);
}
throw new RuntimeException(); // 오류 발생!
}
}
B, C 메서드 모두 정상적인 경우라면 쿠폰 3개를 신규 생성한다. 그렇다면 B와 C 메서드는 동일한 기능을 한다고 볼 수 있을까?
쿠폰을 3개를 모두 생성한 뒤 오류가 발생했다고 가정해보자.
B,C 메서드에서 호출하는 A 메서드에는 트랜잭션 처리가 되어있기 때문에, B,C 모두 3개의 쿠폰을 정상적으로 생성한다고 예상할 수도 있다. 직접 코드를 실행했을 때의 결과는 다음과 같았다.
진입 메서드에 트랜잭션이 적용되어 있지 않은 경우 (B 메서드)
진입 메서드에 트랜잭션이 적용되어 있는 경우 (C 메서드)
이렇게 B, C 메서드를 실행한 결과가 다른 것은 트랜잭션이 프록시로 동작한다는 사실을 이해한다면 당연한 결과이다.
클래스에 @Transactional
처리가 되어 있는 부분(A, C 메서드)이 있다면, Spring은 해당 부분에 트랜잭션 처리를 추가한 프록시를 자동으로 생성한다. 그리고 외부에서 호출하면, 원래 클래스가 아닌 프록시가 대신 호출된다.
① C 메서드를 호출하면, TestService가 아닌 TestService의 프록시에 구현된 C 메서드가 대신 호출된다. 따라서 C와 C에서 호출하는 A 모두 프록시 객체에서 트랜잭션 처리를 해준다.
② 하지만 B 메서드를 호출하는 것은 트랜잭션 처리가 되어 있지 않은 순수 B 메서드를 호출하는 것과 같다. 이때 B에서 호출하는 A 역시 트랜잭션 처리가 되어 있지 않다.
결과적으로 트랜잭션은 객체 외부에서 처음 진입하는 메서드를 기준으로 동작한다는 사실을 알 수 있다.
@Transactional
도 Spring AOP 중 하나로 프록시 방식으로 동작한다.
전반적인 이해해 큰 도움이 되었습니다. 너무 감사합니다!
출처 남긴 후 제 벨로그에도 기록해도 될까요?