이 문서는 단일 가이드 문서다.

관련 문서 : 스프링/Spring Cache/1. 캐시란 무엇인가 · 2. Spring Cache 추상화 · 3. Caffeine 설정과 활용

왜 Redis가 필요한가

스프링/Spring Cache/1. 캐시란 무엇인가에서 로컬 캐시와 분산 캐시의 차이를 봤다. Caffeine은 각 서버의 JVM 메모리에 캐시를 저장한다. 서버가 한 대면 아무 문제 없다.

서버가 여러 대로 늘어나면 문제가 생긴다.

sequenceDiagram participant User as 사용자 participant LB as 로드밸런서 participant A as 서버 A participant B as 서버 B participant CA as A의 Caffeine participant CB as B의 Caffeine User->>LB: 채널 목록 수정 LB->>A: 요청 전달 A->>CA: 캐시 무효화 ✅ Note over CA: 최신 데이터 User->>LB: 채널 목록 조회 LB->>B: 요청 전달 (다른 서버) B->>CB: 캐시 조회 Note over CB: ❌ 옛날 데이터!
A에서 무효화한 걸
B는 모른다 CB-->>User: 오래된 데이터 반환

서버 A에서 데이터를 수정하고 캐시를 무효화했지만, 서버 B의 캐시에는 그 사실이 전달되지 않는다. 사용자가 로드밸런서를 통해 B에 요청하면 오래된 데이터를 보게 된다.

Redis는 이 문제를 해결한다. 모든 서버가 하나의 외부 캐시를 공유하므로, A에서 무효화하면 B도 즉시 최신 데이터를 가져간다.

graph TD A[서버 A] --> R[Redis] B[서버 B] --> R C[서버 C] --> R Note["모든 서버가 하나의 캐시를 공유
A에서 무효화하면 B, C도 즉시 반영"] style R fill:#fff3e0,stroke:#FF9800

Docker로 Redis 실행

# docker-compose-redis.yml
services:
  redis:
    image: redis:7.2-alpine
    container_name: redis
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes

volumes:
  redis-data:
docker compose -f docker-compose-redis.yml up -d
  • redis:7.2-alpine : 경량 Alpine 이미지. 개발/운영 모두 적합.
  • --appendonly yes : AOF(Append Only File) 방식으로 데이터를 디스크에 기록. Redis가 재시작되어도 데이터가 유지된다.
  • ports: 6379:6379 : 기본 포트. 애플리케이션에서 localhost:6379로 접속한다.

Spring 설정

의존성

implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'com.github.ben-manes.caffeine:caffeine'

spring-boot-starter-data-redis를 추가하면 Spring Boot가 RedisConnectionFactoryRedisTemplate을 자동 설정한다.

Caffeine 의존성을 유지하는 이유

Redis 전환 후에도 Caffeine을 로컬 1차 캐시로 사용할 수 있다. Caffeine(로컬) → Redis(분산) 순서로 조회하면 Redis 네트워크 왕복도 줄일 수 있다. 단, 이번 미션에서는 Redis만 사용한다.

application.yml

spring:
  cache:
    type: redis
    cache-names:
      - channels
      - notifications
      - users
    caffeine:
      spec: >
        maximumSize=100,
        expireAfterAccess=600s,
        recordStats
    redis:
      enable-statistics: true
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
설정의미
type: redis캐시 구현체를 Redis로 변경. 이 한 줄로 Caffeine에서 전환
cache-names사용할 캐시 이름. @Cacheable("channels")와 일치해야 함
redis.enable-statisticsActuator에서 캐시 통계를 확인하기 위한 설정
data.redis.host/portRedis 서버 주소. 환경 변수로 주입 가능

핵심은 type: redis 하나다. Spring Cache 추상화 덕분에 @Cacheable, @CacheEvict 코드는 하나도 바꿀 필요 없다. 밑에서 동작하는 캐시 구현체만 Caffeine에서 Redis로 바뀐다.

직렬화 설정

Caffeine은 Java 객체를 JVM 메모리에 그대로 저장했다. Redis는 외부 서버이므로 객체를 직렬화해서 바이트로 변환한 뒤 네트워크로 전송해야 한다. 역직렬화할 때도 원래 타입을 복원할 수 있어야 한다.

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration(ObjectMapper objectMapper) {
        ObjectMapper redisObjectMapper = objectMapper.copy();
        redisObjectMapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance,
            ObjectMapper.DefaultTyping.EVERYTHING,
            JsonTypeInfo.As.PROPERTY
        );

        return RedisCacheConfiguration.defaultCacheConfig()
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new GenericJackson2JsonRedisSerializer(redisObjectMapper)
                )
            )
            .prefixCacheNameWith("discodeit:")
            .entryTtl(Duration.ofSeconds(600))
            .disableCachingNullValues();
    }
}

각 설정의 의미를 보자.

설정의미
objectMapper.copy()기존 ObjectMapper를 복사. 원본을 수정하지 않기 위해
activateDefaultTyping(EVERYTHING)JSON에 타입 정보를 포함시킨다. 역직렬화할 때 원래 클래스를 알 수 있음
GenericJackson2JsonRedisSerializerJSON 형식으로 직렬화. Redis에서 데이터를 직접 읽을 수 있음
prefixCacheNameWith("discodeit:")Redis 키에 접두어 추가. discodeit:channels:abc-123 형태
entryTtl(600s)기본 TTL 10분
disableCachingNullValues()null은 캐시하지 않음
activateDefaultTyping이 없으면

Redis에서 값을 꺼낼 때 어떤 클래스로 역직렬화해야 하는지 모른다. LinkedHashMap으로 반환되어 ClassCastException이 터진다. EVERYTHING 옵션을 켜면 JSON에 @class 필드가 추가되어 정확한 타입으로 복원된다.

직렬화된 데이터 예시

Redis에 저장된 데이터는 이렇게 생겼다.

{
  "@class": "java.util.ArrayList",
  "0": {
    "@class": "com.sprint.mission.discodeit.dto.entity.channel.response.ChannelDto",
    "id": "abc-123",
    "name": "일반",
    "type": "PUBLIC"
  }
}

@class 필드가 타입 정보를 담고 있어서, 역직렬화할 때 정확히 ChannelDto 객체로 복원된다.

@Cacheable 코드는 변경 없음

이것이 Spring Cache 추상화의 힘이다. 서비스 코드는 그대로 유지하면 된다.

// Caffeine일 때도, Redis일 때도 이 코드는 동일하다
@Cacheable("channels")
public List<ChannelDto> getChannelsByUserId(UUID userId) {
    return channelRepository.findByUserId(userId);
}

@CacheEvict(value = "channels", allEntries = true)
public ChannelDto createChannel(ChannelCreateRequest request) {
    // ...
}

application.yml에서 type: caffeinetype: redis로 바꾸면, 같은 @Cacheable 코드가 Redis를 사용하게 된다.

Redis에서 캐시 확인

DataGrip이나 redis-cli로 Redis에 저장된 캐시를 직접 확인할 수 있다.

# Redis 컨테이너에 접속
docker exec -it redis redis-cli

# 모든 키 조회
KEYS *

# 특정 키 값 조회
GET "discodeit:channels::abc-123"

# TTL 확인
TTL "discodeit:channels::abc-123"

키 형식은 {prefix}{cacheName}::{key}다. 예를 들어 prefixCacheNameWith("discodeit:")이고 캐시 이름이 channels, 키가 abc-123이면 Redis 키는 discodeit:channels::abc-123이 된다.

Caffeine vs Redis 비교 정리

구분Caffeine (로컬)Redis (분산)
저장 위치JVM 힙 메모리외부 Redis 서버
속도나노초 (메모리 직접 접근)밀리초 (네트워크 왕복 1~2ms)
서버 간 공유불가능가능
서버 재시작캐시 소멸유지 (AOF/RDB)
직렬화불필요 (객체 그대로)필요 (JSON 등)
설정 복잡도간단직렬화 설정 필요
적합한 환경단일 서버다중 서버
실무에서는 둘 다 쓴다

Caffeine을 1차 캐시(로컬), Redis를 2차 캐시(분산)로 사용하는 다단계 캐싱도 흔하다. 요청이 오면 Caffeine → Redis → DB 순서로 조회한다. Caffeine에 있으면 네트워크 비용도 없고, Redis에 있으면 DB 비용도 없다.

주의사항

DTO가 직렬화/역직렬화 가능해야 한다

Redis에 저장하는 객체는 기본 생성자가 필요하다 (Jackson 역직렬화에 필요). record 타입은 Jackson이 자동 처리하므로 대부분 문제없지만, 커스텀 생성자가 있는 클래스는 @JsonCreator를 붙여야 할 수 있다.

순환 참조 주의

엔티티를 직접 캐시하면 JPA 연관 관계의 순환 참조로 직렬화가 실패할 수 있다. DTO로 변환한 후 캐시하는 것이 안전하다. @Cacheable 메서드가 DTO를 반환하면 이 문제를 자연스럽게 피한다.

Redis 장애 시 대응

Redis가 다운되면 캐시 미스가 발생하고 모든 요청이 DB로 간다. DB가 감당할 수 있으면 서비스는 유지되지만, 응답 속도가 느려진다. Redis 연결 실패를 예외로 터뜨리지 않고 캐시 미스로 처리하는 설정이 필요하다.

spring:
  cache:
    redis:
      cache-null-values: false

Spring Boot의 기본 동작은 Redis 장애 시 예외를 삼키고 캐시 미스로 처리한다. 커스텀 CacheErrorHandler를 등록하면 장애 시 로그를 남기거나 알림을 보낼 수 있다.