클라이언트 사이드 캐싱 — Tracking과 Invalidation
Redis에서 데이터를 가져오는 것도 빠르지만, 아예 네트워크 왕복 없이 로컬에서 바로 읽을 수는 없을까요?
개념 정의
클라이언트 사이드 캐싱 은 Redis에서 읽은 데이터를 로컬 메모리에 저장하고, 데이터가 변경되면 서버가 무효화(invalidation) 메시지 를 보내주는 기능입니다. Redis 6.0의 CLIENT TRACKING 명령으로 공식 지원이 시작되었습니다.
왜 필요한가
- Redis 조회가 아무리 빨라도 네트워크 왕복(RTT)은 0.1~1ms 정도 소요됩니다
- 초당 수십만 요청을 처리하는 서비스에서 이 RTT가 누적되면 상당한 비용입니다
- 로컬 캐시를 사용하면 RTT가 0이 되지만, 데이터가 변경됐을 때 어떻게 알 수 있을까요?
- 바로 이 문제를 Redis의 CLIENT TRACKING이 해결합니다
기본 동작 원리
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 기능을 지원합니다.
# 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 연결이 필요합니다.
# 연결 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)
기본 모드는 클라이언트가 실제로 읽은 키만 추적합니다. 브로드캐스트 모드는 지정한 프리픽스에 해당하는 모든 키의 변경을 알려줍니다.
# user: 프리픽스로 시작하는 모든 키 변경을 추적
127.0.0.1:6379> CLIENT TRACKING ON BCAST PREFIX user: PREFIX session:
기본 모드 vs 브로드캐스트 모드
| 특성 | 기본 모드 | 브로드캐스트 모드 |
|---|---|---|
| 추적 대상 | 클라이언트가 읽은 키만 | PREFIX에 매칭되는 모든 키 |
| 서버 메모리 | 추적 테이블 유지 (클라이언트별) | 프리픽스만 저장 |
| 불필요한 알림 | 적음 | 읽지 않은 키의 변경도 받을 수 있음 |
| 적합한 경우 | 키별로 선택적 캐싱 | 특정 패턴의 키를 모두 캐싱 |
브로드캐스트 모드의 서버 부하
# 기본 모드: 서버가 Invalidation Table 유지
# - 클라이언트 수 × 추적 키 수만큼 메모리 사용
# - 기본 최대 추적 키 수: 클라이언트당 200만 개
127.0.0.1:6379> CONFIG SET tracking-table-max-keys 2000000
# 브로드캐스트 모드: 프리픽스만 저장하므로 서버 메모리 부담 적음
# 하지만 클라이언트에 불필요한 무효화 메시지가 많아질 수 있음
OPTIN / OPTOUT 모드
OPTIN — 선택적으로 추적
기본적으로 아무것도 추적하지 않고, 명시적으로 지정한 키만 추적합니다.
# 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 — 선택적으로 제외
기본적으로 모든 읽기를 추적하고, 명시적으로 지정한 키만 제외합니다.
# 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) 구현 예시
먼저 연결을 생성하고 무효화 리스너를 등록합니다. 서버가 보내는 push 메시지에서 무효화된 키를 감지하여 로컬 캐시에서 제거합니다.
// 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<String> keys = (List<String>) push.getContent().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 설정
@Configuration
public class RedisCachingConfig {
@Bean
public LettuceClientConfigurationBuilderCustomizer clientCacheCustomizer() {
return builder -> builder
.clientOptions(ClientOptions.builder()
.protocolVersion(ProtocolVersion.RESP3)
.build());
}
}
로컬 캐시의 안전장치
무효화 메시지를 놓칠 수 있는 경우를 대비한 안전장치가 필요합니다.
로컬 캐시에도 TTL 설정
// Caffeine 캐시로 로컬 캐시 구현
Cache<String, String> localCache = Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterWrite(Duration.ofSeconds(60)) // 최대 60초 — 무효화를 놓쳐도 60초 후 만료
.build();
연결 끊김 시 전체 초기화
connection.addListener(message -> {
if (message instanceof ConnectionEvent.Disconnected) {
// 연결이 끊기면 로컬 캐시 전체 초기화
localCache.invalidateAll();
}
});
함정/Pitfall
1. Invalidation Table이 메모리를 많이 차지할 수 있다
기본 모드에서 서버는 클라이언트별 추적 키를 메모리에 저장합니다. 클라이언트가 많고 읽는 키도 많으면 테이블이 커지므로, tracking-table-max-keys를 적절히 설정하세요.
2. RESP2에서 REDIRECT 연결이 끊기면 무효화 메시지가 유실된다
RESP2는 push 기능이 없어 별도 Pub/Sub 연결로 무효화 메시지를 받습니다. 이 연결이 끊기면 그 사이의 무효화 메시지를 놓치게 되어 stale read가 발생합니다. 가능하면 RESP3를 사용하세요.
3. 클러스터 환경에서는 노드별로 독립 동작한다
MOVED 리다이렉션으로 다른 노드에 접근하면 해당 노드에서 다시 추적을 설정해야 합니다. 또한 FLUSHDB 실행 시 모든 추적 클라이언트에게 무효화 메시지가 한꺼번에 전송되어 네트워크 부하가 발생할 수 있습니다.
정리
| 핵심 | 설명 |
|---|---|
| 목적 | 네트워크 왕복(RTT) 자체를 없애서 조회 속도를 극대화 |
| 메커니즘 | CLIENT TRACKING으로 서버가 키 변경 시 무효화 메시지 전송 |
| 기본 모드 | 클라이언트가 읽은 키만 추적 (서버가 Invalidation Table 유지) |
| 브로드캐스트 모드 | PREFIX 패턴에 매칭되는 모든 키 변경을 알림 |
| OPTIN/OPTOUT | 추적 대상을 명시적으로 선택/제외 |
| 안전장치 필수 | 로컬 캐시에도 TTL 설정 + 연결 끊김 시 캐시 전체 초기화 |