Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions ai/journal/2026/08/AIJ-0018-enqueue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
id: AIJ-0018
date: 2026-08-19
kind: implement
phase: 3
plan: [3.4.1, 3.4.2, 3.4.3, 3.4.4, 3.4.6]
jira: CY-231
commits: []
agent: claude-opus-5
confidence: high
promoted-to:
---

# 큐 등록 — 순서가 곧 정책이다

## 무엇을

동시 등록 검증(G3.1), 생존 신호 TTL, 큐 길이 상한. 기본 등록과 재등록 순번
유지는 [AIJ-0017](AIJ-0017-clock-monotonic.md) 에서 이미 들어갔다.

## 왜 (근거)

**자리가 둘이 되면 대기 인원이 부풀고 ETA 가 전부 틀어진다.** 조회와 등록을
나누면 그 사이에 다른 요청이 끼어들어 실제로 그렇게 된다 — Lua 로 묶는 이유가
이것이다. 100 동시 등록에 자리 정확히 1개를 확인했다.

**순번이 겹치는지도 봤다.** 같은 마이크로초에 여럿이 들어오면 score 가 같아질
수 있고, 그러면 ZSET 이 **사전순으로 재정렬해 등록 순서와 다른 줄**이 된다.
바닥값 가드가 `floor + 1` 로 밀어 주므로 200 동시 등록에서 전부 달랐다.

**생존 TTL 을 스크립트에 박지 않는다.** 폴링 간격에서 나오는 값이라(2.5.2)
박으면 둘이 갈라진다. 주입받는다.

**재등록도 생존 신호를 갱신한다.** 순번은 그대로지만 살아 있다는 신호는 새로
찍혀야 한다 — 안 그러면 **성실히 새로고침하는 사람이 이탈자로 지워진다.**

**상한 검사는 재등록 판정보다 뒤다.** 순서가 곧 정책이다. 앞에 두면 줄이
길어졌을 때 **이미 선 사람이 재등록하다 쫓겨난다.** 줄이 길어진 것은 그 사람
잘못이 아니다.

## 고려했으나 택하지 않은 것

- **상한 초과 시 오류 반환** — 호출부가 예외로 다루게 된다. 그런데 큐가 꽉
찬 것은 **정상 실패**지 오류가 아니다 (EX-1). `-1` 을 순번 자리에 담아
판정값처럼 다루게 했다.
- **`alive` 를 별도 스크립트로** — 왕복이 하나 는다. 등록과 생존 신호가
갈리면 **한쪽만 성공한 상태**가 생기고, 그때 방금 등록한 사람이 이탈자로
판정된다.

## 확신이 낮은 부분

- **`-1` 을 순번 자리에 담는 것이 읽기 좋은지 모르겠다.** 반환 배열의 첫 칸이
때로는 순번이고 때로는 거부 신호다. 어댑터가 감싸면 밖에서는 안 보이겠지만,
스크립트만 읽는 사람에게는 헷갈릴 수 있다.
- **상한 검사가 `ZCARD` 를 매번 부른다.** O(1) 이라 지금은 문제없지만, 등록
경로에 명령이 하나 는 것은 사실이다. 부하 시험에서 다시 본다.

## 검증

- 통합 28건 전건 통과
- **G3.1 — 같은 사용자 100 동시 등록에 자리 정확히 1개**
- 200 동시 등록에서 순번 중복 0
- 생존 TTL 주입·재등록 갱신·상한 거부·이미 선 사람 보호

## 다음 사람에게

**이 스크립트에서 순서를 바꾸지 마라.** 재등록 판정이 상한 검사보다 앞인 것은
취향이 아니다. 뒤집으면 줄이 꽉 찼을 때 이미 선 사람이 새로고침 한 번에 자리를
잃는다 — 그건 사용자가 가장 억울해하는 종류의 실패다.

판정 사다리(Phase 2)와 같은 원리다. **순서가 곧 정책이다.**
104 changes: 104 additions & 0 deletions ai/journal/2026/08/AIJ-0019-library-first.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
---
id: AIJ-0019
date: 2026-08-19
kind: decision
phase: 3
plan: []
jira: CY-231
commits: []
agent: claude-opus-5
confidence: medium
promoted-to: DS-8
---

# 라이브러리를 먼저 본다 — 그리고 안 쓴 이유를 남긴다

## 무엇을

`DS-8` 을 규칙으로 세웠다. 직접 만들기 전에 라이브러리를 찾고, 안 쓸 거면
근거를 여기 남긴다. 고정한 판들을 Maven Central 로 실측해 갱신했다.

## 왜 (근거)

**`SecondWindowLimiter` 를 두고 "라이브러리 없냐" 는 질문을 받았다.** 있다 —
`resilience4j-ratelimiter` 와 Bucket4j 가 정확히 그 일을 한다. 그런데 **왜 안
썼는지가 어디에도 안 적혀 있었다.** 그건 다음 사람이 반드시 묻는 질문이고,
기록이 없으면 같은 검토를 처음부터 다시 한다.

**우리가 쓴 것은 우리가 고쳐야 한다.** 그 시간은 이 제품이 실제로 어려운
곳 — 공정성과 정합성 — 에 쓰여야지 리트라이나 서킷을 다시 만드는 데 쓰이면
안 된다.

**논블로킹이 고르는 기준의 앞에 온다.** WebFlux 위에 서 있으므로 블로킹 API
하나가 이벤트 루프를 잡으면 전체 처리량이 무너진다. 편의 하나 얻자고 그걸
치르지 않는다.

## `SecondWindowLimiter` 를 직접 만든 이유

셋이고, 앞의 둘은 라이브러리가 **못 하는** 것이다.

**① 두 예산을 전부-아니면-전무로 차감해야 한다.** `resilience4j` 의
`RateLimiter` 는 획득 실패 시 되돌리는 API 가 없다. 나눠 치면 **통과하지 않은
요청이 앞엣것의 예산을 깎고**, 그 유실은 부하 시험 전까지 안 보인다. 이게
`G2.12` 가 지키는 성질이다.

**② 시각을 주입받아야 한다.** `AtomicRateLimiter` 는 내부에서 `nanoTime()` 을
쓴다. 그러면 **초 경계 동작을 시험할 수 없고** 도메인 순수성(DS-1)도 깨진다.
Bucket4j 는 `TimeMeter` 로 시계를 넣을 수 있어 이 항목은 통과하는데, ① 은
여전히 못 한다.

**③ 상한이 매 틱·쿠폰마다 바뀐다.** 인스턴스 설정이 아니라 **호출 인자**여야
한다. `changeLimitForPeriod()` 로 흉내 낼 수는 있지만 쿠폰 수만큼 인스턴스를
만들고 매 틱 갱신해야 하고, 그러면 키 상한(`maxKeys`)을 또 우리가 만들어야
한다.

**반대로 서킷·격벽·타임아웃은 직접 만들지 않는다.** `resilience4j` 를 쓴다 —
계획서가 이미 그렇게 적어 뒀고(Phase 6), 그쪽은 라이브러리가 우리보다 낫다.

## 판을 실측해서 골랐다

문서나 기억에 있는 판을 적지 않는다. Maven Central 을 조회했고 셋이 낡아
있었다.

| | 전 | 후 |
|---|---|---|
| `archunit-junit5` | 1.3.0 | **1.3.2** |
| `pitest-junit5-plugin` | 1.2.1 | **1.2.2** |
| JaCoCo | 0.8.12 | **0.8.13** |

`testcontainers` 1.21.3 과 `gradle-pitest-plugin` 1.15.0 은 이미 최신이었다.
`spring-cloud-dependencies` 는 우리 판(2025.1.2)이 중앙 색인의 최신(2025.0.0)
보다 앞서 있어 그대로 뒀다.

**`resilience4j-spring-boot4` 는 없다.** Boot 4 용 스타터가 아직 안 나와서,
Phase 6 에서 쓸 때 **코어 모듈(`resilience4j-ratelimiter`·`-reactor`)을 직접**
써야 한다. 스타터를 기대하고 계획을 짜면 그때 막힌다.

## 고려했으나 택하지 않은 것

- **Bucket4j 로 갈아타기** — `TimeMeter` 로 시계 주입이 되니 ② 는 풀린다.
그런데 ① 이 안 되고, 토큰 버킷은 우리가 원하는 **초 단위 고정 윈도우**와
의미가 다르다. 배분이 초 단위로 오는데 버킷이 그걸 흐리면 상한 계산과
실제 통과량이 갈린다.
- **`resilience4j` 를 지금 넣기** — 아직 쓸 데가 없다. 안 쓰는 의존성은 빌드를
무겁게 하고 어느 페이즈가 무엇을 요구하는지를 흐린다 (build.gradle 의 규칙).

## 확신이 낮은 부분

- **`resilience4j` 코어 모듈이 Boot 4·Reactor 3.7 과 실제로 맞는지 안 봤다.**
스타터가 없다는 것만 확인했다. Phase 6 착수 전에 붙여 봐야 한다.
- **판 실측을 사람이 기억해서 해야 한다.** Dependabot 이 PR 을 열지만 우리가
고정한 판(`toolVersion` 같은 것)은 안 본다. 검사로 만들 자리일 수 있다.

## 검증

- 판 올림 후 단위 234건 · 통합 28건 전건 통과
- 뮤테이션 생존 2.8% 유지

## 다음 사람에게

**"라이브러리 있지 않아?" 라는 질문에 답할 수 있어야 한다.** 답이 "있는데 안
썼다" 여도 괜찮다 — 근거가 있으면. 근거가 없으면 그건 검토를 안 한 것이다.

그리고 **없는 것을 못 만드는 것과, 있는데 안 쓰는 것은 다르다.** 전자는 무지고
후자는 판단이다. 판단이었다는 증거가 이 문서다.
2 changes: 2 additions & 0 deletions ai/journal/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

| ID | 날짜 | 종류 | 제목 | 확신 | 승격 |
|---|---|---|---|---|---|
| [AIJ-0019](2026/08/AIJ-0019-library-first.md) | 2026-08-19 | decision | 라이브러리를 먼저 본다 — 안 쓴 이유를 남긴다 | medium | DS-8 |
| [AIJ-0018](2026/08/AIJ-0018-enqueue.md) | 2026-08-19 | implement | 큐 등록 — 순서가 곧 정책이다 | high | — |
| [AIJ-0017](2026/08/AIJ-0017-clock-monotonic.md) | 2026-08-19 | implement | 시계가 뒤로 가도 추월시키지 않는다 | high | — |
| [AIJ-0016](2026/08/AIJ-0016-workflow-hygiene.md) | 2026-08-19 | implement | 리뷰 중계가 코멘트마다 돌던 것 | high | — |
| [AIJ-0015](2026/08/AIJ-0015-key-scheme-and-shard-hash.md) | 2026-08-19 | implement | 키 스킴 — 한 번 정하면 못 바꾸는 것 | high | — |
Expand Down
1 change: 1 addition & 0 deletions ai/rules/00-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
| JS-14 | 유틸리티·중첩 클래스는 `static` | ✅ |
| DS-1 | 도메인은 Spring·Redis·시계를 참조하지 않는다 | ✅ |
| DS-2 | 도달 불가 상태를 만들 수 있는 public 생성자 금지 | — |
| DS-8 | 직접 만들기 전에 라이브러리부터 찾는다. 판은 실측해 고른다 | — |
| RX-1 | 블로킹 호출 금지 | ✅ |
| RX-4 | `subscribe()` 결과를 버리지 않는다 | — |
| TS-1 | 구현보다 테스트를 먼저 커밋한다 | — |
Expand Down
45 changes: 45 additions & 0 deletions ai/rules/20-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,48 @@ class RedisQueueRepository implements QueueRepository { ... }

예외: `plan/`에 **명시적으로 예정된** 확장(위 표의 "두 번째 사례")은
그 페이즈에서 도입한다. 계획에 없는 확장은 만들지 않는다.

---

## 6. 잘 만들어진 라이브러리를 먼저 본다

**DS-8 · MUST · 직접 만들기 전에 라이브러리부터 찾는다**

리트라이·서킷·격벽·타임아웃·리미터·재시도 백오프처럼 **이미 잘 풀린 문제**를
다시 구현하지 않는다. 우리가 쓴 것은 우리가 고쳐야 하고, 그 시간은 이 제품이
실제로 어려운 곳(공정성·정합성)에 쓰여야 한다.

**논블로킹이 전제다.** 이 게이트웨이는 WebFlux 위에 서 있으므로 고르는 기준에
**리액티브 지원**이 먼저 온다. 블로킹 API 하나가 이벤트 루프를 잡으면 전체
처리량이 무너진다 — 편의 하나 얻자고 그걸 치르지 않는다 (RX-1).

| 필요 | 먼저 볼 것 |
|---|---|
| 서킷·격벽·타임아웃·리트라이 | `resilience4j` (+ `resilience4j-reactor`) |
| 지표 | Micrometer |
| 컨테이너 기반 시험 | Testcontainers |
| 리액티브 조합·백프레셔 | Reactor 연산자. 직접 스레드를 만들지 않는다 |

**직접 만들려면 근거를 journal 에 남긴다.** 아래 셋 중 하나에 해당하고, 그
사실이 기록돼 있어야 한다.

1. **의미가 다르다** — 라이브러리가 못 하는 계약이 필요하다
2. **순수성을 깬다** — 도메인에 프레임워크·시계가 들어온다 (DS-1)
3. **판이 안 맞는다** — 지원하는 런타임 판이 우리와 다르다

> **판은 실측해서 고른다.** 문서나 기억에 있는 판을 적지 않는다 —
> Maven Central 을 조회해 그 시점의 최신을 확인하고, 우리 런타임과 맞는지까지
> 본다. Boot 4 처럼 새 판에서는 **스타터가 아직 없어 코어 모듈만 쓸 수도** 있다.

### 실제 사례 — `SecondWindowLimiter`

`resilience4j-ratelimiter` 가 있는데 직접 만들었다. 위 1·2 둘 다에 해당한다.

- **두 예산을 전부-아니면-전무로** 차감해야 하는데 라이브러리에 그 계약이 없다.
나눠 치면 통과하지 않은 요청이 예산을 깎고, 그 유실은 부하 시험 전까지 안
보인다 (G2.12)
- **상한이 매 틱·쿠폰마다 바뀐다.** 인스턴스 설정이 아니라 **호출 인자**여야 한다
- **시각을 주입받아야 한다.** 라이브러리는 내부 시계를 쓰는데 그러면 초 경계
동작을 시험할 수 없다 (DS-1 · TS-4)

근거: [AIJ-0010](../journal/2026/08/AIJ-0010-domain-state-and-limiter.md)
6 changes: 3 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ dependencies {

testImplementation 'org.springframework.boot:spring-boot-starter-test'
// 도메인 순수성은 리뷰로 못 지킨다. 한 번 깨지면 조용히 번진다.
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.2'
// 인메모리 대역으로는 Lua 의 복제 동작도 시계도 확인할 수 없다 (TS-3).
// 이 페이즈가 지키려는 것이 정확히 그 둘이라 실물로 붙는다.
testImplementation 'org.testcontainers:junit-jupiter'
Expand Down Expand Up @@ -107,7 +107,7 @@ tasks.named('test') {
// 임계 미달이면 build 가 실패한다. "확인했다" 는 통과가 아니다.

jacoco {
toolVersion = '0.8.12'
toolVersion = '0.8.13'
}

tasks.named('jacocoTestReport') {
Expand Down Expand Up @@ -177,5 +177,5 @@ pitest {
timestampedReports = false
// XML 이 있어야 생존 뮤턴트를 기계로 짚는다. HTML 만 두면 사람이 눈으로 센다.
outputFormats = ['HTML', 'XML']
junit5PluginVersion = '1.2.1'
junit5PluginVersion = '1.2.2'
}
52 changes: 43 additions & 9 deletions src/main/resources/redis/enqueue.lua
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
-- 큐 등록. 조회와 등록을 나누면 새로고침 연타에 항목이 둘 생긴다.
--
-- KEYS[1] queue:{cid} ZSET. score = Redis TIME 의 마이크로초
-- KEYS[2] maxscore:{cid} 시계 역행 방어용 바닥값
-- KEYS[1] queue:{cid} ZSET. score = Redis TIME 의 마이크로초
-- KEYS[2] maxscore:{cid} 시계 역행 방어용 바닥값
-- KEYS[3] alive:{cid}:{member} 생존 신호. 폴링이 곧 하트비트다
-- ARGV[1] memberId
-- ARGV[2] maxscore TTL(초). 양의 정수
-- ARGV[3] alive TTL(초). 양의 정수. 폴링 간격에서 나온 값이라 주입받는다
-- ARGV[4] 큐 길이 상한. 0 이면 상한 없음
--
-- 반환 {score, floorApplied, alreadyQueued}
-- score 이 사람의 순번
-- score 이 사람의 순번. 거부되면 '-1'
-- floorApplied 바닥값이 적용됐는가. 1 이면 시계가 뒤로 갔다는 뜻이다
-- alreadyQueued 이미 줄에 있었는가. 1 이면 순번을 그대로 돌려준 것이다
--
Expand All @@ -19,18 +22,42 @@
-- **쓰기 전에 인자를 검증한다.** Lua 는 중간 오류를 되돌리지 않는다 —
-- ZADD 뒤에서 SET 이 터지면 "같이 남거나 같이 사라진다" 는 계약이 깨지고
-- maxscore 없는 ZSET 이 남는다.
local ttl = tonumber(ARGV[2])
if ttl == nil or ttl < 1 or ttl ~= math.floor(ttl) then
return redis.error_reply('TTL 은 양의 정수여야 한다: ' .. tostring(ARGV[2]))
local function positive_int(value, name)
local n = tonumber(value)
if n == nil or n < 1 or n ~= math.floor(n) then
return nil, name .. ' 은 양의 정수여야 한다: ' .. tostring(value)
end
return n
end

local scoreTtl, err = positive_int(ARGV[2], 'maxscore TTL')
if not scoreTtl then return redis.error_reply(err) end

local aliveTtl
aliveTtl, err = positive_int(ARGV[3], 'alive TTL')
if not aliveTtl then return redis.error_reply(err) end

local maxLen = tonumber(ARGV[4])
if maxLen == nil or maxLen < 0 or maxLen ~= math.floor(maxLen) then
return redis.error_reply('큐 길이 상한은 0 이상 정수여야 한다: ' .. tostring(ARGV[4]))
end

-- **이미 줄에 있으면 그 순번을 지킨다.** 덮어쓰면 새로고침 연타가 자기
-- 자신을 뒤로 민다 — 사용자는 기다릴수록 손해라고 배운다.
--
-- 상한 검사보다 앞이다. 이미 선 사람을 상한 때문에 쫓아내면, 줄이 길어진
-- 것이 그 사람 잘못이 아닌데 그가 자리를 잃는다.
local existing = redis.call('ZSCORE', KEYS[1], ARGV[1])
if existing then
redis.call('SET', KEYS[3], '1', 'EX', aliveTtl)
return {existing, 0, 1}
end

-- 2차 방어다. 1차는 도메인이 낡은 스냅샷으로 판정하므로 여기서 한 번 더 본다.
if maxLen > 0 and redis.call('ZCARD', KEYS[1]) >= maxLen then
return {'-1', 0, 0}
end

local now = redis.call('TIME')
local score = tonumber(now[1]) * 1000000 + tonumber(now[2])

Expand All @@ -41,9 +68,16 @@ if floor >= score then
applied = 1
end

-- 여기서부터는 둘 다 성공한다. Lua 는 효과 기반 복제라 ZADD 와 SET 이
-- 같이 남거나 같이 사라진다 — maxscore 가 ZSET 보다 뒤처지지 않는다.
-- **복제 단위로는 함께 움직인다.** Lua 는 효과 기반 복제라 이 스크립트가
-- 남긴 쓰기는 복제본과 AOF 에 통째로 가거나 통째로 안 간다 — maxscore 가
-- ZSET 보다 뒤처진 채 복제되는 상태는 없다.
--
-- **다만 스크립트 안의 롤백은 없다.** 아래 세 명령 중 하나가 런타임 오류를
-- 내면 앞의 것은 그대로 남는다. 그래서 실패할 수 있는 것(인자 검증)을 전부
-- 위로 올려 뒀다 — 여기 도달하면 남는 실패 경로는 메모리 부족뿐이고,
-- 그건 maxmemory 로 막는다.
redis.call('ZADD', KEYS[1], score, ARGV[1])
redis.call('SET', KEYS[2], score, 'EX', ttl)
redis.call('SET', KEYS[2], score, 'EX', scoreTtl)
redis.call('SET', KEYS[3], '1', 'EX', aliveTtl)

return {tostring(score), applied, 0}
Loading
Loading