• 핵심: 클러스터 모드에서 Lua 스크립트가 접근하는 모든 키는 같은 hash slot에 있어야 한다. 슬롯이 갈리면 실행 자체가 거부된다(CROSSSLOT).
    • 해결책은 하나다 — 같은 원자적 연산으로 묶을 키들에 hash tag({...})를 넣어 슬롯을 강제로 일치시킨다.

왜 이런 제약이 생기나

  • Redis 클러스터는 키를 16384개의 hash slot으로 샤딩한다. 슬롯은 CRC16(key) % 16384로 계산되고, 각 슬롯은 특정 마스터 노드가 소유한다.
  • Lua 스크립트는 단일 노드에서 원자적으로 실행된다. 스크립트가 도중에 다른 노드의 키를 만지려면 노드 간 조율이 필요한데, 클러스터는 그런 분산 트랜잭션을 지원하지 않는다.
  • 그래서 클러스터는 “스크립트가 건드리는 키가 전부 한 슬롯(=한 노드)에 있다"는 걸 보장할 수 있을 때만 실행을 허용한다.
  • MSET, MGET, SUNION 같은 다중 키 명령도 같은 이유로 동일한 제약을 받는다. 스크립트는 그중 특수 케이스일 뿐이다.

CROSSSLOT 에러

  • 스크립트에 넘긴 KEYS가 서로 다른 슬롯으로 흩어지면 노드가 실행 전에 거부한다.
> EVAL "return redis.call('GET', KEYS[1]) .. redis.call('GET', KEYS[2])" 2 user:1:name user:2:age
(error) CROSSSLOT Keys in request don't hash to the same slot
  • user:1:nameuser:2:age는 CRC16 해시가 달라 다른 슬롯에 배치되므로 실패한다.

해결: hash tag로 슬롯 강제 정렬

  • 키 이름에 {...} 패턴이 있으면, 중괄호 안 substring만 해시해서 슬롯을 정한다. 나머지 부분은 슬롯 계산에서 무시된다.
  • 따라서 같은 태그를 공유하는 키들은 항상 같은 슬롯 → 같은 노드에 모인다.
> EVAL "return redis.call('GET', KEYS[1]) .. redis.call('GET', KEYS[2])" 2 {user:1}:name {user:1}:age
  • {user:1}:name, {user:1}:age는 둘 다 user:1만 해시되므로 같은 슬롯. 정상 실행된다.

hash tag 계산 규칙

  • 다음 세 조건을 모두 만족할 때만 태그가 적용된다.
    • 키에 {가 있다.
    • { 오른쪽에 }가 있다.
    • {} 사이에 1글자 이상이 있다.
  • 조건을 못 채우면 키 전체를 해시한다. 첫 번째로 유효한 {...} 쌍에서 멈춘다.
해시 대상이유
{user1000}.followinguser1000정상 태그
foo{}{bar}foo{}{bar} (전체){} 안이 비어 무효, 그대로 전체 해시
foo{{bar}}zap{bar{와 그 오른쪽 첫 } 사이
foo{bar}{zap}bar첫 유효 쌍에서 멈춤
{}foo{}foo (전체)앞의 {}가 무효 → 전체 해시 (바이너리 키에 유용)
  • 참조 구현(Ruby):
def HASH_SLOT(key)
    s = key.index "{"
    if s
        e = key.index "}", s+1
        if e && e != s+1
            key = key[s+1..e-1]
        end
    end
    crc16(key) % 16384
end

스크립트 작성 규칙

  • 스크립트가 접근하는 모든 키는 반드시 KEYS로 명시적으로 넘겨야 한다. 공식 문서가 강제하는 사항이다.
    • 스크립트 안에서 문자열을 조합해 키 이름을 만들거나("prefix:" .. ARGV[1]), 저장된 데이터를 읽어 그 값으로 다른 키에 접근하면 안 된다.
    • 이유: 클러스터는 KEYS에 선언된 키만 보고 슬롯 일치 여부와 라우팅 노드를 판단한다. 선언 안 된 키는 라우팅된 노드가 소유하지 않을 수 있어 에러나 잘못된 데이터로 이어진다.
  • #!lua shebang 유무에 따라 크로스 슬롯 접근 허용 여부가 갈린다 (Redis 7.0+).
    • shebang 없는 구식 스크립트: 서로 다른 슬롯의 키에 접근하는 명령을 (권장되진 않지만) 실행할 수 있었다.
    • shebang 있는 스크립트(#!lua flags=...): 기본 플래그를 상속받아 크로스 슬롯 접근이 차단된다. 새로 작성한다면 이쪽이 안전하다.
#!lua flags=no-writes
local x = redis.call('GET', KEYS[1])
return x

Spring Data Redis / Lettuce 관점

  • Spring의 RedisTemplate.execute(RedisScript, keys, args...)는 내부적으로 EVALSHA를 먼저 시도하고, 캐시에 없으면 EVAL로 폴백한다.
  • 클러스터에서 Lettuce는 넘긴 KEYS의 첫 번째 키로 슬롯을 계산해 실행할 노드를 정한다. 키 없는 명령은 기본 커넥션으로 간다.
  • 따라서 애플리케이션 레벨 대응은 키 설계 단계에서 끝내야 한다. 한 스크립트로 원자 처리할 키들이 같은 태그를 갖도록 상수/헬퍼로 키를 생성한다.
// 한 게임 방의 상태·점수판을 한 스크립트에서 원자적으로 다루려면
// 방 ID를 hash tag로 묶어 같은 슬롯에 배치한다.
fun stateKey(roomId: String) = "{room:$roomId}:state"
fun scoreKey(roomId: String) = "{room:$roomId}:score"

redisTemplate.execute(
    casScript,                                  // RedisScript<Long>
    listOf(stateKey(roomId), scoreKey(roomId)), // 둘 다 {room:$roomId} → 동일 슬롯
    expected, next,
)
  • 반대로 서로 다른 방/유저의 키를 한 스크립트에 섞으면 프로덕션 클러스터에서만 CROSSSLOT이 터진다. 단일 노드(local/test)에서는 슬롯 개념이 없어 통과하므로 테스트에서 잡히지 않는 함정이다.

정리

  • 다중 키 Lua 스크립트를 클러스터에서 돌리려면: ① 원자적으로 묶을 키에 공통 hash tag {...}를 부여하고, ② 접근하는 모든 키를 KEYS로 선언하며, ③ 스크립트 안에서 임의로 키 이름을 만들지 않는다.
  • 이 규칙은 클러스터에서만 강제되지만, 키 설계는 단일 노드 시점부터 미리 해두는 게 안전하다.

참고 자료