donghakim.dev — zsh
← ls ../blog

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

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

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

시작

캐시 무효화가 깨졌다는 신고가 들어왔습니다. "예약을 취소했는데 검색 결과엔 그대로" 라는 증상이었고, 로그는 이렇게 찍혔습니다.

[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 문자열은 거기 적혀 있어도, 그게 어느 노드의 어느 위치인지는 잃습니다.


픽스

해결은 단순했습니다. 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 자체가 일반 KeyScanCursorClusterScanCursor 동작은 검증되지 않습니다. 진짜 fan-out 검증은 multi-shard 통합 테스트나 prod 카나리로만 됩니다. SCAN 코드를 손볼 땐 dev 통과만 보고 안심하면 안 됩니다.


다음에 또 만나면

세 가지가 동시에 성립하면 거의 이 케이스입니다. 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 은 다루기 까다로운 명령어입니다.

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