[Redis] 클러스터 전체 키 스캔 시 IDatabase와 IServer의 차이점
1. 배경
운영 중인 서비스에서 특정 패턴의 Redis 키를 모두 찾아야 할 일이 생겼다.
"그거 그냥 SCAN 돌리면 되는 거 아냐?"라고 생각했다면 반은 맞고 반은 틀렸다.
로컬 개발 환경(단일 노드)에서는 잘 돌아가던 코드가 AWS ElastiCache(Cluster Mode Enabled) 환경에 나가니 이상하게 동작했기 때문이다.
일부 키만 검색되거나, 의도하지 않은 노드에 접근하고 있었다.
Redis 클라이언트 라이브러리로 StackExchange.Redis를 사용하고 있었는데, 이 라이브러리의 구조를 제대로 이해하지 못한 것이 원인이었다.
2. 문제 정의
처음엔 익숙한 IDatabase 인터페이스만 붙잡고 씨름했다. 보통 Redis 쓸 때 이렇게 시작하니까.
var db = _redis.GetDatabase();
// 어? 여기서 SCAN 어떻게 하지?StackExchange.Redis에서 IDatabase는 GET, SET 처럼 특정 키를 대상으로 하는 데이터 작업 전용이다. 그런데 SCAN이나 KEYS 같은 명령은 성격이 다르다. 이건 데이터가 아니라 서버의 상태(키스페이스)를 뒤지는 작업이다.
특히 클러스터 모드에서는 데이터가 여러 노드(Shard)에 분산되어 있다. 단순하게 엔드포인트 하나 잡고 SCAN을 날리면, 그 명령을 받은 해당 노드의 키만 뱉어낸다. 클러스터 전체의 키를 주지 않는다.
결국 문제는 두 가지였다.
IDatabase인터페이스에서는SCAN같은 서버 명령을 제대로 지원하지 않거나(혹은 의도와 다르게 동작하거나).- 클러스터 환경에서는 단일 진입점으로 모든 키를 긁어올 수 없다.
3. IServer의 발견
StackExchange.Redis 문서를 다시 확인하면서 IServer 인터페이스를 발견했다. 구조는 다음과 같다.
- ConnectionMultiplexer: 중앙 관리자. 연결, 재연결, 토폴로지 관리 등을 다 해준다. (싱글톤으로 써야 함)
- IDatabase: 데이터 조작용. (
GET,SET,HGET...). 가볍게 만들어서 쓰고 버려도 된다(cheap pass-thru). - IServer: 서버 관리/진단용. (
SCAN,KEYS,FLUSHDB,INFO...). 특정 노드를 직접 제어한다.
아, SCAN은 데이터 조작이 아니라 서버 관리 명령이었구나. 그래서 IDatabase가 아니라 IServer를 써야 했다.
하지만 여기서 끝이 아니다. AWS ElastiCache 클러스터는 여러 노드로 구성된다. SCAN은 "현재 접속된 서버의 키"만 리턴한다. 즉, 모든 노드를 순회하며 각각 SCAN을 때리고 결과를 합쳐야 한다는 뜻이다.
"Configuration Endpoint 하나로 연결하면 알아서 다 해주겠지"라고 막연히 기대했지만, GET/SET 같은 데이터 작업에서나 라우팅을 해주지, SCAN처럼 전체를 뒤지는 작업은 오토매틱이 아니었다.
데이터 흐름은 다음과 같다.
graph TD
Client["Client App"]
CM["ConnectionMultiplexer"]
subgraph ClusterRC ["Redis Cluster"]
NodeA["Node A (Shard 1)"]
NodeB["Node B (Shard 2)"]
NodeC["Node C (Shard 3)"]
end
Client -->|"1. GetEndPoints"| CM
CM -.->|"2. Node List"| Client
Client -->|"3. GetServer.Keys"| NodeA
Client -->|"3. GetServer.Keys"| NodeB
Client -->|"3. GetServer.Keys"| NodeC
style Client fill:#f9f,stroke:#333
style CM fill:#ff9,stroke:#3334. 구현
결국 해결책은 GetEndPoints()로 모든 노드 정보를 가져온 뒤, 각 노드별로 IServer를 얻어 SCAN을 수행하는 것이다.
참고로 StackExchange.Redis의 server.Keys() 메서드는 내부적으로 KEYS 명령어가 아닌 SCAN을 사용하므로(기본값), 운영 환경에서도 비교적 안전하게 쓸 수 있다.
using StackExchange.Redis;
public async Task<List<RedisKey>> GetAllKeysAsync(string pattern)
{
var keys = new List<RedisKey>();
// 1. 현재 연결된 모든 마스터/슬레이브 노드(EndPoint) 목록을 가져온다.
var endpoints = _connectionMultiplexer.GetEndPoints();
foreach (var endpoint in endpoints)
{
// 2. 특정 노드 전용인 IServer 객체를 얻는다.
var server = _connectionMultiplexer.GetServer(endpoint);
if (server.IsReplica) continue; // 원한다면 마스터만 훑을 수도 있다.
try
{
// 3. 해당 서버에서 패턴 매칭되는 키를 스캔한다.
// server.Keys()는 내부적으로 SCAN 명령어를 사용하여 커서 방식으로 가져온다.
await foreach (var key in server.KeysAsync(pattern: pattern))
{
keys.Add(key);
}
}
catch (RedisServerException)
{
// 특정 노드가 죽어있거나 할 때의 예외 처리 필요
continue;
}
}
// 4. 여러 노드에서 조회했으므로 중복이 있을 수 있다 (리밸런싱 중인 경우).
return keys.Distinct().ToList();
}핵심 포인트
GetEndPoints(): 클러스터 토폴로지를 파악해 살아있는 노드 목록을 준다.GetServer(endpoint): 특정 노드에 1:1로 명령을 날릴 수 있는 핸들이다.server.KeysAsync(): 이름은 Keys지만 실제론SCAN을 쓴다. 블로킹 없이 순회 가능.Distinct(): 클러스터 리밸런싱(Migration) 중에 키가 이동하면서 일시적으로 양쪽 노드 모두에서 발견될 수 있다. 중복 제거가 필요하다.
5. 결과 및 주의사항
이렇게 구현하니 드디어 클러스터 전체 키 목록을 온전하게 가져올 수 있었다. 하지만 이게 "만능 해결책"은 아니다.
- 비용 문제:
SCAN이KEYS보다 낫다지만, 키가 수억 개라면 여전히 DB 부하는 무시 못한다. - 데이터 정합성: Redis 복제는 비동기(Async Replication)다. Replica 노드를 스캔하면 방금 Master에 쓴 데이터가 안 보일 수도 있다. 실시간성이 중요하다면 Master만 스캔해야 한다.
- 운영 팁: 빈번하게 전체 목록이 필요하다면, Redis에 의존하지 말고 별도 인덱스(Set 자료구조 등)를 관리하거나 엘라스틱서치 같은 검색엔진을 붙이는 게 맞다.
SCAN은 가끔 쓰는 관리자 기능이지, 비즈니스 로직의 코어 루프에 넣으면 안 된다.
결국 "Redis는 키-벨류 스토어"라는 기본을 잊지 말자. 전체 검색이 필요하다는 건 데이터 모델링이 어딘가 어긋나 있다는 신호일 수도 있다. 그래도 해야 한다면, IServer와 GetEndPoints를 기억하자.
참고 자료
Loading comments...