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처럼 전체를 뒤지는 작업은 자동으로 처리되지 않았다.
데이터 흐름은 다음과 같다.
4. 구현
결국 해결책은 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에서 시작하면 된다.