diff --git a/ai/journal/2026/08/AIJ-0028-snapshot-holder.md b/ai/journal/2026/08/AIJ-0028-snapshot-holder.md new file mode 100644 index 00000000..e2be74a5 --- /dev/null +++ b/ai/journal/2026/08/AIJ-0028-snapshot-holder.md @@ -0,0 +1,77 @@ +--- +id: AIJ-0028 +date: 2026-08-20 +kind: implement +phase: 4 +plan: [4.1.1, 4.1.2, 4.1.3, 4.1.4, 4.1.5] +jira: CY-269 +commits: [2554e11] +agent: claude-opus-5 +confidence: high +promoted-to: +--- + +# 낡음이 두 종류인 이유 — 섞으면 100% 장애다 + +## 무엇을 했나 + +`SnapshotHolder` 와 `GatewaySnapshot`. 판정 재료를 통째로 들고 있으면서 +**낡음을 두 종류로** 구분해 노출한다. + +| 종류 | 무엇이 멎은 것인가 | 대응 | +|---|---|---| +| `fetchStale` | **이 노드**의 갱신 루프 | 503 — LB 가 이 노드만 뺀다 | +| `dataStale` | **스케줄러** | **200 유지** — 빼면 100% 장애 | + +## 왜 하나로 못 합치나 + +`dataStale` 에 503 을 내면 **전 노드가 동시에 빠진다.** 스케줄러가 멎으면 모든 +게이트웨이가 같은 낡은 값을 보므로, 그걸 이유로 이탈하면 살아남는 노드가 없다. +장애 하나가 전면 장애가 되는 경로다. + +반대로 `fetchStale` 은 이 노드만의 사정이다. 옆 노드는 멀쩡히 받아 오고 있으니 +빠져야 맞다. + +## 발행 시각을 담아야 하는 이유 + +`publishedAt` 은 **스케줄러가 발행한 시각**이지 로컬 수신 시각이 아니다. + +로컬 수신 시각만 쓰면 `dataStale` 을 **영영 못 잡는다** — 스케줄러가 죽어도 +게이트웨이는 같은 해시를 계속 잘 받아 오고, 그때마다 "방금 갱신했다" 고 +**스스로를 속인다.** 값이 안 변한다는 사실은 로컬 시각에 안 나타난다. + +## 락을 안 쓴다 + +읽는 쪽이 요청 경로다. 여기서 잠그면 판정마다 경합이 생긴다. 참조 교체는 +원자적이고 스냅샷은 불변이라(`Map.copyOf`) `volatile` 하나로 족하다. + +## 시험용 구멍을 프로덕션에 내지 않는다 + +처음엔 `시계를_옮긴다(Clock)` 를 패키지 전용으로 뒀다. `Clock.fixed` 가 못 +움직여서였다. 그런데 그 메서드는 **운영에서 아무도 안 쓰는데 지워지지도 않는다** — +다음 사람이 보면 왜 있는지 모르고, 언젠가 누가 부른다. + +픽스처에 `MutableClock` 을 두고 걷어냈다. 프로덕션은 `Clock` 만 알면 된다. + +## 경계값 + +임계와 **같으면 아직 낡지 않았다.** 넘어야 낡음이다. 이걸 안 정해 두면 구현이 +바뀔 때마다 1ms 차이로 게이트가 흔들린다 — 시험으로 못 박았다. + +## 확신이 낮은 부분 + +- **`fetchStale` 임계값이 아직 설정에 없다.** 4.2 에서 갱신 루프를 붙일 때 + `snapshot.fetch`(500ms)와 함께 정한다. +- **`dataAge` 는 스케줄러와 이 노드의 시계 차에 노출된다.** Phase 3 처럼 레디스 + 시계를 쓰지 않는다 — 스냅샷 발행이 Lua 가 아니라 애플리케이션이라서다. + 차이가 크면 `dataStale` 이 잘못 뜬다. + +## 검증 + +- 단위 7건 전건 통과 · 도메인 브랜치 커버리지 임계 유지 +- 첫 갱신 전 둘 다 낡음 · 스케줄러만 멎은 경우 · 이 노드만 멎은 경우 · 경계값 + +## 다음 사람에게 + +**"낡았다" 를 한 단어로 쓰지 마라.** 누가 멎었는지에 따라 대응이 정반대다. +이 구분을 잃으면 헬스체크가 장애를 키우는 쪽으로 동작한다. diff --git a/ai/journal/index.md b/ai/journal/index.md index 4c66aacb..8e8b1cea 100644 --- a/ai/journal/index.md +++ b/ai/journal/index.md @@ -8,6 +8,7 @@ | ID | 날짜 | 종류 | 제목 | 확신 | 승격 | |---|---|---|---|---|---| +| [AIJ-0028](2026/08/AIJ-0028-snapshot-holder.md) | 2026-08-20 | implement | 낡음이 두 종류인 이유 — 섞으면 100% 장애다 | high | — | | [AIJ-0026](2026/08/AIJ-0026-dependency-refresh.md) | 2026-08-20 | decision | 버전은 실측한다 — 검색 색인은 최신을 모른다 | high | — | | [AIJ-0025](2026/08/AIJ-0025-crash-and-promotion.md) | 2026-08-20 | implement | 강제 종료와 승격 — 계획의 전제가 두 군데 틀렸다 | medium | — | | [AIJ-0024](2026/08/AIJ-0024-adapter-coverage.md) | 2026-08-20 | decision | 어댑터 커버리지 — 통합 exec 만으로는 엉뚱한 것을 잰다 | medium | — | diff --git a/src/main/java/com/kafkick/waiting/control/GatewaySnapshot.java b/src/main/java/com/kafkick/waiting/control/GatewaySnapshot.java new file mode 100644 index 00000000..b4c065f4 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/control/GatewaySnapshot.java @@ -0,0 +1,29 @@ +package com.kafkick.waiting.control; + +import com.kafkick.waiting.domain.coupon.CouponState; +import com.kafkick.waiting.domain.coupon.SnapshotMeta; +import java.time.Instant; +import java.util.Map; + +/** + * 한 번에 통째로 갈리는 판정 재료. + * + *
키별 캐시가 아니라 통째 교체다. miss 가 나면 그 순간 레디스로 요청이
+ * 몰리고, 쿠폰마다 낡음 시점이 다르면 "이 판정이 얼마나 낡았나" 를 말할 수 없다.
+ *
+ * @param coupons 쿠폰별 상태. 밖에서 못 바꾼다
+ * @param meta 전 쿠폰 공통 값
+ * @param publishedAt 스케줄러가 발행한 시각. 로컬 수신 시각이 아니다 —
+ * 그것만 쓰면 스케줄러가 죽어도 "방금 갱신했다" 고 속는다
+ */
+public record GatewaySnapshot(Map {@code fetchStale} 은 이 노드의 갱신 루프가 멎은 것이라 503 이고,
+ * {@code dataStale} 은 스케줄러가 멎은 것이라 200 을 유지한다. 섞으면 스케줄러
+ * 장애가 전 게이트웨이 동시 이탈로 번져 100% 장애가 된다.
+ */
+public final class SnapshotHolder {
+
+ private final Duration fetchStaleAfter;
+ private final Duration dataStaleAfter;
+ private final Clock clock;
+
+ /**
+ * 스냅샷과 수신 시각을 한 덩어리로 든다.
+ *
+ * 둘을 따로 두면 갱신 도중 읽는 쪽이 새 스냅샷과 옛 수신 시각을 함께
+ * 본다. 그러면 방금 갱신했는데도 {@code fetchStale} 이 참이 되고, 그 값이
+ * 503 경로에 물려 있어 정상 노드가 빠진다.
+ */
+ private record 상태(GatewaySnapshot snapshot, Instant fetchedAt) {
+ }
+
+ /**
+ * 락을 쓰지 않는다. 읽는 쪽이 요청 경로라, 여기서 잠그면 판정마다
+ * 경합이 생긴다. 참조 교체는 원자적이고 담긴 것이 전부 불변이라 족하다.
+ */
+ private volatile 상태 current = new 상태(GatewaySnapshot.EMPTY, Instant.EPOCH);
+
+ public static SnapshotHolder of(Duration fetchStaleAfter, Duration dataStaleAfter,
+ Clock clock) {
+ return new SnapshotHolder(fetchStaleAfter, dataStaleAfter, clock);
+ }
+
+ private SnapshotHolder(Duration fetchStaleAfter, Duration dataStaleAfter, Clock clock) {
+ this.fetchStaleAfter = fetchStaleAfter;
+ this.dataStaleAfter = dataStaleAfter;
+ this.clock = clock;
+ }
+
+ public GatewaySnapshot current() {
+ return current.snapshot();
+ }
+
+ /** 통째로 갈아 끼운다. 실패한 갱신은 이걸 부르지 않는다 — 옛 값이 남는다. */
+ public void replace(GatewaySnapshot snapshot) {
+ this.current = new 상태(snapshot, clock.instant());
+ }
+
+ /** 이 노드가 마지막으로 받아 온 뒤 흐른 시간. */
+ public Duration fetchAge() {
+ return Duration.between(current.fetchedAt(), clock.instant());
+ }
+
+ /** 스케줄러가 발행한 뒤 흐른 시간. 이 노드의 사정과 무관하다. */
+ public Duration dataAge() {
+ return Duration.between(current.snapshot().publishedAt(), clock.instant());
+ }
+
+ /** 임계와 같으면 아직 낡지 않았다 — 넘어야 낡음이다. */
+ public boolean isFetchStale() {
+ return fetchAge().compareTo(fetchStaleAfter) > 0;
+ }
+
+ public boolean isDataStale() {
+ return dataAge().compareTo(dataStaleAfter) > 0;
+ }
+}
diff --git a/src/test/java/com/kafkick/waiting/control/SnapshotHolderTest.java b/src/test/java/com/kafkick/waiting/control/SnapshotHolderTest.java
new file mode 100644
index 00000000..87665cff
--- /dev/null
+++ b/src/test/java/com/kafkick/waiting/control/SnapshotHolderTest.java
@@ -0,0 +1,152 @@
+package com.kafkick.waiting.control;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.kafkick.waiting.MutableClock;
+import com.kafkick.waiting.domain.coupon.CouponState;
+import com.kafkick.waiting.domain.coupon.SnapshotMeta;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 낡음을 두 종류로 구분한다 (4.1).
+ *
+ * 섞으면 스케줄러 장애가 전 게이트웨이 동시 이탈로 번져 100% 장애가 된다.
+ * 로컬 수신 시각만 쓰면 스케줄러가 죽어도 게이트웨이는 같은 해시를 계속 받아
+ * "방금 갱신했다" 고 스스로를 속인다.
+ */
+class SnapshotHolderTest {
+
+ private static final Instant 지금 = Instant.parse("2026-08-20T00:00:00Z");
+ private static final Duration FETCH_STALE = Duration.ofSeconds(2);
+ private static final Duration DATA_STALE = Duration.ofSeconds(5);
+
+ private static Clock 고정시계(Instant at) {
+ return Clock.fixed(at, ZoneOffset.UTC);
+ }
+
+ private static SnapshotHolder 홀더(Instant at) {
+ return SnapshotHolder.of(FETCH_STALE, DATA_STALE, 고정시계(at));
+ }
+
+ @Test
+ @DisplayName("첫_갱신_전에는_비어_있고_둘_다_낡았다")
+ void 첫_갱신_전에는_비어_있고_둘_다_낡았다() {
+ // 판정 재료가 없으면 못 받는다. EPOCH 이라 어떤 임계로도 낡음이다.
+ SnapshotHolder holder = 홀더(지금);
+
+ assertThat(holder.current().coupons()).isEmpty();
+ assertThat(holder.current().publishedAt()).isEqualTo(Instant.EPOCH);
+ assertThat(holder.isFetchStale()).isTrue();
+ assertThat(holder.isDataStale()).isTrue();
+ }
+
+ @Test
+ @DisplayName("갱신하면_두_나이가_각각_0이_된다")
+ void 갱신하면_두_나이가_각각_0이_된다() {
+ SnapshotHolder holder = 홀더(지금);
+
+ holder.replace(스냅샷(지금));
+
+ assertThat(holder.fetchAge()).isEqualTo(Duration.ZERO);
+ assertThat(holder.dataAge()).isEqualTo(Duration.ZERO);
+ }
+
+ @Test
+ @DisplayName("스케줄러가_멎으면_dataStale만_참이다")
+ void 스케줄러가_멎으면_dataStale만_참이다() {
+ // 이 노드의 갱신 루프는 멀쩡하다 — 낡은 값을 계속 잘 받아 오고 있다.
+ // 여기서 503 을 내면 전 노드가 동시에 빠져 100% 장애가 된다.
+ SnapshotHolder holder = SnapshotHolder.of(
+ FETCH_STALE, DATA_STALE, 고정시계(지금.plusSeconds(10)));
+
+ holder.replace(스냅샷(지금.minusSeconds(10)));
+
+ assertThat(holder.isFetchStale()).isFalse();
+ assertThat(holder.isDataStale()).isTrue();
+ }
+
+ @Test
+ @DisplayName("이_노드의_루프가_멎으면_fetchStale이다")
+ void 이_노드의_루프가_멎으면_fetchStale이다() {
+ MutableClock clock = MutableClock.at(지금);
+ SnapshotHolder holder = SnapshotHolder.of(FETCH_STALE, DATA_STALE, clock);
+ holder.replace(스냅샷(지금));
+
+ clock.앞으로(Duration.ofSeconds(3));
+
+ assertThat(holder.isFetchStale()).isTrue();
+ }
+
+ @Test
+ @DisplayName("경계값은_임계와_같을_때_아직_낡지_않았다")
+ void 경계값은_임계와_같을_때_아직_낡지_않았다() {
+ MutableClock clock = MutableClock.at(지금);
+ SnapshotHolder holder = SnapshotHolder.of(FETCH_STALE, DATA_STALE, clock);
+ holder.replace(스냅샷(지금));
+
+ clock.앞으로(FETCH_STALE);
+ assertThat(holder.isFetchStale()).isFalse();
+
+ clock.앞으로(Duration.ofMillis(1));
+ assertThat(holder.isFetchStale()).isTrue();
+ }
+
+ @Test
+ @DisplayName("스냅샷은_밖에서_못_바꾼다")
+ void 스냅샷은_밖에서_못_바꾼다() {
+ // **가변 맵을 넣고 원본을 흔든다.** Map.of 를 넣으면 방어 복사를
+ // 지워도 이 시험이 통과한다 — 이미 불변인 것을 다시 확인할 뿐이다.
+ Map {@link Clock#fixed} 는 못 움직여서 시각을 옮기려면 프로덕션 코드에
+ * 시험용 구멍을 내게 된다. 그 구멍은 운영에서 아무도 안 쓰지만 지워지지도
+ * 않는다 — 여기 두면 프로덕션은 {@link Clock} 만 알면 된다.
+ */
+public final class MutableClock extends Clock {
+
+ private final ZoneId zone;
+ private Instant now;
+
+ private MutableClock(Instant now, ZoneId zone) {
+ // null 이 들어오면 instant()·getZone() 이 null 을 돌려주는 시계가 된다.
+ // 그건 Clock 이 아니고, 쓰는 쪽에서 엉뚱한 NPE 로 드러난다.
+ this.now = Objects.requireNonNull(now, "now");
+ this.zone = Objects.requireNonNull(zone, "zone");
+ }
+
+ /** 주어진 시각에 멈춰 있는 UTC 시계. {@link #앞으로} 로만 움직인다. */
+ public static MutableClock at(Instant now) {
+ return new MutableClock(now, ZoneId.of("UTC"));
+ }
+
+ public void 앞으로(Duration 만큼) {
+ now = now.plus(만큼);
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return zone;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return new MutableClock(now, zone);
+ }
+
+ @Override
+ public Instant instant() {
+ return now;
+ }
+}