Spring Cache와 Redis — @Cacheable로 캐시를 자동화하는 방법
서비스에서 같은 데이터를 반복해서 DB에서 읽고 있다면, 캐시를 도입하는 가장 간단한 방법은 무엇일까요?
직접 RedisTemplate을 다루며 캐시 로직을 짜는 것도 방법이지만, Spring Cache 추상화를 사용하면 어노테이션 하나로 캐시를 적용할 수 있습니다. 이 글에서는 Spring Cache와 Redis를 연동하는 전체 과정을 정리합니다.
개념 정의
Spring Cache 추상화 는 캐시 저장소(Redis, Caffeine, EhCache 등)에 독립적인 어노테이션 기반 캐시 인터페이스입니다. 비즈니스 로직과 캐시 로직을 분리하여, 캐시 구현체를 바꿔도 코드를 수정할 필요가 없습니다.
Spring Cache 추상화란
Spring Cache의 핵심 구성요소는 세 가지입니다.
@EnableCaching— 캐시 기능을 활성화하는 스위치CacheManager— 캐시 저장소를 관리하는 인터페이스- ** 캐시 어노테이션** —
@Cacheable,@CacheEvict,@CachePut등
@Configuration
@EnableCaching // 이걸 빼먹으면 캐시 어노테이션이 전부 무시됩니다
public class CacheConfig {
}
Spring Boot에서 spring-boot-starter-data-redis를 추가하면 자동으로 RedisCacheManager가 등록됩니다. 별도 CacheManager 빈을 선언하지 않아도 Redis가 기본 캐시 저장소로 동작합니다.
Spring Boot의 Auto-Configuration이 클래스패스에서 Redis를 감지하면
RedisCacheManager를 자동 생성합니다. Caffeine이나 EhCache도 마찬가지로 해당 라이브러리가 클래스패스에 있으면 자동 적용됩니다.
핵심 어노테이션
@Cacheable — 캐시에서 먼저 조회
가장 많이 사용하는 어노테이션입니다. 메서드 호출 전에 캐시를 확인하고, 값이 있으면 메서드를 실행하지 않고 캐시된 값을 반환합니다.
@Cacheable(value = "users", key = "#userId")
public User getUser(Long userId) {
// 캐시 미스일 때만 이 코드가 실행됩니다
return userRepository.findById(userId).orElseThrow();
}
value(=cacheNames) — 캐시 이름. Redis에서는 키 prefix로 사용됩니다key— 캐시 키를 SpEL로 지정. 생략하면 파라미터 전체가 키가 됩니다
condition과 unless
캐시 적용 조건을 세밀하게 제어할 수 있습니다.
@Cacheable(
value = "users",
key = "#userId",
condition = "#userId > 0", // 실행 전 평가: false면 캐시 자체를 건너뜀
unless = "#result.role == 'ADMIN'" // 실행 후 평가: true면 결과를 캐시에 저장하지 않음
)
public User getUser(Long userId) {
return userRepository.findById(userId).orElseThrow();
}
condition과 unless의 평가 시점이 다르다는 게 핵심입니다. condition은 메서드 실행 전에 평가되어 캐시 동작 자체를 건너뛸지 결정하고, unless는 메서드 실행 후에 결과(#result)를 보고 캐시에 저장할지 결정합니다.
@CacheEvict — 캐시 무효화
데이터가 변경되었을 때 오래된 캐시를 삭제합니다.
// 특정 키의 캐시 삭제
@CacheEvict(value = "users", key = "#userId")
public void updateUser(Long userId, UserUpdateDto dto) {
userRepository.update(userId, dto);
}
// 해당 캐시의 모든 엔트리 삭제
@CacheEvict(value = "users", allEntries = true)
public void clearAllUserCache() {
// 메서드 본문은 비어 있어도 됩니다
}
allEntries = true— 해당 캐시 이름 아래의 모든 키를 삭제합니다beforeInvocation = true— 메서드 실행 전에 캐시를 삭제합니다. 메서드가 예외를 던져도 캐시는 이미 삭제된 상태입니다
@CachePut — 항상 실행하고 캐시 갱신
@Cacheable과 달리 캐시 존재 여부와 무관하게 항상 메서드를 실행하고, 그 결과를 캐시에 저장합니다.
@CachePut(value = "users", key = "#userId")
public User updateAndReturn(Long userId, UserUpdateDto dto) {
User user = userRepository.findById(userId).orElseThrow();
user.update(dto);
return userRepository.save(user); // 반환값이 캐시에 저장됩니다
}
데이터를 갱신하면서 동시에 캐시도 최신 상태로 유지하고 싶을 때
@CachePut을 사용합니다. 하지만 동시 요청 환경에서는@CacheEvict로 무효화하는 편이 더 안전한 경우가 많습니다.
@Caching — 여러 어노테이션 조합
한 메서드에 여러 캐시 동작을 동시에 적용해야 할 때 사용합니다.
@Caching(
evict = {
@CacheEvict(value = "users", key = "#userId"),
@CacheEvict(value = "userList", allEntries = true)
}
)
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
사용자를 삭제할 때 개별 사용자 캐시와 목록 캐시를 한꺼번에 무효화하는 패턴입니다.
@CacheConfig — 클래스 레벨 기본값
같은 클래스의 모든 캐시 메서드에 공통 설정을 적용합니다.
@Service
@CacheConfig(cacheNames = "users") // 이 클래스의 모든 캐시 어노테이션에 적용
public class UserService {
@Cacheable(key = "#userId") // value 생략 가능
public User getUser(Long userId) {
return userRepository.findById(userId).orElseThrow();
}
@CacheEvict(key = "#userId") // value 생략 가능
public void updateUser(Long userId, UserUpdateDto dto) {
userRepository.update(userId, dto);
}
}
Redis 연동 설정
의존성 추가
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'
}
application.yml 설정
spring:
data:
redis:
host: localhost
port: 6379
# password: your-password # 필요 시
cache:
type: redis # 캐시 타입 명시 (자동 감지되지만 명시하면 확실)
redis:
time-to-live: 600000 # 기본 TTL 10분 (밀리초)
cache-null-values: false # null 캐싱 비활성화
key-prefix: "myapp::" # 키 접두사
use-key-prefix: true
RedisCacheConfiguration 커스터마이징
application.yml로 안 되는 세밀한 설정은 Java Config로 합니다.
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// 기본 설정
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeKeysWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
// 캐시별 개별 TTL 설정
Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
"users", defaultConfig.entryTtl(Duration.ofMinutes(30)),
"products", defaultConfig.entryTtl(Duration.ofHours(1)),
"sessions", defaultConfig.entryTtl(Duration.ofMinutes(5))
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}
직렬화 설정
Spring Cache + Redis의 기본 직렬화는 JdkSerializationRedisSerializer입니다. 이게 문제가 되는 이유가 있습니다.
- Redis CLI에서 값을 읽으면 바이너리라 사람이 확인할 수 없습니다
- 클래스 패키지 경로나 필드가 바뀌면 역직렬화가 실패합니다
- 다른 언어의 애플리케이션과 캐시를 공유할 수 없습니다
JSON 직렬화로 변경
@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule()); // LocalDateTime 등 지원
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(objectMapper);
return RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(serializer));
}
activateDefaultTyping을 설정하면 JSON에@class필드가 포함되어 역직렬화 시 정확한 타입을 복원할 수 있습니다. 다만 보안 취약점이 될 수 있으므로 신뢰할 수 있는 내부 시스템에서만 사용하는 것이 좋습니다.
캐시 키 전략
SpEL 기반 키 생성
// 단일 파라미터
@Cacheable(value = "users", key = "#userId")
public User getUser(Long userId) { ... }
// 복합 키
@Cacheable(value = "products", key = "#category + ':' + #page")
public List<Product> getProducts(String category, int page) { ... }
// 객체의 필드
@Cacheable(value = "orders", key = "#request.userId + ':' + #request.status")
public List<Order> getOrders(OrderSearchRequest request) { ... }
커스텀 KeyGenerator
SpEL이 복잡해지면 별도 KeyGenerator를 만드는 것이 깔끔합니다.
@Component("customKeyGenerator")
public class CustomKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
// 클래스명::메서드명::파라미터들
return target.getClass().getSimpleName()
+ "::" + method.getName()
+ "::" + Arrays.stream(params)
.map(String::valueOf)
.collect(Collectors.joining(","));
}
}
// 사용
@Cacheable(value = "reports", keyGenerator = "customKeyGenerator")
public Report generateReport(String type, LocalDate from, LocalDate to) { ... }
키 네이밍 컨벤션
Redis에서 캐시 키를 확인할 때 구조가 보이도록 prefix를 잘 설계하는 게 중요합니다.
# 권장 형식
myapp::users::42
myapp::products::electronics::1
# Spring Cache 기본 형식 (prefix 설정 시)
myapp::users::42
실무에서 자주 겪는 문제
1. 같은 클래스 내부 호출은 캐시가 동작하지 않음
Spring Cache는 AOP 프록시 기반으로 동작합니다. 같은 클래스의 메서드를 this로 호출하면 프록시를 거치지 않아 캐시가 적용되지 않습니다.
@Service
public class UserService {
@Cacheable(value = "users", key = "#userId")
public User getUser(Long userId) {
return userRepository.findById(userId).orElseThrow();
}
public UserDto getUserDto(Long userId) {
// 이 호출은 캐시가 동작하지 않습니다 (this를 통한 내부 호출)
User user = this.getUser(userId);
return UserDto.from(user);
}
}
해결 방법은 크게 두 가지입니다.
- ** 별도 클래스로 분리** — 캐시 메서드를 다른 서비스로 추출합니다 (가장 권장)
self주입 — 자기 자신을 주입받아 프록시를 통해 호출합니다
@Service
public class UserService {
private final UserCacheService userCacheService; // 캐시 메서드를 분리
public UserDto getUserDto(Long userId) {
User user = userCacheService.getUser(userId); // 프록시를 통한 호출
return UserDto.from(user);
}
}
2. null 캐싱 문제
기본적으로 Spring Cache는 null 반환값도 캐시합니다. 존재하지 않는 데이터를 반복 조회하면 null이 캐시되어 DB를 보호하는 효과가 있지만, 데이터가 나중에 생성되었을 때 계속 null을 반환하는 문제가 됩니다.
// 방법 1: unless로 null 캐싱 방지
@Cacheable(value = "users", key = "#userId", unless = "#result == null")
public User getUser(Long userId) {
return userRepository.findById(userId).orElse(null);
}
// 방법 2: 글로벌 설정으로 비활성화
RedisCacheConfiguration.defaultCacheConfig()
.disableCachingNullValues();
disableCachingNullValues()를 설정한 상태에서 메서드가 null을 반환하면IllegalArgumentException이 발생합니다. null을 반환할 가능성이 있는 메서드에는unless = "#result == null"을 사용하는 것이 안전합니다.
3. TTL 전략
캐시에 TTL을 설정하지 않으면 데이터가 영원히 남아 메모리 문제가 생깁니다. 반대로 TTL이 너무 짧으면 캐시 히트율이 떨어집니다.
| 데이터 특성 | 권장 TTL | 예시 |
|---|---|---|
| 거의 변하지 않는 데이터 | 1~24시간 | 공지사항, 약관 |
| 주기적으로 변하는 데이터 | 5~30분 | 상품 목록, 랭킹 |
| 실시간성이 중요한 데이터 | 1~5분 또는 캐시 미적용 | 재고, 잔액 |
정리
| 어노테이션 | 동작 | 메서드 실행 |
|---|---|---|
@Cacheable | 캐시 조회 → 미스 시 실행 후 저장 | 캐시 미스 시에만 |
@CacheEvict | 캐시 삭제 | 항상 실행 |
@CachePut | 항상 실행 후 결과를 캐시에 저장 | 항상 실행 |
@Caching | 위 어노테이션들의 조합 | 조합에 따라 다름 |
@CacheConfig | 클래스 레벨 공통 설정 | - |
Spring Cache 추상화를 사용하면 캐시 로직을 비즈니스 코드에서 분리할 수 있고, Redis 외에 다른 캐시 저장소로 교체할 때도 어노테이션은 그대로 유지됩니다. 다만 프록시 기반의 한계(내부 호출 문제)와 직렬화 설정은 반드시 짚고 넘어가야 합니다.