Spring Cache 간단 사용 (AOP)

주요 어노테이션들의 속성을 알아보자.

Featured image

Spring-Cache?

다양한 캐시 제공자(Redis, Caffeine, EHCache 등)를 지원하는 추상화 계층이며 캐시 구현체와 독립적으로 캐시를 사용하고 관리할 수 있다. 대표적인 어노테이션으로는 @Cacheable, @CachePut, @CacheEvict, @Caching 가 있다.

@Cacheable

메서드 호출 결과를 캐시에 저장하고 이후 해당 메서드 호출 시 캐시된 결과를 반환하도록 한다. 여러 속성들이 어떤 기능을 제공하는지 알아본다.

@Cacheable(value = "itemsCache")
public Item getItem(Long id) { return findItemById(id); }
@Cacheable(value = "itemsCache", key = "#id")
public Item getItemWithKey(Long id) { return findItemById(id); }
@Cacheable(value = "itemsCache", keyGenerator = "customKeyGenerator")
public Item getItemWithCustomKeyGenerator(Long id) {
	return findItemById(id);
}
@Cacheable(value = "itemsCache", condition = "#id > 10")
public Item getItemWithCondition(Long id) {
	return findItemById(id);
}
@Cacheable(value = "itemsCache", unless = "#result.price >= 1000")
public Item getItemWithUnless(Long id) { return findItemById(id); }
@Cacheable(value = "itemsCache", key = "#id", sync = true)
public Item getItemWithSync(Long id) { return findItemById(id); }

@CachePut

메서드의 결과를 캐싱하지만 항상 메서드를 실행한다. 업데이트가 필요한 경우 유용하다. @CachePut 어노테이션의 속성은 @Cacheable의 속성과 같다.

@CacheEvict

캐시에서 데이터를 제거하는데 사용된다. @CachePut, @Cacheable 의 속성과 같으며 다른 속성들을 알아본다.

@CacheEvict(value = "items", allEntries = true)
public void removeAllItems() { /*..*/ }
@CacheEvict(value = "items", key = "#id", beforeInvocation = true)
public void removeItemByIdBeforeInvocation(Long id) { /**/ }

@Caching

위의 여러 캐시 관련 어노테이션을 조합해서 사용할 수 있는 어노테이션이다.

@Caching(
    evict = { @CacheEvict(value = "items", key = "#item.id") },
    put = { @CachePut(value = "items", key = "#item.id") }
)
public Item saveItem(Item item) {
    return itemRepository.save(item);
}

cache solution

spring-cache 를 구현하는 여러 솔루션들이 있지만 대표적인 캐시를 소개한다. CaffeineRedis가 있다. 간략한 개요만 알아본다.

특징 Caffeine Redis
구조 로컬 캐시 분산 캐시
성능 매우 빠름 네트워크 지연으로 인해 약간의 오버헤드 존재
데이터 지속성 JVM 종료 시 데이터 소멸 지속성 옵션(AOF, 스냅샷) 제공
확장성 제한적 (단일 JVM 내) 높은 확장성 (클러스터링 지원)
데이터 구조 간단한 키-값 저장 다양한 데이터 구조 (리스트, 셋, 해시 등)
고가용성 지원 안 함 레플리케이션, 페일오버, 클러스터링 지원
복잡한 연산 제한적 Lua 스크립트를 통한 복잡한 연산 가능
사용 사례 단일 서버 애플리케이션, 짧은 수명 데이터 분산 애플리케이션, 세션 관리, 실시간 분석

spring application 에 spring-cache 적용하기

implementation 'org.springframework.boot:spring-boot-starter-cache'

spring cache 는 다양한 캐싱 솔루션을 통합하여 일관된 캐싱 추상화를 제공한다. 때문에 어떤 솔루션을 사용할지 유저가 자유롭게 선택이 가능하다. 아래의 설정은 Redis 를 활용한 설정이며 어노테이션과 함께 동작한다.

@EnableCaching  
@Configuration  
public class CacheConfig {  
  
    @Bean  
    public RedisCacheConfiguration defaultCacheConfig() {  
        return RedisCacheConfiguration  
            .defaultCacheConfig()  
            .entryTtl(Duration.ofHours(1))  
            .disableCachingNullValues()  
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))  
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));  
    }  
  
    @Bean  
    public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {  
        final Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();  
  
        return RedisCacheManager  
            .builder(redisConnectionFactory)  
            .cacheDefaults(defaultCacheConfig())  
            .withInitialCacheConfigurations(cacheConfigurations)  
            .build();  
    }  
}

defaultCacheConfig() 메서드를 통해 RedisCacheConfiguration 의 기본 설정을 할당한다. TTL 기본 시간은 1시간이고 기본적으로 null 값을 캐싱하지 않는다.

redisCacheManager 를 통해 캐싱을 관리할 매니저를 설정한다. 기본 캐시 설정을 defaultCacheConfig() 메서드로 할당하고 초기 캐시 설정을 Map<String, RedisCacheConfiguration> cacheConfigurations 으로 되어있는 설정값을 가져온다.

캐시에 따라 선택적으로 TTL 및 설정을 수정할 수 있다. 아래의 코드는 hello-world 라는 캐시에 대해 TTL(5분) 을 설정하는 코드이다. Map으로 구현한 cacheConfigurations 에 추가해주면 된다.

cacheConfigurations.put("hello-world", defaultCacheConfig().entryTtl(Duration.ofMinutes(10)));

Spring-Cache & AOP

위에서 설명한 Cache 관련 어노테이션을 이용해 캐싱 처리를 수행하면 된다. 캐싱 기능을 구현할 때 스프링은 AOP(Aspect-Oriented-Programming) 를 사용하여 기능을 구현한다. Spring AOP 의 프록시 매커니즘 때문인데 해당 빈의 타겟이 되는 메서드 호출을 가로채어 AOP 어드바이스를 적용한다.

일반적으로 클라이언트 코드가 .xxCacheMethod(); 를 호출하게 되면 아래와 같이 AOP 가 동작하게 된다.

Client -> Proxy (AOP) -> Actual Service.xxCacheMethod();

하지만 this.xxCacheMethod(); 로 호출하게 되면 현재 객체를 참조하므로 프록시를 우회한다. 고로 AOP 어드바이스가 적용되지 않는다.

Client -> Actual Service(this.xxCacheMethod();)

때문에 아래와 같은 코드가 있다면 우리는 캐싱 처리를 올바르게 수행할 것으로 기대하겠지만 AOP 프록시를 우회하므로 실제로 캐싱처리가 되지 않는다. 예를 들어 아래와 같은 코드가 있을 때 this.getItem(); 메서드는 캐싱이 적용되지 않는다.

@Service
@RequiredArgsConstructor
public class ExampleService {
	private final Repository repository;

    @Cacheable(value = "itemsCache", key = "#id")
    public Item getItem(Long id) {
        return repository.findItemById(id);
    }

    public void updateItem(Long id) {
        this.getItem(id); // 캐싱이 적용되지 않는다.
        /*...*/
    }
}

다른 클라이언트 코드가 호출하거나,

@Service
@RequiredArgsConstructor
public class OtherService {
    private final ExampleService exampleService;

    public void someMethod(final Long id) {  
        exampleService.getItem(id);
        /*...*/
    }
}

혹은 이너 클래스로도 풀어낼 수 있다. 방법이 어찌됐건 AOP 프록시를 우회하지 않도록 하여 캐싱 로직을 올바르게 풀어내는 것이다.

@Service
@RequiredArgsConstructor
public class ExampleService {
    private final InnerService innerService;

    public Item getItem(Long id) {
        return innerService.findItemById(id);
    }

    public void updateItem(Long id) {
        innerService.getItem(id); // 캐싱이 적용된다.
    }

    @Service
    @RequiredArgsConstructor
    public static class InnerService {
        private final Repository repository;

        @Cacheable(value = "itemsCache", key = "#id")
        public Item getItem(Long id) {
            return repository.findItemById(id);
        }
    }
}