diff --git a/ai/journal/2026/08/AIJ-0021-sweep.md b/ai/journal/2026/08/AIJ-0021-sweep.md
new file mode 100644
index 00000000..2a97dbf6
--- /dev/null
+++ b/ai/journal/2026/08/AIJ-0021-sweep.md
@@ -0,0 +1,136 @@
+---
+id: AIJ-0021
+date: 2026-08-20
+kind: implement
+phase: 3
+plan: [3.6.1, 3.6.2, 3.6.3, 3.6.4]
+jira: CY-233
+commits: []
+agent: claude-opus-5
+confidence: medium
+promoted-to:
+---
+
+# 이탈자 청소 — 앞부분만 본다
+
+## 무엇을
+
+`sweep.lua`. 생존 신호가 끊긴 앞부분 K 명을 큐에서 빼고 유예 기록으로 옮긴다.
+만료된 기록은 함께 지운다.
+
+## 왜 (근거)
+
+**전체를 훑지 않는다.** 2만 명 큐에서 매 틱 전원을 보면 **청소 자체가 부하**다.
+앞부분만 봐도 되는 이유는 이탈이 앞에서부터 드러나기 때문이다 — 뒤엣사람은
+아직 폴링할 차례가 안 왔을 뿐 죽은 것이 아니다.
+
+**제거와 기록이 갈리면** 자리도 잃고 재방문자로도 식별 안 되는 사람이 생긴다.
+한쪽만 성공한 상태를 없애려고 같은 스크립트에 둔다.
+
+**자리는 보관하지 않는다** (D-11). 유예 기록은 재방문자 식별용이고, 돌아오면
+줄 맨 뒤에 선다. 보관하면 이탈자가 돌아올 때마다 성실히 기다린 사람이 밀린다.
+
+**유예 해시가 무한히 자라지 않게 한다** (RD-7). 해시는 **필드별 TTL 을 못
+걸어서** 값에 시각을 담고 이 스크립트가 직접 지운다. 청소하는 김에 같이 한다 —
+따로 도는 것을 하나 더 만들면 그것도 관리 대상이다.
+
+**값이 깨진 기록도 지운다.** 숫자가 아니면 언제 것인지 알 수 없고, 남겨 두면
+**영원히 안 지워진다.**
+
+**시각을 주입받는다.** `TIME` 을 쓰면 복제본마다 다른 값을 보고, 테스트에서
+만료를 확인할 수도 없다 (TS-4).
+
+## 고려했으나 택하지 않은 것
+
+- **전체 훑기** — 정확하다. 그런데 2만 명 × 쿠폰 수를 매 틱 보는 것은
+ 대기열이 커질수록 나빠진다. K 를 인자로 받아 **부하와 정확도를 운영자가
+ 맞바꾸게** 했다 (P-1).
+- **유예 만료를 별도 작업으로** — 청소와 무관해 보인다. 그런데 그러면 도는
+ 것이 하나 더 늘고, 둘 다 같은 해시를 만져 경합이 생긴다.
+- **`alive` 없는 사람을 즉시 삭제** — 유예를 안 남기면 재방문자를 식별할 수
+ 없다. 그건 Phase 7 의 재방문 처리를 통째로 막는다.
+
+## 리뷰가 짚은 것 — 상한이 절반만 걸려 있었다
+
+**`K` 를 작게 줘도 어딘가에서 전체를 훑으면 그 `K` 는 아무 의미가 없다.**
+처음 판은 `ZRANGEBYSCORE` 로 살아 있는 쪽을 **전부** 받아 집합을 만들었다.
+`K=1` 이어도 `alive` ZSET 크기에 비례해 이벤트 루프를 잡는다.
+
+`ZMSCORE` 로 **앞부분 K 명의 score 만** 묻는다. 만료 신호 정리도
+`ZRANGE ... BYSCORE LIMIT` 으로 예산 안에서만 걷는다.
+
+**`HSCAN COUNT` 는 상한이 아니라 힌트다.** 해시가 조밀하게 인코딩돼 있으면
+한 번에 `budget` 보다 많이 돌아온다. 받은 것을 다 지우면 예산이 있으나 마나라,
+**받은 것 중 예산만큼만** 지우고 나머지는 다음 호출로 미룬다.
+
+**커서도 쓰기 전에 검증한다.** 형식이 틀리면 `HSCAN` 이 오류를 내는데 그때는
+이미 앞의 쓰기가 끝나 있다. Lua 는 롤백하지 않는다 — 이 페이즈에서 같은
+실수를 세 번째 한 자리다.
+
+## 검사가 검사 구실을 못 하던 것
+
+**`KEYS` 정적 검사가 지역 변수를 무조건 허용했다.**
+`local rogue = 'queue:{c1}'` 를 거쳐 들어오면 통과한다 — 검사가 있는데
+아무것도 안 보는 상태다. `KEYS[n]` 이 **대입된** 변수만 추적하게 고쳤다.
+
+**계약 주석 검사가 파일 전체에서 `KEYS[1]` 을 찾았다.** 코드에서 쓰는 것과
+계약을 적어 둔 것은 다르다. 상단 주석 블록만 보고 `ARGV[1]` 도 함께 본다.
+
+**하드코딩 키 검사가 줄 주석만 걷어냈다.** Javadoc 안의 예시가 위반으로
+잡힌다 — 오탐이 나면 사람은 검사를 고치는 대신 우회한다.
+
+## 확신이 낮은 부분
+
+- **K 의 기본값이 없다.** 인자로 받게만 해 뒀다. 무엇에서 나와야 하는지는
+ 아래에 적어 뒀지만 대입할 수치가 실측 전이다.
+- **커서를 호출부가 들고 있어야 한다.** 스크립트가 다음 커서를 돌려주지만
+ 그걸 다음 틱에 넘기는 것은 스케줄러 몫이다 (Phase 4). 안 넘기면 앞부분만
+ 계속 보고 뒤는 영영 안 지워진다.
+
+## K 를 무엇으로 정하는가
+
+숫자는 없지만 기준은 셋이다. **하한 하나와 상한 둘**이고, 상한 중 하나는
+성능이 아니라 정합성 제약이라 이쪽이 먼저 걸린다.
+
+**하한 — 다음 틱에 입장시킬 구간은 덮어야 한다.**
+이탈자가 큐에 남아도 뒷사람을 막지는 않는다. 입장은 pop 이 아니라
+`admitted` 워터마크 비교라 그냥 지나간다. 대신 **워터마크가 유령 위를
+지나가면 그 입장분이 버려진다** — 뒷단 가용량을 안 온 사람에게 쓴 것이다.
+그러니 봐야 할 구간은 "앞에서 적당히" 가 아니라 **배분기가 다음 틱에
+워터마크를 밀어 넘길 그 구간**이다.
+
+ K ≳ (틱당 배분량) × (1 + 여유)
+
+여유는 이탈자가 뭉친 경우와 틱 지터를 흡수한다. 그리고 **청소가 배분보다
+먼저** 같은 틱 안에서 돌아야 한다 — 순서가 뒤집히면 이번 틱은 유령에게
+배분하고 다음 틱에 치운다. 이보다 깊이 봐도 이득은 적다. 더 뒤의 이탈자는
+앞으로 올라오며 걸리고, 그전까지는 ETA 만 부풀릴 뿐 용량을 안 먹는다.
+
+**상한 1 — 밴드 경계를 넘으면 단일 임계가 성립하지 않는다.**
+`alive` score 는 사람마다 자기 밴드의 TTL 로 찍히는데 (`PollIntervalPolicy`
+기준 1·3·10·30 초, TTL 은 그 3 배에 하한 30 초), 청소는 `at < now` 하나로만
+판정한다. 그 판정이 옳으려면 **보는 구간의 폴링 리듬이 균질해야 한다.**
+K 가 ETA 5 초 밴드를 넘어 내려가면 30 초마다 두드리는 게 정상인 사람이 같은
+잣대에 걸린다 — 불변식 4 가 깨지는 자리다. **K 는 가장 앞 밴드 안에 머무는
+것이 안전선**이고, 넘겨야 한다면 K 를 키울 게 아니라 밴드별 임계를 나눈다.
+
+**상한 2 — 스크립트 하나가 이벤트 루프를 잡는 시간.**
+`ZRANGE K` + `ZMSCORE K` + `ZREM` 이라 O(K) 다. 실무적으로 가장 헐거운
+제약이다. 20,000 동시 대기(R4)에서 나올 K 는 수백~수천이고 그 정도는
+서브밀리초라, 상한 1 에 먼저 걸린다.
+
+**재려면 둘이 필요하다** — 이탈률, 그리고 실제 폴링 간격 분포. 후자는
+백그라운드 탭이 분당 1 회로 스로틀되는 것이 `MIN_ALIVE_TTL_SEC = 30` 의
+근거인데 그 30 이 충분한지가 아직 미검증이다.
+
+## 검증
+
+- 통합 9건 전건 통과
+- 앞부분만 제거 · 생존 신호 있으면 유지 · K 가 인자로 동작
+- 제거와 기록이 함께 · 만료·깨진 값 정리 · 잘못된 인자에 무변경
+
+## 다음 사람에게
+
+**"전체를 보면 정확하다" 가 늘 맞지는 않는다.** 이 청소는 매 틱 도는 배경
+작업이라, 정확도를 조금 얻자고 부하를 크게 치르면 그 부하가 다시 이탈을
+만든다. 앞부분만 보는 것은 타협이 아니라 **부하를 유계로 두려는 설계**다.
diff --git a/ai/journal/index.md b/ai/journal/index.md
index 8975eaec..47eb7bfb 100644
--- a/ai/journal/index.md
+++ b/ai/journal/index.md
@@ -8,6 +8,7 @@
| ID | 날짜 | 종류 | 제목 | 확신 | 승격 |
|---|---|---|---|---|---|
+| [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 | — |
| [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 | — |
diff --git a/src/main/java/com/kafkick/waiting/adapter/redis/RedisKeys.java b/src/main/java/com/kafkick/waiting/adapter/redis/RedisKeys.java
index 36f63c97..5ef7cebb 100644
--- a/src/main/java/com/kafkick/waiting/adapter/redis/RedisKeys.java
+++ b/src/main/java/com/kafkick/waiting/adapter/redis/RedisKeys.java
@@ -52,9 +52,16 @@ public static String grace(String couponId, int shards, int shard) {
return "grace:{" + tag(couponId, shards, shard) + "}";
}
- /** 생존 신호. 폴링이 곧 하트비트다. */
- public static String alive(String couponId, int shards, int shard, String memberId) {
- return "alive:{" + tag(couponId, shards, shard) + "}:" + validated(memberId, "memberId");
+ /**
+ * 생존 신호. 폴링이 곧 하트비트다.
+ *
+ *
사람마다 키를 만들지 않는다. 그러면 청소 스크립트가 KEYS 에
+ * 선언되지 않은 키를 만지게 되고 클러스터가 거부한다 (RD-1). 쿠폰당 ZSET
+ * 하나에 만료 시각을 score 로 담는다 — 개별 TTL 은 잃지만 청소가
+ * 어차피 만료를 보므로 잃는 것이 없다.
+ */
+ public static String alive(String couponId, int shards, int shard) {
+ return "alive:{" + tag(couponId, shards, shard) + "}";
}
/**
diff --git a/src/main/resources/redis/enqueue.lua b/src/main/resources/redis/enqueue.lua
index 37e4793e..c440d818 100644
--- a/src/main/resources/redis/enqueue.lua
+++ b/src/main/resources/redis/enqueue.lua
@@ -2,11 +2,12 @@
--
-- KEYS[1] queue:{cid} ZSET. score = Redis TIME 의 마이크로초
-- KEYS[2] maxscore:{cid} 시계 역행 방어용 바닥값
--- KEYS[3] alive:{cid}:{member} 생존 신호. 폴링이 곧 하트비트다
+-- KEYS[3] alive:{cid} 생존 신호 ZSET. score 는 만료 시각(초)
-- ARGV[1] memberId
-- ARGV[2] maxscore TTL(초). 양의 정수
-- ARGV[3] alive TTL(초). 양의 정수. 폴링 간격에서 나온 값이라 주입받는다
-- ARGV[4] 큐 길이 상한. 0 이면 상한 없음
+-- ARGV[5] 지금 시각(초). 생존 신호의 만료 시각을 계산한다
--
-- 반환 {score, floorApplied, alreadyQueued}
-- score 이 사람의 순번. 거부되면 '-1'
@@ -37,6 +38,11 @@ local aliveTtl
aliveTtl, err = positive_int(ARGV[3], 'alive TTL')
if not aliveTtl then return redis.error_reply(err) end
+local now = tonumber(ARGV[5])
+if now == nil or now < 0 then
+ return redis.error_reply('시각은 0 이상이어야 한다: ' .. tostring(ARGV[5]))
+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]))
@@ -49,7 +55,7 @@ end
-- 것이 그 사람 잘못이 아닌데 그가 자리를 잃는다.
local existing = redis.call('ZSCORE', KEYS[1], ARGV[1])
if existing then
- redis.call('SET', KEYS[3], '1', 'EX', aliveTtl)
+ redis.call('ZADD', KEYS[3], now + aliveTtl, ARGV[1])
return {existing, 0, 1}
end
@@ -58,8 +64,10 @@ 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])
+-- 이름을 now 와 겹치지 않게 둔다. 주입받은 시각(초)과 Redis 시계(μs)는
+-- 단위도 출처도 다른 값이라 한 이름을 쓰면 조용히 섞인다.
+local redisTime = redis.call('TIME')
+local score = tonumber(redisTime[1]) * 1000000 + tonumber(redisTime[2])
local floor = tonumber(redis.call('GET', KEYS[2]) or 0)
local applied = 0
@@ -78,6 +86,6 @@ end
-- 그건 maxmemory 로 막는다.
redis.call('ZADD', KEYS[1], score, ARGV[1])
redis.call('SET', KEYS[2], score, 'EX', scoreTtl)
-redis.call('SET', KEYS[3], '1', 'EX', aliveTtl)
+redis.call('ZADD', KEYS[3], now + aliveTtl, ARGV[1])
return {tostring(score), applied, 0}
diff --git a/src/main/resources/redis/queue_status.lua b/src/main/resources/redis/queue_status.lua
index ff27929a..d4aa35e8 100644
--- a/src/main/resources/redis/queue_status.lua
+++ b/src/main/resources/redis/queue_status.lua
@@ -2,10 +2,11 @@
--
-- KEYS[1] queue:{cid} ZSET
-- KEYS[2] admitted:{cid} 입장 임계. 개수가 아니라 score 값이다 (D-8)
--- KEYS[3] alive:{cid}:{member} 생존 신호
+-- KEYS[3] alive:{cid} 생존 신호 ZSET. score 는 만료 시각(초)
-- KEYS[4] grace:{cid} 이탈 기록 해시
-- ARGV[1] memberId
-- ARGV[2] alive TTL(초). 양의 정수
+-- ARGV[3] 지금 시각(초)
--
-- 반환 {state, rank, score}
-- state 'WAITING' | 'ADMITTED' | 'NOT_QUEUED'
@@ -21,6 +22,11 @@ if ttl == nil or ttl < 1 or ttl ~= math.floor(ttl) then
return redis.error_reply('alive TTL 은 양의 정수여야 한다: ' .. tostring(ARGV[2]))
end
+local now = tonumber(ARGV[3])
+if now == nil or now < 0 then
+ return redis.error_reply('시각은 0 이상이어야 한다: ' .. tostring(ARGV[3]))
+end
+
local score = redis.call('ZSCORE', KEYS[1], ARGV[1])
if not score then
-- **0번째와 구분한다.** 없는 것과 맨 앞인 것은 다르다. 뭉치면 유실된
@@ -29,12 +35,13 @@ if not score then
end
-- 폴링이 곧 생존 신호다. 조회한 김에 갱신한다 — 왕복을 늘리지 않는다.
-redis.call('SET', KEYS[3], '1', 'EX', ttl)
+redis.call('ZADD', KEYS[3], now + ttl, ARGV[1])
local admitted = tonumber(redis.call('GET', KEYS[2]) or -1)
if admitted >= 0 and tonumber(score) <= admitted then
-- 차례가 왔다. 큐에서 빼지 않으면 대기 인원이 계속 부풀고 ETA 가 틀어진다.
redis.call('ZREM', KEYS[1], ARGV[1])
+ redis.call('ZREM', KEYS[3], ARGV[1])
redis.call('HSET', KEYS[4], ARGV[1], 'admitted')
return {'ADMITTED', 0, score}
end
diff --git a/src/main/resources/redis/sweep.lua b/src/main/resources/redis/sweep.lua
new file mode 100644
index 00000000..49a9b958
--- /dev/null
+++ b/src/main/resources/redis/sweep.lua
@@ -0,0 +1,111 @@
+-- 이탈자 청소. **한 번의 실행이 하는 일에 상한을 둔다.**
+--
+-- KEYS[1] queue:{cid} ZSET
+-- KEYS[2] grace:{cid} 이탈 기록 해시
+-- KEYS[3] alive:{cid} 생존 신호 ZSET. score 는 만료 시각(초)
+-- ARGV[1] 검사 범위 K. 큐 앞에서 이만큼만 본다
+-- ARGV[2] 지금 시각(초). 도메인처럼 주입받는다
+-- ARGV[3] 유예 보관 기간(초)
+-- ARGV[4] 정리 예산. 만료 신호와 유예 기록을 각각 이만큼까지 지운다
+-- ARGV[5] HSCAN 커서. 첫 호출은 '0'. 반환된 값을 다음에 넘긴다
+--
+-- 반환 {swept, expiredSignals, expiredGrace, nextCursor}
+--
+-- **키를 문자열로 조립하지 않는다.** 사람마다 alive 키를 만들면 KEYS 에
+-- 선언되지 않은 키를 만지게 되고 클러스터가 거부한다 (RD-1).
+--
+-- **모든 순회에 상한이 걸려 있어야 한다.** Lua 는 통째로 도는 동안 다른
+-- 요청을 전부 막는다. K 를 작게 줘도 어딘가에서 전체를 훑으면 그 K 는
+-- 아무 의미가 없다.
+
+local limit = tonumber(ARGV[1])
+if limit == nil or limit < 1 or limit ~= math.floor(limit) then
+ return redis.error_reply('검사 범위는 양의 정수여야 한다: ' .. tostring(ARGV[1]))
+end
+
+local now = tonumber(ARGV[2])
+if now == nil or now < 0 then
+ return redis.error_reply('시각은 0 이상이어야 한다: ' .. tostring(ARGV[2]))
+end
+
+local retention = tonumber(ARGV[3])
+if retention == nil or retention < 1 or retention ~= math.floor(retention) then
+ return redis.error_reply('유예 보관 기간은 양의 정수여야 한다: ' .. tostring(ARGV[3]))
+end
+
+local budget = tonumber(ARGV[4])
+if budget == nil or budget < 1 or budget ~= math.floor(budget) then
+ return redis.error_reply('정리 예산은 양의 정수여야 한다: ' .. tostring(ARGV[4]))
+end
+
+-- **커서도 쓰기 전에 본다.** 형식이 틀리면 HSCAN 이 오류를 내는데, 그때는
+-- 이미 앞의 쓰기가 끝나 있다. Lua 는 롤백하지 않는다.
+local cursor = ARGV[5]
+if cursor == nil or not string.match(cursor, '^%d+$') then
+ return redis.error_reply('커서는 숫자여야 한다: ' .. tostring(cursor))
+end
+
+-- 앞에서 K 명. 뒤는 아직 볼 때가 아니다.
+local front = redis.call('ZRANGE', KEYS[1], 0, limit - 1)
+local swept = 0
+
+if #front > 0 then
+ -- **앞부분의 score 만 묻는다.** ZRANGEBYSCORE 로 살아 있는 쪽을 다 받으면
+ -- K 를 1 로 줘도 alive 전체 크기에 비례해 이벤트 루프를 잡는다.
+ local scores = redis.call('ZMSCORE', KEYS[3], unpack(front))
+
+ local gone = {}
+ local records = {}
+ for i = 1, #front do
+ -- score 가 없거나 이미 지난 것은 폴링이 끊긴 것이다
+ local at = tonumber(scores[i])
+ if at == nil or at < now then
+ gone[#gone + 1] = front[i]
+ -- 자리는 안 보관한다. 재방문자로 식별만 한다 (D-11).
+ records[#records + 1] = front[i]
+ records[#records + 1] = now
+ end
+ end
+
+ if #gone > 0 then
+ redis.call('ZREM', KEYS[1], unpack(gone))
+ redis.call('HSET', KEYS[2], unpack(records))
+ swept = #gone
+ end
+end
+
+-- 만료된 생존 신호도 예산 안에서만 걷는다. ZREMRANGEBYSCORE 는 대상 수만큼
+-- 도므로 한 번에 다 지우려 하면 그 자체가 오래 걸린다.
+local staleSignals = redis.call('ZRANGE', KEYS[3], '-inf', '(' .. now,
+ 'BYSCORE', 'LIMIT', 0, budget)
+local expiredSignals = 0
+if #staleSignals > 0 then
+ redis.call('ZREM', KEYS[3], unpack(staleSignals))
+ expiredSignals = #staleSignals
+end
+
+-- **COUNT 는 힌트지 상한이 아니다.** 해시가 조밀하게 인코딩돼 있으면 한 번에
+-- budget 보다 많이 돌아온다. 받은 것 중 예산만큼만 지우고 나머지는 다음
+-- 호출로 미룬다 — 커서만 넘기면 초과분을 막을 수 없다.
+local scanned = redis.call('HSCAN', KEYS[2], cursor, 'COUNT', budget)
+local fields = scanned[2]
+local cutoff = now - retention
+local doomed = {}
+for i = 1, #fields, 2 do
+ if #doomed >= budget then
+ break
+ end
+ local at = tonumber(fields[i + 1])
+ if at == nil or at < cutoff then
+ doomed[#doomed + 1] = fields[i]
+ end
+end
+
+local expiredGrace = 0
+if #doomed > 0 then
+ redis.call('HDEL', KEYS[2], unpack(doomed))
+ expiredGrace = #doomed
+end
+
+-- 다음 커서를 돌려준다. 호출부가 이어서 넘긴다.
+return {swept, expiredSignals, expiredGrace, scanned[1]}
diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/ClockMonotonicTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/ClockMonotonicTest.java
index 3e836177..c10816bc 100644
--- a/src/test/java/com/kafkick/waiting/adapter/redis/ClockMonotonicTest.java
+++ b/src/test/java/com/kafkick/waiting/adapter/redis/ClockMonotonicTest.java
@@ -25,6 +25,8 @@
@SpringBootTest
class ClockMonotonicTest extends RedisContainerSupport {
+ private static final String NOW = "1800000000";
+
private static final long TTL_SECONDS = 86_400;
private static final Duration WAIT = Duration.ofSeconds(5);
@@ -54,12 +56,12 @@ private List