리액티브 레디스 마스터 노트 시리즈 4편. Spring Cache 추상화의 본질, @Cacheable·@CacheEvict·@CachePut 어노테이션 패턴, WebFlux와 @Cacheable의 결정적 함정과 CacheMono.lookup() 우회법, RedisCacheManager 설정과 TTL 정책 차등화, 캐시 키 SpEL 표현식, Cache-Aside·Write-Through·TTL 만료 전략 비교까지.
이 글은 리액티브 레디스 마스터 노트 시리즈의 네 번째 편입니다. 3편(자료구조)까지 직접 명령 호출이었다면, 이번엔 그 위 한 단계 추상화 — Spring Cache + @Cacheable.
@Cacheable 한 줄로 캐시 자동. 다만 WebFlux와의 미세한 함정이 있습니다. Mono/Flux 반환 메서드에 @Cacheable 박으면 의도와 다르게 동작. 이 함정과 우회법이 이번 편의 핵심.
처음 캐싱이 어렵게 느껴지는 이유
처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, @Cacheable이 너무 단순해 보입니다. 그래서 함정을 못 봅니다 — WebFlux와 안 맞음을. 둘째, TTL·Eviction·Key 정책이 한 번에 쏟아집니다.
해결법은 한 가지예요. "@Cacheable은 Mono/Flux 반환 메서드와 맞지 않는다" 한 줄. WebFlux 환경에선 직접 ReactiveRedisTemplate 사용 또는 CacheMono.lookup() 우회. 이 인식이 시작.
Spring Cache 추상화
@Service
public class UserService {
@Cacheable(value = "users", key = "#id")
public User getUser(String id) {
return userRepo.findById(id).orElseThrow();
}
@CacheEvict(value = "users", key = "#id")
public void deleteUser(String id) {
userRepo.deleteById(id);
}
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
return userRepo.save(user);
}
}
3 어노테이션:
@Cacheable— 캐시 조회, 없으면 메서드 실행 후 저장@CacheEvict— 캐시 삭제@CachePut— 메서드 실행 + 결과 캐시 저장 (조회 X)
활성화:
@SpringBootApplication
@EnableCaching
public class App { }
여기서 정말 중요한 시험 함정 — 이 흐름은 동기 메서드용. WebFlux의 Mono<User> getUser() 반환에 @Cacheable 박으면 Mono 자체를 캐시 → 한 번 구독되면 캐시된 Mono는 빈 값.
WebFlux + @Cacheable 함정
@Cacheable("users")
public Mono<User> getUser(String id) {
return userRepo.findById(id);
}
문제:
- 첫 호출 →
Mono생성 + 캐시 저장 - Subscribe → User 조회 → Mono 완료 (소비됨)
- 두 번째 호출 → 캐시된 Mono 반환 → 이미 소비된 Mono, 빈 값
여기서 정말 중요한 시험 함정 — Mono/Flux는 cold publisher (재구독 시 재실행). @Cacheable은 결과 객체 자체 캐시 → Mono를 다시 캐시에서 꺼내도 정상 동작 안 함.
우회 1 — 직접 ReactiveRedisTemplate
public Mono<User> getUser(String id) {
String key = "user:" + id;
return redisTemplate.opsForValue().get(key)
.map(this::deserialize)
.switchIfEmpty(
userRepo.findById(id)
.flatMap(user ->
redisTemplate.opsForValue()
.set(key, serialize(user), Duration.ofMinutes(5))
.thenReturn(user)
)
);
}
명시적·안전·통제 가능. WebFlux 캐싱의 표준 패턴.
우회 2 — CacheMono.lookup() (Reactor Cache Add-ons)
implementation 'io.projectreactor.addons:reactor-extra'
public Mono<User> getUser(String id) {
return CacheMono
.lookup(this::lookupCache, id)
.onCacheMissResume(() -> userRepo.findById(id))
.andWriteWith(this::writeCache);
}
private Mono<Signal<? extends User>> lookupCache(String id) {
return redisTemplate.opsForValue().get("user:" + id)
.map(this::deserialize)
.map(Signal::next);
}
private Mono<Void> writeCache(String id, Signal<? extends User> signal) {
if (signal.hasValue()) {
return redisTemplate.opsForValue()
.set("user:" + id, serialize(signal.get()), Duration.ofMinutes(5))
.then();
}
return Mono.empty();
}
Reactor의 공식 캐싱 도구. 합성·테스트 자연스러움.
여기서 시험 함정이 하나 있어요. CacheMono는 별도 의존성 (reactor-extra). 표준 Reactor에 없음.
RedisCacheManager 설정 (전통 캐시용)
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig())
.withCacheConfiguration("users", userCacheConfig())
.withCacheConfiguration("products", productCacheConfig())
.build();
}
private RedisCacheConfiguration defaultConfig() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()));
}
private RedisCacheConfiguration userCacheConfig() {
return defaultConfig().entryTtl(Duration.ofMinutes(30)); // 사용자는 30분
}
private RedisCacheConfiguration productCacheConfig() {
return defaultConfig().entryTtl(Duration.ofHours(24)); // 상품은 24시간
}
캐시별 차등 TTL. 운영 환경 표준.
여기서 시험 함정이 하나 있어요. 이 RedisCacheManager는 동기 (non-reactive). WebFlux에선 @Cacheable 함정 그대로. 별도 패턴 필요.
SpEL 키 표현식
@Cacheable(value = "users", key = "#id")
public User getUser(String id) { ... }
@Cacheable(value = "users", key = "#user.id + ':' + #user.region")
public List<Order> getOrders(User user) { ... }
@Cacheable(value = "search", key = "T(java.util.Objects).hash(#q, #page)")
public List<Item> search(String q, int page) { ... }
// 조건부 캐시
@Cacheable(value = "users", condition = "#id != null", unless = "#result == null")
public User getUser(String id) { ... }
SpEL — 메서드 인자·결과 표현 가능.
캐싱 패턴 — 5종 (Part-1 Kafka 7편 보충)
1. Cache-Aside (Lazy Loading) — 가장 일반적
public Mono<User> getUser(String id) {
return cache.get(id)
.switchIfEmpty(
db.findById(id)
.flatMap(user -> cache.set(id, user, TTL).thenReturn(user))
);
}
조회 시 캐시 미스 → DB → 캐시 저장. WebFlux 표준.
2. Write-Through
public Mono<User> updateUser(User user) {
return db.save(user)
.flatMap(saved -> cache.set(saved.getId(), saved, TTL).thenReturn(saved));
}
DB 저장 후 캐시 즉시 갱신. 캐시 항상 최신.
3. Write-Behind (Write-Back)
DB 저장은 비동기. 위험 — 캐시 다운 시 데이터 손실.
4. TTL 만료
자동 만료. 가장 단순. 일관성 ↓ but 단순.
5. Pub/Sub Invalidation (6편)
DB 변경 시 모든 캐시 노드에 무효화 메시지.
캐시 무효화 패턴
Cache-Aside + Update
public Mono<User> updateUser(User user) {
return db.save(user)
.flatMap(saved -> cache.delete("user:" + saved.getId()).thenReturn(saved));
}
여기서 정말 중요한 시험 함정 — DB 저장 후 cache delete. set 아닌 delete가 race 안전. set 하면 아래 시나리오:
1. T1: DB save 시작
2. T2: DB save (다른 데이터)
3. T2: cache set (T2 데이터)
4. T1: cache set (T1 데이터) ← 옛 데이터로 덮어씀!
delete는 다음 조회 때 DB에서 새로 가져오므로 안전.
TTL 결정 가이드
| 데이터 | TTL |
|---|---|
| 자주 변경 (가격) | 5~10분 |
| 보통 (사용자 프로필) | 30분~1시간 |
| 드물게 변경 (상품 카탈로그) | 24시간 |
| 거의 안 변경 (지역 코드) | 7일 |
여기서 시험 함정이 하나 있어요. TTL 너무 길면 stale 데이터, 너무 짧으면 캐시 효과 X. 데이터별 차등이 정답. 짧은 TTL + Pub/Sub 무효화 결합도 가능.
Cache Stampede 방지
캐시 미스 시 동시 다중 요청이 모두 DB 호출:
1000 요청 → 캐시 미스 → 1000 DB 호출 → DB 폭주
해결:
- 분산 락 (Redisson RLock) — 한 인스턴스만 DB 조회
- 확률적 만료 — TTL 만료 직전 일부 요청만 갱신
- 소프트 TTL — 짧은 soft TTL + 긴 hard TTL
public Mono<User> getUser(String id) {
String key = "user:" + id;
String lockKey = "lock:user:" + id;
return redisTemplate.opsForValue().get(key)
.switchIfEmpty(
redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5))
.flatMap(acquired -> {
if (acquired) {
return db.findById(id)
.flatMap(user -> redisTemplate.opsForValue()
.set(key, user, Duration.ofMinutes(5))
.thenReturn(user))
.doFinally(s -> redisTemplate.delete(lockKey).subscribe());
} else {
// 락 못 얻음 → 잠시 대기 후 재시도
return Mono.delay(Duration.ofMillis(100))
.then(getUser(id));
}
})
);
}
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 4편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- Spring Cache 3 어노테이션 —
@Cacheable/@CacheEvict/@CachePut @EnableCaching으로 활성화@Cacheable+ WebFlux Mono = 함정- Mono 자체 캐시 → 두 번째 구독 시 빈 값
- Mono/Flux는 cold publisher
- 우회 1 — 직접 ReactiveRedisTemplate (WebFlux 표준)
- 우회 2 —
CacheMono.lookup()(reactor-extra의존성) - RedisCacheManager = 캐시별 차등 TTL
- 동기 캐시 (WebFlux 함정 그대로)
- SpEL 키 —
#id,#user.id, condition·unless - 캐싱 5 패턴 — Cache-Aside / Write-Through / Write-Behind / TTL / Pub/Sub
- WebFlux 표준 = Cache-Aside + switchIfEmpty
- DB save 후 cache delete (set 아님, race 안전)
- TTL 차등 — 자주 변경 짧게·드물게 길게
- 너무 짧음 = 캐시 효과 X, 너무 김 = stale
- Cache Stampede 방지 — 분산 락·확률적 만료·소프트 TTL
- 분산 락 = setIfAbsent + doFinally delete
시리즈 다른 편
- 1편 — Redis 기본·Spring Boot 연동
- 2편 — Template·Redisson·Serializer
- 3편 — 자료구조 5종
- 4편 — WebFlux 캐싱 (현재 글)
- 5편 — 성능
- 6편 — Pub/Sub·WebSocket
- 7편 — 고급 (Transaction·Persistence·GeoSpatial·ACL)
공식 문서: Spring Cache Abstraction / Reactor Cache Add-ons 에서 더 깊이.
다음 글(5편)에서는 성능 — ReactiveRedisTemplate vs Redisson 벤치마크, 직렬화 비용, 연결 풀, 모니터링까지 풀어 갑니다.