- 핵심: 클러스터 모드에서 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:name과 user: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}.following | user1000 | 정상 태그 |
foo{}{bar} | foo{}{bar} (전체) | 첫 {} 안이 비어 무효, 그대로 전체 해시 |
foo{{bar}}zap | {bar | 첫 {와 그 오른쪽 첫 } 사이 |
foo{bar}{zap} | bar | 첫 유효 쌍에서 멈춤 |
{}foo | {}foo (전체) | 앞의 {}가 무효 → 전체 해시 (바이너리 키에 유용) |
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로 선언하며, ③ 스크립트 안에서 임의로 키 이름을 만들지 않는다. - 이 규칙은 클러스터에서만 강제되지만, 키 설계는 단일 노드 시점부터 미리 해두는 게 안전하다.
참고 자료#