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 coupons, SnapshotMeta meta, + Instant publishedAt) { + + /** 첫 갱신 전. {@link Instant#EPOCH} 이라 어떤 임계로도 낡음이다. */ + public static final GatewaySnapshot EMPTY = + new GatewaySnapshot(Map.of(), new SnapshotMeta(0, 1), Instant.EPOCH); + + public GatewaySnapshot { + coupons = Map.copyOf(coupons); + } +} diff --git a/src/main/java/com/kafkick/waiting/control/SnapshotHolder.java b/src/main/java/com/kafkick/waiting/control/SnapshotHolder.java new file mode 100644 index 00000000..56c94217 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/control/SnapshotHolder.java @@ -0,0 +1,74 @@ +package com.kafkick.waiting.control; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; + +/** + * 판정 재료를 들고 있고 낡음을 두 종류로 구분한다. + * + *

{@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 원본 = new HashMap<>(); + 원본.put("c1", CouponState.always(100)); + SnapshotHolder holder = 홀더(지금); + holder.replace(new GatewaySnapshot(원본, new SnapshotMeta(1000, 3), 지금)); + + 원본.put("c2", CouponState.always(50)); + 원본.remove("c1"); + + assertThat(holder.current().coupons()).containsOnlyKeys("c1"); + assertThatThrownBy(() -> holder.current().coupons().put("c2", null)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + @DisplayName("갱신_직후에는_fetchStale이_아니다") + void 갱신_직후에는_fetchStale이_아니다() { + // 스냅샷과 수신 시각을 따로 두면 읽는 쪽이 새 스냅샷과 옛 시각을 + // 함께 본다. 방금 갱신했는데 503 이 나가는 경로다. + MutableClock clock = MutableClock.at(지금); + SnapshotHolder holder = SnapshotHolder.of(FETCH_STALE, DATA_STALE, clock); + holder.replace(스냅샷(지금.minusSeconds(60))); + + clock.앞으로(Duration.ofSeconds(30)); + holder.replace(스냅샷(지금.plusSeconds(30))); + + assertThat(holder.isFetchStale()).isFalse(); + assertThat(holder.fetchAge()).isEqualTo(Duration.ZERO); + } + + @Test + @DisplayName("교체는_통째로_일어난다") + void 교체는_통째로_일어난다() { + // 키별로 갈아 끼우면 "이 판정이 얼마나 낡았나" 를 말할 수 없다. + SnapshotHolder holder = 홀더(지금); + holder.replace(스냅샷(지금)); + + holder.replace(new GatewaySnapshot(Map.of(), new SnapshotMeta(0, 1), 지금)); + + assertThat(holder.current().coupons()).isEmpty(); + } + + private static GatewaySnapshot 스냅샷(Instant publishedAt) { + Map coupons = Map.of("c1", CouponState.always(100)); + return new GatewaySnapshot(coupons, new SnapshotMeta(1000, 3), publishedAt); + } +} diff --git a/src/testFixtures/java/com/kafkick/waiting/MutableClock.java b/src/testFixtures/java/com/kafkick/waiting/MutableClock.java new file mode 100644 index 00000000..0bbec855 --- /dev/null +++ b/src/testFixtures/java/com/kafkick/waiting/MutableClock.java @@ -0,0 +1,51 @@ +package com.kafkick.waiting; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Objects; + +/** + * 시험이 앞으로 감는 시계 (TS-4). + * + *

{@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; + } +}