조금 오래되긴 했지만 이전에 캐시에 대한 글을 작성하면서 로컬 캐시와 글로벌 캐시의 특징을 다룬 적이 있었다. 이때, 로컬 캐시는 일반적으로 서버 어플리케이션의 메모리에 함께 저장되기 때문에 캐시를 사용할 때 별도의 네트워크 IO가 발생하지 않아 글로벌 캐시에 비해서 더 빠른 성능을 보여준다고 했었다.
하지만 서버가 여러 대가 존재한다면, 동일한 내용들을 복수의 서버에 저장하게 되어서 자원 낭비가 발생할 수 있고, 캐시에 저장된 내용이 변경될 경우엔 다른 서버의 캐시들도 변경해줘야 하는 문제가 발생한다.
여기서 말한 캐시에 저장된 내용이 변경될 경우엔 다른 서버의 캐시들도 변경해줘야 하는 문제가 이번 글에서 다뤄볼 로컬 캐시의 동기화 문제이다. 사실 어떤 일이 발생할 지에 대해서는 이 문장을 읽기만 해도 예상이 갈 수 있지만, 준비한 예제를 보면서 상황을 이해해 보고, 로컬 캐시 동기화하는 방법 중 하나인 Pub/Sub을 이용해서 동기화를 예제를 진행해 보자.
로컬 캐시에서 발생할 수 있는 동기화 문제
캐시는 조회 성능을 높여주는 기술이다. 그럼, 아무래도 수정이나 삭제작업보다는 읽기가 빈번하게 발생할 상황에서 사용되면 이점을 보일 것이다. 아마도 아래 카카오톡 이미지처럼 프로필사진, 닉네임, 상태 메시지 같은 경우에 캐싱을 하면 좋지 않을까 싶다. (나만 자주 안 바꾸나?)
일반적인 상황에서 캐싱을 해서 조회 속도를 높이고, 만약 프로필 사진이나 이름, 상태메세지를 변경하면 그때 캐시를 삭제하거나 수정하는 식의 방법을 이용할 것 같다. 동작을 한다면 아래 이미지 순서처럼 동작할 것 같다.
![]() |
![]() |
![]() |
하지만 이런 이상적인 상황은 서버가 오직 1대일 때만 가능한 경우다. 앞서서 로컬 캐시에서 발생할 수 있는 문제는 대체로 다른 서버들이 존재하는 경우에서 발생하기 때문이다.
똑같은 상황에서 서버가 여러대인 경우 발생할 수 있는 문제를 살펴보자.
![]() |
![]() |
![]() |
처음에 조회를 할 때엔 정상적으로 동작하겠지만, 수정된 이후에는 수정을 처리한 서버 외에 이전 데이터에 대한 캐시가 남아있는 것을 볼 수 있다. 캐시의 TTL 설정에 따라서 일정 시간이 지난 이후에는 삭제가 될 것이지만, 아무래도 일정 시간 동안 서로 다른 데이터를 보여준다는 점이 불편한 부분이다.
그럼 어떻게 이 문제를 해결할 수 있을까?
로컬 캐시에서 Redis나 Memcached와 같은 글로벌 캐시 서비스를 이용한다면 이런 문제에서 벗어날 수 있겠지만, 우선은 로컬 캐시를 유지하는 방법 중에서 생각해 보자.
가장 대표적인 방법 중 하나는 Pub/Sub 서비스를 이용해 데이터 수정이 발생했을 때, 다른 서버들에게 이를 알리는 메세지를 전송하는 방법이 있다. 이때 새로운 캐시를 등록해주거나 기존의 캐시를 삭제하는 방법에 대해서는 상황에 맞게 더 효율적인 방법을 택하면 좋을 것 같다.
Pub/Sub 기능을 이용해서 로컬 캐시에 대한 동기화 작업을 진행하는 과정을 알아보자.
Pub/Sub 서비스를 통한 로컬 캐시 동기화
그냥 설명하기엔 이해가 어려울 수 있으니, 예제와 함께 설명을 진행하고자 한다.
우선 예제에 대한 전체 코드는 여기에서 살펴볼 수 있다.
예제 소개
예제는 간단하다. 카카오톡 메인 화면의 기능을 생각하며 제작했다.
회원의 정보를 조회하는 기능과 상태 메세지를 수정하는 기능을 가지고 있으며, 회원 조회를 할 때엔 이름, 나이, 상태메시지, 그리고 다른 서버에서 조회된다는 걸 보여주기 위한 포트번호를 담았다.
아래와 같은 서버가 구성된다고 생각하시면 좋을 것 같다.
사용될 API는 다음과 같다.
앞단의 NginX서버에게 일반적으로 요청을 보내고, 수정한 이후 수정을 한 서버에게 보낸 요청 그리고 다른 서버들에게도 요청을 보내 그 결과를 확인해 보자.
### GET MemberData on Spring Server
GET http://localhost:8080/member/1
### GET MemberData on NginX
GET http://localhost/member/1
### PATCH -> Member StatusMessage Change
PATCH http://localhost:8080/member/1
content-type: application/json
{
"message": "hi bros~"
}
예제에서 보이는 문제점
앞서서 살펴봤던 문제점을 단순한 이미지가 아닌 실제 결괏값을 불러서 확인해 보자.
NginX에게 회원 1의 정보를 요청한 결과는 다음과 같다. 3개의 서버에게 요청을 보냈고, 모두 같은 결과를 얻을 수 있었다.
![]() |
![]() |
![]() |
특히 아래 코드와 같이 캐시를 적용해 이후에는 캐시를 이용해 더 빠른 조회가 가능하도록 설정이 되었다. 그리고 수정을 할 때엔, 기존의 캐시를 삭제하도록 설정해 주었다.
@Cacheable(cacheNames = "user", key = "#id")
public MemberDto findMemberById(Long id){
log.info("cache Miss Member id : {}", id);
Member member = memberRepository.findById(id)
.orElseThrow();
return new MemberDto(member, port);
}
@CacheEvict(cacheNames = "user", key = "#id")
public void updateStatueMessage(Long id, String message){
Member member = memberRepository.findById(id)
.orElseThrow();
member.updateStatusMessage(message);
// messagePublisher.publishCacheEvictMessage(new CacheEvictMessage("user", id));
}
하지만 8080 서버의 상태 메시지를 수정한 이후에는 어떻게 될까?
![]() |
![]() |
![]() |
8080 서버는 기존 i am son으로 등록된 캐시가 삭제되어 최신 정보가 리턴된 것을 볼 수 있지만, 8081과 8082에서는 최신화가 되지 않은 점을 볼 수 있다.
Redis를 이용한 Pub/Sub 기능 구현
Pub/Sub 기능을 제공하는 다른 서비스(RabbitMQ, Apache Kafka)도 존재하지만, 현재 예제에서는 Redis를 이용해 Pub/Sub기능을 구현했다. 그 이유로는 다른 서비스들에 비해서 Redis를 더 사용해 봤을 가능성이 높아 예제를 이해하는데 조금이라도 도움이 될 것이라고 생각했다. 그리고 다른 서비스들은 메세지 큐의 역할을 하기 때문에 Publish 된 메세지를 저장하지만, Redis는 딱히 그렇지는 않아서 캐시 동기화를 위한 작업에는 이 부분이 조금 더 적합하다고 생각했기 때문에, Redis를 이용해서 예제를 진행하고자 한다.
Redis로 Pub/Sub 기능을 구현하는 자세한 내용은 이 공식문서를 읽어보면 좋을 듯하다.
Spring에서는 다양한 MessageListener를 등록할 수 있고, 얘들이 전해오는 메세지에 대한 처리를 담당한다. 이를 Redis 버전으로 구현하기 위해선 아래와 같은 설정을 해주자. 메세지 리스너에 대한 구현체(RedisSubListener)를 구현한 후 레디스에서 이용할 수 있도록 어댑터 클래스에게 등록을 해준다. 그리고 얘를 `RedisMessageListenerContainer`에게 등록해주면 토픽("cache-sync")로 publish된 메세지에 대해서 처리를 담당하게 된다.
@Bean
public RedisSubListener redisSubListener(){
return new RedisSubListener(redisTemplate(), cacheEvictService);
}
@Bean
public MessageListenerAdapter messageListenerAdapter(){
return new MessageListenerAdapter(redisSubListener());
}
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(){
RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer();
redisMessageListenerContainer.setConnectionFactory(redisConnectionFactory());
redisMessageListenerContainer.addMessageListener(messageListenerAdapter(), new ChannelTopic("cache-sync"));
return redisMessageListenerContainer;
}
@Slf4j
@RequiredArgsConstructor
public class RedisSubListener implements MessageListener {
private final RedisTemplate<String,Object> redisTemplate;
private final CacheEvictService cacheEvictService;
@Override
public void onMessage(Message message, byte[] pattern) {
String body = redisTemplate.getStringSerializer().deserialize(message.getBody());
try {
CacheEvictMessage cacheEvictmessage = new ObjectMapper().readValue(body, CacheEvictMessage.class);
cacheEvictService.evictCache(cacheEvictmessage);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
이렇게 등록을 해준 이후에 RedisTemplate의 convertAndSend()라는 메서드를 이용하면 간단하게 메세지를 전송할 수 있고, 그럼 위 RedisSubListener 클래스의 onMessage()로 결과를 핸들링할 수 있다.
이제 Redis의 Pub/Sub기능을 활용하기 위해 앞서 보았던 정보 수정 메서드의 주석을 지워보자. 그럼 아래처럼 캐시 이름과 키를 담은 메세지를 전송하게 될 것이고, CacheEvictService에서 캐시가 삭제될 것이다. 테스트를 해보자.
@CacheEvict(cacheNames = "user", key = "#id")
public void updateStatueMessage(Long id, String message){
Member member = memberRepository.findById(id)
.orElseThrow();
member.updateStatusMessage(message);
messagePublisher.publishCacheEvictMessage(new CacheEvictMessage("user", id));
}
@RequiredArgsConstructor
@Service
public class RedisMessagePublisher {
private final RedisTemplate<String,Object> redisTemplate;
public void publishCacheEvictMessage(CacheEvictMessage cacheEvictmessage){
try {
String json = new ObjectMapper().writeValueAsString(cacheEvictmessage);
redisTemplate.convertAndSend("cache-sync", json);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
@RequiredArgsConstructor
@Service
public class CacheEvictService {
private final CacheManager cacheManager;
public void evictCache(CacheEvictMessage cacheEvictMessage){
Cache cache = cacheManager.getCache(cacheEvictMessage.getCacheName());
if(cache == null)
return;
cache.evict(cacheEvictMessage.getKey());
}
}
앞선 케이스들과 마찬가지로 수정 전에 조회를 해보고, 수정 후에 조회를 했다. 레디스가 Pub/Sub기능을 수행해 수정한 이후에 모두 최신화된 데이터(Hi bros~)를 리턴하는 것을 확인할 수 있다.
수정 이전 | ||
![]() |
![]() |
![]() |
수정 후 | ||
![]() |
![]() |
![]() |
이를 도표로 정리하면 다음과 같다.
수정 작업이 이루어지면 레디스에게 삭제 메세지를 전송하고, 이걸 구독자(스프링 서버)가 받고 캐시를 삭제하는 방식으로 동작한다. 이를 통해서 로컬 캐시에서 발생하는 동기화 문제를 해결할 수 있었다.
![]() |
![]() |
정리
지금까지 로컬 캐시에서 발생할 수 있는 동기화가 되지 않는 문제와 그에 대한 해결책 중 하나인 Pub/Sub 서비스 이용 방법에 대해서 알아보았다. 로컬 캐시는 글로벌 캐시에 비해서 더 빠른 성능을 보여주지만, 복수의 서버가 존재하는 환경에서는 중복 저장으로 인한 자원 낭비나 동기화 작업을 해줘야 한다는 이슈들이 존재했다. 로컬 캐시를 이용할 때엔, 장점과 단점에 대해 충분히 고려한 이후에 사용하고, 단점을 해결할 수 있는 방법 역시 적용해 주자.