[Spring] Spring AOP 사용 시 주의점 (With @Transactional)

김효권·2023년 8월 29일
post-thumbnail

문제 상황

실무에서 @Transactional 어노테이션을 사용하면서 겪은 문제이다. 다음과 같은 코드에서 문제가 발생했다.

    public void update(){
        _update(); // 경고 발생
        // 비즈니스 로직
    }

    @Transactional
    public void _update()    {
		// DB 접근 로직
    }

IntelliJ의 플러그인으로 사용하고 있는 SonarLint에서 버그를 감지했는데, 메세지를 확인해 보면 다음과 같다.

확인해보면 실제 트랜잭션이 동작되지 않는다고 써있는데, 그 이유는 뭘까?

@Transactional의 동작 원리

먼저 @Transactional은 스프링AOP 기반(프록시)으로 동작된다. 스프링 AOP는 부가기능(Advice)적용할 대상(Pointcut)에 대해 런타임에 프록시를 생성하고, 생성한 프록시를 빈으로 등록해서 사용하게 된다.
출처: 인프런-스프링 핵심 원리 고급편(김영한)
위 그림은 스프링 AOP에서 부가기능을 적용한 프록시를 생성하는 과정을 나타낸다. 먼저 생성된 객체가 BeanPostProcessor에 전달되면, 해당 객체를 부가기능을 적용한 프록시 객체로 변환 후 스프링 빈 저장소에 저장한다. 여기서 핵심은 부가기능을 적용한 메서드를 사용하기 위해서는 저장소에 등록된 프록시 객체를 사용해야한다.

참고: @Transactional에 관련된 AOP 클래스는 다음과 같다.
Advisor: BeanFactoryTransactionAttributeSourceAdvisor
Advice: TransactionInterceptor
Pointcut: TransactionAttributeSourcePointcut
위 클래스에 의해서 코드 내의 @Transactional가 존재하면 트랜잭션 적용 대상으로 인식하여, 프록시 객체를 생성한 뒤 빈 저장소에 등록한다.

문제 원인

    public void update(){
        _update(); // 경고 발생
        // 비즈니스 로직
    }

    @Transactional
    public void _update()    {
		// DB 접근 로직
    }

그래서 위 코드에서 발생한 경고 메세지에 대한 원인은 다음과 같이 정리할 수 있다.

  1. @Transactional이 있으므로, 트랜잭션이 적용되는 대상에 선정됨
  2. 해당 클래스는 트랜잭션이 적용된 프록시가 생성되어 빈 저장소에 등록되었음
  3. 하지만, 트랜잭션이 필요한 메서드인 _update()를 클래스 내부에서 직접 호출하기 때문에 트랜잭션이 적용되지 않음
  4. 클래스 내부에서 호출하는 행위는 프록시를 호출하는 것이 아니라 자기 자신(this)를 호출하는 것

문제 해결

간단한 예제 코드와 함께 여러 문제 해결 방법을 소개한다.

@Aspect
@Slf4j
public class SimpleTransactionAspect {

    @Around("@annotation(simpleTransactional)")
    public Object doTx(ProceedingJoinPoint joinPoint, SimpleTransactional simpleTransactional) throws Throwable {
        log.info("[tx] start");
        try {
            Object result = joinPoint.proceed();
            log.info("[tx] commit");
            return result;
        } catch (Throwable throwable) {
            log.info("[tx] rollback");
            throw e;
        } finally {
            log.info("[tx] end");
        }
    }
}
@Service
@Slf4j
public class MemberService {

    public void save() {
        _save();
        // 트랜잭션이 끝나고 로직 수행
    }

    @SimpleTransactional
    public void _save() {
        log.info("[MemberService] save");
    }
}

@SimpleTransactional이 존재하는 클래스에 대해 트랜잭션을 적용하는 Aspect를 생성하였다. MemberServicesave()메서드를 수행하는 테스트 코드를 작성하고 실행했다.

@SpringBootTest
@Import(SimpleTransactionAspect.class)
class MemberServiceTest {

    @Autowired private MemberService memberService;

    @Test
    void save() {
        memberService.save();
    }
}


역시나 트랜잭션 적용대상임에도 불구하고 적용이 되어있지 않다.

대안1. 자기 자신 주입 & 지연 로딩

Spring boot 2.6 이상인 경우에는 Bean간의 순환 참조는 금지되어 있기 때문에, 자기 자신을 주입하기 위해서는 지연 로딩을 같이 사용해야한다.

@Service
@Slf4j
public class MemberService {

    private MemberService memberService;

    @Autowired
    @Lazy
    public void setMemberService(MemberService memberService) {
        this.memberService = memberService;
    }

    public void save() {
        memberService._save();
        // 트랜잭션이 끝나고 로직 수행
    }

    @SimpleTransactional
    public void _save() {
        log.info("[MemberService] save");
    }
}

MemberService에 자기자신을 Setter 주입받는다. @Lazy 를 통해 memberService를 사용하는 시점에 Setter 주입한다.

다시 한번 테스트 코드를 실행해보면, 정상적으로 트랜잭션 기능이 적용되어 있다.

대안2. 구조 변경

@Component
@Slf4j
public class InternalMemberService {
    @SimpleTransactional
    public void _save() {
        log.info("[MemberService] save");
    }
}

@RequiredArgsConstructor
@Service
@Slf4j
public class MemberService {

    private final InternalMemberService memberService;

    public void save() {
        memberService._save();
        // 트랜잭션이 끝나고 로직 수행
    }
}

클래스를 따로 만든 뒤에, MemberSerivce에서 해당 클래스를 주입받아서 사용하는 방법이다. 해당 방법도 마찬가지로 정상적으로 트랜잭션이 적용된다. 구조 변경하는 방법은 위 방법 말고도 여러가지가 있을 수 있다.

참고

인프런 - 스프링 핵심 원리 고급편

0개의 댓글