리액티브 레디스 — WebFlux 캐싱·Spring Cache

2026-05-03확률과 통계 마스터 노트

리액티브 레디스 마스터 노트 시리즈 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);
}

문제:

  1. 첫 호출 → Mono 생성 + 캐시 저장
  2. Subscribe → User 조회 → Mono 완료 (소비됨)
  3. 두 번째 호출 → 캐시된 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

시리즈 다른 편

공식 문서: Spring Cache Abstraction / Reactor Cache Add-ons 에서 더 깊이.

다음 글(5편)에서는 성능 — ReactiveRedisTemplate vs Redisson 벤치마크, 직렬화 비용, 연결 풀, 모니터링까지 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!