Skip to content
Merged
136 changes: 136 additions & 0 deletions ai/journal/2026/08/AIJ-0021-sweep.md
Original file line number Diff line number Diff line change
@@ -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 가 인자로 동작
- 제거와 기록이 함께 · 만료·깨진 값 정리 · 잘못된 인자에 무변경

## 다음 사람에게

**"전체를 보면 정확하다" 가 늘 맞지는 않는다.** 이 청소는 매 틱 도는 배경
작업이라, 정확도를 조금 얻자고 부하를 크게 치르면 그 부하가 다시 이탈을
만든다. 앞부분만 보는 것은 타협이 아니라 **부하를 유계로 두려는 설계**다.
1 change: 1 addition & 0 deletions ai/journal/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | — |
Expand Down
13 changes: 10 additions & 3 deletions src/main/java/com/kafkick/waiting/adapter/redis/RedisKeys.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
/**
* 생존 신호. 폴링이 곧 하트비트다.
*
* <p><b>사람마다 키를 만들지 않는다.</b> 그러면 청소 스크립트가 KEYS 에
* 선언되지 않은 키를 만지게 되고 클러스터가 거부한다 (RD-1). 쿠폰당 ZSET
* 하나에 <b>만료 시각을 score 로</b> 담는다 — 개별 TTL 은 잃지만 청소가
* 어차피 만료를 보므로 잃는 것이 없다.
*/
public static String alive(String couponId, int shards, int shard) {
return "alive:{" + tag(couponId, shards, shard) + "}";
}

/**
Expand Down
18 changes: 13 additions & 5 deletions src/main/resources/redis/enqueue.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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]))
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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}
11 changes: 9 additions & 2 deletions src/main/resources/redis/queue_status.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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번째와 구분한다.** 없는 것과 맨 앞인 것은 다르다. 뭉치면 유실된
Expand All @@ -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
Expand Down
111 changes: 111 additions & 0 deletions src/main/resources/redis/sweep.lua
Original file line number Diff line number Diff line change
@@ -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]}
Loading
Loading