캐싱이 필요한 순간

API 응답시간 3초. 사용자들이 '이 앱 왜 이렇게 느려요?'라고 리뷰를 쏟아냈다. DB 쿼리 최적화도 해봤고 인덱스도 걸어봤지만 한계가 있었다. Redis 캐시를 도입하고 나서? 0.2초. 사용자 리뷰가 '앱이 빨라졌어요!'로 바뀌던 그 순간의 쾌감은 아직도 잊을 수 없다.

캐싱은 자주 조회되지만 변경이 드문 데이터에 특히 효과적입니다. 하지만 무분별한 캐싱은 데이터 정합성 문제를 야기할 수 있어, 전략적인 접근이 중요합니다.

Redis를 선택한 이유

캐시 저장소로 Redis를 선택한 데는 몇 가지 이유가 있습니다. 먼저 인메모리 기반이라 읽기/쓰기 속도가 매우 빠릅니다. 둘째, 다양한 자료구조(String, Hash, List, Set, Sorted Set)를 지원하여 캐시 데이터의 성격에 맞게 저장할 수 있습니다. 셋째, TTL(Time To Live) 설정으로 캐시 만료를 자동 관리할 수 있습니다.

Spring Boot Redis 의존성 설정

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-cache'
}

Redis 연결 설정

// application.yml
spring:
  redis:
    host: localhost
    port: 6379
    timeout: 3000ms
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 2
  cache:
    type: redis
    redis:
      time-to-live: 600000  # 10분
      cache-null-values: false

Spring Cache Abstraction 활용

Spring의 Cache Abstraction을 사용하면 어노테이션만으로 캐싱을 적용할 수 있습니다. 핵심 어노테이션 세 가지를 이해하면 대부분의 캐싱 시나리오를 처리할 수 있습니다.

@Cacheable - 캐시 조회 및 저장

@Service
public class ProductService {

    @Cacheable(value = "products", key = "#categoryId",
               unless = "#result == null || #result.isEmpty()")
    public List<ProductDto> getProductsByCategory(Long categoryId) {
        log.info("DB에서 상품 목록 조회: categoryId={}", categoryId);
        return productRepository.findByCategoryId(categoryId)
                .stream()
                .map(ProductDto::from)
                .collect(Collectors.toList());
    }
}

@CacheEvict - 캐시 무효화

@CacheEvict(value = "products", key = "#product.categoryId")
public ProductDto updateProduct(Product product) {
    Product saved = productRepository.save(product);
    return ProductDto.from(saved);
}

@CacheEvict(value = "products", allEntries = true)
@Scheduled(fixedRate = 3600000) // 1시간마다
public void evictAllProductCache() {
    log.info("전체 상품 캐시 초기화");
}

@CachePut - 캐시 갱신

@CachePut(value = "product", key = "#productId")
public ProductDto refreshProduct(Long productId) {
    return ProductDto.from(productRepository.findById(productId)
            .orElseThrow(() -> new NotFoundException("상품 없음")));
}

커스텀 Redis 캐시 설정

기본 설정으로는 부족한 경우가 많습니다. 캐시마다 TTL을 다르게 적용하거나, 직렬화 방식을 커스터마이징해야 할 때 Configuration 클래스를 작성합니다.

@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(
            RedisConnectionFactory connectionFactory) {

        RedisCacheConfiguration defaultConfig =
            RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .serializeKeysWith(
                    SerializationPair.fromSerializer(
                        new StringRedisSerializer()))
                .serializeValuesWith(
                    SerializationPair.fromSerializer(
                        new GenericJackson2JsonRedisSerializer()))
                .disableCachingNullValues();

        Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
            "products", defaultConfig.entryTtl(Duration.ofMinutes(30)),
            "categories", defaultConfig.entryTtl(Duration.ofHours(2)),
            "userProfile", defaultConfig.entryTtl(Duration.ofMinutes(5))
        );

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(cacheConfigs)
                .transactionAware()
                .build();
    }
}

캐시 적용 시 주의사항

1. 캐시 스탬피드(Cache Stampede) 방지

인기 캐시가 만료되는 순간 수많은 요청이 동시에 DB로 몰리는 현상입니다. 분산 락을 사용하여 한 번에 하나의 요청만 DB를 조회하도록 제어합니다.

@Cacheable(value = "hotProducts", sync = true)
public List<ProductDto> getHotProducts() {
    return productRepository.findTop100ByOrderBySalesDesc()
            .stream().map(ProductDto::from).collect(Collectors.toList());
}

2. 직렬화 이슈

캐시에 저장되는 객체는 반드시 직렬화 가능해야 합니다. DTO 클래스에 기본 생성자가 없거나 LocalDateTime 같은 타입의 직렬화 설정이 빠지면 런타임 에러가 발생합니다. Jackson ObjectMapper에 JavaTimeModule을 등록하는 것을 잊지 마세요.

3. 캐시 키 설계

캐시 키가 너무 광범위하면 불필요한 데이터까지 캐싱되고, 너무 세분화하면 캐시 히트율이 떨어집니다. 비즈니스 요구사항에 맞는 적절한 키 설계가 중요합니다.

성능 개선 결과

Redis 캐싱 적용 후 측정한 성능 지표입니다. 상품 목록 조회 API의 평균 응답 시간이 820ms에서 45ms로 약 18배 개선되었습니다. DB 커넥션 풀 사용률도 70%에서 15%로 크게 줄었습니다. 캐시 히트율은 안정화 이후 약 92%를 유지하고 있습니다.

운영 환경에서의 모니터링

Redis 캐시를 운영에 적용할 때는 반드시 모니터링을 함께 구축해야 합니다. Redis의 메모리 사용량, 캐시 히트율, 키 만료 비율 등을 Spring Boot Actuator와 Prometheus를 통해 수집하고, Grafana 대시보드로 시각화하면 문제를 빠르게 감지할 수 있습니다.

마무리

Redis 캐싱은 적절히 적용하면 서비스 성능을 극적으로 개선할 수 있지만, 데이터 정합성과 장애 시나리오를 항상 고려해야 합니다. Cache-Aside 패턴을 기본으로 하되, 서비스 특성에 맞는 캐시 전략을 수립하는 것이 핵심입니다. 다음 글에서는 Redis Cluster 환경에서의 캐싱 전략을 다뤄보겠습니다.

← 목록으로 다음 글 →
Jaeseong
Jaeseong

10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.

GitHub →

💬 댓글