본문으로 바로가기

프로젝트의 전체 소스 코드는이곳에서 확인하실 수 있습니다.


캐싱에 적합한 데이터

현재 진행하고 있는 프로젝트는 일반 중고거래 사이트와 다르게 상품과 상품 브랜드 등록 권한이 관리자에게만 부여된다. 캐시 히트율을 고려했을때 업데이트가 잦지 않고, 가장 자주 조회되는 브랜드 조회상품 조회 정도에 적합하다고 생각했다.

 

한번 읽어온 조회 결과를 캐시 메모리에 저장해둔다면 매번 DB로부터 호출하지 않고 데이터를 가져올 수 있기 때문에 효율적인 READ가 가능해진다.

 

이번 포스팅에서는 상품/브랜드 CRUD에서 캐싱을 적용한 과정을 소개하려고 한다.

Local Cahce VS Global Cache

캐시의 종류에는 크게 두 가지가 존재한다.

 

로컬 캐싱은 서버 내부 저장소에 캐시 데이터를 저장하는 것이다. 따라서 속도는 빠르지만 서버 간의 데이터 공유가 안된다는 단점이 있다. 예를 들어, 사용자가 같은 리소스에 대한 요청을 반복해서 보내더라도, A 서버에서는 이전 데이터를, B 서버에서는 최신 데이터를 반환하여 각 캐시가 서로 다른 상태를 가질 수도 있다. 즉, 일관성 문제가 발생할 수 있다는 것이다. 이 외에도 서버별 중복된 캐시 데이터로 인한 서버 자원 낭비, 힙 영역에 저장된 데이터로 발생하는 GC에 대한 문제 등을 고려해야 한다.

 

글로벌 캐싱은 서버 내부 저장소가 아닌 별도의 캐시 서버를 두어 서버에서 캐시 서버를 참조하는 것이다. 캐시 데이터를 얻으려 할 때 마다 네트워크 트래픽이 발생하기 때문에 로컬 캐싱보다 속도는 느리지만, 서버간 데이터를 쉽게 공유할 수 있기 때문에 로컬 캐싱의 정합성 문제와 중복된 캐시 데이터로 인한 서버 자원 낭비 등 문제점을 해결할 수 있다.

 

 

캐싱 전략 선택

해당 프로젝트에서는 지속적인 트래픽 증가로 인해 어플리케이션의 서버를 점점 확장(Scale-out)한다는 가정 하에 진행하고 있기 때문에 Global Cache 전략을 선택하기로 했다.

또한 마침 Shoe-auction 프로젝트에서 세션 저장소로 Redis 서버를 사용하고 있기도 하고, 다양한 자료구조를 지원하는 장점을 가지며, 스프링 기반의 환경에서 가장 많이 사용하는 Redis를 캐시 저장소로 사용하기로 했다.

캐시 적용 과정

1. build.gradle 설정
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
2. 캐시 적용을 위한 캐시매니저 등록
@RequiredArgsConstructor
/** @EnableCaching :해당 애플리케이션에서 캐싱을 이용하겠다는 명시를 처리해줘야 한다.
해당 어노테이션을 적용하게 되면 @Cacheable 라는 어노테이션이 적용된 메서드가 실행될 때 마다 
AOP의 원리인 후처리 빈에 의해 해당 메소드에 프록시가 적용되어 캐시를 적용하는 부가기능이 추가되어 작동하게 된다.
*/
@EnableCaching
@Configuration
public class CacheConfig {

  /** 이전에 Redis를 이용한 세션 스토리지 등록시 사용했던 Lettuce 기반의 Redis client를 
  Bean으로 등록하여사용하고 있다. 
  */
    private final RedisConnectionFactory redisConnectionFactory;  

    /**
    CacheProperties : 캐싱이 적용되는 대상마다 캐시의 만료기간을 쉽게 변경할 수 있도록
    yml(또는 properties) 파일에서 종류별로 만료 기간을 Map에 바인딩한다.
    */
    private final CacheProperties cacheProperties; 

    private ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        mapper.registerModule(new JavaTimeModule());
        mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
        return mapper;
    }

    /**
    RedisCacheConfiguration : redisCacheManager에 여러가지 옵션을 부여할 수 있는 오브젝트이다.
    여기서는 캐시의 Key/Value를 직렬화-역직렬화 하는 Pair를 설정했다.
    */
    private RedisCacheConfiguration redisCacheDefaultConfiguration() { 
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration
            .defaultCacheConfig()
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper())));
        return redisCacheConfiguration;
    }


    /**
   CacheProperties에서 바인딩해서 가져온 캐시명과 TTL 값으로  RedisCacheConfiguration을 만들고 
   Map에 넣어 반환한다. 
   Map을 사용하는 이유는 캐시의 만료기간이 다른 여러개의 캐시매니저를 만들게 됨으로써 발생하는 
   성능저하를 방지하기 위해 하나의 캐시매니저에 Map을 이용하여 캐시 이름별 만료기간을 다르게 사용하기 위함이다.
    */
    private Map<String, RedisCacheConfiguration> redisCacheConfigurationMap() { 
        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
        for (Entry<String, Long> cacheNameAndTimeout : cacheProperties.getTtl().entrySet()) {
            cacheConfigurations
                .put(cacheNameAndTimeout.getKey(), redisCacheDefaultConfiguration().entryTtl(
                    Duration.ofSeconds(cacheNameAndTimeout.getValue())));
        }
        return cacheConfigurations;
    }

    /**
    캐시 매니저를 등록한다.  스프링에서 기본적으로 지원하는 캐시 저장소는 JDK의 ConcuurentHashMap이며 
    그 외 캐시 저장소를 사용하기 위해서는 캐시 매니저를 Bean으로 등록해서 사용해야 한다.
withInitialCacheConfigurations에 캐시의 종류별로 만료기간을 설정한  redisCacheConfigurationMap을 
    */ 
    @Bean
    public CacheManager redisCacheManager() {
        RedisCacheManager redisCacheManager = RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(redisConnectionFactory)
            .cacheDefaults(redisCacheDefaultConfiguration())
            .withInitialCacheConfigurations(redisCacheConfigurationMap()).build();
        return redisCacheManager;
    }
}

3. @Cacheable
@Cacheable(value = "product", key = "#id")
public ProductInfoResponse getProductInfo(Long id) {
     return productRepository.findById(id).orElseThrow(() -> new ProductNotFoundException())
         .toProductInfoResponse();
}

@Cacheable 동작 원리

@Cacheable 은 AOP 기반으로 동작하며, 스프링 빈으로 등록된 모든 빈들을 빈 후처리기로 보내 해당 빈에서 동작하는 메소드에 @Cacheable 어노테이션이 있는지 확인한다. 만약 @Cacheable 어노테이션이 적용된 메소드라면 다이내믹 프록시에 의해 캐싱 부가기능이 적용된 프록시를 대신 빈으로 등록되는 과정이 이루어진다.

 

쉽게 말해서 @Cacheable 이 적용된 메소드는 DB에서 쿼리문을 날려 데이터를 가져오는 것이 아닌, 캐시 메모리에서 데이터를 반환하는 것이다.

 

캐시의Value값은 필수로 지정해줘야 하고, key 값은 선택적으로 적용할 수 있다. 여기서는 key값을 Product의 ID[PK]로 관리하였다. 만약 key값을 비어둔다면 해당 메서드의 파라미터 변수명이 key값으로 등록된다. 만약 파라미터가 존재하지 않을경우 key값은 0으로 처리된다.

 

하지만 Key값을 지정하지 않는다면 특정 상황에서 의도치 않은 side effect가 발생할 수 있기 때문에 웬만하면 Key값을 명시적으로 사용하는것을 권장한다.

4. @CacheEvict
@CacheEvict(value = "product", key = "#id")
@Transactional
public void updateProduct(Long id, SaveRequest updatedProduct) {
    Product savedProduct = productRepository.findById(id)
       .orElseThrow(ProductNotFoundException::new);

     checkDuplicateUpdatedModelNumber(savedProduct.getModelNumber(),
         updatedProduct.getModelNumber());

      savedProduct.update(updatedProduct);
}

@CacheEvict은 지정된 Key값에 해당하는 모든 캐시를 삭제하는 어노테이션이다.

해당 상품에대한 수정이나 삭제가 일어났을때 캐시를 삭제하지 않는다면 해당 상품을 캐시메모리에서 조회할때 변경하기 전의 상품 정보를 반환하게 되면서 일관성이 깨지게 된다.

쉽게 말해서 데이터의 추가/변경에 따른 캐시 메모리의 refresh를 위한 어노테이션이라고 할 수 있다.


캐싱 적용 후 동작 방식

캐싱 동작 방식


주의사항

@Cacheable 를 사용할때 명시적으로 Key값을 지정하지 않았을 경우 발생할 수 있는 Side effect에 대해 알아보자.

@Cacheable(value = "brands")
public List<BrandInfo> getBrandInfos() {
    return brandRepository.findAll().stream()
        .map(Brand::toBrandInfo)
        .collect(Collectors.toList());
 }
  1. 예를들어 브랜드 정보를 조회하는 기능에 @Cacheable을 적용했다고 가정해보자. 여기서는 Key값을 명시적으로 지정하지 않았다.
    조회된 브랜드 예시 : { "brandName" : "nike" }
@CacheEvict(value = "brands")
@Transactional
public void updateBrand(SaveRequest updatedBrand) {
       Brand savedBrand = brandRepository.findById(updateBand.getId())
           .orElseThrow(BrandNotFoundException::new);

      checkDuplicateUpdatedNameKor(savedBrand.getNameKor(), updatedBrand.getNameKor());
       checkDuplicateUpdatedNameEng(savedBrand.getNameEng(), updatedBrand.getNameEng());

    savedBrand.update(updatedBrand);
}
  1. 해당 메서드는 브랜드 정보를 업데이트 하는 메소드이다. brandName을 nike에서 nike korea로 업데이트 했다고 가정해보자. 또한 조회하려던 데이터가 수정되었기 때문에 해당 메서드에 @CacheEvict 어노테이션을 적용해 캐시 데이터의 refresh를 적용하자.
@Cacheable(value = "brands")
public List<BrandInfo> getBrandInfos() {
    return brandRepository.findAll().stream()
        .map(Brand::toBrandInfo)
        .collect(Collectors.toList());
 }
  1. 2번에서 브랜드 이름을 변경했기 때문에 해당 브랜드 조회시 nike가 아닌 nike korea가 반환되어야 한다.
  2. 하지만 반환 결과로 { "brandName" : "nike" }가 출력된다. 즉 캐시 데이터가 refresh 되지 않았다.

이렇게 의도치 않은 side effect가 발생하는 이유는 @Cacheable 적용 당시 key값을 명시적으로 지정해주지 않았기 때문에 파라미터가 없는 getBrandInfos 메서드의 캐시 key값은 0으로 설정되었을 것이다.

 

반면에 updateBrand 메서드는 파라미터가 존재하기 때문에 변수명에 해당하는 updatedBrand가 key값으로 설정되었을 것이다.

 

즉, updatedBrand라는 key값을 찾아 캐시 데이터를 삭제하려고 했지만 해당 key값을 가진 데이터가 존재하지 않기 때문에 삭제에 실패한 것이다.

 

이러한 Side effect를 해결하기 위해서 명시적으로 key값을 설정하거나, 아래 예시처럼 allEntries(value에 해당하는 모든 캐시를 의미한다)의 값을 true로 설정해야 한다.

@CacheEvict(value = "brands", allEntries = true)
@Transactional
public void updateBrand(Long id, SaveRequest updatedBrand) {
    //...생략
}