Theme:

Redis에서 데이터를 가져오는 것도 빠르지만, 아예 네트워크 왕복 없이 로컬에서 바로 읽을 수는 없을까요?

클라이언트 사이드 캐싱이란

클라이언트 사이드 캐싱은 Redis에서 읽은 데이터를 클라이언트 애플리케이션의 로컬 메모리에 저장하고, Redis 서버가 해당 데이터가 변경되면 클라이언트에게 알려주는(invalidation) 기능입니다. Redis 6.0에서 CLIENT TRACKING 명령과 함께 공식 지원이 시작되었습니다.

왜 필요한가

  • Redis 조회가 아무리 빨라도 네트워크 왕복(RTT)은 0.1~1ms 정도 소요됩니다
  • 초당 수십만 요청을 처리하는 서비스에서 이 RTT가 누적되면 상당한 비용입니다
  • 로컬 캐시를 사용하면 RTT가 0이 되지만, 데이터가 변경됐을 때 어떻게 알 수 있을까요?
  • 바로 이 문제를 Redis의 CLIENT TRACKING이 해결합니다

기본 동작 원리

PLAINTEXT
1. 클라이언트가 CLIENT TRACKING ON 실행
2. 클라이언트가 GET key1 실행 → 서버가 "이 클라이언트가 key1을 읽었다" 기록
3. 클라이언트가 key1의 값을 로컬에 캐시
4. 누군가 key1을 변경 (SET key1 newvalue)
5. 서버가 클라이언트에게 무효화(invalidation) 메시지 전송
6. 클라이언트가 로컬 캐시에서 key1 삭제
7. 다음 조회 시 Redis에서 새 값을 가져옴

CLIENT TRACKING 활성화

RESP3 프로토콜 사용 시

RESP3는 서버가 클라이언트에게 비동기적으로 메시지를 보낼 수 있는 push 기능을 지원합니다.

BASH
# RESP3으로 전환
127.0.0.1:6379> HELLO 3

# 추적 활성화
127.0.0.1:6379> CLIENT TRACKING ON

# 데이터 읽기 → 서버가 이 키를 추적 목록에 추가
127.0.0.1:6379> GET user:1001
"Alice"

# 다른 클라이언트가 key를 변경하면
# 이 연결에 push 메시지가 옴:
# > 1) "invalidate"
# > 2) 1) "user:1001"

RESP2 프로토콜 사용 시 (REDIRECT)

RESP2는 push 기능이 없으므로 별도의 Pub/Sub 연결이 필요합니다.

BASH
# 연결 1: 무효화 메시지를 받을 Pub/Sub 연결
127.0.0.1:6379> SUBSCRIBE __redis__:invalidate
# 이 연결의 CLIENT ID 확인
# (다른 연결에서 CLIENT ID로 확인)

# 연결 2: 데이터 연결
127.0.0.1:6379> CLIENT TRACKING ON REDIRECT 42
# 42는 연결 1의 CLIENT ID

127.0.0.1:6379> GET user:1001
"Alice"

# user:1001이 변경되면 연결 1에서:
# 1) "message"
# 2) "__redis__:invalidate"
# 3) 1) "user:1001"

브로드캐스트 모드 (BCAST)

기본 모드는 클라이언트가 실제로 읽은 키만 추적합니다. 브로드캐스트 모드는 지정한 프리픽스에 해당하는 모든 키의 변경을 알려줍니다.

BASH
# user: 프리픽스로 시작하는 모든 키 변경을 추적
127.0.0.1:6379> CLIENT TRACKING ON BCAST PREFIX user: PREFIX session:

기본 모드 vs 브로드캐스트 모드

특성기본 모드브로드캐스트 모드
추적 대상클라이언트가 읽은 키만PREFIX에 매칭되는 모든 키
서버 메모리추적 테이블 유지 (클라이언트별)프리픽스만 저장
불필요한 알림적음읽지 않은 키의 변경도 받을 수 있음
적합한 경우키별로 선택적 캐싱특정 패턴의 키를 모두 캐싱

브로드캐스트 모드의 서버 부하

BASH
# 기본 모드: 서버가 Invalidation Table 유지
# - 클라이언트 수 × 추적 키 수만큼 메모리 사용
# - 기본 최대 추적 키 수: 클라이언트당 200만 개
127.0.0.1:6379> CONFIG SET tracking-table-max-keys 2000000

# 브로드캐스트 모드: 프리픽스만 저장하므로 서버 메모리 부담 적음
# 하지만 클라이언트에 불필요한 무효화 메시지가 많아질 수 있음

OPTIN / OPTOUT 모드

OPTIN — 선택적으로 추적

기본적으로 아무것도 추적하지 않고, 명시적으로 지정한 키만 추적합니다.

BASH
# OPTIN 모드 활성화
127.0.0.1:6379> CLIENT TRACKING ON OPTIN

# 일반 GET — 추적 안 됨
127.0.0.1:6379> GET user:1001

# 추적 지정 후 GET — 이 키만 추적
127.0.0.1:6379> CLIENT CACHING YES
127.0.0.1:6379> GET user:1001
# 이제 user:1001이 변경되면 무효화 메시지를 받음

CLIENT CACHING YES는 바로 다음 명령에서 조회하는 키에만 적용됩니다.

OPTOUT — 선택적으로 제외

기본적으로 모든 읽기를 추적하고, 명시적으로 지정한 키만 제외합니다.

BASH
# OPTOUT 모드 활성화
127.0.0.1:6379> CLIENT TRACKING ON OPTOUT

# 모든 GET이 추적됨
127.0.0.1:6379> GET user:1001    # 추적됨
127.0.0.1:6379> GET user:1002    # 추적됨

# 특정 키를 추적에서 제외
127.0.0.1:6379> CLIENT CACHING NO
127.0.0.1:6379> GET temp:data    # 추적 안 됨

실제 구현 패턴

Java(Lettuce) 구현 예시

JAVA
// Lettuce 6.x — RESP3 + 클라이언트 사이드 캐싱
RedisClient client = RedisClient.create("redis://localhost:6379");

// 로컬 캐시 맵
ConcurrentHashMap<String, String> localCache = new ConcurrentHashMap<>();

// 트래킹 활성화 및 무효화 리스너 등록
StatefulRedisConnection<String, String> connection = client.connect();
connection.addListener(message -> {
    if (message instanceof PushMessage) {
        PushMessage push = (PushMessage) message;
        if ("invalidate".equals(push.getType())) {
            List<Object> content = push.getContent();
            // 무효화된 키들을 로컬 캐시에서 제거
            List<String> keys = (List<String>) content.get(1);
            keys.forEach(localCache::remove);
        }
    }
});

RedisCommands<String, String> commands = connection.sync();
commands.clientTracking(TrackingArgs.Builder.enabled());

// 조회 시 로컬 캐시 우선
public String get(String key) {
    String cached = localCache.get(key);
    if (cached != null) return cached;

    String value = commands.get(key);
    if (value != null) {
        localCache.put(key, value);
    }
    return value;
}

Spring Boot + Lettuce 설정

JAVA
@Configuration
public class RedisCachingConfig {

    @Bean
    public LettuceClientConfigurationBuilderCustomizer clientCacheCustomizer() {
        return builder -> builder
            .clientOptions(ClientOptions.builder()
                .protocolVersion(ProtocolVersion.RESP3)
                .build());
    }
}

로컬 캐시의 안전장치

무효화 메시지를 놓칠 수 있는 경우를 대비한 안전장치가 필요합니다.

로컬 캐시에도 TTL 설정

JAVA
// Caffeine 캐시로 로컬 캐시 구현
Cache<String, String> localCache = Caffeine.newBuilder()
    .maximumSize(100_000)
    .expireAfterWrite(Duration.ofSeconds(60))  // 최대 60초 — 무효화를 놓쳐도 60초 후 만료
    .build();

연결 끊김 시 전체 초기화

JAVA
connection.addListener(message -> {
    if (message instanceof ConnectionEvent.Disconnected) {
        // 연결이 끊기면 로컬 캐시 전체 초기화
        localCache.invalidateAll();
    }
});

주의사항

  1. Invalidation Table 크기: 기본 모드에서 서버는 클라이언트별 추적 키를 메모리에 저장합니다. tracking-table-max-keys를 적절히 설정하세요
  2. FLUSHDB/FLUSHALL: 실행 시 모든 추적 중인 클라이언트에게 무효화 메시지가 전송됩니다
  3. 키 이름만 전달: 무효화 메시지에는 키 이름만 포함되고, 새 값은 포함되지 않습니다. 새 값이 필요하면 다시 GET 해야 합니다
  4. RESP2 제한: REDIRECT를 사용할 때 대상 연결이 끊기면 무효화 메시지가 유실됩니다
  5. 클러스터 환경: 각 노드별로 독립적으로 동작하므로, MOVED 리다이렉션 후에는 해당 노드에서 다시 추적을 설정해야 합니다

정리

클라이언트 사이드 캐싱은 Redis의 빠른 속도에서 한 단계 더 나아가, 네트워크 왕복 자체를 없애는 기술입니다. CLIENT TRACKING으로 서버가 변경 사항을 자동으로 알려주므로 일관성도 유지됩니다. 다만 무효화 메시지 유실에 대비하여 로컬 캐시에도 TTL을 설정하고, 연결 끊김 시 캐시를 초기화하는 안전장치를 반드시 마련하세요.

댓글 로딩 중...