Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions ai/journal/2026/08/AIJ-0023-cluster-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
id: AIJ-0023
date: 2026-08-20
kind: implement
phase: 3
plan: [3.8.3]
jira: CY-235
commits: [fcf07d5, ec485a8, a7a83bc]
agent: claude-opus-5
confidence: medium
promoted-to:
---

# 클러스터 모드 — 무엇을 잡고 무엇을 못 잡나

## 무엇을 했나

`ClusterModeScriptTest`. 전 스크립트(`enqueue`·`queue_status`·`sweep`·
`leader_acquire`·`leader_release`)를 `cluster-enabled yes` 인 레디스에 실제로
태운다. 정적 검사는 **의도**를 보고 이 시험은 **사실**을 본다.

## 왜 단독 노드인가

여러 노드를 띄우면 클러스터가 컨테이너 **내부 주소**를 돌려줘 밖에서 못 붙는다.
그 배선을 맞추는 데 시간을 다 쓰고 정작 검증 대상인 스크립트를 못 태운다.
슬롯 전량을 한 노드에 주면 붙는 문제가 사라진다.

## 실측 — 무엇을 잡고 무엇을 못 잡나

세 가지를 직접 태워 봤다. 추정하지 않고 쳐 봤다.

| 태운 것 | 결과 |
|---|---|
| 선언된 `KEYS` 둘이 서로 다른 슬롯 | `CROSSSLOT` 으로 **거부** |
| `KEYS` 에 없는 리터럴 키 | **통과한다** |
| 같은 슬롯 키 둘 | 통과 (정상) |

**두 번째가 이 시험의 사각지대다.** 한 노드가 전 슬롯을 가지면 선언되지 않은
키도 로컬이라 클러스터가 물지 않는다. 여러 노드였다면 그 키가 다른 노드로
가면서 터졌을 것이다.

그래서 `LuaKeysDeclarationTest` 를 버릴 수 없다. **여기서 통과한다는 것이
여러 노드에서 통과한다는 뜻이 아니다.** 이 경계를 모르면 "클러스터에서 돌려
봤다" 는 말이 실제보다 넓게 들린다 — 그래서 사각지대를 시험으로 못 박았다
(`KEYS_밖의_키는_이_시험이_못_잡는다`).

## 막혔던 것 — 정적 초기화 블록 안의 대기

`cluster_state:ok` 로 안 넘어가 30초 대기가 계속 터졌다. 컨테이너 안에서
`redis-cli` 로 치면 5초 안에 서는데 시험에서는 안 섰다.

원인은 클러스터가 아니라 **대기가 놓인 자리**였다. Awaitility 는 조건을
**다른 스레드**에서 평가하는데, 그 스레드가 아직 초기화 중인 이 클래스의
정적 필드를 건드리면 JVM 의 **클래스 초기화 락**에 걸려 영영 멈춘다. 조건이
한 번도 안 돌았는데 겉으로는 그냥 시간 초과로 보인다.

같은 스레드에서 도는 손수 만든 루프는 통과했다 — 초기화 락은 재진입이
되기 때문이다. 그래서 한동안 "Awaitility 만 이상하다" 로 보였다.

**컨테이너 준비를 `@BeforeAll` 로 옮겼다.** 정적 블록이 아니라 시험 스레드에서
돌므로 락에 걸리지 않는다.

찾는 데 오래 걸린 이유는 실패가 아무것도 안 알려줬기 때문이다. 그래서 대기
실패 시 **마지막으로 본 `cluster info` 를 붙여** 던지게 했고, 그 값이 비어
있다는 사실이 "조건이 한 번도 안 돌았다" 를 가리켜 원인을 좁혔다.

슬롯 배정과 상태 확인은 둘 다 컨테이너 안 `redis-cli` 로 친다 — 클라이언트
API 를 거치면 무엇이 실제로 나갔는지가 한 겹 가려진다.

## 확신이 낮은 부분

- **여러 노드에서의 동작은 여전히 미검증이다.** Phase 10 에서 실제 클러스터를
세울 때 다시 봐야 한다. 그때 KEYS 미선언 접근이 있다면 여기서는 안 잡힌다.
- **인자가 손으로 적혀 있다.** 스크립트가 늘면 `keysFor`·`argsFor` 에도
넣어야 하고, 안 넣으면 예외로 죽는다 — 조용히 건너뛰지는 않게 해 뒀다.

## 검증

- 통합 3건 전건 통과
- 슬롯 교차가 실제로 거부됨 (자기검증) · 사각지대가 시험으로 고정됨

## 다음 사람에게

**정적 초기화 블록 안에서 다른 스레드를 기다리지 마라.** 클래스 초기화 락은
그 스레드를 세우고, 그 사실은 시간 초과로만 보인다. 컨테이너 준비는
`@BeforeAll` 에 둔다.

**"클러스터에서 돌려 봤다" 는 말의 범위를 좁게 잡아라.** 단독 노드 클러스터는
슬롯 교차는 잡지만 미선언 키는 못 잡는다. 둘을 뭉뚱그리면 정적 검사를 지워도
된다고 착각하게 되고, 그 착각은 Phase 10 부하 시험 중에 드러난다.
1 change: 1 addition & 0 deletions ai/journal/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

| ID | 날짜 | 종류 | 제목 | 확신 | 승격 |
|---|---|---|---|---|---|
| [AIJ-0023](2026/08/AIJ-0023-cluster-mode.md) | 2026-08-20 | implement | 클러스터 모드 — 무엇을 잡고 무엇을 못 잡나 | medium | — |
| [AIJ-0022](2026/08/AIJ-0022-leader-election.md) | 2026-08-20 | implement | 리더는 한 대다 — 소유권을 값에 담는 이유 | high | — |
| [AIJ-0021](2026/08/AIJ-0021-sweep.md) | 2026-08-20 | implement | 이탈자 청소 — 앞부분만 본다 | medium | — |
| [AIJ-0020](2026/08/AIJ-0020-queue-status.md) | 2026-08-19 | implement | 순번 조회 — 한 번에 끝나야 하는 이유 | high | — |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package com.kafkick.waiting.adapter.redis;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import io.lettuce.core.RedisClient;
import io.lettuce.core.ScriptOutputType;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import org.awaitility.Awaitility;
import org.awaitility.core.ConditionTimeoutException;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;

/**
* 전 스크립트를 <b>클러스터 모드에서 실제로 태운다</b> (G3.4 · RD-2).
*
* <p>정적 검사({@link LuaKeysDeclarationTest})는 의도를 보고 이 시험은 사실을
* 본다. 슬롯 교차는 단독 모드에서 <b>조용히 통과하고</b> Phase 10 에서 터진다.
*/
@Tag("integration")
class ClusterModeScriptTest {

private static final Path SCRIPTS = Path.of("src/main/resources/redis");

/**
* <b>슬롯 전량을 한 노드에 준다.</b> 여러 노드를 띄우면 클러스터가 내부
* 주소를 돌려줘 컨테이너 밖에서 못 붙는다 — 그 배선을 맞추느라 정작
* 검증 대상인 스크립트를 못 태운다.
*/
@SuppressWarnings("resource") // JVM 종료까지 살려 둔다
private static final GenericContainer<?> CLUSTER =
new GenericContainer<>(RedisContainerSupport.IMAGE)
.withExposedPorts(6379)
.withCommand("redis-server", "--cluster-enabled", "yes",
"--cluster-require-full-coverage", "no", "--appendonly", "no");

private static RedisClient client;
private static StatefulRedisConnection<String, String> connection;

/**
* <b>정적 초기화 블록에 두지 않는다.</b> 아래 대기가 조건을 다른 스레드에서
* 평가하는데, 그 스레드가 초기화 중인 이 클래스를 건드리면 JVM 의 클래스
* 초기화 락에 걸려 영영 멈춘다 — 대기는 그저 시간 초과로만 보인다.
*/
@BeforeAll
static void 클러스터를_세운다() {
CLUSTER.start();
슬롯을_전부_준다();
클러스터가_설_때까지_기다린다();
client = RedisClient.create(
"redis://%s:%d".formatted(CLUSTER.getHost(), CLUSTER.getMappedPort(6379)));
connection = client.connect();
}

@AfterAll
static void 닫는다() {
connection.close();
client.shutdown();
}

/**
* 슬롯 전량을 이 노드에 준다. 컨테이너 안의 {@code redis-cli} 로 친다 —
* 클라이언트 API 를 거치면 무엇이 실제로 나갔는지가 한 겹 가려진다.
*/
private static void 슬롯을_전부_준다() {
String out = 컨테이너에서("cluster", "addslotsrange", "0", "16383");
if (!out.contains("OK")) {
throw new IllegalStateException("슬롯 배정 실패: " + out);
}
}

/**
* 슬롯을 받아도 상태가 {@code ok} 가 되기까지 클러스터 크론이 몇 바퀴 돈다.
*
* <p>못 서면 <b>마지막으로 본 상태를 붙여</b> 던진다. "안 떴다" 만 남으면
* 슬롯이 안 붙은 것인지 크론이 늦은 것인지 가릴 수 없다.
*/
private static void 클러스터가_설_때까지_기다린다() {
AtomicReference<String> last = new AtomicReference<>("");
try {
Awaitility.await().atMost(Duration.ofSeconds(30))
.pollInterval(Duration.ofMillis(500))
.until(() -> {
last.set(컨테이너에서("cluster", "info"));
return last.get().contains("cluster_state:ok");
});
} catch (ConditionTimeoutException e) {
throw new IllegalStateException("클러스터가 서지 않았다: " + last.get(), e);
}
}

private static String 컨테이너에서(String... args) {
String[] command = new String[args.length + 1];
command[0] = "redis-cli";
System.arraycopy(args, 0, command, 1, args.length);
try {
var result = CLUSTER.execInContainer(command);
return result.getStdout() + result.getStderr();
} catch (IOException | InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("컨테이너 명령 실패", e);
}
}

/** 쿠폰 하나가 쓰는 키 묶음. 해시 태그가 같아 한 슬롯에 모인다. */
private static List<String> keysFor(String script) {
return switch (script) {
case "enqueue.lua" -> List.of(
RedisKeys.queue("c1", 1, 0),
RedisKeys.maxScore("c1", 1, 0),
RedisKeys.alive("c1", 1, 0));
case "queue_status.lua" -> List.of(
RedisKeys.queue("c1", 1, 0),
RedisKeys.admitted("c1", 1, 0),
RedisKeys.alive("c1", 1, 0),
RedisKeys.grace("c1", 1, 0));
case "sweep.lua" -> List.of(
RedisKeys.queue("c1", 1, 0),
RedisKeys.grace("c1", 1, 0),
RedisKeys.alive("c1", 1, 0));
case "leader_acquire.lua", "leader_release.lua" -> List.of(RedisKeys.LEADER);
default -> throw new IllegalStateException("인자를 안 정한 스크립트: " + script);
};
}

private static List<String> argsFor(String script) {
return switch (script) {
case "enqueue.lua" -> List.of("m1", "60", "30", "0", "1000");
case "queue_status.lua" -> List.of("m1", "30", "1000");
case "sweep.lua" -> List.of("10", "1000", "300", "50", "0");
case "leader_acquire.lua" -> List.of("node-1", "2000");
case "leader_release.lua" -> List.of("node-1");
default -> throw new IllegalStateException("인자를 안 정한 스크립트: " + script);
};
}

private static List<Path> 스크립트들() {
try (Stream<Path> paths = Files.list(SCRIPTS)) {
return paths.filter(p -> p.toString().endsWith(".lua")).sorted().toList();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}

@Test
@DisplayName("모든_스크립트가_클러스터_모드에서_오류_없이_실행된다")
void 모든_스크립트가_클러스터_모드에서_오류_없이_실행된다() throws IOException {
RedisCommands<String, String> redis = connection.sync();
List<String> failures = new ArrayList<>();

List<Path> scripts = 스크립트들();
for (Path script : scripts) {
String name = script.getFileName().toString();
String body = Files.readString(script, StandardCharsets.UTF_8);
String[] keys = keysFor(name).toArray(String[]::new);
String[] args = argsFor(name).toArray(String[]::new);
try {
redis.eval(body, ScriptOutputType.MULTI, keys, args);
} catch (RuntimeException e) {
// 무엇이 터졌는지까지 남긴다 — "실패" 만 보면 어느 것인지 모른다.
failures.add("%s → %s".formatted(name, e.getMessage()));
}
}

assertThat(failures)
.withFailMessage("클러스터에서 실패한 스크립트 %d 건%n%s",
failures.size(), String.join("\n", failures))
.isEmpty();
// 한 건도 안 태우고 통과하면 검사가 아니다 (TS-9).
assertThat(scripts).hasSizeGreaterThanOrEqualTo(5);
}

@Test
@DisplayName("슬롯을_넘는_스크립트는_거부된다")
void 슬롯을_넘는_스크립트는_거부된다() {
// 통과만 하는 검사는 모든 스크립트를 통과시킨다 (TS-9).
// 해시 태그가 다른 키 둘을 만지면 클러스터가 물어야 한다.
String rogue = "redis.call('SET', KEYS[1], '1') "
+ "redis.call('SET', KEYS[2], '1') return 1";

assertThatThrownBy(() -> connection.sync().eval(rogue, ScriptOutputType.INTEGER,
new String[] {"{a}k", "{b}k"}, new String[0]))
.hasMessageContaining("CROSSSLOT");
}

@Test
@DisplayName("KEYS_밖의_키는_이_시험이_못_잡는다")
void KEYS_밖의_키는_이_시험이_못_잡는다() {
// **사각지대를 못 박아 둔다.** 한 노드가 전 슬롯을 가지면 선언되지
// 않은 키도 로컬이라 클러스터가 안 문다. 그래서 정적 검사
// (LuaKeysDeclarationTest) 를 버릴 수 없다 — 여기서 통과한다는 것이
// 여러 노드에서 통과한다는 뜻이 아니다.
String undeclared = "redis.call('SET', 'literal:key', '1') return 1";

Object result = connection.sync().eval(undeclared, ScriptOutputType.INTEGER,
new String[] {"{a}k"}, new String[0]);

assertThat(result).isEqualTo(1L);
}
}
Loading