Spring AOP의 Self Invocation 문제

Kim, Beomgoo·2023년 5월 13일
1
post-thumbnail

문제상황

초기 코드

@Service
@Transactional
@RequiredArgsConstructor
public class ChattingService {

	private final UserRepository userRepository;
    
	private MessageResponse getMessageResponse(final MessageRequest request) {
		User user = userRepository.findById(request.getUserId()).orElseThrow(IllegalArgumentException::new);
		return new MessageResponse(user.getId(),user.getName(), request.getContent()); //보낼 메세지 객체를 리턴
	}
    
}

위의 코드처럼 ChattingService.getMessageResponse() 메소드가 있다. 이 메소드는 매우 자주 호출되는 메소드로, repository를 통해 DB에서 데이터를 조회하는 로직이 포함되어 있어 캐시를 이용할 필요가 있다고 느꼈다. 그래서 맨 처음 리팩토링한 코드는 다음과 같았다.

1차 리팩토링

@Service
@Transactional
@RequiredArgsConstructor
public class ChattingService {

	private final UserRepository userRepository;
    
    @Cacheable(value = "UserInfo", key = "#userId", cacheManager = "redisCacheManager")
    public UserDto.UserInfo getUserInfoById(UUID userId) {
        User findUser = userRepository.findById(userId).orElseThrow(IllegalArgumentException::new);

        return UserDto.UserInfo.of(findUser);
    }
    
	private MessageResponse getMessageResponse(final MessageRequest request) {
		UserDto.UserInfo userInfo = getUserInfoById(request.getUserId()).orElseThrow(IllegalArgumentException::new);
		return new MessageResponse(userInfo.getId(), userInfo.getName(), request.getContent()); 
	}
    
}

User 엔티티를 조회하는 로직을 다른 메소드로 분리했다. 분리된 UserDto.UserInfo getUserInfoById(UUID userId) 메소드는 조회한 엔티티를 DTO로 변환하여 리턴하는 메소드로, 만약 캐시에 해당 값이 있다면 DB에 직접 조회하는 것이 아닌 캐시의 값을 이용하는 로직이다. 캐시 이용은 Spring Cache의 @Cacheable 어노테이션을 이용했다.

그런데

실행하고 로그를 보니 처음 조회 시만 DB 쿼리 로그가 나가고 그 다음부터는 로그가 뜨면 안 되는데 계속해서 로그가 뜨고 있었다. 마찬가지로 캐시에도 아무런 값이 들어가 있지 않았다.

원인

Spring Cache는 AOP를 기반으로 한다.

다른 클래스에서 @Cacheable을 사용할 때는 캐싱이 잘 됐던 것이 이상해서, 찾아보니 Spring Cache는 AOP를 기반으로 동작한다고 한다.

AOP Self Invocation Problem

AOP에서 가장 중요한 키워드 중 하나를 고르라고 한다면 프록시 패턴일 것이다.
코드상으로만 보면 흐름은 다음과 같다.

그러나 실제로는 다음과 같이 동작한다.

ChattingService.getMessageResponse() 호출은 프록시의 메소드를 호출하는 것이다. 이후 프록시의 부가기능이 실행되면 실제 Target의 getMessageResponse()를 호출한다. 그 다음 getUserInfoById()를 호출하는데, 이때 부가기능이 적용된 프록시의 메소드가 아닌 this의 메소드를 호출한다. 따라서 당연하게도 캐시 부가기능은 적용되지 않는다.

AOP가 적용된 로직은 같은 클래스 내에서 호출하면 안 된다.

가장 자주 흔하게 쓰이지만 AOP가 적용되었다는 점을 모르고 쓰는 것이 바로 @Transactional 어노테이션이다.

해결

클래스 분리

메소드를 아예 다른 클래스로 분리하여 해결했다.

  • UserService.java
@Service
@Transactional
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    @Cacheable(value = "UserInfo", key = "#userId", cacheManager = "redisCacheManager")
    public UserDto.UserInfo getUserInfoById(UUID userId) {
        User findUser = userRepository.findById(userId).orElseThrow(IllegalArgumentException::new);

        return UserDto.UserInfo.of(findUser);
    }
}
  • ChattingService.java
@Service
@Transactional
@RequiredArgsConstructor
public class ChattingService {

    private final UserService userService;
    
	private MessageResponse getMessageResponse(final MessageRequest request) {
		UserDto.UserInfo userInfo = userService.getUserInfoById(request.getUserId()).orElseThrow(IllegalArgumentException::new);
		return new MessageResponse(userInfo.getId(), userInfo.getName(), request.getContent()); 
	}
    
}

실행해 보니 캐싱이 잘 작동하는 것을 확인했다.

참고 - Spring AOP

profile
하나에 하나를 보탠다

0개의 댓글