Home Spring Boot에서 CacheManager를 에러 핸들링하는 방법
Post
Cancel

Spring Boot에서 CacheManager를 에러 핸들링하는 방법

본 글은 아래 링크의 글의 내용과 이어집니다.

Spring에서 제공하는 @Cacheable을 이용하면 캐쉬를 AOP 기반으로 쉽게 사용할 수 있습니다. 이전 글에서 다뤘듯, 기본적으로 @Cacheable 을 실행하는 Aspect 메소드에서 예외가 발생하면 전체 메소드 자체가 실패하게 됩니다. 해당 상황에서 에러 핸들링을 통해 특정 상황에서 Exception이 발생하지 않도록 변경하는 방법을 소개합니다.

@CacheableCacheErrorHandler 동작 원리

Spring 문서에 따르면 Error Handler는 SimpleCacheErrorHandler를 기본 값으로 사용하고 있으며 기본적으로 에러를 클라이언트에 직접 반환합니다.SimpleCacheErrorHandler 코드는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// package org.springframework.cache.interceptor;
public class SimpleCacheErrorHandler implements CacheErrorHandler {

	@Override
	public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
		throw exception;
	}

	@Override
	public void handleCachePutError(RuntimeException exception, Cache cache, Object key, @Nullable Object value) {
		throw exception;
	}

	@Override
	public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
		throw exception;
	}

	@Override
	public void handleCacheClearError(RuntimeException exception, Cache cache) {
		throw exception;
	}
}

해당 ErrorHandler를 CachingConfigurer 에 대한 구현체 중 errorHandler() 의 리턴값으로 등록하면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// package org.springframework.cache.annotation;

public interface CachingConfigurer {

  ...
  
	@Nullable
	default CacheManager cacheManager() {
		return null;
	}
	
	...

	@Nullable
	default CacheErrorHandler errorHandler() {
		return null;
	}

}

CachingConfigurer 를 빈으로 등록하면 spring-context에서에서 기본 빈으로 등록되는 AbstractCachingConfiguration 로 인해 등록되게 됩니다. 코드는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// package org.springframework.cache.annotation;

@Configuration(proxyBeanMethods = false)
public abstract class AbstractCachingConfiguration implements ImportAware {

	@Nullable
	protected AnnotationAttributes enableCaching;

	@Nullable
	protected Supplier<CacheManager> cacheManager;

	@Nullable
	protected Supplier<CacheResolver> cacheResolver;

	@Nullable
	protected Supplier<KeyGenerator> keyGenerator;

	@Nullable
	protected Supplier<CacheErrorHandler> errorHandler;

  ...

	@Autowired
	void setConfigurers(ObjectProvider<CachingConfigurer> configurers) {
		Supplier<CachingConfigurer> configurer = () -> {
			List<CachingConfigurer> candidates = configurers.stream().collect(Collectors.toList());
			if (CollectionUtils.isEmpty(candidates)) {
				return null;
			}
			if (candidates.size() > 1) {
				throw new IllegalStateException(candidates.size() + " implementations of " +
						"CachingConfigurer were found when only 1 was expected. " +
						"Refactor the configuration such that CachingConfigurer is " +
						"implemented only once or not at all.");
			}
			return candidates.get(0);
		};
		useCachingConfigurer(new CachingConfigurerSupplier(configurer));
	}

실제로 setConfigurers()@Autowired 를 이용해 setter Injection을 통해 의존성 주입을 진행해주게 됩니다.여기서 CachingConfigurer를 추가로 빈으로 등록하지 않는다면, @Cacheable 이 실제로 동작하는 CacheAspectSupport 에서 초기화 할 때 기본값인 SimpleCacheErrorHandler가 등록되게 됩니다. 관련 코드는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// package org.springframework.cache.interceptor;
public abstract class CacheAspectSupport extends AbstractCacheInvoker
		implements BeanFactoryAware, InitializingBean, SmartInitializingSingleton {
		
		...
		
		public void configure(
					@Nullable Supplier<CacheErrorHandler> errorHandler, @Nullable Supplier<KeyGenerator> keyGenerator,
					@Nullable Supplier<CacheResolver> cacheResolver, @Nullable Supplier<CacheManager> cacheManager) {
		
				this.errorHandler = new SingletonSupplier<>(errorHandler, SimpleCacheErrorHandler::new);
				this.keyGenerator = new SingletonSupplier<>(keyGenerator, SimpleKeyGenerator::new);
				this.cacheResolver = new SingletonSupplier<>(cacheResolver,
						() -> SimpleCacheResolver.of(SupplierUtils.resolve(cacheManager)));
			}
}

CacheAspectSupportProxyCachingConfiguration에서 빈으로 등록합니다. AspectJCachingConfiguration는 앞서 설정 값(CachingConfigurer)들을 setter injection으로 의존성 주입을 받은 AbstractCachingConfiguration를 상속받으며, 이 주입받은 값들을 이용해 빈으로 등록하게 됩니다. 해당 코드는 다음과 같으며 등록하는 빈인 AnnotationCacheAspectCacheAspectSupport를 상속받습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// package org.springframework.cache.annotation;
@Configuration(proxyBeanMethods = false)  
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)  
public class ProxyCachingConfiguration extends AbstractCachingConfiguration {

	@Bean(name = CacheManagementConfigUtils.CACHE_ASPECT_BEAN_NAME)
	@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
	public AnnotationCacheAspect cacheAspect() {
		AnnotationCacheAspect cacheAspect = AnnotationCacheAspect.aspectOf();
		cacheAspect.configure(this.errorHandler, this.keyGenerator, this.cacheResolver, this.cacheManager);
		return cacheAspect;
	}

}

// package org.springframework.cache.aspectj;
@Aspect
public class AnnotationCacheAspect extends AbstractCacheAspect {
	 ...
}

구현 코드

내부 원리를 확인해봤으니 실제 코드를 작성해보겠습니다. 문제 상황은 캐쉬 조회(CacheGet) 시 SerializationException이 발생하는 경우이기에, 이를 상속받아 원하는 대로 동작하도록 변경하면 다음과 같습니다. SerializationException 가 발생했을 때 로그만 남기고 예외를 무시하도록 처리했습니다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Slf4j
public class CustomCacheErrorHandler extends SimpleCacheErrorHandler {

    @Override
    public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
        if (exception instanceof SerializationException) {
            log.warn("Failed to deserialize cache value for key: {}", key, exception);
            return;
        }

        super.handleCacheGetError(exception, cache, key);
    }

}

캐시 설정은 다음과 같습니다. 전체 캐시(Spring Boot Cache)에 대한 책임을 CacheConfig에 두었고 레디스 캐시 설정에 대한 책임을 RedisCacheConfig에 뒀습니다,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Configuration(proxyBeanMethods = false)
@EnableCaching
@RequiredArgsConstructor
public class CacheConfig implements CachingConfigurer {

    private final CacheManager redisCacheManager;

    @Override
    @Bean
    @Primary
    public CacheManager cacheManager() {
        return redisCacheManager;
    }

    @Override
    @Bean
    public CacheErrorHandler errorHandler() {
        return new CustomCacheErrorHandler();
    }

}

@Configuration(proxyBeanMethods = false)
public class RedisCacheConfig{

    @Bean
    public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
        return RedisCacheManager.builder(redisConnectionFactory)
            .cacheDefaults(cacheConfiguration())
            .withCacheConfiguration(PRODUCT_CACHE,
                RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(10)))
            .build();
    }

    public RedisCacheConfiguration cacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(60))
            .disableCachingNullValues()
            .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }

    public static class CacheName {
        public static final String PRODUCT_CACHE = "productCache_V2";
    }
}

캐시를 이용해 비즈니스 로직을 처리하는 서비스 코드는 다음과 같습니다. @Cacheable을 이용해 해당 key에 대한 값이 캐시에 존재하면 캐시에서 조회하고 그렇지 않으면 캐시를 등록하는 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Service
@Slf4j
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    @PostConstruct
    void initProducts() {
        productRepository.saveAll(List.of(
                        new Product("box", new BigDecimal(1000)),
                        new Product("snack", new BigDecimal(4000)),
                        new Product("chicken", new BigDecimal(20000))
                )
        );
    }

    @Cacheable(cacheNames = PRODUCT_CACHE, key = "'top10'")
    public List<ProductResponse> getTenProduct() {
        log.warn("NO CACHE - find top 10 products from DB");
        return ProductResponse.listOf(productRepository.findTop10By());
    }

    @CacheEvict(cacheNames = PRODUCT_CACHE, key = "'top10'")
    public void evict() {
        log.warn("Cache Evicted");
    }

}

@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @GetMapping("/products/top10")
    public ResponseEntity<?> getTop10Products() {
        return ResponseEntity.ok(productService.getTenProduct());
    }
}

위의 CustomCacheErrorHandlerCachingConfigurer 를 설정해주지 않은 상태에서api.ProductResponse 패키지 경로로 해당 객체의 캐시를 만들고 api.v2.ProductResponse 패키지로 옮긴 다음에 동일한 키로 조회를 하면 SerializationException이 발생하면서 ExceptionHandler를 통한 에러 응답이 오게됩니다.

위의 코드를 적용하고 동일한 상황을 재현해보면 warn 로그가 찍히고 응답이 정상적으로 가는 것을 볼 수 있습니다. 이로 인해 새로운 버전의 클래스로 캐시를 덮어쓰게 되면서 이후 요청에 대해서는 정상적으로 캐시를 조회하게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2024-03-31 20:13:28.248  WARN 43092 --- [nio-8080-exec-1] c.e.r.config.CustomCacheErrorHandler     : Failed to deserialize cache value for key: top10

org.springframework.data.redis.serializer.SerializationException: Cannot deserialize; nested exception is org.springframework.core.serializer.support.SerializationFailedException: Failed to deserialize payload. Is the byte array a result of corresponding serialization for DefaultDeserializer?; nested exception is org.springframework.core.NestedIOException: Failed to deserialize object type; nested exception is java.lang.ClassNotFoundException: com.example.redisinactions.api.v2.ProductResponse
	at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.deserialize(JdkSerializationRedisSerializer.java:84) ~[spring-data-redis-2.7.2.jar:2.7.2]
	at org.springframework.data.redis.serializer.DefaultRedisElementReader.read(DefaultRedisElementReader.java:49) ~[spring-data-redis-2.7.2.jar:2.7.2]
	at org.springframework.data.redis.serializer.RedisSerializationContext$SerializationPair.read(RedisSerializationContext.java:272) ~[spring-data-redis-2.7.2.jar:2.7.2]
	at org.springframework.data.redis.cache.RedisCache.deserializeCacheValue(RedisCache.java:298) ~[spring-data-redis-2.7.2.jar:2.7.2]
	at org.springframework.data.redis.cache.RedisCache.lookup(RedisCache.java:95) ~[spring-data-redis-2.7.2.jar:2.7.2]
	at org.springframework.cache.support.AbstractValueAdaptingCache.get(AbstractValueAdaptingCache.java:58) ~[spring-context-5.3.22.jar:5.3.22]
	at org.springframework.cache.interceptor.AbstractCacheInvoker.doGet(AbstractCacheInvoker.java:73) ~[spring-context-5.3.22.jar:5.3.22]
	...
2024-03-31 20:13:28.250  WARN 43092 --- [nio-8080-exec-1] c.e.redisinactions.api.ProductService    : NO CACHE - find top 10 products from DB
2024-03-31 20:13:28.298 DEBUG 43092 --- [nio-8080-exec-1] org.hibernate.SQL                        : select product0_.id as id1_0_, product0_.description as descript2_0_, product0_.price as price3_0_, product0_.quantity as quantity4_0_ from product product0_ limit ?
Hibernate: select product0_.id as id1_0_, product0_.description as descript2_0_, product0_.price as price3_0_, product0_.quantity as quantity4_0_ from product product0_ limit ?
2024-03-31 20:13:28.311 DEBUG 43092 --- [nio-8080-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Using 'application/json', given [*/*] and supported [application/json, application/*+json, application/json, application/*+json]
2024-03-31 20:13:28.311 DEBUG 43092 --- [nio-8080-exec-1] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Writing [[com.example.redisinactions.api.ProductResponse@3c54979a, com.example.redisinactions.api.ProductResp (truncated)...]
2024-03-31 20:13:28.317 DEBUG 43092 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed 200 OK
2024-03-31 20:14:47.332 DEBUG 43092 --- [nio-8080-exec-3] o.s.web.servlet.DispatcherServlet        : GET "/api/v1/products/top10", parameters={}
2024-03-31 20:14:47.332 DEBUG 43092 --- [nio-8080-exec-3] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.redisinactions.api.ProductController#getTop10Products()
2024-03-31 20:14:47.336 DEBUG 43092 --- [nio-8080-exec-3] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Using 'application/json', given [*/*] and supported [application/json, application/*+json, application/json, application/*+json]
2024-03-31 20:14:47.336 DEBUG 43092 --- [nio-8080-exec-3] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Writing [[com.example.redisinactions.api.ProductResponse@39bba9c2, com.example.redisinactions.api.ProductResp (truncated)...]
2024-03-31 20:14:47.337 DEBUG 43092 --- [nio-8080-exec-3] o.s.web.servlet.DispatcherServlet        : Completed 200 OK

하지만 소개한 해결 방법은 배포 전략에 따라 문제가 될 수 있습니다. 단번에 배포를 변경하는 Blue-Green 전략의 경우 롤백하지 않는다면 새로 배포된 인스턴스는 새로운 캐시만 바라보게 되므로 문제가 되지 않습니다.

하지만 만약 캐시에 해당하는 클래스의 패키지를 변경하고 카나리 배포를 진행하고 구 버전과 신 버전의 인스턴스가 동시에 떠있는 상태라면 CacheName은 동일하기에 새로운 버전의 인스턴스(api.v2.ProductResponse)와 구 버전의 인스턴스(api.ProductResponse)에 대한 캐시 쓰기가 반복될 수 있습니다. 이 경우 트래픽이 많은 서비스라면 캐시 저장소에 대한 부하가 커질 수 있다는 단점이 존재합니다. 따라서 카나리의 경우는 이전에 제시한 해결책인 새로운 CacheName을 가진 캐시를 새로 만드는게 더 안전한 방법일 수 있습니다. 따라서 본 글의 목적인 상황에 맞게 캐시의 에러 핸들링 전략을 취하면 적절할 것 같습니다.

코드는 다음 링크에서 확인해볼 수 있습니다.

This post is licensed under CC BY 4.0 by the author.

분산 시스템 환경에서 Spring Cloud Bus 없이 Spring Cloud Config 프로퍼티 Refresh하는 방법

-