개발자는 기록이 답이다

로컬 캐시 만료 정책을 설계할때, 노드 간 일관성은 어떻게 맞출 수 있을까? 본문

카테고리 없음

로컬 캐시 만료 정책을 설계할때, 노드 간 일관성은 어떻게 맞출 수 있을까?

slow-walker 2024. 10. 27. 22:00

기존에 프로젝트에서 Redis만 사용해 캐시를 구현했었고, 로컬 캐시의 필요성을 깊이 고려해 본 적이 없었습니다. 하지만 최근 네카라쿠배당토 중 면접에서 로컬 캐시 설계 관련된 질문을 받았고, 그때 제대로 답변하지 못한 경험을 되돌아보며 이를 복기하고, 보완하고자 포스팅을 작성하게 되었습니다.


 

데이터베이스에서 Disk I/O를 줄이기 위해 일반적으로 Redis와 같은 메모리 저장소를 도입하는데, 이런 외부 저장소에 의존할 경우 과부하가 발생하거나 단일 장애 지점(SPOF)이 될 위험이 있습니다.

 

모든 데이터를 외부 저장소에만 캐시하지 않고 내부적으로 관리하려는 경우 로컬 캐시를 도입하게 됩니다. 로컬 캐시를 사용할 경우, 예를 들어 @CachePut 어노테이션이나 write-back과 같은 캐시 전략을 적용하기 어려운 경우가 발생할 수 있습니다. 이러한 제약 속에서 로컬 캐시의 삭제 전략(eviction)을 어떻게 설계할 수 있을지 고민해 보겠습니다.

📌로컬 캐시란 ?

로컬 캐시는 Redis와 같은 외부 저장소가 아닌, 애플리케이션 자체의 메모리에 데이터를 저장하는 방식입니다. 로컬 캐시 라이브러리를 사용하지 않더라도 메모리에 올라간 모든 데이터를 캐시 메모리라고 부릅니다.

  • 라이브러리 사용: Spring에서 CacheManager를 통해 Caffeine, EhCache, Guava, Hazelcast 등의 캐시 프로바이더를 사용할 수 있습니다.
  • 라이브러리 미사용: ConcurrentHashMap과 클래스를 사용해 로컬 캐시를 구현할 수 있습니다.

📌 로컬 캐시 구현 예제

Spring Cache를 활용하면 캐시 관리가 간편해지고, 애노테이션을 통해 쉽게 적용할 수 있습니다. 또한, 다양한 캐시 프로바이더를 선택할 수 있어 설정이 간단해집니다. 반면, 직접 캐시를 구현하면 더 세밀한 제어가 가능하지만, 캐시의 만료, 갱신, 데이터 일관성 등을 수동으로 관리해야 하므로 복잡해질 수 있습니다.

 

Spring Cache에서 프로바이더 사용

 

Guava는 기본적인 캐시 만료와 eviction 기능을 제공합니다.

Cache<String, User> userCache = CacheBuilder.newBuilder()
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .maximumSize(100)
        .build();

...

public User getUserById(String userId) {
    return userCache.get(userId, () -> userRepository.findById(userId));
}
직접 구현
class CacheEntry {
    private User user;
    private long timestamp; // TTL 설정
    
    public CacheEntry(User user, long timestamp) {
        this.user = user;
        this.timestamp = timestamp;
    }
    
    // 캐시 만료 확인 로직
    public boolean isExpired() {
        return System.currentTimeMillis() - timestamp > 60000; // 1분 TTL
    }
    
    public User getUser() {
        return user;
    }
}

... 

private Map<String, CacheEntry> cache = new ConcurrentHashMap<>();

public User getUserById(String userId) {
    CacheEntry entry = cache.get(userId);
    if (entry != null && !entry.isExpired()) {
        return entry.getUser();
    }
    User user = userRepository.findById(userId);
    cache.put(userId, new CacheEntry(user, System.currentTimeMillis()));
    return user;
}

 

그렇다면 로컬 캐시에는 어떤 데이터를 캐싱하면 좋을까요?

 

확장성을 고려해봤을때, 로컬 캐시에는 전역적으로 적용되는 데이터를 캐싱하는 것이 적합합니다. 예를 들어, stateless 환경에서는 모든 사용자에게 동일하게 적용될 수 있는 공통 데이터를 로컬 캐시에 저장할 수 있습니다. 반면, 클라이언트별로 상태가 존재하는 경우에는 개별 사용자 또는 세션에 특화된 데이터를 중앙(Global) 캐시에 저장하는 것이 바람직합니다. 이처럼 데이터의 특성에 맞는 캐시 방식을 적용하면, 캐시 효율성과 데이터 일관성을 유지하는 데 도움이 됩니다.

 

하지만 분산 서버 환경에서 로컬 캐시 일관성 문제가 발생할 수 있습니다

📌 분산 서버에서 로컬 캐시 일관성 문제

A 노드와 B 노드가 서로 다른 인스턴스에 위치하다 보니 서버 간 시간이 일치하지 않는 문제가 발생합니다. 이로 인해 A 노드에서는 캐시 미스가 발생해서 최신 데이터를 가져오지만, B 노드에서는 여전히 캐시 히트가 발생하며 이전 데이터를 반환하는 상황이 발생할 수 있습니다. 따라서 캐시의 동기화를 유지하기 위해 몇 가지 고려 사항이 있습니다.

 

첫번째, 특정 노드에서 캐시의 만료 시점을 어떻게 추적할까?

 

캐시가 만료되는 시점을 지속적으로 추적하지 않으면 각 노드의 캐시 상태를 정확히 파악하기 어렵습니다.이를 해결하기 위한 방안으로, 일정한 주기로 캐시 상태를 점검하는 스케줄링 방식을 사용하는 것입니다. 주기적으로 캐시를 점검하여 만료된 데이터는 갱신하거나 삭제해 캐시 일관성을 일정 부분 유지할 수 있습니다. 예를 들어, A노드에서 캐시가 만료된 상태일때 해당 이벤트를 B노드에 전달해서 캐시를 delete하면 됩니다.

 

그러나 1초 단위로 캐시 상태를 스케줄링한다고 하더라도, 노드 간 갱신 타이밍이 미세하게 어긋나면 여전히 캐시 불일치가 발생할 수 있습니다. 따라서 이 방법은 완벽한 해결책이 아닌, 일관성을 어느 정도 보완하는 방식으로 사용됩니다.

 

두번째, 다른 노드에게 어떻게 알릴 것인가?

 

캐시가 만료되거나 업데이트될 때 이를 다른 노드에 알리는 방식을 통해 일관성을 개선할 수 있습니다. API 통신메시지 큐 사용 방식 두 가지가 대표적입니다.

 

API 통신 방식은 메시지 큐에 비해 네트워크 비용이 상대적으로 적게 발생합니다. 이유는 API 통신에서는 A 노드가 B 노드로 직접 요청을 보내면 되지만, 메시지 큐 방식에서는 프로듀서, 브로커, 컨슈머 등 세 가지 단계를 거쳐야 하기 때문입니다.

 

🖋️ hop이란?

데이터가 한 노드에서 다른 노드로 이동하는 과정에서 거치는 중간 단계로 하나의 장치에서 다른 장치로 데이터가 전달될 때마다 발생하는 하나의 통신 단위를 Hop이라고 부릅니다. Ack 플래그(Acknowledgment, 응답 확인 신호) 기준이 아닌, 실제 데이터가 노드 간에 이동하는 통신 횟수를 기준으로 계산하기 때문에, Hop 수가 많아질수록 네트워크에서 데이터가 전달될 때 더 많은 장치를 거치게 되므로 지연 시간이 늘어날 수 있습니다.

 

하지만 API통신은 동기적이기 때문에 A 노드가 B 노드에 API로 캐시 삭제 요청을 보낼 때, A 노드는 B 노드가 응답할 때까지 기다리게 됩니다. 이 대기 시간 동안 A 노드는 리소스를 잡아먹으며 대기 상태에 들어가야 하므로 스레드를 계속 물고 있게 됩니다.

 

또한, Auto scaling처럼 확장성이 동적이라면 변경 사항을 직접  모든 노드에게 전파하기 어려울 것이라고 생각했습니다.

따라서 API 통신을 해도 직접 다른 노드에 통신하는 것이 아니라 중앙 서버가 필요합니다. 중앙 서버가 없다면, 노드 간 직접적인 통신이 필요하게 되고 복잡도가 증가하며, 관리가 어려워지고, 중앙 서버를 두게 되면 메세지 큐와 동일한 hop 수로 통신이 이루어질 수 있습니다.

(추측하건데, 중앙 서버는 api gateway로 할 수 있을듯 합니다...)

 

메시지 큐 방식에서는 producer-consumer 방식 또는 pub/sub 방식 중 하나를 선택할 수 있습니다.

 

producer-consumer 방식의 경우 전송할 데이터가 없을때에도 consumer가 계속 메세지큐에 polling하기 때문에 네트워크 부하가 발생하기 합니다. 반면, pub/sub 방식은 이벤트가 발생했을때 해당 인스턴스 ID를 구독하고 있는 다른 노드들에게 이벤트를 알릴 수 있습니다.

  • producer-consumer 방식에서는 메시지를 수신할 데이터가 없는 상황에서도 consumer가 메시지 큐를 계속 폴링(polling)하여 네트워크 부하가 발생할 수 있습니다.
  • pub/sub 방식에서는 이벤트가 발생할 때마다 해당 이벤트를 구독하고 있는 모든 노드(인스턴스 ID)에 이벤트가 전파됩니다. 이는 네트워크 부하를 줄이고, 이벤트 발생 시에만 데이터를 수신할 수 있어 효율적입니다.

 

📌 정리

이번 포스팅에서는 로컬 캐시 일관성을 맞추기 위해 어떤 지식들이 필요할지 다루어보았습니다.

아직 제가 가진 지식과 이론으로만 생각한 내용이라 정확하게 이게 맞는지는 모르겠지만, 실제로 프로젝트에 적용해보면서 좀 더 테스트를 해야지 구체적으로 와닿을 것 같아요. 또한 로컬 캐시의 일관성을 맞추기 위해서 "분산 트랜잭션"이라는 키워드도 필요한데, 이 부분에 대해서는 조금 더 학습이 필요할 것 같습니다.