본문 바로가기
Spring

[Spring] 프로젝트에 Cache 적용하기

by 케로베로 2021. 3. 28.

 현재 진행중인 프로젝트 Shoe-Auction에 조회 성능 개선을 위하여 캐싱을 적용하였다. 기존의 조회 방법은 원하는 정보를 얻기 위해 요청이 들어올 때마다 반복적으로 외부 Disk 기반 DB 서버에 조회 요청을 보내고 응답을 받아야 했다.

 

 캐시를 이용한다면 상대적으로 속도가 빠른 스토리지인 메모리에 조회한 데이터를 임시적으로 저장하고 요청 시 캐시에서 값을 조회하기 때문에 더 빠르고 서버에 부담없이 조회가 가능하다.

 

 이 글에서는 본 프로젝트에서 캐시를 도입하고 적용한 과정에 대하여 가볍게 포스팅하려고 한다.

 


🤔 어떤 데이터를 캐싱해야 할까?

 모든 조회 로직에 캐싱을 적용하는 것은 바람직하지 않다. 해당 조회에 캐싱을 적용하기 전에 캐싱하기에 적합한 데이터인지 고민해본 후 적용해야만 한다. 그렇다면 어떤 데이터를 캐싱해야 할까?

 

  • 요청마다 동일한 데이터를 반환하는 조회에만 사용한다. 예를 들어 나의 정보 조회와 같이 요청한 사용자마다 다른 값을 반환하는 조회를 캐싱한다면 캐싱된 회원 정보를 다른 회원까지 조회할 수 있게 되므로 적절한 캐싱 대상이라고 볼 수 없을 것이다.
  • 업데이트가 자주 발생하지 않는 데이터에 사용한다. 잦은 업데이트가 발생하는 데이터를 캐싱한다면 업데이트가 발생할 때마다 DB의 데이터와 캐시 데이터의 정합성을 맞추는 작업을 실시해야 하기 때문에 오히려 성능에 악영향을 줄 수 있다.
  • 자주 조회되는 데이터에 사용한다. 조회 요청이 거의 없는 데이터를 캐싱한다면 그저 메모리만 낭비하는 데이터가 될 수 있으며, 조회시에도 본 스토리지에 요청을 보내기 전 캐시에 데이터가 존재하는지 확인해야하는 작업을 거치기 때문에 오히려 조회 속도가 더 느려질 수 있다.

 위와 같은 조건에 따라 Shoe-Auction에서는 우선 자주 조회되며 업데이트가 상대적으로 자주 발생하지 않는 브랜드 전체 조회와 상품 상세 조회 시 캐싱을 적용하기로 결정하였다.

 

🤔Local Cache와 Global Cache

 캐싱을 저장할 대상을 선택한 다음 또 고려해야 하는 사항이 있다. 바로 적절한 캐싱 전략을 선택하는 것이다. 캐싱 전략에는 로컬 캐싱 전략과 글로벌 캐싱 전략이 있다.

 

 로컬 캐싱 전략은 서버마다 각자 캐시를 저장하는 전략이다. 캐시 데이터 조회시 외부 캐시 서버와 통신할 필요가 없어 빠른 조회가 가능하다.

 

 하지만 서버마다 중복된 데이터를 저장하게 될 수 있으므로 서버의 개수에 비례하여 저장하는 데이터도 늘어나기 쉬우며, 각 서버 캐시간의 정합성을 맞춰야 하는 경우에는 캐시 업데이트마다 다른 서버와 통신하는 과정이 필요하다.

 

 글로벌 캐싱 전략은 외부에 캐시 서버를 두고 각 서버들이 해당 캐시 서버에서 캐싱된 값들을 조회하는 전력이다. 각 서버가 동일한 캐시 서버를 참조하기 때문에 캐시 데이터를 서로 맞추어줄 필요가 없으며, 서버 확장으로 서버의 개수가 증가하더라도 저장되는 캐시 데이터의 양은 증가하지 않는다.

 

 하지만 로컬 캐싱과 달리 서버 내부의 리소스에 데이터를 캐싱한 것이 아니기 때문에 외부에서 데이터를 가져오기 위해 캐시 서버와 통신 과정이 불가피하다.

 

 Shoe-Auction 프로젝트는 지속적인 트래픽 증가로 인한 Scale-out 방식의 지속적인 확장을 고려하여 설계하고 진행중이다. 따라서 서버의 개수가 증가할 수록 더 적합한 방식인 글로벌 캐싱 전략을 채택하였다.

 

 캐시 스토리지로는 Redis를 선택하였다. 기존에 세션 저장소로 채택하여 이미 학습하였고, 캐시 저장소로 사용하는데에도 문제가 없으며 Spring Data Redis를 이용해 쉽게 사용이 가능하기 때문이다.

 

💻 Spring boot에서 Cache 적용하기!

Redis를 캐시 저장소로 설정하기

우선  spring-boot-starter-data-redis 의존성을 추가해주어야 한다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

 properties 또는 yml에 Redis의 host와 port를 설정해 주어야 한다. 본 프로젝트에는 yml을 사용했다.

spring:
  redis:
    host:localhost
    port:6379

 

 다음으로 Configuration 클래스를 만들어서 @EnableCaching 어노테이션을 적용하고, 캐시 매니저를 등록해 주어야 한다.

 

@EnableCaching 어노테이션은 @Cacheable, @CacheEvict 등의 캐시 어노테이션 활성화를 위한 어노테이션이다. 스프링에서는 AOP 를 이용한 캐싱 기능을 추상화 시켜주므로 별도의 캐시 관련 로직없이 간단하게 적용해 줄 수 있다.

 

 기본적으로 스프링에서 지원하는 캐시 기능의 캐시 저장소는 JDK  ConcurrentHashMap 이며 그 외 캐시 저장소를 사용하기 위해서는 CacheManager Bean 으로 등록하여 사용할 수 있다.

@RequiredArgsConstructor
@EnableCaching
@Configuration
public class CacheConfig {

    private final CacheProperties cacheProperties;

    @Value("$")
    private String redisHost;

    @Value("$")
    private int redisPort;

    @Bean(name = "redisCacheConnectionFactory")
    public RedisConnectionFactory redisCacheConnectionFactory() {
        LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisHost,
            redisPort);
        return lettuceConnectionFactory;
    }

    /*
     * Jackson2는 Java8의 LocalDate의 타입을 알지못해서적절하게 직렬화해주지 않는다.
     * 때문에 역직렬화 시 에러가 발생한다.
     * 따라서 적절한 ObjectMapper를 Serializer에 전달하여 직렬화 및 역직렬화를 정상화 시켰다.
     */
    private ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        mapper.registerModule(new JavaTimeModule());
        return mapper;
    }

    private RedisCacheConfiguration redisCacheDefaultConfiguration() {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration
            .defaultCacheConfig()
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper())));
        return redisCacheConfiguration;
    }

    /*
     * properties에서 가져온 캐시명과 ttl 값으로 RedisCacheConfiguration을 만들고 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;
    }

    @Bean
    public CacheManager redisCacheManager(@Qualifier("redisCacheConnectionFactory") RedisConnectionFactory redisConnectionFactory) {
        RedisCacheManager redisCacheManager = RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(redisConnectionFactory)
            .cacheDefaults(redisCacheDefaultConfiguration())
            .withInitialCacheConfigurations(redisCacheConfigurationMap()).build();
        return redisCacheManager;
    }
}

 

 위 코드에서는 GenericJackson2JsonRedisSerializer가 자바8의 LocalDate를 적절하게 역직렬화하지 못하는 이슈 때문에 objectMapper를 커스텀하게 설정하고 Serializer에 전달하여 해결하였다.

 

 또한 캐싱 시 Key 별 다른 timeout 값을 설정하기 위하여 처음에는 Key 별로 각자 CacheManager를 생성하는 방법을 적용하였지만, CacheManager가 불필요하게 증가한다는 단점을 해소하기 위하여 yml 설정값으로 ConfigurationMap을 만들고 CacheManager에 적용하여 하나의 CacheManager가 key별로 동적으로 timeout을 부여하는 방법으로 수정하였다.

 

추상화된 캐시 어노테이션 적용하기

 스프링에서는 캐시 관련 로직을 추상화하여 비즈니스 로직에 영향없이 어노테이션만 적용하여 쉽게 적용할 수 있도록 제공해준다. 해당 과정은 모든 스프링 빈에서 public 메소드에 캐싱 어노테이션이 있는지 검사하는 빈 후처리기를 트리거 한다. 그 후 동적 프록시를 이용하여 어노테이션이 붙은 메소드의 호출을 가로채고 캐싱 관련 동작을 수행한다.

 

 @Cacheable 어노테이션을 캐싱 대상에 적용하면 해당 조회가 일어날 때 캐시 저장소에 해당 key로 저장된 데이터가 존재하는지 확인한다. 존재한다면 해당 값을 바로 반환하며, 존재하지 않는다면 원본 데이터가 있는 스토리지에서 데이터를 요청하고 캐시 저장소에 데이터를 저장한 후 반환한다. 

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

 

 @CacheEvict 어노테이션은 캐싱한 값들을 퇴거하는 역할을 한다. 캐싱된 데이터가 업데이트 되었을 때 원본 저장소인 DB와 캐시 저장소의 데이터의 일관성을 맞추기 위하여 캐싱되었던 값을 퇴거하여야 한다. 

@CacheEvict(value = "product", key = "#id")
    @Transactional
    public void updateProduct(Long id, SaveRequest updatedProduct) {
 
 		//update 로직
}

 

❗ 적용 확인하기!

상품을 생성하고 최초로 조회했을 경우 아래와 같은 조회 쿼리 로그가 발생한다.

 

 또한 레디스에 아래와 같이 방금 조회한 데이터가 캐싱된다.

 

 그 이후에 추가로 동일 상품을 조회할 경우 캐시 저장소인 레디스의 값을 가져와 반환해주기 때문에 위와 같은 조회 쿼리 로그는 발생하지 않는다.

 

 해당 상품을 업데이트하게되면 저장된 캐시 데이터가 퇴거되므로 조회되지 않는다.

 

🏠 전체 코드 보기

 

f-lab-edu/shoe-auction

개인 간 신발 거래 서비스. Contribute to f-lab-edu/shoe-auction development by creating an account on GitHub.

github.com