[무신사 watcher] @Cacheable 중복되는 key값 어떻게 처리할까?
무신사 왓쳐의 데이터의 업데이트는 하루마다 이뤄지므로 캐시의 의존성이 크다. 그런데 spring에서 제공하는 @Cacheable을 사용했을 때 발생가능한 key중복 문제에 대해서 정리하고 무신사 왓쳐에서 cache key중복을 해결한 방법에 대해 소개한다.
@Cacheable
@Cacheable은 스프링에서 제공하는 캐시관련 어노테이션으로 aop방식으로 동작한다. 별도의 설정없이도 어노테이션을 선언하면 캐시적용이 가능하기 때문에 간편하지만 여차하면 잘못된 캐시 결과를 가져올 수 있다. 예시를 보자
@EnableCaching
@Service
public class CacheService {
@Cacheable(value = "productCache")
public long findProductCount() {
System.out.println("상품 수에 대한 캐시를 저장합니다.");
return 100;
}
@Cacheable(value = "productCache")
public long findBrandCount() {
System.out.println("브랜드 수에 대한 캐시를 저장합니다.");
return 10;
}
}
두 메서드는 상품 수, 브랜드 수에 대한 값을 캐싱한다. @Cacheable어노테이션을 잘 보면 캐시 이름에 대한 설정은 productCache로 되어있지만 key값을 설정하고 있진 않다. 이런 경우 어떻게 될까?
@SpringBootTest
@ExtendWith(SpringExtension.class)
class CacheServiceTest {
@Autowired
private CacheService cacheService;
public void saveCache(){
cacheService.findProductCount();
cacheService.findBrandCount();
}
@Test
@DisplayName("상품 수와 브랜드 수가 캐시되는지 확인한다.")
void cacheTest() {
saveCache();
assertEquals(100, cacheService.findProductCount());
assertEquals(10, cacheService.findBrandCount());
}
}
상품 수에 대한 캐시만 저장되었고 브랜드 수가 담긴 값을 저장하지않았다. 오히려 상품 수에 대한 캐시값이 호출되었다.
이는 @Cacheable에서 사용하는 default key generator로서 사용되는 SimpleKeyGenerator를 확인하면 원인을 파악할 수 있다. SimpleKeyGenerator에서 사용되는 메서드를 보자.
public static Object generateKey(Object... params) {
if (params.length == 0) {
return SimpleKey.EMPTY;
}
if (params.length == 1) {
Object param = params[0];
if (param != null && !param.getClass().isArray()) {
return param;
}
}
return new SimpleKey(params);
}
- 기본적으로 파라미터가 없다면 SimpleKey.Empty가 key로 사용된다.
- 파라미터가 하나만 존재하고 array형태와 null이 아니라면 파라미터를 key로 사용한다
- 그 이외에는 파리미터를 SimpleKey에 주입하여 SimpleKey객체를 key로 사용한다.
이번에는 SimpleKey의 동작과정을 보자
public SimpleKey(Object... elements) {
Assert.notNull(elements, "Elements must not be null");
this.params = elements.clone();
// Pre-calculate hashCode field
this.hashCode = Arrays.deepHashCode(this.params);
}
여러개의 파라미터가 존재하는 경우 Arrays.deepHashCode메서드로 파라미터를 넘겨서 hashcode로 사용한다.
그럼 deepHashCode의 역할은 무엇일까?
public static int deepHashCode(Object a[]) {
if (a == null)
return 0;
int result = 1;
for (Object element : a) {
final int elementHash;
final Class<?> cl;
if (element == null)
elementHash = 0;
else if ((cl = element.getClass().getComponentType()) == null)
elementHash = element.hashCode();
else if (element instanceof Object[])
elementHash = deepHashCode((Object[]) element);
else
elementHash = primitiveArrayHashCode(element, cl);
result = 31 * result + elementHash;
}
return result;
}
코드를 확인해보면 파라미터들에 대한 hashcode를 활용한다. 만약 파라미터가 array형태라면 있다면 재귀적으로 메서드를 호출하여 hashcode를 만든다. 여기서 중요한점은 파라미터가 여러개인 key를 만들때 각 파라미터에 대한 hashcode를 사용한다.
파라미터가 존재하지 않는다면 key는 SimpleKey.Empty로 사용된다.
public static final SimpleKey EMPTY = new SimpleKey();
// Effectively final, just re-calculated on deserialization
private transient int hashCode;
@Override
public final int hashCode() {
// Expose pre-calculated hashCode field
return this.hashCode;
}
hashcode는 여러개의 파라미터가 있을 때 생성자단에서 메서드를 호출하여 저장된다. 파라미터가 없이 객체가 생성된다면 따로 hashcode는 계산되지않고 항상 일정한 값을 반환한다.
@Test
@DisplayName("SimpleKey()의 hashcode값이 고정된 값을 반환하는지 확인한다.")
void cacheTest() {
assertEquals(new SimpleKey().hashCode(), new SimpleKey().hashCode());
assertEquals(new SimpleKey().hashCode(), new SimpleKey().hashCode());
assertEquals(new SimpleKey().hashCode(), new SimpleKey().hashCode());
}
위의 테스트는 항상 성공한다. new SimpleKey()에서의 hashCode()는 항상 동일한 반환결과를 갖는다.
이번에는 어떻게 key를 비교하는지 보자.
@Override
public boolean equals(@Nullable Object other) {
return (this == other ||
(other instanceof SimpleKey && Arrays.deepEquals(this.params, ((SimpleKey) other).params)));
}
@Override
public final int hashCode() {
// Expose pre-calculated hashCode field
return this.hashCode;
}
SimpleKey는 기본적으로 hashCode()와 equals()메서드가 오버라이딩되어있으며 equals는 Arrays.deepEquals를 사용하여 두 파라미터간의 값이 같나 확인한다.
정리하자면 @Cacheable 어노테이션의 default key generator 전략과 비교는 이렇다.
- 파라미터가 있는지 확인한다. 없다면 Simple.Empty로 항상 동일한 hashcode를 반환하는 인스턴스를 만든다.
- 파라미터가 하나라면 그 자체를 key로 사용하고 여러개라면 SimpleKey(params)를 key로 사용한다.
- SimpleKey에 파라미터를 넘기면 hashcode를 계산하여 저장한다.
- key값 비교시 오버라이딩된 hashCode()와 equals()를 사용하며 equals()는 Arrays.deepEquals()를 통해 파라미터 간의 값을 비교한다.
다시 처음으로 돌아와보자.
@Cacheable(value = "productCache")
public long findProductCount() {
System.out.println("상품 수에 대한 캐시를 저장합니다.");
return 100;
}
@Cacheable(value = "productCache")
public long findBrandCount() {
System.out.println("브랜드 수에 대한 캐시를 저장합니다.");
return 10;
}
public void saveCache(){
cacheService.findProductCount();
cacheService.findBrandCount();
}
@Test
@DisplayName("상품 수와 브랜드 수가 캐시되는지 확인한다.")
void cacheTest() {
saveCache();
assertEquals(100, cacheService.findProductCount());
assertEquals(10, cacheService.findBrandCount());
}
이제 이 테스트가 왜 실패했는지 알 수 있다. findProductCount()와 findBrandCount()는 파라미터가 없어 SimpleKey.Empty를 키로 사용하기때문에 둘의 key가 중복된다. 그래서 캐시가 각각 저장되지 않았다.
프로젝트내 cache key설정 (Custom key generator)
@Cacheable(value = "productCache")
public Page<ProductResponseDto> findProductsPageByBrand(FilterVo filterVo, Pageable pageable) {
return productRepository.findProductByBrand(filterVo, pageable);
}
@Cacheable(value = "productCache")
public Page<ProductResponseDto> findProductsPageByCategory(FilterVo filterVo, Pageable pageable) {
return productRepository.findProductByCategoryAndDate(filterVo, pageable);
}
이제 프로젝트로 돌아와서 key값을 설정하는 상황을 보자. 만약 FilterVo가 아래와 같다면 캐시는 적용되지 않는다. 아니, 정확히 말하면 호출시마다 새로운 캐시가 저장된다.
public class FilterVo {
private final String[] brands;
private final Category[] categories;
private final Integer minPrice;
private final Integer maxPrice;
private final Boolean onlyTodayUpdatedData;
}
그 이유는 FilterVo는 항상 다른 hashCode값을 반환한다. 그렇기 때문에 hashcode와 equals메서드를 오버라이딩 해야한다.
public class FilterVo {
private final String[] brands;
private final Category[] categories;
private final Integer minPrice;
private final Integer maxPrice;
private final Boolean onlyTodayUpdatedData;
@Override
public boolean equals(Object obj) {
if (!(obj instanceof FilterVo)) {
return false;
}
FilterVo input = (FilterVo) obj;
return this.toString().equals(input.toString());
}
@Override
public int hashCode() {
return Objects.hashCode(this.toString());
}
@Override
public String toString() {
return "FilterVo{" +
"brands=" + Arrays.toString(brands) +
", categories=" + Arrays.toString(categories) +
", minPrice=" + minPrice +
", maxPrice=" + maxPrice +
", onlyTodayUpdatedData=" + onlyTodayUpdatedData +
'}';
}
}
그럼 이제 key값의 중복은 해결되었을까? 그렇지 않다.
@Cacheable(value = "productCache")
public Page<ProductResponseDto> findProductsPageByBrand(FilterVo filterVo, Pageable pageable) {
return productRepository.findProductByBrand(filterVo, pageable);
}
@Cacheable(value = "productCache")
public Page<ProductResponseDto> findProductsPageByCategory(FilterVo filterVo, Pageable pageable) {
return productRepository.findProductByCategoryAndDate(filterVo, pageable);
}
이런 상황을 고려해보자. 두 메서드가 호출되는데 FilterVo는 저장된 필드 값이 같면 파라미터로서 비교하는 것 만으로는 부족하다. 다른 메서드에 대해서도 파라미터 값이 같다면 같은 캐시 key가 생성된다. 이걸 해결하는 단순한 방법은 key값에 접두어를 붙여 구분할 수 있다.
@Cacheable(value = "productCache", key = "'findProductsPageByBrand' + #filterVo + #pageable")
public Page<ProductResponseDto> findProductsPageByBrand(FilterVo filterVo, Pageable pageable) {
return productRepository.findProductByBrand(filterVo, pageable);
}
그런데 모든 메서드마다 중복을 고려하여 작성하려니 코드도 지저분해지고 귀찮았다. 그래서 keyGenerator를 새롭게 만들어서 사용하기로 했다.
public class CustomKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
StringBuilder keyBuilder = new StringBuilder();
keyBuilder.append(method.getName());
keyBuilder.append(SimpleKeyGenerator.generateKey(params));
return keyBuilder.toString();
}
}
...
@Bean
public KeyGenerator keyGenerator() {
return new CustomKeyGenerator();
}
KeyGenerator를 새롭게 만들어 bean으로 등록했다. key값으로 method명을 앞에 붙이고 나머지는 기존의 SimpleKey를 활용한다. 따라서 @Cacheable를 사용하는 것만으로도 key값은 메서드 + defalut key generator를 통해 생성된 값을 사용한다.
추가적으로 custom key generator를 사용하면 좋은 상황을 생각해보자. 외부 라이브러리를 사용하는 상황에서 hashcode, equals가 내가 원하는 대로 작성되어 있지 않다면 내 의도대로 캐시가 저장되지 않을 수 있다. 이런 상황에서 keyGenerator를 사용하면 특정 클래스에 대해서 instance of로 확인하고 key generator전략을 커스텀 할 수있다.
결론
아무래도 지금까지 @Cacheable key가 왜 중복이 발생하고 어떻게 key가 생성되는지 정리하다보니 글이 길어졌다. 정리하자면 이렇다!
- @Cacheable은 기본적으로 parameter의 수에 따라 key생성 전략이 다르다.
- 파라미터가 여러개, 혹은 없다면 SimpleKey를 사용한다. SimpleKey는 파라미터에 대한 hashcode를 계산하여 저장한다.
- 만약 parameter가 중복되는 메서드끼리 @Cacheable을 사용하고 있다면 key의 중복이 발생한다. 주의하자.
- key 생성방식을 변경하고 싶다면 KeyGenerator를 구현하고 bean으로 등록하면된다.
- hashcode, equals는 오버라이딩에 대해 생각할 수 있는 과정이었다.