Nick Dev

[Outstagram] 왜 같은 클래스에서 @Cacheable 달린 메서드 호출하면 씹힐까... 본문

Outstagram

[Outstagram] 왜 같은 클래스에서 @Cacheable 달린 메서드 호출하면 씹힐까...

Nick99 2024. 12. 12. 02:02
반응형

@Cacheable이란?

  • springframework의 어노테이션으로 메서드에 target은 메서드이고
  • 메서드의 리턴 값을 편리하게 캐시해주는 것
  • 이때, @cacheable은 특정 캐시(Redis) 등에 종속되지 않은 추상화된 기능이기에 캐시를 변경하여도 애플리케이션 코드에 영향을 주지 않는다
  • 동작 방식
  • Spring AOP 방식으로 프록시 패턴을 통해 메서드의 반환 값을 자동으로 캐시해주는 방식이다.
  • 간단하게 Spring AOP와 프록시 패턴에 대해 알아보자정의
  • Spring AOP
  • Aspect-Oriented Programming (관점 지향 프로그래밍)의 줄임말
  • 관점을 바탕으로 모듈별(메서드별)로 중복해서 나오는 횡단 관심사(cross concern)을 메서드로 걷어내고, 해당 관심사를 필요로 하는 곳에 해당 메서드를 주입하는 것이다
  • 즉, AOP는 횡단 관심사 로직들을 주입해주는 것이다

간단한 예시

  • 서비스 전반에서 현재 로그인한 유저의 정보를 가져오고 싶을 때, 서비스 계층 등의 각 메서드에서 현재 로그인한 유저의 정보를 조회하는 로직이 다 들어가야 된다
  • 이러한 애플리케이션 전반에 걸친 횡단 관심사를 걷어내 메서드로 만들어 놓고, 각자 해당 로직이 필요할 때 간단하게 어노테이션 등을 통해 로직을 주입 받을 수 있다.
  • @Slf4j @Component @Aspect public class LoginAspect { @Pointcut("execution(* *(.., @com.outstagram.outstagram.common.annotation.Login (*), ..))") public void loginRequired() { }
@Around("loginRequired()")
public Object checkSession(ProceedingJoinPoint joinPoint) throws Throwable {

    log.info("AOP - @Login Check Started");
    ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    HttpServletRequest request = Objects.requireNonNull(attributes).getRequest();
    HttpSession session = request.getSession(false);

    if (session == null) {
        log.info("AOP - @Login Check Result - session empty");
        throw new ApiException(ErrorCode.UNAUTHORIZED_USER);
    }

    UserDTO user = (UserDTO) session.getAttribute(LOGIN_USER);
    if (user == null) {
        log.info("AOP - @Login Check Result - user empty");
        throw new ApiException(ErrorCode.UNAUTHORIZED_USER);
    }

    /*
     joinPoint 안는 현재 실행중인 메서드에 대한 정보들을 담고 있음(메소드 이름, 타입, 파라미터)
     getArgs() : 현재 메서드에 전달된 파라미터들을 객체 배열 형태로 리턴
     이 파라미터들 중에서 타입이 UserDTO인 것을 찾아 현재 세션의 유저 정보를 넣어줄 것임
    */
    Object[] args = joinPoint.getArgs();
    for (int i = 0; i < args.length; i++) {
        if (args[i] instanceof UserDTO) {
            args[i] = user;
        }
    }

    /*
     @LoginSession UserDto user -> 이 user에 현재 session에 있는 유저를 넣어준다
     위에서 변경한 파라미터를 적용하려면 파라미터 배열을 proceed() 메서드에 전달해야 함
    */
    return joinPoint.proceed(args);

}

}


```java

/**
 * 로그인한 유저 정보 세션에서 찾아오는 애노테이션
 */
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {

}
@PostMapping
public ResponseEntity<ApiResponse> createPost(
    @ModelAttribute @Valid CreatePostReq createPostReq, @Login UserDTO user) {
    postService.insertPost(createPostReq, user.getId());

    return ResponseEntity.ok(
        ApiResponse.builder().isSuccess(true).httpStatus(HttpStatus.OK).message("게시물을 저장했습니다.")
            .build());
}
  • @Login 어노테이션을 호출하면 LoginAspect 의 코드가 실행되면서 현재 세션에 있는 로그인된 유저의 정보를 가져와서 파라미터인 UserDTO user에 넣어준다

⭐ 그럼 어떻게 이렇게 작동되는가...?

  • 프록시 패턴을 통해서 AOP를 적용해준다
  1. 클라이언트에 의해 컨트롤러의 createPost 메서드를 호출하면 실제로는 프록시 객체가 이 호출을 받는다.
  2. 프록시 객체가 @Login을 보고 LoginAspect 로직을 실행해 파라미터 user에 현재 세션에 로그인된 유저의 정보를 넣어준다.
  3. 이렇게 수정된 파라미터들을 가지고 실제 컨트롤러 객체의 createPost 메서드를 실행한다.
  • 즉, 프록시 객체가 실제 객체의 createPost 메서드 실행하기 직전에 파라미터를 수정하고, 수정된 파라미터들을 넣어서 실제 객체의 createPost 메서드를 실행한다

✅ Spring AOP의 단점..?!

  • 프록시 패턴을 통해 AOP를 수행하기에 실제 메서드를 호출하기 전, 후 즈음에만 로직을 집어 넣을 수 있고, 실제 메서드 안에 로직을 집어넣을 수는 없다.
  • 그래서 메서드 실행 직전, 직후, 파라미터 등에만 로직을 집어넣을 수 있는 것이 프록시 패턴을 통해 AOP를 구현하기 때문이다.정의
  • 프록시 패턴이란?
  • 제어 흐름을 조정하기 위한 목적으로 중간에 대리자(프록시 객체)를 두는 패턴

중요 포인트

  • 대리자(Proxy) 객체를 통해서 실제 객체의 참조를 숨기며, 대리자는 실제 객체와 같은 인터페이스를 구현한다
  • 클라이언트는 대리자를 통해 실제 객체를 사용할 수 있다
  • 실제 객체의 리턴 값을 수정하지 않는다!!! 그저 실제 객체의 메서드 실행 전 후로 제어 흐름을 조정할 수 있을 뿐,,,,
  • OCP, DIP가 적용된 설계 패턴

어디에 활용되는가?

  • 로깅, 모니터링 작업 등에서 유용하게 이용된다
    • 프록시를 통해 메서드 호출 정보를 기록하거나, 시스템의 성능을 기록할 수 있다.
  • 지연 로딩에도 활용된다
    • 프록시는 진짜로 해당 데이터가 필요할 때 데이터를 생성함

다시 본론으로...

현재 문제점

  • 같은 PostService 내부에서 @Cacheable이 적용된 메서드를 호출하면 @Cacheable이 적용되지 않고 메서드가 호출된다
  • 아래 예시를 통해 확인해보쟈

코드 예시

@Slf4j
@Service
@RequiredArgsConstructor
public class PostService {
    ...
    @Cacheable(value = POST, key = "#postId")
    public PostDTO getPost(Long postId) {
        return postMapper.findById(postId);
    }

    ...

    public PostDetailsDTO getPostDetails(Long postId, Long userId) {
        PostDTO post = getPost(postId);
        ...
    }
}

코드 설명

  • getPost()
    • postId를 통해 DB에서 post 데이터 가져오기
    • @Cacheable을 통해 캐시에 없으면 findById()를 통해 DB에서 가져온 후 캐시에 넣고 캐시에 있으면 캐시의 데이터를 반환해줌
  • getPostDetails()
    • 게시물, 게시물 댓글 등을 모두 조합해서 반환하는 메서드
    • 조합할 때, 같은 파일에 있는 getPost()를 통해 게시물 데이터가 캐시에 있다면 캐시의 데이터를, 캐시에 없다면 DB의 데이터를 가져오고 싶다
    • 하지만 위에서처럼 그냥 PostDTO post = getPost(postId);getPost() 메서드를 호출하면 캐시에 데이터가 있든 없든 무조건 DB에서 조회해온다...

같은 클래스 내에 있는 메서드를 호출했을 때는 왜 @Cacheable이 안 적용될까??

  • 왜냐하면 @Cacheable은 위에서 말했다시피 Spring AOP를 통해 적용된다.
  • Spring AOP는 프록시 객체를 통해 동작하는데, 프록시 객체는 외부에서 호출될 때만 프록시 객체를 거쳐서 메서드를 호출한다.
  • 즉, @Cacheable을 적용하려면 프록시 객체를 통해 메서드가 호출되어야 된다
  • 하지만 클래스 내부에서 메서드를 호출하면 프록시 객체를 거치지 않고 직접 호출되기 때문에 AOP가 적용되지 않는다

@Cacheable 동작 원리

  • 프록시를 통해 해당 메서드가 호출 될 때, 실제 메서드 실행 직전에 캐시를 검사하고, 캐시에 없으면 실제 메서드를 호출하여 결과를 캐시에 저장한다.
  • 같은 클래스 내에서 메서드를 호출하면 프록시가 동작하지 않기 때문에 캐시를 확인하지 않고 바로 메서드를 실행해버린다
  • 그래서 getPostDetails()에서 getPost()를 호출했을 때 캐시를 확인하지 않고 무조건 DB에서 조회해오는 것임

해결 방법

  • AopContext.currentProxy() 를 통해 현재 실행 중인 AOP 프록시 객체를 반환받을 수 있다.
  • 이를 통해서 클래스 내부에서도 자기 자신의 메서드를 프록시를 통해 호출할 수 있다
public PostDetailsDTO getPostDetails(Long postId, Long userId) {

    PostService proxy = (PostService) AopContext.currentProxy();

    PostDTO post = proxy.getPost(postId);
    ...
}
  • 현재 실행 중인 프록시 객체를 통해서 getPost()를 호출하면 캐시에 있다면 캐시의 데이터를, 캐시에 없다면 DB에서 조회한 데이터를 반환해준다.

AopContext.currentProxy() 사용을 위한 몇 가지 세팅

  1. 메인 Application 클래스에 @EnableAspectJAutoProxy(exposeProxy = true) 어노테이션 달기 ( + 캐싱을 위해 @EnableCaching도 달기)
  2. @EnableAspectJAutoProxy(exposeProxy = true) @EnableCaching @SpringBootApplication public class OutstagramApplication { public static void main(String[] args) { SpringApplication.run(OutstagramApplication.class, args); }

}

2. application.properties or application.yml에서 설정해주기
```yml
spring.aop.proxy-target-class=true
spring.aop.expose-proxy=true

⭐ 정리

  • @Cacheable은 Spring AOP를 통해 동작함
  • 프록시 객체가 @Cacheable 달려 있는 메서드 수행을 받아서 실제 메서드 수행 전에 캐시에 데이터 있는지 확인
  • 있으면 캐시 데이터 바로 리턴(실제 메서드 수행 안됨)
  • 없으면 실제 메서드 수행하고 결과를 캐시에 저장
  • 프록시 객체를 통해서 메서드를 수행해야지 캐시 조회 및 저장이 가능하다
  • 그래서 같은 클래스 내의 메서드를 호출하면 @Cacheable이 적용되지 않을 수 있다
  • 그럴 땐, AopContext.currentProxy()를 통해 현재 실행 중인 AOP 프록시 객체를 반환받아서 메서드를 실행하자!
반응형