diff --git a/ai/journal/2026/08/AIJ-0023-cluster-mode.md b/ai/journal/2026/08/AIJ-0023-cluster-mode.md new file mode 100644 index 00000000..e3651399 --- /dev/null +++ b/ai/journal/2026/08/AIJ-0023-cluster-mode.md @@ -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 부하 시험 중에 드러난다. diff --git a/ai/journal/index.md b/ai/journal/index.md index 1f445560..a96237ce 100644 --- a/ai/journal/index.md +++ b/ai/journal/index.md @@ -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 | — | diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/ClusterModeScriptTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/ClusterModeScriptTest.java new file mode 100644 index 00000000..17439225 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/ClusterModeScriptTest.java @@ -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; + +/** + * 전 스크립트를 클러스터 모드에서 실제로 태운다 (G3.4 · RD-2). + * + *
정적 검사({@link LuaKeysDeclarationTest})는 의도를 보고 이 시험은 사실을
+ * 본다. 슬롯 교차는 단독 모드에서 조용히 통과하고 Phase 10 에서 터진다.
+ */
+@Tag("integration")
+class ClusterModeScriptTest {
+
+ private static final Path SCRIPTS = Path.of("src/main/resources/redis");
+
+ /**
+ * 슬롯 전량을 한 노드에 준다. 여러 노드를 띄우면 클러스터가 내부
+ * 주소를 돌려줘 컨테이너 밖에서 못 붙는다 — 그 배선을 맞추느라 정작
+ * 검증 대상인 스크립트를 못 태운다.
+ */
+ @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 못 서면 마지막으로 본 상태를 붙여 던진다. "안 떴다" 만 남으면
+ * 슬롯이 안 붙은 것인지 크론이 늦은 것인지 가릴 수 없다.
+ */
+ private static void 클러스터가_설_때까지_기다린다() {
+ AtomicReference