Lettuce cluster 의 SCAN cursor 는 값이 아니라 인스턴스였습니다
cursor 객체를 새로 만들면 라우팅 정보가 통째로 사라져 첫 shard 만 보입니다

한 줄 룰: Lettuce cluster 의 SCAN 루프에서 다음 cursor 를 넘길 때, 그 cursor 는 Lettuce 가 만든
ClusterScanCursor인스턴스 그 자체여야 합니다.new KeyScanCursor<>()로 복사한 인스턴스를 넘기면 다음 호출이 첫 shard 부터 다시 시작되어, 다른 shard 의 키를 영영 못 봅니다.
시작
캐시 무효화가 깨져 있다는 신고가 들어왔습니다. "예약을 취소했는데 검색 결과에는 그대로" 라는 사용자 입장의 증상이었고, 로그는 이렇게 찍혔습니다.
[Cache] SCAN - pattern=*:<feature>:*:<key-fragment>*, 매칭=0건
[Cache] DEL_PATTERN - 패턴 1건, 매칭 0건, 삭제 0건
같은 패턴을 redis-cli 로 직접 SCAN 하면 키가 명백히 살아 있습니다. 그런데 애플리케이션 코드의 SCAN 은 0건이 나옵니다. local single-node 환경에서도 매칭이 잘 됩니다. 깨지는 건 prod 의 cluster mode (multi-shard) 인스턴스 한정이었습니다.
원인까지는 가까웠습니다. 첫 shard 의 키만 보고 있다는 뜻이거든요. 그런데 코드 자체에는 다 shard 를 도는 do-while 루프가 분명히 있었습니다.
깨진 코드
이 사이트는 spring-data-redis 의 SCAN 추상화가 다른 함정에 부딪혀, 이미 Lettuce native 로 내려와 있었습니다 (그 사건은 다른 글에 정리해 두었습니다. Valkey Serverless 의 unsigned 64-bit cursor 가 Java long 범위를 넘어 NPE 까지 가는 길). 그래서 코드는 다음과 같았습니다.
ScanCursor cur = ScanCursor.INITIAL;
do {
KeyScanCursor<String> result = scanOnce(nativeConn, cur, args);
keys.addAll(result.getKeys());
cur = result;
} while (!cur.isFinished());
scanOnce 는 byte[] 결과를 String 으로 바꿔서 돌려주는 헬퍼였습니다.
// ❌ 함정의 정체
KeyScanCursor<String> stringResult = new KeyScanCursor<>();
stringResult.setCursor(bytesResult.getCursor()); // 문자열 cursor 만 복사
stringResult.setFinished(bytesResult.isFinished()); // finished flag 만 복사
for (byte[] keyBytes : bytesResult.getKeys()) {
stringResult.getKeys().add(new String(keyBytes, StandardCharsets.UTF_8));
}
return stringResult;
읽으면 자연스럽습니다. cursor 문자열과 finished 플래그, 그리고 키 목록을 모두 옮겼으니 충분해 보이거든요. cursor 라는 것을 "값" 이라고 생각하면 그렇습니다.
문제는 Lettuce 의 cluster mode 에서 cursor 가 값이 아니라는 점이었습니다.
Lettuce 가 cluster SCAN 을 어떻게 도는가
RedisAdvancedClusterCommands.scan(ScanCursor, ScanArgs) 의 동작을 요약하면 이렇게 생겼습니다.
static <T extends ScanCursor, K, V> T clusterScan(
StatefulRedisClusterConnection<K, V> connection,
ScanCursor cursor,
BiFunction<RedisCommands<K, V>, ScanCursor, T> scanFunction) {
List<String> nodeIds = ClusterScanSupport.getNodeIds(connection, cursor);
String currentNodeId = ClusterScanSupport.getCurrentNodeId(cursor, nodeIds);
ScanCursor continuation = ClusterScanSupport.getContinuationCursor(cursor);
RedisCommands<K, V> rc = connection.getConnection(currentNodeId).sync();
T scanResult = scanFunction.apply(rc, continuation);
return (T) ClusterScanSupport.scanCursorFactory().create(scanResult, currentNodeId, nodeIds);
}
핵심은 getNodeIds, getCurrentNodeId, getContinuationCursor 세 함수입니다. 이들은 인자로 받은 cursor 가 ClusterScanCursor 인스턴스일 때만 라우팅 정보를 활용합니다.
ClusterScanCursor 가 아니면 (즉, 일반 ScanCursor 나 KeyScanCursor) 다음과 같이 동작합니다.
getNodeIds는 connection 에서 처음부터 다시 조회합니다. 전체 master 리스트로 초기화됩니다.getCurrentNodeId는 첫 노드부터 시작합니다.getContinuationCursor는ScanCursor.INITIAL로 재시작합니다.
즉, 매 SCAN 호출이 첫 shard 의 cursor 0 에서 다시 시작합니다. 그 shard 가 cursor 0 을 돌려주는 순간 isFinished=true 가 되고, 루프는 종료됩니다. 결과적으로 루프 전체가 항상 첫 shard 만 봅니다.
위의 깨진 코드에서 cur = result 가 일반 KeyScanCursor<String> 인스턴스를 다음 cursor 로 넘기는 순간, cluster 라우팅 메타가 통째로 사라집니다. cursor 문자열은 거기 적혀 있지만 그 cursor 가 어느 노드의 어느 위치인지를 잃어버리는 셈입니다.
픽스
해결은 단순했습니다. scanOnce 가 byte[] 결과 객체 그 자체를 돌려주고, String 변환은 호출자가 따로 하도록 바꿉니다. cursor 인스턴스는 Lettuce 가 만든 것을 그대로 다음 호출에 넘깁니다.
@SuppressWarnings("unchecked")
private KeyScanCursor<byte[]> scanOnce(Object nativeConn, ScanCursor cur, ScanArgs args) {
if (nativeConn instanceof StatefulRedisClusterConnection) {
return ((StatefulRedisClusterConnection<byte[], byte[]>) nativeConn).sync().scan(cur, args);
}
// ... 다른 native 타입 분기 동일하게 byte[] 결과 그대로 반환
}
ScanCursor cur = ScanCursor.INITIAL;
do {
KeyScanCursor<byte[]> result = scanOnce(nativeConn, cur, args);
if (result == null) return Collections.emptySet();
for (byte[] keyBytes : result.getKeys()) {
keys.add(new String(keyBytes, StandardCharsets.UTF_8));
}
cur = result; // ✅ Lettuce 가 만든 ClusterScanCursor 자체
} while (!cur.isFinished());
이제 cur 가 ClusterScanCursor 이므로 getCurrentNodeId 와 getContinuationCursor 가 라우팅 메타를 사용해 정확한 다음 shard, 정확한 다음 cursor 로 이어갑니다.
왜 dev 와 local 만 통과했나
local 환경은 docker 로 띄운 single-node Redis 였습니다. shard 가 1개라 첫 shard 만 봐도 모든 키가 매칭됩니다. dev 환경은 매니지드 Redis-호환 서비스의 serverless 인스턴스를 썼는데, 사용량이 적어 우연히 1 shard 로만 운영되고 있었습니다. 똑같은 코드가 거기서는 통과했습니다.
prod 는 같은 인스턴스가 사용량 증가에 따라 자동 분산되어 multi-shard 가 되면서 노출됐습니다. 이 코드의 정확성이 인프라가 어떤 모양인지에 좌우되고 있었다는 뜻입니다. dev 통과가 cluster 모드 정확성을 보장하지 않습니다.
단위 테스트로는 안 잡힙니다
테스트에서 KeyScanCursor<byte[]> 를 mock 으로 만들어 scan() 반환을 stub 해 봐야, mock 자체는 일반 KeyScanCursor 라서 ClusterScanCursor 동작은 검증되지 않습니다. 진짜 fan-out 검증은 multi-shard 환경 통합 테스트나 prod 카나리로만 가능합니다. 다음에 SCAN 코드를 손볼 때 dev 통과만 보고 안심하지 말아야 한다는 것, 이게 이 글의 진짜 한 줄 룰일 수도 있겠어요.
진단 시그니처
다음에 같은 증상을 만났을 때 빠르게 가리려고 정리해 둡니다. 다음 세 가지가 동시에 성립하면 거의 100% 이 케이스입니다.
- SCAN 루프가 byte[] / String 변환을 위해 새
KeyScanCursor인스턴스를 만들어 반환한다. - dev 와 local 에서는 매칭이 정상이고, prod 의 multi-shard 인스턴스에서만 매칭 0건이다.
redis-cli SCAN 0 MATCH "<같은 패턴>" COUNT 1000으로 직접 SCAN 하면 키가 보인다.
대처는 한 줄입니다. new KeyScanCursor<>() 를 만들지 말고, Lettuce 가 만든 객체를 그대로 다음 호출에 넘기세요.
P.S. 한 픽스가 다른 함정을 깐 패턴
흥미로운 부분은 이 버그의 기원입니다. 이 코드는 원래 spring-data-redis 의 SCAN 추상화를 그대로 썼었습니다. 그러던 중 Valkey Serverless 의 unsigned 64-bit cursor 가 Java long 범위를 넘어 NPE 까지 가는 별개의 사고를 만나, 그 추상화를 우회해 Lettuce native API 로 내려왔습니다. 이전 사고의 픽스 PR 에서 byte[] → String 변환 한 줄을 깔끔하게 새 인스턴스로 만들었고, 그게 이번 cluster cursor 손실이라는 새 함정으로 이어졌습니다.
같은 시리즈의 long overflow → NPE 글과 같이 읽으면, "캐시 무효화 한 패스 안에서 한 픽스가 다른 함정을 만든다" 는 패턴이 더 또렷이 보일 거예요. SCAN 은 정말 손이 많이 가는 명령어입니다.