donghakim.dev — zsh
← ls ../blog

Lettuce cluster 의 SCAN cursor 는 값이 아니라 인스턴스였습니다

cursor 객체를 새로 만들면 라우팅 정보가 통째로 사라져 첫 shard 만 보입니다

Lettuce cluster 의 SCAN cursor 는 값이 아니라 인스턴스였습니다 · cover

한 줄 룰: 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 세 함수입니다. 이들은 인자로 받은 cursorClusterScanCursor 인스턴스일 때만 라우팅 정보를 활용합니다.

ClusterScanCursor 가 아니면 (즉, 일반 ScanCursorKeyScanCursor) 다음과 같이 동작합니다.

  • getNodeIds 는 connection 에서 처음부터 다시 조회합니다. 전체 master 리스트로 초기화됩니다.
  • getCurrentNodeId 는 첫 노드부터 시작합니다.
  • getContinuationCursorScanCursor.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());

이제 curClusterScanCursor 이므로 getCurrentNodeIdgetContinuationCursor 가 라우팅 메타를 사용해 정확한 다음 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% 이 케이스입니다.

  1. SCAN 루프가 byte[] / String 변환을 위해 새 KeyScanCursor 인스턴스를 만들어 반환한다.
  2. dev 와 local 에서는 매칭이 정상이고, prod 의 multi-shard 인스턴스에서만 매칭 0건이다.
  3. 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 은 정말 손이 많이 가는 명령어입니다.

#cluster#java#lettuce#redis#scan#spring-data-redis#troubleshooting#valkey