분산 캐시 동기화 문제, Redis Pub/Sub으로 해결하기

2025. 8. 9. 19:55·프로젝트

 

너무 느린 외부 API

우리 팀은 외부 시스템과의 연동 프로젝트를 진행하게 되었다. 요구사항은 간단해 보였다. "해당 일자에 주문이 가능한지 외부 API를 통해 확인할 수 있어야 한다." 하지만 실제로 구현해보니, 고객에게 정확한 정보를 전달하기 위해선 한 화면에서 40~60건의 날짜별 배송 계획을 한 번에 조회해야 했다.

 

병렬 처리를 적용했음에도 불구하고 API 응답 시간은 500ms에서 1초, 심지어 요청이 여러 번 겹치면 그 이상 소요되었다. 연동사에서 제공해준 bulk API를 사용했는데 오히려 더 느려졌다.

사용자가 주문 가능 일자를 확인할 때마다 1초 이상을 기다려야 한다니, 이건 명백히 사용성에 심각한 문제였다.

 

우리는 데이터가 일자 단위로 예측 가능하고 실시간이 덜 중요하다는 점을 주목했다.

캐시 적용은 당연한 선택이었다.

 

Redis 캐시 도입

Redis 캐시를 적용한 후, 성능은 확실히 개선되었다.

1초가 걸리던 API 호출이 50~100ms로 줄어들었으니 약 10배 정도 빨라진 셈이었다.

하지만 여전히 뭔가 아쉬웠다.

Redis 캐시 적용:       50 ~ 100ms
외부 API 직접 호출:   500ms ~ 1초

 

"왜 캐시를 적용했는데도 여전히 수십 밀리초가 걸릴까?"

 

답은 간단했다. 아무리 빨라도 네트워크는 네트워크였던 것이다. Redis가 아무리 빠르다고 해도, 네트워크를 통해 데이터를 가져오고, 수십 건의 데이터를 직렬화/역직렬화하는 과정은 피할 수 없었다.

 

로컬 캐시의 등장

"그럼 애플리케이션 메모리에 캐시를 두면 어떨까?"

로컬 캐시(Caffeine)를 Redis 캐시 앞단에 추가했다. 결과는 놀라웠다.

L1 Cache (로컬 메모리):   ~10ms ⚡
L2 Cache (Redis):             50~100ms
외부 API 직접 호출:               500ms ~ 1초

 

로컬 캐시는 자바 Object를 메모리에 그대로 저장하기 때문에 네트워크 왕복과 JSON 파싱이 과정이 생략된다. 따라서 눈에 띄게 속도가 빨라진 것이다.

 

새로운 문제: 분산 환경의 딜레마

하지만 우리가 놓친 게 있다. 우리 서비스는 여러 대의 서버에서 동작하는 분산 시스템이었다.

문제 상황을 그려보면 아래와 같다.

분산 시스템에서의 캐시

이때 ServerA에서 리뷰를 수정했다고 해보자.

분산 시스템에서의 캐시 - 캐시 업데이트로 인한 데이터 부정합 발생

ServerA는 자신의 로컬 캐시를 비우고 새로운 값을 넣었지만, ServerB는 여전히 옛날 데이터를 가지고 있게 된다. 사용자가 어느 서버에 정보를 요청하느냐에 따라 다른 가격 정보를 보게 되는 것이다!

 

 

해결책?

캐시 무효화 이벤트를 모든 서버에 전파할 방법이 필요했다. 그리고 우리가 선택한 해결책은 Redis Pub/Sub이었다.

 

Redis Pub/Sub은 무엇인가?

Redis Pub/Sub을 라디오 방송에 비유해보겠다.

 

DJ가 사연을 읽으면, 그 주파수에 맞춰놓은 모든 청취자가 동시에 같은 내용을 듣게 된다. 심지어 사연을 보낸 본인도 라디오를 켜놓았다면 자신의 사연을 듣게 된다.

 

Redis Pub/Sub도 똑같다.

 

  • Redis(방송국): 메시지를 중간에서 받아 전달해주는 메시지 브로커(Message Broker) 역할
  • Publisher(사연자): 메시지를 보내는 주체
  • Subscriber(청취자): 특정 채널을 구독하고 메시지를 받는 주체
  • Channel(주파수): 메시지가 전달되는 통로

 

왜 Redis Pub/Sub을 선택했을까?

Redis Pub/Sub을 사용하게된 이유는 아래와 같다.

  1. 이미 Redis를 쓰고 있었다: 추가 인프라 없이 바로 사용 가능
  2. 실시간 전파: 메시지 전달 지연이 거의 없음 (수십 ms 이내)
  3. 간단한 구현: 복잡한 설정 없이 바로 적용 가능
  4. Fire-and-Forget: 메시지를 보내고 신경 쓸 필요 없음

 

 

구현해보기

1. Spring CompositeCacheManager의 한계와 커스텀 구현

Spring이 제공하는 CompositeCacheManager은 여러모로 아쉬운 점이 많다.

 

CompositeCacheManager는 캐시 매니저를 List로 가지고 있으며, 캐시를 사용할 때 캐시 매니저 리스트를 순회하며 첫 번째로 발견되는 캐시만 반환한다. 그래서 아래와 같은 문제가 발생하게 된다.

 

  • 캐시 간 동기화 없음: 각 캐시 매니저가 독립적으로 동작한다. Put이나 Evict을 해도 선택된 캐시에만 적용된다. L1/L2 캐시의 일관성이 깨진다는 말이다.
  • 백필(Backfill) 미지원: L1 미스 → L2 히트 시, L2 데이터를 L1에 자동 저장하지 않음

 

Spring Github 이슈에서도 한 번 언급된 적이 있었다. 스프링 측의 답변은 명확했다. "CompositeCacheManager는 fallback 용이지 multi-level용이 아닙니다."

  • Gihub Issue: Spring cache multiple level cache support

 

따라서 우리는 요구사항에 맞는 캐시 매니저 구현체를 직접 구현하기로 결정했다.

 

데코레이터 패턴을 활용해봤다.

아래 코드를 보면 캐시를 반환할 때, CompositeCache라는 Cache 구현체로 감싸서 반환하는 것을 확인할 수 있다.

@Component
public class CustomCompositeCacheManager implements CacheManager {
    private final List<CacheManager> cacheManagers;
    
    public CustomCompositeCacheManager(
        CacheManager l1CacheManager,
        CacheManager l2CacheManager) {
        // 순서 중요! L1 → L2 순으로 조회됨
        this.cacheManagers = List.of(l1CacheManager, l2CacheManager);
    }
    
    @Override
    public Cache getCache(String name) {
        List<Cache> caches = cacheManagers.stream()
            .map(manager -> manager.getCache(name))
            .filter(Objects::nonNull)
            .toList();
            
        return caches.isEmpty() ? null : new CompositeCache(caches);
    }
}
// CompositeCache.java - 실제 Multi-Level 로직
public class CompositeCache implements Cache {
    private final List<Cache> caches;
    
    @Override
    public ValueWrapper get(Object key) {
        List<Cache> missedCaches = new ArrayList<>();
        
        for (Cache cache : caches) {
            ValueWrapper value = cache.get(key);
            if (value != null) {
                // 핵심! 상위 캐시들에 백필
                backfillCaches(key, value.get(), missedCaches);
                return value;
            }
            missedCaches.add(cache);  // 미스된 캐시 추적
        }
        return null;
    }
    
    private void backfillCaches(Object key, Object value, List<Cache> missedCaches) {
        // L1 미스 → L2 히트 시, L1에 자동 저장
        missedCaches.forEach(cache -> cache.put(key, value));
    }
    
    @Override
    public void put(Object key, Object value) {
        // Write-Through: 모든 레벨에 동시 저장
        caches.forEach(cache -> cache.put(key, value));
    }
    
    @Override
    public void evict(Object key) {
        // 모든 레벨에서 동시 제거
        caches.forEach(cache -> cache.evict(key));
    }
}

 

이제 커스텀 캐시 매니저로 L1/L2 캐시를 주입해서 빈으로 등록하면 끝이다.

L1을 로컬 캐시, L2를 글로벌 캐시로 설정해주면 된다.

@Configuration
public class CacheConfig {
    
    @Bean
    @Primary
    public CacheManager cacheManager(
        @Qualifier("l1CacheManager") CacheManager l1,
        @Qualifier("l2CacheManager") CacheManager l2) {
        
        return new CustomCompositeCacheManager(l1, l2);
    }
}

 

이 구조를 그림으로 그려본다면 아래와 같다.

GET을 시도했을 때, L1/L2 캐시가 모두 존재하는 경우

L1/L2 캐시가 모두 성공하면 로컬 캐시를 조회한다.

GET을 시도했을 때, L2 캐시만 존재하는 경우

L2 캐시만 존재하면 레디스를 조회한다. 대신 backfill을 통해 로컬 캐시를 채워준다.

EVICT를 시도하면 모든 캐시 계층에 접근하여 삭제

커스텀 캐시 매니저는 2개의 캐시를 모두 반환하기 때문에 Eviction이 일어날 때 두 계층의 캐시를 모두 삭제한다.

 

2. Redis Pub/Sub을 통한 캐시 정합성 보장

먼저 Redis 설정이 필요하다.

@Configuration
public class RedisConfig {
    
    @Bean
    public RedisConnectionFactory pubSubConnectionFactory(RedisProperties properties) {
        LettuceConnectionFactory factory = new LettuceConnectionFactory(
            new RedisStandaloneConfiguration(properties.host(), properties.port()));
            
        // PubSub 전용 연결 풀 설정
        factory.setShareNativeConnection(false);
        return factory;
    }
    
    @Bean
    public RedisTemplate<String, String> pubSubRedisTemplate(
        RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, String> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setDefaultSerializer(new StringRedisSerializer());
        return template;
    }
}

 

그리고 메세지를 전송할 Publisher를 선언한다.

@Component
public class RedisPublisher implements MessagePublisher {
    private final RedisTemplate<String, String> redisTemplate;
    private final ObjectMapper objectMapper;
    
    @Override
    public void publish(String channel, Object message) {
        try {
            String json = objectMapper.writeValueAsString(message);
            redisTemplate.convertAndSend(channel, json);
            log.debug("Published message to channel: {}", channel);
        } catch (JsonProcessingException e) {
            // 발행 실패해도 로컬 캐시는 이미 무효화됨
            log.error("Failed to publish message", e);
        }
    }
}

 

그리고 메시지를 구독하는 부분을 만들어야 하는데 MessageListenerAdapter와 RedisMessageListenerContainer 이렇게 2개의 구현체를 활용할 것이다.

RedisMessageListenerContainer의 내부 동작은 아래와 같다.

 

  • 초기화: Spring 컨텍스트 시작 시 Redis SUBSCRIBE 명령 실행
  • 블로킹 리스닝: 별도 스레드에서 Redis 연결을 통해 메시지 대기
  • 메시지 수신: Redis로부터 메시지 수신 시 TaskExecutor로 처리 위임
  • 비동기 처리: 메시지마다 별도 스레드에서 리스너 호출
  • 자동 재연결: 연결 끊김 시 자동으로 재구독 시도

 

@Configuration
public class MessageConfig {

    @Bean
    public MessageListenerAdapter messageListenerAdapter(
        CacheMessageDelegate delegate) {
        // handleMessage 메서드로 메시지 라우팅
        return new MessageListenerAdapter(delegate, "handleMessage");
    }
    
    @Bean
    public RedisMessageListenerContainer messageListenerContainer(
        RedisConnectionFactory connectionFactory,
        MessageListenerAdapter listenerAdapter) {
        
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        
        // 에러 핸들러 설정
        container.setErrorHandler(throwable -> log.error("Error in message listener", throwable));
        
        // 구독할 채널 등록
        container.addMessageListener(
            listenerAdapter, 
            new ChannelTopic("cache:evict")
        );        
        return container;
    }
}

CacheMessageDelegate를 통해 구독한 메시지를 수신하여 Eviction 동작을 실행하게 만들 것이다.

@Component
public class CacheMessageDelegate {
    private final CacheManager l1CacheManager;
    private final ObjectMapper objectMapper;
    private final String serverId = UUID.randomUUID().toString();
    
    public void handleMessage(String message, String channel) {
        try {
            // 1. JSON 파싱
            CacheMessage cacheMessage = objectMapper.readValue(
                message, CacheMessage.class);
            
            // 2. 자기 메시지 필터링 (무한 루프 방지!)
            if (serverId.equals(cacheMessage.getSenderId())) {
                log.debug("Ignoring self message");
                return;
            }
            
            // 3. L1 캐시에서만 무효화 (L2는 이미 최신 상태)
            Cache l1Cache = l1CacheManager.getCache(cacheMessage.getCacheName());
            if (l1Cache != null) {
                l1Cache.evict(cacheMessage.getKey());
            }
            
        } catch (Exception e) {
            // 메시지 처리 실패해도 다음 메시지는 계속 처리
            log.error("Failed to handle cache message", e);
        }
    }
}
public record CacheMessage(
	String senderId, 
    String cacheName, 
    String key, 
    Object value) {}

 

Redis Pub/Sub을 사용하기 위한 준비는 다 끝났다.

이제 메시지 발행을 어떻게 할 것인지만 결정하면 된다.

 

메시지 퍼블리싱은 다양한 방법으로 구현해볼 수 있겠지만, 여기서는 데코레이터 패턴을 통해 구현할 것이다.

PubSubCache라는 Cache구현체로 캐시를 감싸서 메시지를 퍼블리싱하도록 했다.

public class PubSubCache implements Cache {

    private final Cache delegate;  // 실제 캐시
    private final MessagePublisher publisher;
    
    @Override
    public void evict(Object key) {
        // 1. PubSub 메시지 발행
        publishEvictMessage(key);
        
        // 2. 실제 캐시에서 제거
        delegate.evict(key);
    }
    
    private void publishEvictMessage(Object key) {
        publisher.publish(
        	"cache:evict", // 채널 이름
            new CacheMessage(MessageConfig.getSenderId(), delegate.getName(), key.toString()));
    }
    
    // ...
}

 

캐시 매니저 또한 PubSubCacheManager로 감싸, 캐시 매니저가 PubSubCache를 통해 메시지를 발행할 수 있도록 구성한다.

public class PubSubCacheManager implements CacheManager {

  private final CacheManager cacheManager;
  private final MessagePublisher messagePublisher;

  public PubSubCacheManager(
      final CacheManager cacheManager, final MessagePublisher messagePublisher) {
    this.cacheManager = cacheManager;
    this.messagePublisher = messagePublisher;
  }

  @Override
  public Cache getCache(final String name) {
    return new PubSubCache(cacheManager.getCache(name), messagePublisher);
  }

  @Override
  public Collection<String> getCacheNames() {
    return cacheManager.getCacheNames();
  }
}

이제 CacheConfig에서 데코레이터로 감싸진 캐시 매니저를 사용하도록 설정하면 끝이다.

@Configuration
public class CacheConfig {
    
    @Bean
    @Primary
    public CacheManager cacheManager(
        @Qualifier("l1CacheManager") CacheManager l1,
        @Qualifier("l2CacheManager") CacheManager l2,
        CircuitBreaker circuitBreaker,
        MessagePublisher publisher) {
        
        // 1. 기본 Multi-Level 캐시
        CacheManager composite = new CustomCompositeCacheManager(l1, l2);
        
        // 2. PubSub 기능 추가
        CacheManager withPubSub = new PubSubCacheManager(composite, publisher);
        
        return withPubSub;
    }
}

 

이미지로 도식화하면 아래와 같은 플로우처럼 메시지 발행 및 구독이 이뤄질 것이다.

메시지 발행

메시지 발행 플로우

메시지 구독

메시지 구독자는 메시지를 받아 캐시를 제거하게 된다.

 

 

성과와 교훈

최종 성과

초기 상태:                500ms ~ 1초
Redis 캐시 적용:     50 ~ 100ms (10배 개선)
로컬 캐시 추가:      ~10ms (추가 5~10배 개선)

최종 개선율:             50~100배 🚀

 

Trade-off

Redis Pub/Sub은 메시지 순서를 보장하지 않고, 메시지가 유실될 수 있다.

하지만 캐시의 순서가 바뀔 정도로 업데이트가 잦지 않았고, TTL로 최종 일관성이 보장할 수 있었기 때문에 Redis Pub/Sub으로 충분하다고 생각했다.

 

마치며: 여러분의 상황에 맞는 선택

우리가 Redis Pub/Sub을 선택한 이유는

  • 이미 Redis를 사용 중이었고
  • 구현이 간단해 빠르게 도입할 수 있었으며,
  • 완벽한 일관성보다는 성능이 우선이었기 때문이다.

만약 상황이 다르다면, 다른 선택지도 충분히 고려해봐야 한다.

  • Kafka: 내구성/재처리/순서 중요, 소비 지연 허용
  • RabbitMQ: 복잡한 라우팅이 필요
  • Hazelcast: JVM 통합

 

질리도록 듣는 얘기겠지만 기술 선택에 정답은 없다. 현재 상황과 제약사항을 잘 파악하고, Trade-off를 고려해서 "지금 우리에게 가장 적합한" 기술을 선택하는 것이 중요하다.

 

'프로젝트' 카테고리의 다른 글
  • UPDATE 한 줄로 끝내는 동시성 문제
gakko
gakko
좌충우돌 개발기
  • gakko
    MYVELOP 마이벨롭
    gakko
  • 전체
    오늘
    어제
    • 분류 전체보기 (205)
      • Spring (23)
        • Spring (10)
        • Spring Boot (7)
        • Spring Security (1)
        • Hibernate (4)
      • Test (3)
      • 끄적끄적 (6)
      • 활동 (35)
        • 부스트캠프 (23)
        • 동아리 (3)
        • 컨퍼런스 (3)
        • 글또 (5)
        • 오픈소스 컨트리뷰션 (1)
      • 디자인패턴 (0)
      • Git & GitHub (22)
        • Git (13)
        • Github Actions (1)
        • 오류해결 (5)
        • 기타(마크다운 등) (3)
      • 리눅스 (6)
        • 기초 (6)
        • 리눅스 서버 구축하기 (0)
      • Infra (2)
        • Docker (1)
        • Elastic Search (0)
        • Jenkins (1)
        • AWS (1)
      • MySQL (7)
        • 기초 (6)
        • Real MySQL (1)
      • 후기 (3)
        • Udemy 리뷰 (3)
      • CS (26)
        • 웹 기본지식 (0)
        • 자료구조 (13)
        • 운영체제 OS (12)
        • 데이터베이스 (1)
        • 시스템 프로그래밍 (0)
        • 기타 (0)
      • Tools (1)
        • 이클립스 (1)
        • IntelliJ (0)
      • 프로젝트 (2)
        • 모여모여(부스트캠프) (1)
      • JAVA (32)
        • Maven (6)
        • 오류해결 (11)
        • 자바 클래스&메소드 (1)
        • JSP & Servlet (12)
      • Javascript (5)
        • 기초 (3)
        • React (2)
      • Python (28)
        • 파이썬 함수 (9)
        • 알고리즘 문제풀이 (16)
        • 데이터 사이언스 (2)
        • 웹 크롤링 (1)
      • 단순정보전달글 저장소 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 우진님
  • 공지사항

  • 인기 글

  • 태그

    파이썬
    Git
    스프링부트
    jsp
    운영체제
    MySQL
    오류해결
    자바
    자바스크립트
    Spring
    웹개발
    Python
    java
    부스트캠프 멤버십
    스프링
    알고리즘
    GitHub
    부스트캠프
    부스트캠프 7기
    os
  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.0
gakko
분산 캐시 동기화 문제, Redis Pub/Sub으로 해결하기
상단으로

티스토리툴바