DB 부하를 줄이는 Spring Cache와 Redis 기반 캐싱 전략

데이터베이스 아키텍처나 JPA 영속성 컨텍스트를 공부하다 보면 결국 백엔드 성능 최적화의 최종 관문과 마주하게 됩니다. 바로 조회(Read) 성능 최적화입니다.

대규모 트래픽이 몰리는 서비스에서 매번 똑같은 데이터를 조회하기 위해 무거운 RDB(관계형 데이터베이스)에 쿼리를 날리는 것은 비효율적일 뿐만 아니라, 전체 시스템을 다운시키는 주범이 되기도 합니다.

오늘은 스프링이 제공하는 선언적 캐시 추상화(Spring Cache)를 이해하고, 실무에서 글로벌 캐시 저장소로 가장 많이 사랑받는 Redis를 연동하여 시스템 처리량을 몇 배 이상 끌어올리는 캐싱 아키텍처를 구축해 보겠습니다.

1. 왜 Redis인가? 로컬 캐시 vs 글로벌 캐시

스프링은 내부 메모리를 사용하는 로컬 캐시(ConcurrentHashMap, Caffeine 등)를 기본적으로 지원합니다. 하지만 실무 환경처럼 서버를 여러 대 두고 로드밸런싱을 하는 분산 서버 환경에서는 치명적인 문제가 발생합니다.

  • 로컬 캐시(Local Cache): 1번 서버에서 캐시를 갱신해도 2번 서버는 알 방법이 없습니다. 사용자가 어떤 서버로 요청을 보내느냐에 따라 서로 다른 데이터를 보게 되는 데이터 불일치(Data Inconsistency)가 발생합니다.
  • 글로벌 캐시(Global Cache): 모든 서버가 외부의 공통 캐시 저장소를 바라보게 만듭니다. 인메모리(In-Memory) 기반의 데이터 구조 저장소인 Redis가 이 역할을 수행하는 실무 표준(De-facto) 기술입니다.

2. Redis 캐싱 아키텍처 설계 3단계

1단계: 의존성 추가 및 Redis 설정 (CacheManager)

스프링 부트에서 Redis를 캐시 저장소로 쓰기 위해서는 spring-boot-starter-data-redis 의존성을 추가한 뒤, Redis 캐시 설정을 위한 Configuration 클래스를 작성해야 합니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;

@Configuration
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // 기본 캐시 설정 (만료 시간 10분, JSON 형태로 직렬화)
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10)) // TTL(Time To Live) 설정
                .disableCachingNullValues()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                .build();
    }
}

그리고 애플리케이션 메인 클래스(@SpringBootApplication) 위에 @EnableCaching 어노테이션을 붙여 스프링의 캐시 기능을 활성화해 줍니다.

2단계: 서비스 레이어에 캐시 어노테이션 적용 (@Cacheable)

이제 조회가 빈번하고 데이터 변경이 적은 비즈니스 로직(예: 상품 상세 정보 조회, 공지사항 등)에 캐시를 적용합니다.

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    // 캐시가 존재하면 메서드를 실행하지 않고 캐시 값을 반환, 없으면 DB 조회 후 캐시에 저장
    @Cacheable(value = "products", key = "#productId", unless = "#result == null")
    @Transactional(readOnly = true)
    public ProductResponse getProduct(Long productId) {
        System.out.println("--- [DB 조회 발생] 캐시가 없으므로 DB에서 데이터를 가져옵니다. ---");
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new BusinessException(ErrorCode.PRODUCT_NOT_FOUND));
        return ProductResponse.from(product);
    }
}

3단계: 데이터 변경 시 캐시 정화 (@CacheEvict)

데이터가 수정되거나 삭제되었는데 캐시가 그대로 남아있다면 클라이언트는 유령 데이터를 보게 됩니다. 데이터가 바뀔 때는 기존 캐시를 지워주어야 합니다.

import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class ProductService {
    
    private final ProductRepository productRepository;

    // 상품 수정 시 해당 상품의 캐시 데이터를 삭제(Evict)하여 데이터 일관성 유지
    @CacheEvict(value = "products", key = "#productId")
    @Transactional
    public void updateProduct(Long productId, ProductUpdateRequest request) {
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new BusinessException(ErrorCode.PRODUCT_NOT_FOUND));
        product.update(request);
    }
}

3. 실무 캐시 전략 수립 시 반드시 고려해야 할 2가지 함정

① 캐시 스탬피드 (Cache Stampede) 현상

대규모 트래픽 환경에서 만료 시간이 타이트하게 설정된 특정 캐시 키가 동시에 만료될 때 발생하는 현상입니다. 수많은 서버가 순간적으로 캐시가 깨진 것을 확인하고 일제히 DB에 똑같은 조회를 날리면서 DB가 그대로 뻗어버릴 수 있습니다.

  • 해결책: 캐시 만료 시간(TTL)에 약간의 난수(Random 추가 시간)를 부여해 만료 시점을 분산시키거나, 백그라운드에서 캐시를 미리 갱신하는 정기 스케줄러를 활용해야 합니다.

② 직렬화(Serialization) 에러와 DTO 관리

캐시에 객체를 저장할 때 GenericJackson2JsonRedisSerializer 등을 주로 사용합니다. 이때 캐싱할 대상 클래스에 기본 생성자가 없거나, 패키지 경로가 변경되면 레디스에서 데이터를 읽어올 때 역직렬화(Deserialization) 에러가 발생합니다. 캐싱 대상은 가급적 변경이 적고 구조가 단순한 DTO 객체로 한정 짓는 것이 안전합니다.

4. 결론 및 요약

스프링 캐시 추상화(@Cacheable, @CacheEvict)와 Redis의 결합은 복잡한 인메모리 제어 코드를 비즈니스 로직에서 완벽히 격리해 주는 마법 같은 아키텍처입니다.

하지만 캐시는 무조건 많이 쓴다고 좋은 것이 아닙니다. “자주 조회되지만 변하지 않는 데이터”를 선별하는 선구안이 필요하며, 데이터 정합성이 깨지지 않도록 정교한 TTL 정책과 캐시 삭제 전략을 설계해야만 진정한 고성능 백엔드 시스템을 완성할 수 있습니다.