Post

분산 시스템에서 Local Cache 동기화하기 – Redis Pub/Sub 전략

🧩 개요

분산 시스템에서 Redis만으로 캐시를 처리하는 것은 한계가 있습니다.
그래서 많은 시스템은 로컬 메모리 캐시(Caffeine 등)와 Redis를 함께 사용하는 2단계 캐싱(two-level caching) 구조를 채택합니다.

하지만 이 구조는 인스턴스 간 캐시 데이터의 불일치 문제를 일으킬 수 있습니다.
이 글에서는 이를 해결하기 위한 전략으로, Redis Pub/Sub을 활용한 캐시 무효화 메시지 브로드캐스트 방식을 소개하고, 구현 예제도 함께 설명합니다.


❓ 문제 정의: 왜 Local Cache는 불일치가 발생할까?

2단계 캐싱에서는 각 인스턴스가 자체의 Local Cache를 유지합니다.
이로 인해 하나의 인스턴스에서 데이터가 변경되더라도, 다른 인스턴스는 이를 인지하지 못해 오래된 캐시를 사용할 수 있습니다.

📌 예시 시나리오

  • 인스턴스 A에서 상품의 정보를 수정하고 해당 캐시를 변경함
  • 인스턴스 B는 이를 알지 못하고 기존 캐시 데이터를 그대로 사용함
  • 이로 인해 데이터 불일치 발생

💡 해결 전략 : Redis Pub/Sub으로 Cache 무효화 메시지 브로드캐스트

Redis의 Pub/Sub 기능을 활용하면, 데이터 변경 이벤트를 모든 인스턴스에 전파할 수 있습니다.
각 인스턴스는 이 메시지를 수신해 자신의 Local Cache에서 해당 항목을 삭제(evict)하게 됩니다.

✅ 왜 Pub/Sub인가?

  • 구현이 간단하며 Redis 자체 기능만으로 가능
  • 외부 메시지 브로커(Kafka 등) 없이도 실시간 전파 가능
  • 실시간성이 중요하지만, 완전한 메시지 보장은 필요 없는 상황에 적합

🔄 처리 흐름

  1. 인스턴스 A에서 상품 정보를 변경
  2. 변경 후, Redis의 특정 채널에 캐시 무효화 메시지 발행
  3. 모든 인스턴스는 해당 채널을 구독 중
  4. 메시지를 수신한 인스턴스들이 해당 캐시 키를 Local Cache에서 삭제

🛠️ 구현 예제 (Spring Boot + Redis + Caffeine)

1️⃣ 캐시 무효화 메시지 발행 (Publisher)

데이터 변경 시 Redis 채널에 메시지를 발행합니다.

1
2
3
4
5
6
7
8
9
10
@Component
@RequiredArgsConstructor
public class RedisCachePublisher {
    private final RedisTemplate<String, Object> redisTemplate;

    public void publishEviction(String cacheName, String key) {
        String message = cacheName + ":" + key;
        redisTemplate.convertAndSend(CACHE_EVICT_CHANNEL, message);
    }
}

2️⃣ 메시지 수신 및 Local Cache 삭제 (Subscriber)

메시지를 수신하면, Local Cache에서 해당 키를 삭제합니다. (필요에 따라 수정 로직 추가)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
@RequiredArgsConstructor
public class RedisCacheSubscriber implements MessageListener {

    private final LocalCacheManager localCacheManager;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String payload = new String(message.getBody(), StandardCharsets.UTF_8);
        String[] tokens = payload.split(":");
        if (tokens.length != 2) return;

        String cacheName = tokens[0];
        String key = tokens[1];
        
		Cache cache = cacheManager.getCache(cacheName);
        if (cache instanceof CaffeineCache caffeineCache) {
            caffeineCache.getNativeCache().invalidate(key);
        }
    }
}

3️⃣ Redis Listener 설정

인스턴스가 스케일 아웃되더라도 자동으로 구독할 수 있도록 RedisMessageListenerContainer를 설정합니다.

1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class RedisListenerConfig {

    @Bean
    public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory, RedisCacheSubscriber subscriber) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(subscriber, new ChannelTopic(CACHE_EVICT_CHANNEL));
        return container;
    }
}

⚠️ 주의할 점

📭 메시지 유실 가능성

  • Redis Pub/Sub은 메시지 큐가 아니므로, 구독자가 일시적으로 오프라인일 경우 메시지가 유실될 수 있습니다.
  • 보다 강한 일관성이 필요한 경우, Redis Streams나 Kafka 같은 메시지 큐 기반 시스템을 사용하는 것이 적합합니다.

🏁 결론: 간단하지만 충분히 효과적인 전략

이 구조는 아주 강력한 구조는 아닙니다. 하지만 간단하고, 운영 중인 서비스에 부담 없이 적용할 수 있으며, 대부분의 문제 상황에서는 충분히 효과적입니다.

  • Redis Pub/Sub 기반 전략은 구현이 간단하고 성능에도 영향을 주지 않으면서, 일정 수준의 캐시 일관성을 유지할 수 있습니다.
  • 특히 API 서버 간 데이터 동기화가 필요하거나, 읽기 성능이 중요한 서비스에 적합합니다.
  • 완전한 동기화가 필요하지 않다면, Caffeine + Redis 조합은 현실적이고 효과적인 선택입니다.

This post is licensed under CC BY 4.0 by the author.