웹 개발/무신사 스토어 watcher

[무신사 watcher] @Cacheable 중복되는 key값 어떻게 처리할까?

제리 . 2021. 6. 4. 19:11

무신사 왓쳐의 데이터의 업데이트는 하루마다 이뤄지므로 캐시의 의존성이 크다. 그런데 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 전략과 비교는 이렇다.

  1. 파라미터가 있는지 확인한다. 없다면 Simple.Empty로 항상 동일한 hashcode를 반환하는 인스턴스를 만든다.
  2. 파라미터가 하나라면 그 자체를 key로 사용하고 여러개라면 SimpleKey(params)를 key로 사용한다.
  3. SimpleKey에 파라미터를 넘기면 hashcode를 계산하여 저장한다. 
  4. 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가 생성되는지 정리하다보니 글이 길어졌다. 정리하자면 이렇다!

 

  1. @Cacheable은 기본적으로 parameter의 수에 따라 key생성 전략이 다르다.
  2. 파라미터가 여러개, 혹은 없다면 SimpleKey를 사용한다. SimpleKey는 파라미터에 대한 hashcode를 계산하여 저장한다.
  3. 만약 parameter가 중복되는 메서드끼리 @Cacheable을 사용하고 있다면 key의 중복이 발생한다. 주의하자.
  4. key 생성방식을 변경하고 싶다면 KeyGenerator를 구현하고 bean으로 등록하면된다.
  5. hashcode, equals는 오버라이딩에 대해 생각할 수 있는 과정이었다.