관련 문서 : 스프링/Spring Cache/1. 캐시란 무엇인가 · 2. Spring Cache 추상화 · 3. Caffeine 설정과 활용
왜 Redis가 필요한가
스프링/Spring Cache/1. 캐시란 무엇인가에서 로컬 캐시와 분산 캐시의 차이를 봤다. Caffeine은 각 서버의 JVM 메모리에 캐시를 저장한다. 서버가 한 대면 아무 문제 없다.
서버가 여러 대로 늘어나면 문제가 생긴다.
A에서 무효화한 걸
B는 모른다 CB-->>User: 오래된 데이터 반환
서버 A에서 데이터를 수정하고 캐시를 무효화했지만, 서버 B의 캐시에는 그 사실이 전달되지 않는다. 사용자가 로드밸런서를 통해 B에 요청하면 오래된 데이터를 보게 된다.
Redis는 이 문제를 해결한다. 모든 서버가 하나의 외부 캐시를 공유하므로, A에서 무효화하면 B도 즉시 최신 데이터를 가져간다.
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가 RedisConnectionFactory와 RedisTemplate을 자동 설정한다.
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-statistics | Actuator에서 캐시 통계를 확인하기 위한 설정 |
data.redis.host/port | Redis 서버 주소. 환경 변수로 주입 가능 |
핵심은 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에 타입 정보를 포함시킨다. 역직렬화할 때 원래 클래스를 알 수 있음 |
GenericJackson2JsonRedisSerializer | JSON 형식으로 직렬화. Redis에서 데이터를 직접 읽을 수 있음 |
prefixCacheNameWith("discodeit:") | Redis 키에 접두어 추가. discodeit:channels:abc-123 형태 |
entryTtl(600s) | 기본 TTL 10분 |
disableCachingNullValues() | null은 캐시하지 않음 |
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: caffeine → type: 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를 등록하면 장애 시 로그를 남기거나 알림을 보낼 수 있다.