공부/Spring

[공부/Spring] Spring AOP로 나만의 캐싱기능 구현하기

yulee_to 2024. 7. 25. 16:53

배경

Redis를 이용해서 캐싱 기능을 구현하려고 한다. 스프링에서 캐싱을 위해 @Cacheable 이라는 어노테이션을 지원해주지만 단순히 메서드의 반환 값을 캐시하여 이후 동일한 인수로 호출될 때 캐시된 값을 반환만 해준다. @Cacheable은 복잡한 캐싱 전략을 사용하기 어렵고, Redis의 여러 기능들을 사용하려면 추가적인 설정이 필요하다. 일단 "나만의 캐싱 기능을 구현"하는 것이 목적이기 때문에 스프링에서 제공하는 @Cacheable를 사용하지 않고 기능을 구현하려고 한다.

나만의 캐싱 전략

어떤 서비스냐에 따라 캐싱 전략 또한 변해야 한다. 아직 생각하고 있는 서비스는 없으므로, 가장 일반적인 읽기는 많고 그에 비해 쓰기는 비교적 적은 서비스를 위한 캐싱 전략을 생각해보고자 한다.

읽기 전략

개인적으로 사용자 편의성을 우선시하기 때문에 다음과 같은 읽기 전략을 생각해봤다.

  1. 읽기 요청이 들어오면 Redis를 먼저 탐색한다.
  2. Redis에 해당하는 데이터가 있으면(Cache Hit) 바로 사용자에게 반환한다.
  3. Redis에 해당하는 데이터가 없으면(Cache Miss) DB에 접근해 데이터를 조회해온다.
  4. 조회한 데이터는 바로 사용자에게 응답으로 보내주고, Redis에 데이터를 캐싱하는 것은 비동기로 수행한다.
    Look Aside와 Read Through 패턴 중에서는 Look Aside 패턴의 전략이다. Cache 저장소와 DB간 일관성 문제가 발생할 수 있지만 캐시 데이터의 TTL을 설정해 보완하려고 한다.

쓰기 전략

쓰기가 비교적 적은 서비스를 가정했고, 빠른 속도와 DB 성능을 보장하기 위해 Write Back을 사용하고자 한다.
주기적인 동기화로 인해 중간에 일관성 문제가 있을 수 있고, 비동기로 수행하기 때문에 예외 처리를 해줘야 한다.

생각만 해보는 기능들

Redis cluster를 사용해서 고가용성을 확보하거나, Write Back의 주기적인 동기화 외에 Redis가 가득찼을 때 동기화를 해주는 이벤트 기반 동기화, 비동기 작업 실패시 재시도 등을 적용해볼 수 있지만 너무 복잡해질 것 같아서 일단 간단한 기능만 구현하고자 한다.

캐싱 구현하기

AOP 이외의 방식들

RedisCacheManager를 사용하는 방식과 RedisTemplate을 쓰는 두가지 방식이 추가로 있다. RedisCacheManager 방식은 캐시 수가 늘어날수록 설정을 하나하나 해줘야 하고, 캐시하려는 메서드와 TTL 설정 메서드가 분리되어 있어 관리가 어렵다는 단점이 있다. RedisTemplate 방식은 캐시를 위한 로직이 비즈니스 로직과 섞이고, 캐시를 사용하는 클래스마다 RedisTemplate을 주입받아서 써야 한다는 단점이 있다.

AOP로 캐싱 구현하기

커스텀 어노테이션 만들기

  • @CachingXXX : 메서드의 return할 객체를 캐싱

  • @CacheoutXXX : 파라미터로 넘어온 id를 기준으로 캐싱된 데이터 삭제

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface CachingXXX {
    
      String key();
    
      String cacheManager();
    }
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface CacheoutXXX {
      String key();
      String cacheManager();
    }

    Aspect

    @Around("@annotation(org.ass1.annotation.CachingXXX)")로 id값에 해당하는 Member 객체를 캐시에서 먼저 찾아보고 없으면 JdbcTemplate을 이용해 MySQL에서 데이터를 찾도록 구현했다.
    @Around("@annotation(org.ass1.annotation.CacheoutXXX)")로 id값에 해당하는 Member 객체를 캐시에서 삭제하도록 구현했다.

실행 결과

처음에 2번 Member를 조회할 땐 MySQL에서 데이터를 찾아오고, 그 다음 조회에서는 Redis에서 가져온다.

Redis에서 2번 Member를 삭제한 뒤에 다시 조회하면 MySQL에서 데이터를 찾아온다.

트러블 슈팅

AOP 동작 안됨

커스텀 어노테이션을 붙여준 메서드를 실행할 때 AOP가 Adivce를 실행시켜주지 못했다.
어노테이션이 다른 패키지에 있어서 안되는 것 같아서 어노테이션의 전체 경로를 적어주니 동작했다.

후기

캐싱 전략은 나름대로 구상을 해봤지만 실제로 구현하는 데 시간이 오래걸렸다. 읽기 전략은 나름대로 구현했으나 쓰기 전략은 나중에 보충이 필요할 것 같다.
코드도 중구난방으로 짠거 같아서 아쉬움이 남지만 AOP가 제대로 동작하고, 처음 조회할 땐 MySQL에서 가져오고 두번째부터는 Redis에서 가져온다는 결과를 확인했으니 가장 기본적인 목적은 달성한 것 같다.

728x90