Spring AOP 스터디 - (3) 프록시 객체의 내부 메서드 호출 문제

kindof

·

2022. 11. 20. 16:47

1. 문제

이번 글에서는 Spring AOP를 공부할 때 반드시 알아야 할 프록시 객체의 내부 메서드 호출 문제에 대해 다뤄보려고 합니다.


테스트 환경을 만들기 위해 이전 글에서 작성했던 예제 코드 일부를 조금 수정해보겠습니다.

 

[UserService.java]

@Service
@RequiredArgsConstructor
@Import(PerfAspect.class)
public class UserService {
    private final UserRepository userRepository;

    public void save(User user) throws UserAlreadyExistException{
        if(isAlreadyExistUser(user)){
            throw new UserAlreadyExistException("이미 존재하는 회원입니다.");
        }
        userRepository.save(user);
    }

    public boolean isAlreadyExistUser(User user) {
        return userRepository.findByEmail(user.getEmail()) != null;

    }
    
    ...
}

save() 메서드는 (1) 컨트롤러로부터 회원의 이메일과 닉네임을 받은 뒤, (2) 해당 회원이 이미 존재하는지를 체크(isAlreadyExistUser())하고 회원가입을 하도록 합니다.

 

그리고 UserService 클래스에는 아래 PerfAspect를 적용(Import)하여 각 메서드의 호출 시간을 기록하고자 합니다.

[PerfAspect.java]

@Slf4j
@Aspect
public class PerfAspect {

    @Around("execution(* com.sh.springaop.UserService.*(..))")
    public Object perfCheck(ProceedingJoinPoint joinPoint) throws Throwable {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        Object result = joinPoint.proceed();

        stopWatch.stop();
        log.info("{} takes {} ms.", joinPoint.getSignature(), stopWatch.getTotalTimeMillis());

        return result;
    }
}

 

이제, 아래 테스트코드를 실행하여 AOP가 정상적으로 적용되었는지 보겠습니다.

 

의도한대로 AOP가 동작한다면

  • (1) userService.getClass()를 통해 가져온 객체는 CGLIB가 포장한 프록시 객체여야 하고
  • (2) save() 메서드와 isAlreadyExistUser() 메서드에 대한 수행시간 로그가 찍혀야 합니다.

테스트 코드

하지만 테스트 결과는 아래와 같습니다.

테스트 결과

CGLIB 프록시로 생성된 UserService 객체는 정상적으로 확인되지만, isAlreadyExistUser() 메서드에 대한 로그가 없습니다.

 

save() 메서드를 실행하면 isAlreadyExistUser() 메서드도 호출이 되어야하고, 이 메서드에도 PerfAspect가 적용되어 있을텐데요.

 

왜 이런 문제가 발생할까요?

 

2. Spring AOP와 프록시 객체

위 결과를 이해해보기 위해 아래와 같이 디버깅 포인트를 걸고 테스트 코드를 실행시켜보겠습니다.

Debugging point

맨 처음 save() 메서드가 호출되는 시점에 해당 메서드를 가진 객체는 아래와 같이 프록시 객체인 것을 확인할 수 있습니다. 이 내용은 테스트 코드의 log.info()를 통해서도 확인했었죠.

save() 메서드의 주체가 되는 객체는 프록시 객체다.

 

그리고 F7(Step into) 명령어를 통해 다음 순간 호출되는 코드를 살펴보면 intercept() 메서드로 가게 됩니다.

intercept()

그러면 빨간색 상자로 표시한 것처럼 프록시 객체의 정보와 save() 메서드가 method 정보를 알 수 있습니다. 다시 Step into 하여 진행시켜보겠습니다.

MethodMatcher.TRUE

getInterceptorsAndDynamicInterceptionAdvice() 메서드에서는 해당 Method가 Advisor의 Pointcut에 해당하는지를 체크하는데, 이 때 값이 'TRUE' 입니다.

 

이렇게 위와 같은 내부 동작을 거쳐서 save() 메서드가 호출되면, joinPoint.proceed() 전 후로 시간 체크 로직이 수행되는 것이죠.

 

 

이제 몇 단계의 과정을 거치다가 isAlreadyExistUser() 메서드를 호출하는 시점으로 가보겠습니다.

isAlreadyExistUser() 호출 시점

사진에서 확인할 수 있듯 isAlreadyExistUser() 메서드가 호출되는 객체는 this = {UserService@7300} 입니다. 프록시 객체가 아닌 순수한 객체입니다.

 

따라서, 그 이후에 Intercept() 메서드가 호출되지도 않고 단순히 객체가 가지고 있는 isAlreadyExistUser() 메서드만 호출하고 종료됩니다.

 

 

지금까지 디버깅을 통해 실험한 내용을 요약하면 다음과 같습니다.

 

  • AOP를 적용하기 위해서는 CGLIB가 감싼 프록시 객체를 호출하여 intercept() 로직을 실행시켜야 한다.
  • save() 메서드 호출 시점에는 프록시 객체가 호출되어 intercept() 로직을 수행하고, AOP가 적용된다.
  • 하지만 프록시 객체 내에서 내부의 다른 메서드를 호출하게 되면 '프록시를 거치지 않고' 직접 this 참조를 통한 객체를 호출한다.
  • 그 결과, 내부 메서드 호출 시 AOP는 적용되지 않는다.

 

* 아래는 Spring 공식 문서에서 위 내용을 설명한 부분입니다.

public class Main {

    public static void main(String[] args) {
        ProxyFactory factory = new ProxyFactory(new SimplePojo());
        factory.addInterface(Pojo.class);
        factory.addAdvice(new RetryAdvice());

        Pojo pojo = (Pojo) factory.getProxy();
        // this is a method call on the proxy!
        pojo.foo();
    }
}

https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#aop-understanding-aop-proxies

 

그렇다면 이제 아래 문제를 풀어야 합니다.

 

프록시 객체 내부에서 직접 메서드를 호출할 때는 어떻게 AOP를 적용할까?

 

3. 해결 방법

| 3-1. 자기 자신은 이미 Bean으로 등록되어 있음을 이용한다.

위에서 내부 함수를 호출했을 때 AOP가 적용되지 않는 이유는 프록시 객체를 통하지 않고, this 참조를 통해 메서드를 호출하기 때문이라고 했습니다.

 

그렇다면 this가 아닌 ApplicationContext에 등록된 자기 자신의 Bean을 명시적으로 지정해주면 어떨까요?

ApplicationContext에 있는 Bean을 명시적으로 지정
isAlreadyExistUser() 메서드도 로그가 남는다.

당연히 우리가 의도했던 것처럼 프록시 객체로 등록된 Bean을 통해 함수를 호출했으니 AOP가 잘 적용됩니다.

 

하지만, AOP의 목적 자체가 저런 불필요한 코드를 분리하는 것에 있었는데 이런 방식으로 해결한다는 것은 주객이 전도된 느낌을 들게 합니다.

 

 

| 3-2. 메서드 분리

https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#aop-proxying - (5.8.1)

Spring 공식 문서에서는 위와 같은 문제의 해결책으로 "코드를 리팩토링하라"고 합니다. 즉, 내부 메서드 호출이 일어나지 않도록 만드는 것이 최선이라는 설명인데요.

 

그래서 위에서 isAlreadyExistUser() 메서드를 다른 클래스로 빼서 작성해보겠습니다.

[UserServiceUtils.java]

@Component
@RequiredArgsConstructor
@Slf4j
public class UserServiceUtils {

    private final UserRepository userRepository;

    public boolean isAlreadyExistUser(User user) {
        return userRepository.findByEmail(user.getEmail()) != null;
    }
}

위와 같이 UserServiceUtils 라는 클래스를 만들고, @Component 어노테이션을 통해 Bean으로 등록합니다.

PerfAspect의 PointCut 수정

그리고 PerfAspect.java에서 UserServiceUtils도 Target이 되도록 Pointcut을 수정했습니다.

 

이제 UserService 로직을 아래와 같이 바꾸고 테스트를 돌려보면 원하는대로 isAlreadyExistUser()에 대한 로그도 남는 것을 확인할 수 있습니다.

 

userServiceUtils.isAlreadyExistUser()로 호출
isAlreadyExistUser() 메서드의 로그가 남는다.

 

 

3. Reference

https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#aop-proxying

 

Core Technologies

In the preceding scenario, using @Autowired works well and provides the desired modularity, but determining exactly where the autowired bean definitions are declared is still somewhat ambiguous. For example, as a developer looking at ServiceConfig, how do

docs.spring.io