Repository navigation
feat(control): 스냅샷 홀더와 두 낡음 (CY-269) #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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건 전건 통과 · 도메인 브랜치 커버리지 임계 유지 | ||
| - 첫 갱신 전 둘 다 낡음 · 스케줄러만 멎은 경우 · 이 노드만 멎은 경우 · 경계값 | ||
|
|
||
| ## 다음 사람에게 | ||
|
|
||
| **"낡았다" 를 한 단어로 쓰지 마라.** 누가 멎었는지에 따라 대응이 정반대다. | ||
| 이 구분을 잃으면 헬스체크가 장애를 키우는 쪽으로 동작한다. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
29 changes: 29 additions & 0 deletions
29
src/main/java/com/kafkick/waiting/control/GatewaySnapshot.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
||
| /** | ||
| * 한 번에 통째로 갈리는 판정 재료. | ||
| * | ||
| * <p><b>키별 캐시가 아니라 통째 교체다.</b> miss 가 나면 그 순간 레디스로 요청이 | ||
| * 몰리고, 쿠폰마다 낡음 시점이 다르면 "이 판정이 얼마나 낡았나" 를 말할 수 없다. | ||
| * | ||
| * @param coupons 쿠폰별 상태. 밖에서 못 바꾼다 | ||
| * @param meta 전 쿠폰 공통 값 | ||
| * @param publishedAt <b>스케줄러가 발행한 시각.</b> 로컬 수신 시각이 아니다 — | ||
| * 그것만 쓰면 스케줄러가 죽어도 "방금 갱신했다" 고 속는다 | ||
| */ | ||
| public record GatewaySnapshot(Map<String, CouponState> 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); | ||
| } | ||
| } |
74 changes: 74 additions & 0 deletions
74
src/main/java/com/kafkick/waiting/control/SnapshotHolder.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| package com.kafkick.waiting.control; | ||
|
|
||
| import java.time.Clock; | ||
| import java.time.Duration; | ||
| import java.time.Instant; | ||
|
|
||
| /** | ||
| * 판정 재료를 들고 있고 <b>낡음을 두 종류로</b> 구분한다. | ||
| * | ||
| * <p>{@code fetchStale} 은 이 노드의 갱신 루프가 멎은 것이라 503 이고, | ||
| * {@code dataStale} 은 스케줄러가 멎은 것이라 200 을 유지한다. 섞으면 스케줄러 | ||
| * 장애가 전 게이트웨이 동시 이탈로 번져 100% 장애가 된다. | ||
| */ | ||
| public final class SnapshotHolder { | ||
|
|
||
| private final Duration fetchStaleAfter; | ||
| private final Duration dataStaleAfter; | ||
| private final Clock clock; | ||
|
|
||
| /** | ||
| * 스냅샷과 수신 시각을 <b>한 덩어리로</b> 든다. | ||
| * | ||
| * <p>둘을 따로 두면 갱신 도중 읽는 쪽이 새 스냅샷과 옛 수신 시각을 함께 | ||
| * 본다. 그러면 방금 갱신했는데도 {@code fetchStale} 이 참이 되고, 그 값이 | ||
| * 503 경로에 물려 있어 <b>정상 노드가 빠진다.</b> | ||
| */ | ||
| private record 상태(GatewaySnapshot snapshot, Instant fetchedAt) { | ||
| } | ||
|
|
||
| /** | ||
| * <b>락을 쓰지 않는다.</b> 읽는 쪽이 요청 경로라, 여기서 잠그면 판정마다 | ||
| * 경합이 생긴다. 참조 교체는 원자적이고 담긴 것이 전부 불변이라 족하다. | ||
| */ | ||
| 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; | ||
| } | ||
| } |
152 changes: 152 additions & 0 deletions
152
src/test/java/com/kafkick/waiting/control/SnapshotHolderTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
||
| /** | ||
| * 낡음을 <b>두 종류로</b> 구분한다 (4.1). | ||
| * | ||
| * <p>섞으면 스케줄러 장애가 전 게이트웨이 동시 이탈로 번져 100% 장애가 된다. | ||
| * 로컬 수신 시각만 쓰면 스케줄러가 죽어도 게이트웨이는 같은 해시를 계속 받아 | ||
| * <b>"방금 갱신했다" 고 스스로를 속인다.</b> | ||
| */ | ||
| 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<String, CouponState> 원본 = 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<String, CouponState> coupons = Map.of("c1", CouponState.always(100)); | ||
| return new GatewaySnapshot(coupons, new SnapshotMeta(1000, 3), publishedAt); | ||
| } | ||
| } | ||
51 changes: 51 additions & 0 deletions
51
src/testFixtures/java/com/kafkick/waiting/MutableClock.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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). | ||
| * | ||
| * <p>{@link Clock#fixed} 는 못 움직여서 시각을 옮기려면 프로덕션 코드에 | ||
| * <b>시험용 구멍</b>을 내게 된다. 그 구멍은 운영에서 아무도 안 쓰지만 지워지지도 | ||
| * 않는다 — 여기 두면 프로덕션은 {@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")); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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; | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.