From fcf07d5a305c9fb99d1d5f964bc8e772ab6aef9e Mon Sep 17 00:00:00 2001 From: UHeeJoon Date: Thu, 20 Aug 2026 11:42:14 +0900 Subject: [PATCH 1/4] =?UTF-8?q?test(redis):=20=ED=81=B4=EB=9F=AC=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20=EC=A0=84=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 슬롯 교차는 단독 모드에서 조용히 통과하고 Phase 10 에서 터진다. 사각지대(미선언 키)도 시험으로 못 박아 정적 검사를 못 버리게 했다. Refs: CY-235 --- .../adapter/redis/ClusterModeScriptTest.java | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 src/test/java/com/kafkick/waiting/adapter/redis/ClusterModeScriptTest.java 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..de6a1993 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/ClusterModeScriptTest.java @@ -0,0 +1,207 @@ +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.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +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 final RedisClient CLIENT; + private static final StatefulRedisConnection CONNECTION; + + static { + 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} 가 되기까지 클러스터 크론이 몇 바퀴 돈다. + * + *

못 서면 마지막으로 본 상태를 붙여 던진다. "안 떴다" 만 남으면 + * 슬롯이 안 붙은 것인지 크론이 늦은 것인지 가릴 수 없다. + */ + private static void 클러스터가_설_때까지_기다린다() { + String info = ""; + for (int i = 0; i < 60; i++) { + info = 컨테이너에서("cluster", "info"); + if (info.contains("cluster_state:ok")) { + return; + } + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("대기 중 끊겼다", e); + } + } + throw new IllegalStateException("클러스터가 서지 않았다: " + info); + } + + 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 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 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 스크립트들() { + try (Stream 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 redis = CONNECTION.sync(); + List failures = new ArrayList<>(); + + List 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); + } +} From ec485a8eba8b967d03033a10c9c42a12eccce8f7 Mon Sep 17 00:00:00 2001 From: UHeeJoon Date: Thu, 20 Aug 2026 11:42:14 +0900 Subject: [PATCH 2/4] =?UTF-8?q?docs(journal):=20=ED=81=B4=EB=9F=AC?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EB=AA=A8=EB=93=9C=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs: CY-235 --- ai/journal/2026/08/AIJ-0023-cluster-mode.md | 65 +++++++++++++++++++++ ai/journal/index.md | 1 + 2 files changed, 66 insertions(+) create mode 100644 ai/journal/2026/08/AIJ-0023-cluster-mode.md 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..9b209e39 --- /dev/null +++ b/ai/journal/2026/08/AIJ-0023-cluster-mode.md @@ -0,0 +1,65 @@ +# AIJ-0023 · 클러스터 모드 — 무엇을 잡고 무엇을 못 잡나 + +- **날짜** 2026-08-20 +- **종류** implement +- **확신** medium +- **티켓** CY-235 + +## 무엇을 했나 + +`ClusterModeScriptTest`. 전 스크립트(`enqueue`·`queue_status`·`sweep`· +`leader_acquire`·`leader_release`)를 `cluster-enabled yes` 인 레디스에 실제로 +태운다. 정적 검사는 **의도**를 보고 이 시험은 **사실**을 본다. + +## 왜 단독 노드인가 + +여러 노드를 띄우면 클러스터가 컨테이너 **내부 주소**를 돌려줘 밖에서 못 붙는다. +그 배선을 맞추는 데 시간을 다 쓰고 정작 검증 대상인 스크립트를 못 태운다. +슬롯 전량을 한 노드에 주면 붙는 문제가 사라진다. + +## 실측 — 무엇을 잡고 무엇을 못 잡나 + +세 가지를 직접 태워 봤다. 추정하지 않고 쳐 봤다. + +| 태운 것 | 결과 | +|---|---| +| 선언된 `KEYS` 둘이 서로 다른 슬롯 | `CROSSSLOT` 으로 **거부** | +| `KEYS` 에 없는 리터럴 키 | **통과한다** | +| 같은 슬롯 키 둘 | 통과 (정상) | + +**두 번째가 이 시험의 사각지대다.** 한 노드가 전 슬롯을 가지면 선언되지 않은 +키도 로컬이라 클러스터가 물지 않는다. 여러 노드였다면 그 키가 다른 노드로 +가면서 터졌을 것이다. + +그래서 `LuaKeysDeclarationTest` 를 버릴 수 없다. **여기서 통과한다는 것이 +여러 노드에서 통과한다는 뜻이 아니다.** 이 경계를 모르면 "클러스터에서 돌려 +봤다" 는 말이 실제보다 넓게 들린다 — 그래서 사각지대를 시험으로 못 박았다 +(`KEYS_밖의_키는_이_시험이_못_잡는다`). + +## 막혔던 것 + +`cluster_state:ok` 로 안 넘어가 30초 대기가 계속 터졌다. 컨테이너 안에서 +`redis-cli` 로 치면 5초 안에 서는데 클라이언트로 치면 안 섰다. 원인을 좁히는 +대신 **슬롯 배정과 상태 확인을 둘 다 컨테이너 안 `redis-cli` 로** 옮겼다 — +클라이언트 API 를 거치면 무엇이 실제로 나갔는지가 한 겹 가려진다. + +대기 실패 시 **마지막으로 본 `cluster info` 를 붙여** 던지게 했다. "안 떴다" +만 남으면 슬롯이 안 붙은 것인지 크론이 늦은 것인지 가릴 수 없다. + +## 확신이 낮은 부분 + +- **여러 노드에서의 동작은 여전히 미검증이다.** Phase 10 에서 실제 클러스터를 + 세울 때 다시 봐야 한다. 그때 KEYS 미선언 접근이 있다면 여기서는 안 잡힌다. +- **인자가 손으로 적혀 있다.** 스크립트가 늘면 `keysFor`·`argsFor` 에도 + 넣어야 하고, 안 넣으면 예외로 죽는다 — 조용히 건너뛰지는 않게 해 뒀다. + +## 검증 + +- 통합 3건 전건 통과 +- 슬롯 교차가 실제로 거부됨 (자기검증) · 사각지대가 시험으로 고정됨 + +## 다음 사람에게 + +**"클러스터에서 돌려 봤다" 는 말의 범위를 좁게 잡아라.** 단독 노드 클러스터는 +슬롯 교차는 잡지만 미선언 키는 못 잡는다. 둘을 뭉뚱그리면 정적 검사를 지워도 +된다고 착각하게 되고, 그 착각은 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 | — | From a7a83bc4db828c5bcfa3a97100b64a53c6fcf4fe Mon Sep 17 00:00:00 2001 From: UHeeJoon Date: Thu, 20 Aug 2026 11:44:27 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(test):=20=ED=81=B4=EB=9F=AC=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=20=EC=A4=80=EB=B9=84=EB=A5=BC=20BeforeAll=20=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정적 초기화 블록에서 기다리면 조건 평가 스레드가 클래스 초기화 락에 걸려 멈춘다. 겉으로는 시간 초과로만 보인다. Refs: CY-235 --- ai/journal/2026/08/AIJ-0023-cluster-mode.md | 29 +++++++--- .../adapter/redis/ClusterModeScriptTest.java | 54 +++++++++++-------- 2 files changed, 54 insertions(+), 29 deletions(-) diff --git a/ai/journal/2026/08/AIJ-0023-cluster-mode.md b/ai/journal/2026/08/AIJ-0023-cluster-mode.md index 9b209e39..c3212b62 100644 --- a/ai/journal/2026/08/AIJ-0023-cluster-mode.md +++ b/ai/journal/2026/08/AIJ-0023-cluster-mode.md @@ -36,15 +36,28 @@ 봤다" 는 말이 실제보다 넓게 들린다 — 그래서 사각지대를 시험으로 못 박았다 (`KEYS_밖의_키는_이_시험이_못_잡는다`). -## 막혔던 것 +## 막혔던 것 — 정적 초기화 블록 안의 대기 `cluster_state:ok` 로 안 넘어가 30초 대기가 계속 터졌다. 컨테이너 안에서 -`redis-cli` 로 치면 5초 안에 서는데 클라이언트로 치면 안 섰다. 원인을 좁히는 -대신 **슬롯 배정과 상태 확인을 둘 다 컨테이너 안 `redis-cli` 로** 옮겼다 — -클라이언트 API 를 거치면 무엇이 실제로 나갔는지가 한 겹 가려진다. +`redis-cli` 로 치면 5초 안에 서는데 시험에서는 안 섰다. -대기 실패 시 **마지막으로 본 `cluster info` 를 붙여** 던지게 했다. "안 떴다" -만 남으면 슬롯이 안 붙은 것인지 크론이 늦은 것인지 가릴 수 없다. +원인은 클러스터가 아니라 **대기가 놓인 자리**였다. Awaitility 는 조건을 +**다른 스레드**에서 평가하는데, 그 스레드가 아직 초기화 중인 이 클래스의 +정적 필드를 건드리면 JVM 의 **클래스 초기화 락**에 걸려 영영 멈춘다. 조건이 +한 번도 안 돌았는데 겉으로는 그냥 시간 초과로 보인다. + +같은 스레드에서 도는 손수 만든 루프는 통과했다 — 초기화 락은 재진입이 +되기 때문이다. 그래서 한동안 "Awaitility 만 이상하다" 로 보였다. + +**컨테이너 준비를 `@BeforeAll` 로 옮겼다.** 정적 블록이 아니라 시험 스레드에서 +돌므로 락에 걸리지 않는다. + +찾는 데 오래 걸린 이유는 실패가 아무것도 안 알려줬기 때문이다. 그래서 대기 +실패 시 **마지막으로 본 `cluster info` 를 붙여** 던지게 했고, 그 값이 비어 +있다는 사실이 "조건이 한 번도 안 돌았다" 를 가리켜 원인을 좁혔다. + +슬롯 배정과 상태 확인은 둘 다 컨테이너 안 `redis-cli` 로 친다 — 클라이언트 +API 를 거치면 무엇이 실제로 나갔는지가 한 겹 가려진다. ## 확신이 낮은 부분 @@ -60,6 +73,10 @@ ## 다음 사람에게 +**정적 초기화 블록 안에서 다른 스레드를 기다리지 마라.** 클래스 초기화 락은 +그 스레드를 세우고, 그 사실은 시간 초과로만 보인다. 컨테이너 준비는 +`@BeforeAll` 에 둔다. + **"클러스터에서 돌려 봤다" 는 말의 범위를 좁게 잡아라.** 단독 노드 클러스터는 슬롯 교차는 잡지만 미선언 키는 못 잡는다. 둘을 뭉뚱그리면 정적 검사를 지워도 된다고 착각하게 되고, 그 착각은 Phase 10 부하 시험 중에 드러난다. diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/ClusterModeScriptTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/ClusterModeScriptTest.java index de6a1993..17439225 100644 --- a/src/test/java/com/kafkick/waiting/adapter/redis/ClusterModeScriptTest.java +++ b/src/test/java/com/kafkick/waiting/adapter/redis/ClusterModeScriptTest.java @@ -12,10 +12,15 @@ 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; @@ -44,22 +49,28 @@ class ClusterModeScriptTest { .withCommand("redis-server", "--cluster-enabled", "yes", "--cluster-require-full-coverage", "no", "--appendonly", "no"); - private static final RedisClient CLIENT; - private static final StatefulRedisConnection CONNECTION; + private static RedisClient client; + private static StatefulRedisConnection connection; - static { + /** + * 정적 초기화 블록에 두지 않는다. 아래 대기가 조건을 다른 스레드에서 + * 평가하는데, 그 스레드가 초기화 중인 이 클래스를 건드리면 JVM 의 클래스 + * 초기화 락에 걸려 영영 멈춘다 — 대기는 그저 시간 초과로만 보인다. + */ + @BeforeAll + static void 클러스터를_세운다() { CLUSTER.start(); 슬롯을_전부_준다(); 클러스터가_설_때까지_기다린다(); - CLIENT = RedisClient.create( + client = RedisClient.create( "redis://%s:%d".formatted(CLUSTER.getHost(), CLUSTER.getMappedPort(6379))); - CONNECTION = CLIENT.connect(); + connection = client.connect(); } @AfterAll static void 닫는다() { - CONNECTION.close(); - CLIENT.shutdown(); + connection.close(); + client.shutdown(); } /** @@ -80,20 +91,17 @@ class ClusterModeScriptTest { * 슬롯이 안 붙은 것인지 크론이 늦은 것인지 가릴 수 없다. */ private static void 클러스터가_설_때까지_기다린다() { - String info = ""; - for (int i = 0; i < 60; i++) { - info = 컨테이너에서("cluster", "info"); - if (info.contains("cluster_state:ok")) { - return; - } - try { - Thread.sleep(500); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("대기 중 끊겼다", e); - } + AtomicReference 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); } - throw new IllegalStateException("클러스터가 서지 않았다: " + info); } private static String 컨테이너에서(String... args) { @@ -152,7 +160,7 @@ private static List argsFor(String script) { @Test @DisplayName("모든_스크립트가_클러스터_모드에서_오류_없이_실행된다") void 모든_스크립트가_클러스터_모드에서_오류_없이_실행된다() throws IOException { - RedisCommands redis = CONNECTION.sync(); + RedisCommands redis = connection.sync(); List failures = new ArrayList<>(); List scripts = 스크립트들(); @@ -185,7 +193,7 @@ private static List argsFor(String script) { String rogue = "redis.call('SET', KEYS[1], '1') " + "redis.call('SET', KEYS[2], '1') return 1"; - assertThatThrownBy(() -> CONNECTION.sync().eval(rogue, ScriptOutputType.INTEGER, + assertThatThrownBy(() -> connection.sync().eval(rogue, ScriptOutputType.INTEGER, new String[] {"{a}k", "{b}k"}, new String[0])) .hasMessageContaining("CROSSSLOT"); } @@ -199,7 +207,7 @@ private static List argsFor(String script) { // 여러 노드에서 통과한다는 뜻이 아니다. String undeclared = "redis.call('SET', 'literal:key', '1') return 1"; - Object result = CONNECTION.sync().eval(undeclared, ScriptOutputType.INTEGER, + Object result = connection.sync().eval(undeclared, ScriptOutputType.INTEGER, new String[] {"{a}k"}, new String[0]); assertThat(result).isEqualTo(1L); From b5e785febe5e8ddd3a621b486c144eaf544f4e79 Mon Sep 17 00:00:00 2001 From: UHeeJoon Date: Thu, 20 Aug 2026 13:17:43 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(journal):=20AIJ-0023=20=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8=EB=A7=A4=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 형식을 확인하지 않고 앞 글의 본문만 베껴 프론트매터를 빠뜨렸다. Refs: CY-235 --- ai/journal/2026/08/AIJ-0023-cluster-mode.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/ai/journal/2026/08/AIJ-0023-cluster-mode.md b/ai/journal/2026/08/AIJ-0023-cluster-mode.md index c3212b62..e3651399 100644 --- a/ai/journal/2026/08/AIJ-0023-cluster-mode.md +++ b/ai/journal/2026/08/AIJ-0023-cluster-mode.md @@ -1,9 +1,17 @@ -# AIJ-0023 · 클러스터 모드 — 무엇을 잡고 무엇을 못 잡나 - -- **날짜** 2026-08-20 -- **종류** implement -- **확신** medium -- **티켓** CY-235 +--- +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: +--- + +# 클러스터 모드 — 무엇을 잡고 무엇을 못 잡나 ## 무엇을 했나