이전 편에서 Caffeine 기반 로컬 캐시를 다뤘다. 로컬 캐시는 빠르지만 서버가 여러 대일 때 각 서버의 캐시가 따로 논다. Redis는 애플리케이션 외부에 캐시를 두어 여러 서버가 하나의 캐시를 공유할 수 있게 한다.

Redis가 캐시에 적합한 이유

Redis는 Remote Dictionary Server의 약자다. 메모리에 데이터를 저장하는 인메모리 데이터 스토어다.

캐시 저장소로 Redis가 널리 쓰이는 이유는 다음과 같다.

  • 빠르다 : 메모리 기반이므로 읽기/쓰기 지연이 1ms 미만이다. 디스크 기반 DB와는 차원이 다르다.
  • TTL을 네이티브로 지원한다 : 키별로 만료 시간을 설정할 수 있다. 캐시의 핵심 요구사항이다.
  • 분산 환경에 적합하다 : 별도의 서버에서 동작하므로, 여러 애플리케이션 인스턴스가 같은 캐시를 공유한다.
  • 풍부한 자료구조 : 문자열, 리스트, 해시, 셋 등 다양한 자료구조를 제공한다. 단순한 키-값 캐시를 넘어서 활용할 수 있다.
flowchart TB subgraph "애플리케이션 서버들" S1["서버 A"] S2["서버 B"] S3["서버 C"] end S1 & S2 & S3 -->|"캐시 조회/저장"| Redis["Redis Server"] S1 & S2 & S3 -->|"Cache Miss"| DB[(DB)]
Redis는 캐시 전용이 아니다

Redis는 세션 관리, 메시지 큐, 실시간 랭킹, 분산 락 등 다양한 용도로 활용된다. 여기서는 Spring Cache와 연동하여 캐시 저장소로 사용하는 방법에 집중한다.

의존성과 기본 설정

의존성 추가

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

spring-boot-starter-data-redis에는 Redis 클라이언트인 Lettuce가 포함되어 있다. Lettuce는 비동기, 논블로킹 방식의 Redis 클라이언트로, Spring Boot의 기본 Redis 클라이언트다.

Redis 연결 설정

spring:
  data:
    redis:
      host: localhost
      port: 6379
      password:          # 비밀번호가 없으면 비워둔다
      timeout: 3000ms    # 연결 타임아웃

로컬에서 개발할 때는 Docker로 Redis를 실행하는 것이 가장 간편하다.

docker run -d --name redis -p 6379:6379 redis:7-alpine

자동 설정으로 바로 사용하기

위 의존성과 연결 설정만 추가하면, Spring Boot가 자동으로 RedisCacheManager를 등록한다. @EnableCaching을 붙이고 @Cacheable을 사용하면 바로 Redis에 캐시된다.

@Configuration
@EnableCaching
public class CacheConfig {
    // 별도 Bean 등록 없이 자동 설정이 동작한다
}

하지만 자동 설정은 모든 캐시 영역에 동일한 기본 설정을 적용하고, TTL이 설정되지 않는다. 운영 환경에서는 직접 RedisCacheManager를 구성해야 한다.

RedisCacheManager 직접 구성하기

기본 구성

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
            .defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues()
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer()));

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

각 설정의 역할을 하나씩 살펴보자.

  • entryTtl(Duration.ofMinutes(10)) : 기본 TTL을 10분으로 설정한다.
  • disableCachingNullValues() : null 값을 캐시에 저장하지 않는다.
  • serializeKeysWith(...) : 캐시 키를 문자열로 직렬화한다. Redis에서 키를 읽을 수 있게 된다.
  • serializeValuesWith(...) : 캐시 값을 JSON으로 직렬화한다.

캐시 영역별 다른 TTL 적용

@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
    RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
        .defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(10))
        .disableCachingNullValues()
        .serializeKeysWith(
            RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
        .serializeValuesWith(
            RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));

    Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
        "weather", defaultConfig.entryTtl(Duration.ofMinutes(5)),
        "feed", defaultConfig.entryTtl(Duration.ofMinutes(30)),
        "feeds", defaultConfig.entryTtl(Duration.ofMinutes(10))
    );

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

withInitialCacheConfigurations에 영역별 설정을 전달하면, 해당 영역은 개별 설정이 적용되고, 나머지는 cacheDefaults의 기본 설정이 적용된다.

defaultConfig.entryTtl(Duration.ofMinutes(5))는 새로운 RedisCacheConfiguration 객체를 반환한다. RedisCacheConfiguration은 불변 객체이므로 원래 defaultConfig는 변하지 않는다.

직렬화 설정

Redis는 데이터를 바이트 배열로 저장한다. Java 객체를 Redis에 넣으려면 직렬화가 필요하고, 꺼낼 때는 역직렬화가 필요하다.

JDK 직렬화의 문제

Spring Data Redis의 기본 직렬화 방식은 JDK 직렬화다. JdkSerializationRedisSerializer를 사용한다.

이 방식에는 심각한 문제가 있다.

  • 가독성 제로 : Redis CLI에서 값을 확인하면 바이너리 데이터가 보인다. 디버깅이 불가능하다.
  • 호환성 문제 : 클래스 구조가 바뀌면 역직렬화에 실패한다. 필드 하나 추가했다고 전체 캐시가 깨진다.
  • 용량 비효율 : JDK 직렬화는 클래스 메타데이터까지 포함하므로 JSON보다 데이터 크기가 크다.
flowchart LR subgraph "JDK 직렬화" J1["Java 객체"] --> J2["바이너리 데이터"] J2 --> J3["가독성 ❌\n호환성 ❌\n용량 큼"] end subgraph "JSON 직렬화" K1["Java 객체"] --> K2["JSON 문자열"] K2 --> K3["가독성 ✅\n호환성 ✅\n용량 작음"] end

JSON 직렬화로 전환

.serializeValuesWith(
    RedisSerializationContext.SerializationPair
        .fromSerializer(new GenericJackson2JsonRedisSerializer()))

GenericJackson2JsonRedisSerializer는 객체를 JSON 문자열로 변환한다. Redis CLI에서 값을 바로 읽을 수 있고, 클래스 구조가 변경되어도 유연하게 대응할 수 있다.

GenericJackson2JsonRedisSerializer의 타입 정보

GenericJackson2JsonRedisSerializer는 JSON에 클래스 타입 정보(@class)를 함께 저장한다. 역직렬화할 때 이 정보로 원래 클래스를 복원한다. 패키지 경로가 바뀌면 역직렬화에 실패하므로, 캐시 대상 클래스의 패키지 구조를 변경할 때는 기존 캐시를 모두 비워야 한다.

타입 안전한 직렬화

타입 정보(@class) 없이 직렬화하고 싶다면 Jackson2JsonRedisSerializer에 대상 클래스를 명시할 수 있다.

.serializeValuesWith(
    RedisSerializationContext.SerializationPair
        .fromSerializer(new Jackson2JsonRedisSerializer<>(WeatherResponse.class)))

하지만 이 방식은 캐시 영역마다 다른 Serializer를 설정해야 하므로 코드가 복잡해진다. 대부분의 경우 GenericJackson2JsonRedisSerializer면 충분하다.

Redis 키 구조

Redis에 캐시된 데이터의 키는 캐시 이름 + 구분자 + 캐시 키 형태로 저장된다.

weather::서울
weather::부산
feed::123
feed::456
flowchart LR Key["Redis 키 구조"] Key --> CacheName["캐시 이름\nweather"] Key --> Sep["구분자\n::"] Key --> CacheKey["캐시 키\n서울"] CacheName --- Sep --- CacheKey Result["weather::서울"]

기본 구분자는 ::다. 이 구분자를 변경할 수도 있다.

RedisCacheConfiguration.defaultCacheConfig()
    .prefixCacheNameWith("weatherfit:")
    .computePrefixWith(cacheName -> cacheName + ":")

Redis CLI에서 캐시 데이터를 확인할 때 이 키 구조를 알아야 한다.

# 모든 weather 캐시 키 조회
redis-cli KEYS "weather::*"

# 특정 키의 값 조회
redis-cli GET "weather::서울"

# 특정 키의 TTL 확인
redis-cli TTL "weather::서울"
KEYS 명령은 운영 환경에서 쓰지 마라

KEYS 명령은 모든 키를 순회하므로 키가 많을 때 Redis를 멈추게 할 수 있다. 운영 환경에서는 SCAN 명령을 사용하라.

Redis 장애 대응

Redis가 다운되면 캐시가 동작하지 않는다. 이때 애플리케이션까지 함께 죽으면 안 된다.

기본 동작 — 예외 전파

기본적으로 Redis 연결이 실패하면 예외가 발생하고, @Cacheable이 붙은 메서드 호출 자체가 실패한다. 캐시 장애가 서비스 장애로 이어지는 최악의 상황이다.

CacheErrorHandler로 장애 격리

CacheErrorHandler를 등록하면 캐시 오류를 무시하고 원본 메서드를 직접 실행할 수 있다.

@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {

    @Override
    public CacheErrorHandler errorHandler() {
        return new SimpleCacheErrorHandler() {
            @Override
            public void handleCacheGetError(
                    RuntimeException exception, Cache cache, Object key) {
                log.warn("캐시 조회 실패: cache={}, key={}", cache.getName(), key, exception);
                // 예외를 삼켜서 원본 메서드가 실행되도록 한다
            }

            @Override
            public void handleCachePutError(
                    RuntimeException exception, Cache cache, Object key, Object value) {
                log.warn("캐시 저장 실패: cache={}, key={}", cache.getName(), key, exception);
            }

            @Override
            public void handleCacheEvictError(
                    RuntimeException exception, Cache cache, Object key) {
                log.warn("캐시 제거 실패: cache={}, key={}", cache.getName(), key, exception);
            }

            @Override
            public void handleCacheClearError(
                    RuntimeException exception, Cache cache) {
                log.warn("캐시 초기화 실패: cache={}", cache.getName(), exception);
            }
        };
    }
}

이 설정을 적용하면 Redis가 다운되어도 애플리케이션은 정상 동작한다. 캐시 없이 DB에서 직접 조회하므로 응답은 느려지지만, 서비스 자체는 멈추지 않는다.

flowchart TD A["@Cacheable 메서드 호출"] --> B{"Redis 정상?"} B -->|정상| C["캐시 조회"] C -->|Hit| D["캐시 반환"] C -->|Miss| E["메서드 실행 → 캐시 저장"] B -->|장애| F{"CacheErrorHandler 등록?"} F -->|등록됨| G["오류 로그 기록"] G --> H["메서드 직접 실행"] H --> I["DB에서 조회 → 응답"] F -->|미등록| J["예외 발생 → 서비스 장애"]
캐시 장애 격리는 필수다

캐시는 성능을 위한 보조 수단이다. 캐시 장애가 서비스 장애로 이어지는 구조는 잘못된 설계다. 운영 환경에서는 반드시 CacheErrorHandler를 등록하라.

Lettuce vs Jedis

Spring Data Redis의 Redis 클라이언트로 Lettuce와 Jedis 두 가지를 선택할 수 있다.

  • Lettuce : Spring Boot의 기본 클라이언트다. 비동기, 논블로킹 방식이다. 하나의 커넥션을 여러 스레드가 공유할 수 있어 커넥션 풀이 필요 없다.
  • Jedis : 동기, 블로킹 방식이다. 각 스레드가 독립된 커넥션을 사용하므로 커넥션 풀이 필요하다. 구버전 프로젝트에서 많이 쓰였다.

새로 시작하는 프로젝트라면 Lettuce를 그대로 사용하면 된다. 별도의 설정 없이 spring-boot-starter-data-redis에 포함되어 있다.

자주 하는 실수

JDK 직렬화를 그대로 사용하기

JDK 직렬화는 클래스 구조가 바뀌면 역직렬화에 실패한다. 필드를 추가하거나 타입을 변경하면 기존 캐시가 모두 깨진다. JSON 직렬화로 반드시 변경하라. 운영 중에 직렬화 방식을 바꾸면 기존 캐시를 모두 비워야 하므로, 프로젝트 초기에 설정하는 것이 중요하다.

[!DANGER] CacheErrorHandler 없이 운영 환경 배포하기

Redis가 다운되면 @Cacheable 호출 자체가 예외를 던진다. 캐시 장애가 서비스 전체 장애로 확산된다. 반드시 CacheErrorHandler를 등록하여 캐시 오류를 격리하라. "Redis가 죽을 일이 있겠어?"라는 생각은 운영 환경에서 통하지 않는다.

[!DANGER] TTL 없이 Redis 캐시 사용하기

Redis에 TTL 없이 데이터를 저장하면, Redis 메모리가 계속 쌓인다. Redis 서버의 메모리가 가득 차면 maxmemory-policy에 따라 데이터가 무차별로 삭제되거나 쓰기가 거부된다. 모든 캐시 영역에 적절한 TTL을 설정하고, Redis 서버에도 maxmemorymaxmemory-policy를 설정하라.