diff --git a/.claude/commands/review.md b/.claude/commands/review.md new file mode 100644 index 00000000..cabde7be --- /dev/null +++ b/.claude/commands/review.md @@ -0,0 +1,60 @@ +--- +description: PR 을 올리기 전 로컬 리뷰. 기계 검사 + 영역별 리뷰 에이전트 +--- + +PR 을 올리기 전에 **CodeRabbit 이 볼 것을 먼저 본다.** 원격에서 지적받고 고치는 +왕복은 비싸고, 무엇보다 그 사이에 잘못된 코드가 브랜치에 남는다. + +## 1. 기계 검사 + +```bash +.claude/hooks/review-branch.sh ${1:-origin/develop} +``` + +이 스크립트는 `check-java.sh`·`check-lua.sh` 를 **브랜치 변경 전체**에 태운다. +두 훅은 `Write|Edit` 도구로 쓴 파일만 보므로, 힙독이나 스크립트로 만든 파일은 +그냥 지나간다 — 실제로 그렇게 들어간 위반이 CodeRabbit 까지 간 적이 있다. + +위반이 나오면 **고치고 다시 돌린다.** 통과할 때까지 2번으로 넘어가지 않는다. + +## 2. 빌드와 품질 임계 + +```bash +./gradlew build jacocoTestCoverageVerification pitest --no-daemon -q +``` + +도메인 분기 100%, 뮤테이션 생존 ≤10%. 생존 뮤턴트가 나오면 **숫자만 보지 말고 +어디가 살아남았는지** 본다 — 임계를 통과해도 이미 버그가 났던 자리에 몰려 +있으면 그건 통과가 아니다. + +## 3. 영역별 리뷰 에이전트 + +변경 경로에 따라 `.claude/agents/` 에서 고른다. 1번 스크립트가 마지막에 +어느 에이전트를 돌릴지 알려준다. + +| 변경 | 에이전트 | +|---|---| +| `domain/**` | `domain-guardian` | +| `*.lua` · `redis/**` | `redis-cluster-checker` | +| 장애·회복·서킷·리트라이 | `resilience-auditor` | +| `src/test/**` · `src/testFixtures/**` | `test-quality-reviewer` | +| 전 영역 (항상) | `style-enforcer` | + +각 에이전트에게 **변경 범위를 명시**해서 넘긴다 — 저장소 전체를 훑게 하면 +이번 변경과 무관한 지적이 섞여 진짜 지적이 묻힌다. + +## 4. 사람이 볼 것 + +기계도 에이전트도 못 보는 것이 남는다. + +- **판정 순서를 바꿨다면** 계획서의 사다리 표도 함께 고쳤는가 (PK-A4) +- **계획서와 구현이 어긋난다면** 어느 쪽이 목표에 가까운가. 계획서가 스스로 + 모순인 경우가 있다 (AIJ-0012) +- **테스트 이름이 실제로 검증하는 것과 같은가.** 이름만 맞고 내용이 다른 + 테스트는 통과하면서 버그를 덮는다 + +## 5. 그다음 + +- 저널이 필요한 변경인가 — `src/**`·`*.gradle`·`.github/workflows/**` +- 커밋 제목이 50칸 이내 명사형인가, 푸터에 `Refs: CY-###` 가 있는가 +- 푸시 전에 **무엇을 바꿨는지 요약**한다 diff --git a/.claude/hooks/check-commit-msg.sh b/.claude/hooks/check-commit-msg.sh index be59cf3e..7a8444b4 100755 --- a/.claude/hooks/check-commit-msg.sh +++ b/.claude/hooks/check-commit-msg.sh @@ -35,47 +35,19 @@ if [[ -z "$subject" ]]; then exit 2 fi -types='feat|fix|test|refactor|perf|docs|build|ci|chore' -errors=() +# 규칙은 .githooks/lib/ 하나에만 둔다. 여기에 복사하면 git 훅과 갈라지고, +# 그때부터 어느 쪽이 맞는지 알 수 없다. +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../.githooks/lib/commit-subject-rules.sh +source "$here/../../.githooks/lib/commit-subject-rules.sh" -printf '%s' "$subject" | grep -qE "^($types)(\([a-z0-9-]+\))?: .+" \ - || errors+=("형식이 맞지 않는다: '(): ' / 허용 type: $types") - -# 글자 수가 아니라 **표시 폭**으로 센다. 한글은 터미널에서 두 칸을 차지하므로 -# 50자로 재면 100칸이 되어 git log 가 줄바꿈된다. 50칸은 git 의 관례다. -chars=$(printf '%s' "$subject" | wc -m) -wide=$(printf '%s' "$subject" | grep -oP '[\x{AC00}-\x{D7A3}\x{3130}-\x{318F}]' | wc -l) -width=$((chars + wide)) -((width > 50)) && errors+=("제목이 ${width}칸이다 (한글은 두 칸). 50칸 이내로 줄인다") - -printf '%s' "$subject" | grep -qE '\.$' \ - && errors+=("제목 끝에 마침표를 쓰지 않는다") - -# 제목은 요약이지 문장이 아니다. 한글 종결어미로 끝나면 서술문이다 — -# 명사형은 '다' 로 끝나지 않으므로 이 한 글자로 갈린다. -printf '%s' "$subject" | grep -qP '다$' \ - && errors+=("제목을 명사형으로 끝낸다. '~했다/한다' 는 요약이 아니라 문장이다") - -# 제목은 한글로 쓴다. type·scope 만 영문이다. -printf '%s' "$subject" | grep -qP '[\x{AC00}-\x{D7A3}]' \ - || errors+=("제목을 한글로 쓴다 (type·scope 는 영문)") - -printf '%s' "$subject" | grep -qE '\bCY-[0-9]+' \ - && errors+=("Jira 키는 제목이 아니라 'Refs: CY-###' 푸터에 둔다") - -if ((${#errors[@]} > 0)); then +if ! violations=$(check_commit_subject "$subject"); then { echo "커밋 메시지 규약 위반" echo " 제목: $subject" echo - printf ' - %s\n' "${errors[@]}" - echo - echo "예시:" - echo " test(admission): 한산한 쿠폰의 무대기 통과 검증" - echo " feat(admission): 전역 크레딧 기반 통과 상한 산출" - echo - echo "푸터에 'Refs: CY-###' 를 남긴다." - echo "규칙 전문: ai/rules/60-workflow.md" + echo "$violations" + commit_rule_help } >&2 exit 2 fi diff --git a/.claude/hooks/check-java.sh b/.claude/hooks/check-java.sh index fa296562..1162d8e7 100755 --- a/.claude/hooks/check-java.sh +++ b/.claude/hooks/check-java.sh @@ -90,10 +90,30 @@ fi # ── JS-14 중첩 클래스는 static ──────────────────────────────────────────────── # 들여쓰기된 class 선언 = 중첩. 수식어가 없는 경우도 잡는다. -report "JS-14" "중첩 클래스는 static — 바깥 인스턴스를 붙들어 누수를 만든다" \ - "$(scan 'JS-14' \ - '^[0-9]+:[[:space:]]+((public|protected|private|final|abstract)[[:space:]]+)*class[[:space:]]' \ - 'static')" +# +# **JUnit 5 의 @Nested 는 제외한다.** 그쪽은 static 이면 아예 실행되지 않는다 — +# 규칙과 프레임워크가 충돌하는 자리라 규칙이 진다. 여기서 오탐을 내면 사람은 +# 훅을 고치는 대신 우회하고, 그러면 진짜 위반도 같이 지나간다. +# @Nested 이후 **선언까지의 연속 어노테이션 줄**을 전부 건너뛴다. 개수를 못 +# 박으면 @Tag 하나 붙는 순간 오탐이 되고, 오탐이 나면 훅이 우회된다. +nested_class_lines=$(awk ' + # `@Nested class Inner {` 처럼 한 줄에 같이 오면 그 줄이 곧 선언이다. + # pending 을 켠 채 넘어가면 **다음 중첩 클래스가 대신 면제된다.** + /^[[:space:]]*@Nested([[:space:]]|\(|$)/ && /class[[:space:]]/ { print NR; pending=0; next } + /^[[:space:]]*@Nested([[:space:]]|\(|$)/ { pending=1; next } + # 어노테이션 인자가 여러 줄에 걸치면 이어지는 줄은 @ 로 시작하지 않는다. + # 개수나 형태를 못 박지 말고 **선언 줄을 만날 때까지** 건너뛴다. + pending && /class[[:space:]]/ { print NR; pending=0; next } + pending { next } +' "$file") +js14=$(scan 'JS-14' \ + '^[0-9]+:[[:space:]]+((public|protected|private|final|abstract)[[:space:]]+)*class[[:space:]]' \ + 'static') +for n in $nested_class_lines; do + js14=$(printf '%s\n' "$js14" | grep -vE "^[[:space:]]*$n:") +done +js14=$(printf '%s' "$js14" | grep -v '^[[:space:]]*$') +report "JS-14" "중첩 클래스는 static — 바깥 인스턴스를 붙들어 누수를 만든다" "$js14" # ── JS-6 Javadoc 5줄 초과 (원본에서 검사한다) ───────────────────────────────── hits=$(awk ' diff --git a/.claude/hooks/guard-pr.sh b/.claude/hooks/guard-pr.sh new file mode 100755 index 00000000..d166c660 --- /dev/null +++ b/.claude/hooks/guard-pr.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# PR 을 올리기 전에 로컬 리뷰를 강제한다. +# +# **왜 차단인가.** "올리기 전에 돌려라" 는 규범은 잊힌다. 실제로 잊었고, +# CodeRabbit 이 두 라운드에 걸쳐 14건을 지적했는데 그중 셋은 우리 자신의 +# MUST 규칙 위반이었다 — 로컬에서 1초면 잡히는 것들이다. +# +# PreToolUse(Bash) 훅. `gh pr create` 를 만나면 기계 검사를 돌리고 +# 위반이 있으면 exit 2 로 막는다. + +set -uo pipefail + +input=$(cat) +cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') + +# PR 생성이 아니면 통과 +[[ "$cmd" != *"gh pr create"* ]] && exit 0 + +# **검사를 못 돌리면 막는다.** 통과시키면 게이트가 인프라 오류 한 번에 +# 조용히 사라진다 — 가드는 fail closed 여야 한다. +if ! ROOT=$(git rev-parse --show-toplevel 2>/dev/null); then + echo "git 저장소가 아니라 로컬 리뷰를 돌릴 수 없다. PR 은 저장소 안에서 연다." >&2 + exit 2 +fi +RUNNER="$ROOT/.claude/hooks/review-branch.sh" +if [[ ! -x "$RUNNER" ]]; then + echo "로컬 리뷰 러너를 실행할 수 없다: $RUNNER" >&2 + echo " chmod +x .claude/hooks/*.sh" >&2 + exit 2 +fi + +# base 를 명령에서 뽑는다. 없으면 develop +# **명령 문자열 전체를 훑지 않는다.** `--title "--base release"` 처럼 인용부호 +# 안에 들어간 값을 옵션으로 착각한다. 인자를 토큰으로 쪼갠 뒤 옵션 자리만 본다. +# +# 실행하지 않고 쪼갠다 — `xargs` 는 셸 인용 규칙을 그대로 따르면서 명령을 +# 부르지 않는다. +base="" +mapfile -t args < <(printf '%s' "$cmd" | xargs -n1 printf '%s\n' 2>/dev/null) +for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[i]}" in + --base=*) base="${args[i]#--base=}"; break ;; + -B=*) base="${args[i]#-B=}"; break ;; + --base|-B) + base="${args[i + 1]:-}" + break ;; + esac +done +base="${base:-develop}" +# 이미 접두가 붙어 있으면 겹치지 않게 둔다. origin/origin/develop 이 되면 +# 러너가 폴백을 타고, 폴백마저 없으면 브랜치 커밋을 하나도 안 보고 통과한다. +[[ "$base" != origin/* ]] && base="origin/$base" + +out=$("$RUNNER" "$base" 2>&1) +status=$? + +if ((status != 0)); then + { + echo "PR 을 올리기 전에 로컬 리뷰가 통과해야 한다." + echo + # 전체를 보여 준다. 걸러내면 정작 필요한 줄이 빠진다. + printf '%s\n' "$out" + echo + echo "고친 뒤 다시 시도한다. 수동 실행: .claude/hooks/review-branch.sh $base" + echo "전체 절차: /review" + } >&2 + exit 2 +fi + +# 통과했어도 기계가 못 보는 것이 남는다 — 막지는 않고 알린다. +{ + echo "로컬 기계 검사 통과. 아직 안 한 것이 있는지 본다:" + echo " · ./gradlew build jacocoTestCoverageVerification pitest" + printf '%s\n' "$out" | sed -n '/사람·에이전트가 볼 것/,$p' | sed 's/^/ /' +} >&2 +exit 0 diff --git a/.claude/hooks/review-branch.sh b/.claude/hooks/review-branch.sh new file mode 100755 index 00000000..fb37090f --- /dev/null +++ b/.claude/hooks/review-branch.sh @@ -0,0 +1,391 @@ +#!/usr/bin/env bash +# 브랜치 전체 로컬 리뷰. PR 을 올리기 전에 돌린다. +# +# **왜 필요한가.** check-java.sh · check-lua.sh 는 PostToolUse(Write|Edit) 훅이라 +# 그 도구로 쓴 파일만 본다. 힙독이나 스크립트로 쓴 파일은 훅을 통째로 지나간다 — +# 실제로 그렇게 들어간 JS-6·JS-12·JS-13 위반이 CodeRabbit 까지 갔다. +# +# 이 스크립트는 **파일을 어떻게 만들었든** 브랜치의 변경 전체를 같은 검사에 태운다. +# 검사 내용을 여기 복사하지 않고 기존 훅을 그대로 호출한다 — 사본이 생기면 갈라진다. +# +# 사용: .claude/hooks/review-branch.sh [base] 기본 base 는 origin/develop +# .claude/hooks/review-branch.sh --self-test 검사가 실제로 무는지 확인 + +set -uo pipefail +ROOT_SCRIPT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}") +cd "$(git rev-parse --show-toplevel)" || exit 1 + +HOOKS=".claude/hooks" +# 자기검증이 갈아 끼운다 — 저장소의 진짜 색인 상태에 결과가 흔들리면 안 된다. +JOURNAL_INDEX="ai/journal/index.md" +findings=0 + +say() { printf '%s\n' "$*"; } +head2() { printf '\n\033[1m%s\033[0m\n' "$*"; } + +# 기존 훅을 그 훅의 입력 형식으로 호출한다. +run_hook() { + local hook=$1 file=$2 out + # **절대경로로 넘긴다.** 훅은 `*/src/test/*` 로 테스트 여부를 가르는데 + # git 은 선행 슬래시 없는 상대경로를 준다 — 그대로 넘기면 테스트가 + # 프로덕션 규칙으로 검사되어 오탐이 나고, 동시에 테스트 전용 검사는 + # 아예 안 돈다. 이미 절대경로면 그대로 둔다. + [[ "$file" != /* ]] && file="$PWD/$file" + out=$(jq -nc --arg f "$file" '{tool_input:{file_path:$f}}' \ + | "$HOOKS/$hook" 2>&1) + [[ -n "$out" ]] && { printf '%s\n' "$out"; return 1; } + return 0 +} + +# ── .coderabbit.yaml 의 path_instructions 중 기계로 볼 수 있는 것 ───────────── +# 나머지(설계 의도·판정 순서의 타당성)는 .claude/agents/ 가 본다. + +# JS-12 · 값/상태 객체의 public 생성자 (class 한정. record 는 언어 제약) +check_js12() { + local file=$1 + # **주석을 걷어낸 뒤 실제 선언만 본다.** 파일 전문을 훑으면 `// record Service(` + # 같은 주석 한 줄이 진짜 위반을 면제해 버린다. + local code + code=$(awk '{ + line = $0 + sub(/\/\/.*/, "", line) + if (line ~ /^[[:space:]]*\*/) line = "" + if (line ~ /^[[:space:]]*\/\*/) line = "" + printf "%d:%s\n", NR, line + }' "$file") + + printf '%s\n' "$code" \ + | grep -E '^[0-9]+:[[:space:]]*public [A-Z][A-Za-z0-9_]*\(' \ + | while IFS=: read -r n rest; do + local name + name=$(printf '%s' "$rest" | sed -E 's/^[[:space:]]*public[[:space:]]+([A-Za-z0-9_]+).*/\1/') + # 그 이름의 타입이 record 로 선언됐으면 정규 생성자라 막을 수 없다 + if printf '%s\n' "$code" \ + | grep -qE "^[0-9]+:.*(^|[[:space:]])record[[:space:]]+$name([[:space:]]|\()"; then + continue + fi + printf ' JS-12 %s:%s public 생성자 — 정적 팩토리를 쓴다\n' "$file" "$n" + done +} + +# TS-11 · 약한 단언 +check_ts11() { + local file=$1 + grep -nE '\.(isNotEmpty|isNotNull|isNotZero)\(\)\s*;' "$file" 2>/dev/null \ + | sed "s|^| TS-11 $file:|;s|:\s*| |" \ + | sed 's/$/ ← 무엇이 들어 있는지까지 단언한다/' +} + +# TS-4 · 실제 시간 의존 / TS-7 · 비활성 테스트 +check_test_misc() { + local file=$1 + # **주석 줄은 보지 않는다.** "Instant.now() 를 부르지 않는다" 라고 적은 + # 문서가 위반으로 잡히면, 규칙을 설명한 것이 규칙 위반이 된다. + grep -nE 'Thread\.sleep|Instant\.now\(\)|System\.currentTimeMillis' "$file" 2>/dev/null \ + | grep -vE '^[0-9]+:[[:space:]]*(//|\*|/\*)' \ + | sed "s|^| TS-4 $file:|" | sed 's/$/ ← 시각·대기를 주입한다/' + grep -nE '@Disabled|@RepeatedTest\(.*\)\s*//.*(불안정|flaky)' "$file" 2>/dev/null \ + | sed "s|^| TS-7 $file:|" | sed 's/$/ ← 불안정 테스트를 덮지 않는다/' +} + +# EX-1 · 정상 실패를 예외로 +check_ex1() { + local file=$1 + grep -nE 'throw new .*(SoldOut|QueueFull|Overload).*Exception' "$file" 2>/dev/null \ + | sed "s|^| EX-1 $file:|" | sed 's/$/ ← 매진·큐 상한은 판정값이지 예외가 아니다/' +} + +# RX-2 · Flux.interval +check_rx2() { + local file=$1 + grep -nE 'Flux\.interval\(' "$file" 2>/dev/null \ + | sed "s|^| RX-2 $file:|" | sed 's/$/ ← repeatWhen 을 쓴다. 회복 시 몰아서 터진다/' +} + +# 작업 로그 형식·색인 동기화. CI(_verify-conventions.yml)와 **같은 것**을 본다. +# 여기서 안 보면 프론트매터를 통째로 빠뜨린 글이 푸시된 뒤에야 드러난다. +# +# 색인 경로를 인자로 받는다 — 자기검증이 저장소의 진짜 색인을 읽으면 누가 +# 그 ID 를 실제로 등록하는 순간 검사가 아니라 상태가 결과를 바꾼다. +check_journal() { + local file=$1 index=${2:-ai/journal/index.md} out="" k id front + # **프론트매터 안만 본다.** 파일 전체를 훑으면 본문에 'date:' 한 줄만 + # 있어도 프론트매터가 있는 것으로 쳐서 검사가 통과한다. + # + # **닫는 --- 까지 확인한다.** 여는 줄만 보고 끝까지 읽으면 닫히지 않은 + # 파일에서 본문 전체가 프론트매터 행세를 해 같은 구멍이 다시 열린다. + local status + front=$(awk ' + NR == 1 { if ($0 != "---") exit 1; next } + $0 == "---" { closed = 1; exit 0 } + { print } + END { if (!closed) exit 1 } + ' "$file") + status=$? + if ((status != 0)); then + printf '%s' " JN-1 $file:1 ← 프론트매터가 없거나 --- 로 닫히지 않았다"$'\n' + return + fi + for k in id date kind confidence; do + printf '%s\n' "$front" | grep -qE "^$k:" \ + || out+=" JN-1 $file:1 ← 프론트매터에 '$k:' 없음"$'\n' + done + # 값 **전체**가 AIJ-<숫자> 여야 한다. 끝 경계가 없으면 'AIJ-123-extra' 가 + # 'AIJ-123' 으로 잘려 엉뚱한 ID 로 색인을 찾는다. + id=$(printf '%s\n' "$front" | sed -nE 's/^id:[[:space:]]*(AIJ-[0-9]+)[[:space:]]*$/\1/p') + if [[ -z "$id" ]]; then + out+=" JN-1 $file:1 ← id 가 'AIJ-<숫자>' 형식이 아니다"$'\n' + # 부분 일치를 막는다. 색인에 AIJ-1234 만 있어도 AIJ-123 이 통과했다. + elif ! grep -qE "(^|[^0-9A-Za-z-])$id([^0-9]|$)" "$index" 2>/dev/null; then + out+=" JN-2 $file:1 ← $id 가 색인에 없다 — 색인에서 빠지면 없는 것과 같다"$'\n' + fi + printf '%s' "$out" +} + +review_files() { + local -a files=("$@") + local java=() lua=() journal=() + for f in "${files[@]}"; do + [[ -f "$f" ]] || continue + case "$f" in + *.java) java+=("$f") ;; + *.lua) lua+=("$f") ;; + */ai/journal/*/*/AIJ-*.md|ai/journal/*/*/AIJ-*.md) journal+=("$f") ;; + esac + done + + if ((${#java[@]})); then + head2 "Java — 기존 훅 (JS-1·2·4·6·9·13·14 · DS-1 · RX-1 · LG-5·6)" + local clean=1 + for f in "${java[@]}"; do run_hook check-java.sh "$f" || clean=0; done + ((clean)) && say " 위반 없음" + ((clean)) || findings=$((findings + 1)) + + head2 "Java — 훅이 안 보는 것 (.coderabbit.yaml path_instructions)" + local out="" + for f in "${java[@]}"; do + case "$f" in + src/test/*|src/testFixtures/*|*/src/test/*|*/src/testFixtures/*) + out+=$(check_ts11 "$f")$'\n' + out+=$(check_test_misc "$f")$'\n' ;; + *) + out+=$(check_js12 "$f")$'\n' + out+=$(check_ex1 "$f")$'\n' + out+=$(check_rx2 "$f")$'\n' ;; + esac + done + out=$(printf '%s' "$out" | grep -v '^[[:space:]]*$') + if [[ -n "$out" ]]; then say "$out"; findings=$((findings + 1)); else say " 위반 없음"; fi + fi + + if ((${#lua[@]})); then + head2 "Lua — RD-1·2·10" + local clean=1 + for f in "${lua[@]}"; do run_hook check-lua.sh "$f" || clean=0; done + ((clean)) && say " 위반 없음" || findings=$((findings + 1)) + fi + + if ((${#journal[@]})); then + head2 "작업 로그 — 프론트매터·색인" + local jout="" one + for f in "${journal[@]}"; do + # 명령 치환이 후행 줄바꿈을 먹는다. 그냥 이으면 앞 파일의 마지막 + # 지적과 다음 파일의 첫 지적이 한 줄에 붙는다. + one=$(check_journal "$f" "$JOURNAL_INDEX") + [[ -n "$one" ]] && jout+="$one"$'\n' + done + jout=$(printf '%s' "$jout" | grep -v '^[[:space:]]*$') + if [[ -n "$jout" ]]; then say "$jout"; findings=$((findings + 1)) + else say " 위반 없음"; fi + fi +} + +# ── 자기검증 — 통과만 하는 검사는 검사가 아니다 ────────────────────────────── +self_test() { + local tmp; tmp=$(mktemp -d); local fail=0 + probe() { # 이름, 경로, 내용, 기대규칙 + local name=$1 path=$2 body=$3 want=$4 + mkdir -p "$(dirname "$tmp/$path")"; printf '%s' "$body" > "$tmp/$path" + local got; got=$(review_files "$tmp/$path" 2>&1) + if printf '%s' "$got" | grep -q "$want"; then + printf ' ✓ %s\n' "$name" + else + printf ' ✗ %s — %s 를 못 잡았다\n' "$name" "$want"; fail=1 + fi + } + probe "public 생성자" "src/main/java/A.java" \ + $'class A {\n public A(int x) {}\n}\n' "JS-12" + probe "Javadoc 6줄 (check-java.sh 위임)" "src/main/java/B.java" \ + $'/**\n * 1\n * 2\n * 3\n * 4\n * 5\n * 6\n */\nclass B {}\n' "JS-6" + probe "약한 단언" "src/test/java/CTest.java" \ + $'class CTest { void t() { assertThat(x).isNotEmpty(); } }\n' "TS-11" + probe "실제 시간" "src/test/java/DTest.java" \ + $'class DTest { void t() { Thread.sleep(10); } }\n' "TS-4" + probe "정상 실패를 예외로" "src/main/java/E.java" \ + $'class E { void f() { throw new SoldOutException(); } }\n' "EX-1" + probe "Flux.interval" "src/main/java/F.java" \ + $'class F { void f() { Flux.interval(d).subscribe(); } }\n' "RX-2" + # 주석 한 줄이 진짜 위반을 면제하던 회귀 + probe "주석 속 record 는 면제가 아니다" "src/main/java/G.java" \ + $'// record G(int x)\nclass G {\n public G() {}\n}\n' "JS-12" + # record 와 클래스가 한 파일에 섞여도 클래스만 잡는다 + probe "record 가 있어도 다른 클래스는 검사한다" "src/main/java/H.java" \ + $'public record Marker(int x) {}\n\nclass H {\n public H() {}\n}\n' "JS-12" + # **색인을 갈아 끼운다.** 저장소의 진짜 색인을 읽으면 누가 이 ID 를 + # 실제로 등록하는 순간 검사가 아니라 상태가 결과를 바꾼다. + JOURNAL_INDEX="$tmp/index.md" + printf '| [AIJ-9990](x.md) | 2026-08-20 | implement | 있음 | high | — |\n' \ + > "$JOURNAL_INDEX" + + # 프론트매터를 통째로 빠뜨린 글이 CI 까지 갔다. 검사가 실제로 무는지 본다. + probe "저널 프론트매터 누락" "ai/journal/2026/08/AIJ-9998-probe.md" \ + $'# 제목\n\n- **날짜** 2026-08-20\n' "JN-1" + probe "저널이 색인에 없음" "ai/journal/2026/08/AIJ-9999-probe.md" \ + $'---\nid: AIJ-9999\ndate: 2026-08-20\nkind: implement\nconfidence: high\n---\n\n# 제목\n' "JN-2" + # 본문의 'date:' 한 줄이 프론트매터 행세를 하던 구멍 + probe "본문 키는 프론트매터가 아니다" "ai/journal/2026/08/AIJ-9997-probe.md" \ + $'# 제목\n\nid: AIJ-9997\ndate: 2026-08-20\nkind: implement\nconfidence: high\n' "JN-1" + # 끝 경계가 없어 'AIJ-9990-extra' 가 'AIJ-9990' 으로 잘리던 구멍 + probe "id 는 값 전체가 맞아야 한다" "ai/journal/2026/08/AIJ-9996-probe.md" \ + $'---\nid: AIJ-9990-extra\ndate: 2026-08-20\nkind: implement\nconfidence: high\n---\n' "JN-1" + # 여는 --- 만 있고 안 닫힌 파일이 본문까지 프론트매터로 치던 구멍 + probe "닫는 구분자가 없으면 프론트매터가 아니다" "ai/journal/2026/08/AIJ-9995-probe.md" \ + $'---\nid: AIJ-9990\ndate: 2026-08-20\nkind: implement\nconfidence: high\n\n# 제목\n' "JN-1" + # 색인의 AIJ-9990 이 AIJ-999 를 통과시키던 구멍 + probe "색인 부분 일치는 등록이 아니다" "ai/journal/2026/08/AIJ-999-probe.md" \ + $'---\nid: AIJ-999\ndate: 2026-08-20\nkind: implement\nconfidence: high\n---\n' "JN-2" + JOURNAL_INDEX="ai/journal/index.md" + + # **상대경로 회귀.** git 은 선행 슬래시 없는 경로를 준다. 절대경로로 + # 안 바꾸면 테스트 전용 검사가 아예 안 돌고(미탐), 동시에 테스트가 + # 프로덕션 규칙으로 검사된다(오탐). 둘 다 실제로 났다. + # + # 저장소 안의 빌드 경로에 둔다 — 러너가 git 루트에서 도는 것을 전제하므로 + # 저장소 밖으로 나가면 재현이 안 된다. + local relprobe="build/review-selftest/src/test/java/RelTest.java" + mkdir -p "$(dirname "$relprobe")" + printf 'class RelTest {\n void t() throws Exception {\n Thread.sleep(10);\n }\n}\n' \ + > "$relprobe" + local rel; rel=$(review_files "$relprobe" 2>&1) + rm -rf build/review-selftest + if printf '%s' "$rel" | grep -q "TS-4"; then + printf ' ✓ 상대경로에서도 테스트 검사가 돈다\n' + else + printf ' ✗ 상대경로에서 테스트 검사가 안 돈다 (미탐)\n'; fail=1 + fi + # 헤더 줄에도 규칙 ID 가 적혀 있다. 지적 형식([RX-1])으로만 본다. + if printf '%s' "$rel" | grep -q "\[RX-1\]"; then + printf ' ✗ 테스트를 프로덕션 규칙으로 검사한다 (오탐)\n'; fail=1 + else + printf ' ✓ 테스트를 프로덕션 규칙으로 검사하지 않는다\n' + fi + + # 규칙을 설명한 주석이 규칙 위반으로 잡히던 오탐 + probe "주석 속 시각 표현은 위반이 아니다" "src/test/java/ITest.java" \ + $'class ITest {\n // Instant.now() 를 부르지 않는다\n void t() {}\n}\n' "위반 없음" + + # **티켓 혼입 회귀.** `git add -A` 가 브랜치를 옮겨도 따라온 미추적 파일을 + # 쓸어 담아 다른 티켓의 코드가 섞였고 CI 가 깨졌다. 검사가 실제로 무는지 본다. + local repo; repo="$tmp/mixed" + mkdir -p "$repo" && ( + cd "$repo" || exit 1 + git init -q . && git config user.email t@t && git config user.name t + : > seed.txt && git add seed.txt && git commit -q -m 'chore: 씨앗' + git branch -q base + : > a.txt && git add a.txt + git commit -q -m 'feat(a): 하나' -m 'Refs: CY-1' + : > b.txt && git add b.txt + git commit -q -m 'feat(b): 둘' -m 'Refs: CY-2' + ) >/dev/null 2>&1 + local mixed mixed_status + mixed=$(cd "$repo" && "$ROOT_SCRIPT" base 2>&1); mixed_status=$? + # 문구만 보면 findings 를 안 올리는 회귀를 놓친다. 종료 상태까지 본다. + if printf '%s' "$mixed" | grep -q '티켓이 둘 이상' && ((mixed_status != 0)); then + printf ' ✓ 티켓이 섞이면 알리고 실패로 끝난다\n' + else + printf ' ✗ 티켓이 섞였는데 통과시켰다 (exit %d)\n' "$mixed_status"; fail=1 + fi + + rm -rf "$tmp" + findings=0 + return $fail +} + +if [[ "${1:-}" == "--self-test" ]]; then + head2 "자기검증 — 각 검사가 실제로 무는가" + self_test && { say $'\n자기검증 통과'; exit 0; } || { say $'\n자기검증 실패'; exit 1; } +fi + +BASE="${1:-origin/develop}" +# **다른 ref 로 대체하지 않는다.** 요청한 기준이 아닌 것을 보면 검사 결과가 +# 실제 PR 과 어긋나고, 어긋난 통과는 통과가 아니다. +if ! git rev-parse --verify "$BASE^{commit}" >/dev/null 2>&1 \ + || ! git merge-base "$BASE" HEAD >/dev/null 2>&1; then + say "기준을 해석할 수 없다: $BASE" + say " git fetch 하거나 기준을 인자로 준다 — 이 상태로는 무엇이 바뀌었는지 알 수 없다" + exit 1 +fi + +# 커밋된 것만 보면 **아직 안 커밋한 위반을 놓친다.** 개발 중에 돌릴 때가 +# 오히려 더 중요하므로 작업 트리까지 합친다. +mapfile -t CHANGED < <({ + git diff --name-only "$BASE"...HEAD + git diff --name-only HEAD + git diff --name-only --cached + git ls-files --others --exclude-standard +} | sort -u) + +if ((${#CHANGED[@]} == 0)); then + say "$BASE 대비 변경 없음"; exit 0 +fi + +head2 "$BASE 대비 변경 ${#CHANGED[@]}건 (작업 트리 포함)" +printf ' %s\n' "${CHANGED[@]}" + +review_files "${CHANGED[@]}" + +# ── 브랜치에 섞여 든 파일 ──────────────────────────────────────────────────── +# `git add -A` 는 **브랜치를 옮겨도 따라온 미추적 파일**까지 쓸어 담는다. +# 실제로 다른 티켓의 Lua 와 테스트가 워크플로 브랜치에 딸려 가 CI 가 깨졌다. +# 커밋 푸터의 티켓과 변경 경로가 어긋나면 알린다. +# 기준을 못 읽으면 0 이 나와 조용히 통과한다. 위에서 이미 막았지만 +# 여기서 다시 확인한다 — 이 검사만 따로 불릴 수도 있다. +if ! git rev-parse --verify "$BASE^{commit}" >/dev/null 2>&1; then + head2 "기준을 해석할 수 없어 티켓 혼입을 못 본다: $BASE" + findings=$((findings + 1)) +fi +# **릴리스·핫픽스는 예외다.** 여러 티켓을 모아 main 으로 올리는 것이 이 +# 브랜치들의 목적이라(WF-3), 여기서 막으면 규범이 규범을 막는다. +current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) +tickets=$(git log --format=%B "$BASE"..HEAD | grep -oE 'Refs: CY-[0-9]+' | sort -u | wc -l) +case "$current_branch" in + release/*|hotfix/*) tickets=1 ;; +esac +if ((tickets > 1)); then + head2 "브랜치에 티켓이 둘 이상 섞였다" + git log --format=' %h %s%n %(trailers:key=Refs,valueonly)' "$BASE"..HEAD 2>/dev/null \ + | grep -v '^[[:space:]]*$' + say "" + say " 브랜치 하나에 티켓 하나다 (WF-3). CI 는 브랜치명에서 키를 뽑으므로" + say " 섞이면 엉뚱한 티켓으로 전이된다. 쪼개거나 잘못 담긴 커밋을 뺀다." + findings=$((findings + 1)) +fi + +head2 "사람·에이전트가 볼 것" +say " 기계 검사는 형태만 본다. 판정 순서의 타당성, 불변식이 실제로 지켜지는지," +say " 픽스처가 도달 불가능한 상태를 만들 수 있는지는 .claude/agents/ 가 본다." +printf '\n' +# 경로 판정은 선행 슬래시를 요구하지 않는다 — git 이 주는 형식이 상대경로다. +all=" ${CHANGED[*]} " +[[ "$all" == *"/domain/"* ]] && say " → domain-guardian" +[[ "$all" == *".lua"* || "$all" == *"/redis/"* ]] && say " → redis-cluster-checker" +[[ "$all" == *"src/test/"* || "$all" == *"src/testFixtures/"* ]] && say " → test-quality-reviewer" +# 장애·회복 경로는 파일명으로 안 드러난다. 이름에 단서가 있을 때만 권한다. +[[ "$all" == *"esilience"* || "$all" == *"ircuit"* || "$all" == *"etry"* \ + || "$all" == *"ailover"* || "$all" == *"eader"* || "$all" == *"haos"* ]] \ + && say " → resilience-auditor" +say " → style-enforcer (항상)" + +printf '\n' +((findings)) && { say "위반 있음 — 고치고 다시 돌린다"; exit 1; } +say "기계 검사 통과" diff --git a/.claude/hooks/self-test.sh b/.claude/hooks/self-test.sh index bd3cd0a0..0c6f97cf 100755 --- a/.claude/hooks/self-test.sh +++ b/.claude/hooks/self-test.sh @@ -8,6 +8,7 @@ set -uo pipefail HOOKS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HOOKS/../.." && pwd)" tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT @@ -67,6 +68,46 @@ file_case check-java.sh 'class A { class Inner { } }' 'src/main/java/G2.java' block 'JS-14 수식어 없는 중첩 (회귀)' + +file_case check-java.sh '/** 한 줄 javadoc */ +class Z { + void a() {} + void b() {} + void c() {} + void d() {} + void e() {} + void f() {} +}' 'src/main/java/Z.java' allow 'JS-6 한 줄 Javadoc 은 본문 0줄 (회귀) — 사본이 여기서 갈렸다' + +file_case check-java.sh 'class N { + @Nested + class Inner { + } +}' 'src/test/java/NTest.java' allow 'JS-14 @Nested 는 면제 (회귀) — static 이면 실행되지 않는다' + +file_case check-java.sh 'class N2 { + @Nested + @DisplayName("설명") + @Tag("slow") + class Inner { + } +}' 'src/test/java/N2Test.java' allow 'JS-14 @Nested 어노테이션 3개 (회귀) — 개수를 못 박지 않는다' + +file_case check-java.sh 'class N3 { + @Nested class Inner { + } + + class Leaked { + } +}' 'src/test/java/N3Test.java' block 'JS-14 @Nested 가 같은 줄이면 다음 중첩이 새지 않는다 (회귀)' + +# 상대경로로 넘어오면 테스트가 프로덕션 규칙으로 검사된다. 러너가 절대경로로 +# 넘기는지를 여기서 고정한다 — 이 회귀가 실제로 났다. +file_case check-java.sh 'class R { + void t() throws Exception { + Thread.sleep(10); + } +}' 'src/test/java/RTest.java' allow 'RX-1 은 테스트 소스셋에 적용되지 않는다 (회귀)' file_case check-java.sh 'class A { void f() { mono.block(); @@ -214,6 +255,165 @@ bash_case check-commit-msg.sh "git commit --amend --no-edit" allow '--amend --no bash_case check-commit-msg.sh "git status" allow 'commit 아닌 명령' bash_case check-commit-msg.sh "echo 'nothing to do with version control'" allow '무관한 명령' +# ── git commit-msg 훅 ──────────────────────────────────────────────────────── +# 도구 훅만 검증하면 터미널 직접 커밋 경로가 비어 있다. +echo +echo ".githooks/commit-msg" + +# git 훅은 0 통과 / 1 차단이다. Claude 훅의 exit 2 규약과 달라서 +# verdict 를 그대로 쓰면 차단을 전부 통과로 읽는다. +git_case() { # 메시지 기대 설명 + local msg=$1 expect=$2 label=$3 + local f; f=$(mktemp) + printf '%s\n' "$msg" > "$f" + "$ROOT/.githooks/commit-msg" "$f" >/dev/null 2>&1 + local rc=$? + rm -f "$f" + local actual=allow + ((rc != 0)) && actual=block + if [[ "$actual" == "$expect" ]]; then + printf ' ok %s\n' "$label"; pass=$((pass + 1)) + else + printf ' FAIL %s (기대 %s, 실제 %s)\n' "$label" "$expect" "$actual"; fail=$((fail + 1)) + fi +} + +git_case 'feat(app): 진입점 추가 +Refs: CY-18' allow '정상' +git_case '진입점 추가 +Refs: CY-18' block '형식 위반' +git_case 'feat(app): add entrypoint +Refs: CY-18' block '영문 제목' +git_case 'feat(app): 진입점을 추가했다 +Refs: CY-18' block '종결어미' +git_case 'feat(app): 진입점 추가' block 'Refs 푸터 없음' +git_case 'feat(app): 진입점 추가 +Refs: CY-18 +Plan: 1.2.1' block '계획서 ID (커밋에 남기지 않는다)' +git_case 'feat(app): CY-18 진입점 추가 +Refs: CY-18' block '제목에 Jira 키' +git_case 'Merge branch develop' allow '병합 커밋은 대상 아님' + +# ── 브랜치 리뷰 러너 ───────────────────────────────────────────────────────── +# 이 러너가 없으면 힙독·스크립트로 쓴 파일은 어떤 검사도 안 받는다. +# 러너 자신의 자기검증을 여기서 함께 돌린다. +echo +echo '[review-branch.sh]' +if "$ROOT/.claude/hooks/review-branch.sh" --self-test >/dev/null 2>&1; then + printf ' ok 러너 자기검증 (JS-12·JS-6·TS-11·TS-4·EX-1·RX-2)\n'; pass=$((pass + 1)) +else + printf ' FAIL 러너 자기검증 — 검사 중 하나가 위반을 못 잡는다\n'; fail=$((fail + 1)) +fi + +# ── PR 가드 ────────────────────────────────────────────────────────────────── +echo +echo '[guard-pr.sh]' +# **프로덕션 소스 트리에 쓰지 않는다.** 스크립트가 중간에 죽으면 public 생성자를 +# 가진 JS-12 위반 파일이 도메인 패키지에 남는다. 저장소 루트의 임시 디렉터리에 +# 두고 trap 을 건다 — gitignore 에 걸리면 러너의 변경 목록에 안 잡혀 무의미하다. +probe_dir="$ROOT/.selftest-probe" +probe="$probe_dir/Probe.java" +mkdir -p "$probe_dir" +# EXIT 트랩은 하나뿐이라 앞의 것을 덮는다. 둘 다 지우게 합친다. +trap 'rm -rf "$tmp" "$probe_dir"' EXIT +cat > "$probe" <<'PROBE' +class Probe { + public Probe() { + } +} +PROBE + +# 러너가 이 파일을 실제로 본다는 것부터 확인한다. 안 보면 아래 차단 검증이 +# 통과해도 그건 다른 이유로 막힌 것이다. +# 러너는 위반이 있으면 1 을 낸다. pipefail 아래서 파이프로 바로 받으면 +# grep 이 맞아도 파이프라인이 실패로 읽힌다 — 출력을 먼저 담는다. +runner_out=$("$ROOT/.claude/hooks/review-branch.sh" 2>&1 || true) +probe_seen=0 +printf '%s' "$runner_out" | grep -q '.selftest-probe/Probe.java' && probe_seen=1 + +printf '{"tool_input":{"command":"gh pr create --base develop"}}' \ + | "$ROOT/.claude/hooks/guard-pr.sh" >/dev/null 2>&1 +blocked=$? + +rm -rf "$probe_dir" + +# **저장소 상태에 기대지 않는다.** 개발자의 작업 트리가 더럽다고 이 케이스를 +# 건너뛰면 로컬에서만 조용히 통과하고 CI 에서 처음 드러난다 — 실제로 그렇게 +# 났다. stash 로 씻는 것도 답이 아니다. 스크립트가 중간에 죽으면 남의 작업이 +# stash 로 숨는다. **깨끗한 임시 저장소를 만들어 거기서 시험한다.** +clean_repo="$tmp/clean" +mkdir -p "$clean_repo/.claude/hooks" +cp "$HOOKS/review-branch.sh" "$HOOKS/check-java.sh" "$HOOKS/check-lua.sh" \ + "$clean_repo/.claude/hooks/" 2>/dev/null +chmod +x "$clean_repo/.claude/hooks/"*.sh 2>/dev/null +( + cd "$clean_repo" || exit 1 + # **초기 브랜치를 develop 과 분리한다.** init.defaultBranch=develop 인 + # 환경이면 `git branch develop` 이 실패하고, 그러면 기준과 HEAD 가 같아져 + # 빈 diff 를 검사하고 조용히 통과한다. + git init -q -b selftest-base . + git config user.email t@t + git config user.name t + : > seed.txt + git add -A + git commit -q -m 'chore: 씨앗' + git branch -q develop + git switch -q -c selftest-work + printf 'class Ok {\n private Ok() {\n }\n}\n' > Ok.java + git add -A + git commit -q -m 'feat(a): 깨끗한 변경' -m 'Refs: CY-1' +) >/dev/null 2>&1 + +cp "$HOOKS/guard-pr.sh" "$clean_repo/.claude/hooks/" && chmod +x "$clean_repo/.claude/hooks/guard-pr.sh" + +# 원격이 없는 임시 저장소라 origin/develop 이 없다. 원격 이름을 붙여 두어 +# 가드가 실제와 같은 경로(origin/)를 타게 한다. +git -C "$clean_repo" remote add origin "$clean_repo" >/dev/null 2>&1 +git -C "$clean_repo" update-ref refs/remotes/origin/develop refs/heads/develop + +# 기준과 HEAD 가 같으면 빈 diff 를 보는 것이라 시험이 무의미하다. 먼저 확인한다. +diff_ok=0 +[[ -n "$(git -C "$clean_repo" diff --name-only origin/develop...HEAD 2>/dev/null)" ]] && diff_ok=1 + +clean=$(cd "$clean_repo" && printf '{"tool_input":{"command":"gh pr create --base develop"}}' \ + | "$clean_repo/.claude/hooks/guard-pr.sh" >/dev/null 2>&1; echo $?) + +printf '{"tool_input":{"command":"git status"}}' \ + | "$ROOT/.claude/hooks/guard-pr.sh" >/dev/null 2>&1 +unrelated=$? + +# 검사를 못 돌리는 상황에서 통과시키면 게이트가 조용히 사라진다 (fail closed) +failclosed=$(cd /tmp && printf '{"tool_input":{"command":"gh pr create"}}' \ + | "$ROOT/.claude/hooks/guard-pr.sh" >/dev/null 2>&1; echo $?) + +if ((probe_seen)); then + printf ' ok 러너가 변경된 프로브 파일을 본다\n'; pass=$((pass + 1)) +else + printf ' FAIL 러너가 프로브를 못 본다 — 차단 검증이 무의미하다\n'; fail=$((fail + 1)) +fi +if ((failclosed == 2)); then + printf ' ok 저장소 밖에서는 막는다 (fail closed)\n'; pass=$((pass + 1)) +else + printf ' FAIL 저장소 밖인데 통과시켰다 (exit %d)\n' "$failclosed"; fail=$((fail + 1)) +fi +if ((blocked == 2)); then + printf ' ok 실제 위반에서 PR 생성을 막는다\n'; pass=$((pass + 1)) +else + printf ' FAIL 위반이 있는데 PR 생성을 통과시켰다 (exit %d)\n' "$blocked"; fail=$((fail + 1)) +fi +if ((diff_ok == 0)); then + printf ' FAIL 임시 저장소의 기준과 HEAD 가 같다 — 빈 diff 를 검사한다\n'; fail=$((fail + 1)) +elif ((clean == 0)); then + printf ' ok 깨끗하면 막지 않는다\n'; pass=$((pass + 1)) +else + printf ' FAIL 깨끗한데 막았다 (exit %d)\n' "$clean"; fail=$((fail + 1)) +fi +if ((unrelated == 0)); then + printf ' ok PR 생성이 아닌 명령은 건드리지 않는다\n'; pass=$((pass + 1)) +else + printf ' FAIL 무관한 명령을 막았다 (exit %d)\n' "$unrelated"; fail=$((fail + 1)) +fi + echo printf '통과 %d · 실패 %d\n' "$pass" "$fail" ((fail == 0)) diff --git a/.claude/settings.json b/.claude/settings.json index 60e9689d..e1503d30 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -20,6 +20,10 @@ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/check-commit-msg.sh" + }, + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard-pr.sh" } ] } diff --git a/.coderabbit.yaml b/.coderabbit.yaml index dac20d5b..69f5e4b6 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -30,6 +30,13 @@ reviews: - 'docs:' # 문서만 바꾸는 PR — path_filters 로도 걸리지만 이중으로 - 'docs(' - 'chore(deps)' # dependabot PR 은 dependency-review 가 본다 + # **릴리스는 이미 리뷰가 끝난 커밋을 모은 것이다.** develop→main PR 을 + # 다시 보면 누적 diff 전체를 재검토하게 되고, 지나간 지적으로 + # request_changes 가 걸려 병합까지 막힌다. + # + # base_branches 에서 main 을 빼지는 않는다 — 핫픽스는 main 으로 바로 + # 가는 **새 코드**라 리뷰가 필요하다. 제목으로 가른다. + - 'chore(release)' path_filters: # 문서는 리뷰하지 않는다. diff --git a/.gitattributes b/.gitattributes index 25f5c717..c058b97d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,7 @@ -* text=auto eol=lf -*.jar binary -*.png binary -gradlew text eol=lf -gradlew.bat text eol=crlf +# 작업 로그 색인은 **모두가 맨 위에 한 줄씩** 넣는다. 동시에 도는 브랜치가 +# 여럿이면 매번 같은 자리에서 충돌하고, 그 충돌을 풀자고 리베이스하면 +# 승인된 PR 위에 이력을 다시 쓰게 된다 (force push). +# +# union 은 양쪽 줄을 모두 살린다. 색인은 "빠진 엔트리가 없을 것" 만 검사하므로 +# (CI 규범 검사) 순서가 잠깐 어긋나도 규범이 깨지지 않는다. +ai/journal/index.md merge=union diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 00000000..87296c92 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# git commit-msg 훅 — 터미널에서 직접 커밋해도 규약이 걸린다. +# +# Claude Code 의 PreToolUse 훅만 두면 `git commit` 을 손으로 치는 순간 +# 검사가 통째로 우회된다. 실제로 초기 커밋 전부가 그렇게 빠져나갔다. +# +# 설치: .githooks/install.sh (core.hooksPath 를 이 디렉터리로 잡는다) +set -uo pipefail + +msg_file="$1" +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=lib/commit-subject-rules.sh +source "$here/lib/commit-subject-rules.sh" + +# 주석과 빈 줄을 걷어낸 첫 줄이 제목이다. +subject=$(grep -vE '^[[:space:]]*#' "$msg_file" | grep -vE '^[[:space:]]*$' | head -1) + +# 병합·리버트 커밋은 git 이 문구를 정하므로 규약 대상이 아니다. +case "$subject" in + Merge\ *|Revert\ *|fixup!\ *|squash!\ *) exit 0 ;; +esac + +[[ -z "$subject" ]] && exit 0 # 빈 메시지는 git 이 알아서 막는다 + +if ! violations=$(check_commit_subject "$subject"); then + { + echo "커밋 메시지 규약 위반" + echo " 제목: $subject" + echo + echo "$violations" + commit_rule_help + } >&2 + exit 1 +fi + +# 푸터 — Jira 키는 여기 하나로만 추적한다. +if ! grep -qE '^Refs: CY-[0-9]+' "$msg_file"; then + echo "[WF-1] 푸터에 'Refs: CY-###' 를 남긴다." >&2 + exit 1 +fi + +# 계획서 ID 는 커밋에 남기지 않는다. 문서를 개편하면 바뀌는데 커밋 이력은 +# 못 고쳐서, 박아두면 죽은 참조만 남는다 (60-workflow.md 3절). +if grep -qE '^Plan:' "$msg_file"; then + echo "[WF-1] 계획서 ID 를 커밋에 남기지 않는다. 추적은 'Refs:' 하나로." >&2 + exit 1 +fi + +exit 0 diff --git a/.githooks/install.sh b/.githooks/install.sh new file mode 100755 index 00000000..ff01d290 --- /dev/null +++ b/.githooks/install.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# git 훅 설치. 저장소를 클론한 뒤 한 번 실행한다. +# +# ./.githooks/install.sh +# +# `.git/hooks/` 에 복사하지 않고 core.hooksPath 를 쓴다 — 복사하면 훅을 +# 고칠 때마다 각자 다시 깔아야 하고, 누가 안 깔았는지 알 수 없다. +set -euo pipefail + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +root=$(git -C "$here" rev-parse --show-toplevel) + +chmod +x "$here"/commit-msg "$here"/lib/*.sh +git -C "$root" config core.hooksPath .githooks + +echo "설치됨 — core.hooksPath = .githooks" +echo +echo "확인:" +echo " git -C '$root' config core.hooksPath" +echo +echo "해제하려면:" +echo " git -C '$root' config --unset core.hooksPath" diff --git a/.githooks/lib/commit-subject-rules.sh b/.githooks/lib/commit-subject-rules.sh new file mode 100755 index 00000000..2af05c3d --- /dev/null +++ b/.githooks/lib/commit-subject-rules.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# 커밋 제목 규약 — 검증 규칙의 유일한 출처. ai/rules/60-workflow.md (WF-1) +# +# 이 파일을 두 곳이 쓴다. +# - .claude/hooks/check-commit-msg.sh Claude Code 가 부르는 PreToolUse 훅 +# - .githooks/commit-msg git 이 부르는 훅 (터미널 직접 커밋) +# +# **규칙을 양쪽에 복사하지 않는다.** 사본이 생기면 한쪽만 고쳐지고, 그때부터 +# 어느 쪽이 맞는지 알 수 없다. 도구 훅만 두면 터미널 커밋이 우회하고, +# 한쪽만 막으면 막지 않은 것과 같다. +# +# 사용: check_commit_subject "<제목>" → 위반 메시지를 stdout 으로, 있으면 1 + +check_commit_subject() { + local subject="$1" + local types='feat|fix|test|refactor|perf|docs|build|ci|chore' + local errors=() + + printf '%s' "$subject" | grep -qE "^($types)(\([a-z0-9-]+\))?: .+" \ + || errors+=("형식이 맞지 않는다: '(): ' / 허용 type: $types") + + # 글자 수가 아니라 **표시 폭**으로 센다. 한글은 터미널에서 두 칸을 차지하므로 + # 50자로 재면 100칸이 되어 git log 가 줄바꿈된다. 50칸은 git 의 관례다. + local chars wide width + chars=$(printf '%s' "$subject" | wc -m) + wide=$(printf '%s' "$subject" | grep -oP '[\x{AC00}-\x{D7A3}\x{3130}-\x{318F}]' | wc -l) + width=$((chars + wide)) + ((width > 50)) && errors+=("제목이 ${width}칸이다 (한글은 두 칸). 50칸 이내로 줄인다") + + printf '%s' "$subject" | grep -qE '\.$' \ + && errors+=("제목 끝에 마침표를 쓰지 않는다") + + # 제목은 요약이지 문장이 아니다. 한글 종결어미로 끝나면 서술문이다 — + # 명사형은 '다' 로 끝나지 않으므로 이 한 글자로 갈린다. + printf '%s' "$subject" | grep -qP '다$' \ + && errors+=("제목을 명사형으로 끝낸다. '~했다/한다' 는 요약이 아니라 문장이다") + + # 제목은 한글로 쓴다. type·scope 만 영문이다. + printf '%s' "$subject" | grep -qP '[\x{AC00}-\x{D7A3}]' \ + || errors+=("제목을 한글로 쓴다 (type·scope 는 영문)") + + printf '%s' "$subject" | grep -qE '\bCY-[0-9]+' \ + && errors+=("Jira 키는 제목이 아니라 'Refs: CY-###' 푸터에 둔다") + + ((${#errors[@]} == 0)) && return 0 + + printf ' - %s\n' "${errors[@]}" + return 1 +} + +# 위반 시 함께 보여줄 안내. 두 훅이 같은 문구를 쓴다. +commit_rule_help() { + cat <<'EOF' + +예시: + test(admission): 한산한 쿠폰의 무대기 통과 검증 + feat(admission): 전역 크레딧 기반 통과 상한 산출 + +푸터에 'Refs: CY-###' 를 남긴다. +규칙 전문: ai/rules/60-workflow.md +EOF +} diff --git a/.github/actions/setup-gradle/action.yml b/.github/actions/setup-gradle/action.yml index f588114e..694cfeff 100644 --- a/.github/actions/setup-gradle/action.yml +++ b/.github/actions/setup-gradle/action.yml @@ -37,7 +37,7 @@ runs: fi - if: steps.probe.outputs.buildable == 'true' - uses: actions/setup-java@v4 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: distribution: temurin java-version: ${{ inputs.java-version }} @@ -45,7 +45,7 @@ runs: # 브랜치가 아니라 역할로 가른다. PR 의 ref 는 refs/pull/N/merge 라 # 브랜치로 가르면 PR 에서는 아무도 캐시를 못 쓴다. - if: steps.probe.outputs.buildable == 'true' - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d # v4 with: cache-read-only: ${{ inputs.cache-write != 'true' }} diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index f2e74dc8..797f13aa 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false @@ -36,7 +36,7 @@ jobs: run: ./gradlew --no-daemon assemble testClasses - if: inputs.upload-artifact && steps.setup.outputs.buildable == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: build-libs path: build/libs/ diff --git a/.github/workflows/_load-test.yml b/.github/workflows/_load-test.yml index 90df500e..50a57250 100644 --- a/.github/workflows/_load-test.yml +++ b/.github/workflows/_load-test.yml @@ -34,7 +34,7 @@ jobs: passed: ${{ steps.evaluate.outputs.passed }} summary: ${{ steps.evaluate.outputs.summary }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false @@ -62,7 +62,7 @@ jobs: run: docker compose -f test/load/compose.yml up -d --wait - if: steps.probe.outputs.ready == 'true' - uses: grafana/setup-k6-action@v1 + uses: grafana/setup-k6-action@c00053c996b4061198c9c24a00474b748abdf8b7 # v1 - name: k6 실행 id: run @@ -112,7 +112,7 @@ jobs: run: docker compose -f test/load/compose.yml logs --tail=200 || true - if: always() && hashFiles('k6-summary.json') != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: load-${{ inputs.scenario }} path: k6-summary.json diff --git a/.github/workflows/_report.yml b/.github/workflows/_report.yml index a7108b40..8da01ca0 100644 --- a/.github/workflows/_report.yml +++ b/.github/workflows/_report.yml @@ -78,7 +78,7 @@ jobs: HAS_CONFLUENCE: ${{ secrets.CONFLUENCE_SPACE_KEY != '' && secrets.ATLASSIAN_BASE_URL != '' }} HAS_JIRA: ${{ secrets.ATLASSIAN_BASE_URL != '' && secrets.ATLASSIAN_API_TOKEN != '' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false @@ -88,7 +88,7 @@ jobs: # "알림 실패가 빌드를 깨뜨리지 않는다" 는 전제가 무너진다. - id: confluence if: inputs.confluence-title != '' && env.HAS_CONFLUENCE == 'true' - uses: coupon-yaho/cy-ci-actions/actions/confluence@v1 + uses: coupon-yaho/cy-ci-actions/actions/confluence@c5d6391379ae34d56894538027ff3631ea4923a2 # v1 with: space-key: ${{ secrets.CONFLUENCE_SPACE_KEY }} parent-id: ${{ secrets.CONFLUENCE_PARENT_ID }} @@ -100,7 +100,7 @@ jobs: token: ${{ secrets.ATLASSIAN_API_TOKEN }} - if: always() && inputs.jira-key != '' && env.HAS_JIRA == 'true' - uses: coupon-yaho/cy-ci-actions/actions/jira@v1 + uses: coupon-yaho/cy-ci-actions/actions/jira@c5d6391379ae34d56894538027ff3631ea4923a2 # v1 with: issue-key: ${{ inputs.jira-key }} comment: ${{ inputs.jira-comment }} diff --git a/.github/workflows/_security.yml b/.github/workflows/_security.yml index 0877ff51..07866a24 100644 --- a/.github/workflows/_security.yml +++ b/.github/workflows/_security.yml @@ -47,14 +47,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false # 이미지 빌드 전이라 fs 모드. 차단과 업로드에 SARIF 하나를 같이 쓴다 — # 나누면 같은 스캔이 두 번 돈다 (DB 내려받기까지). - name: Trivy - uses: aquasecurity/trivy-action@v0.36.0 + uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0 with: scan-type: fs scan-ref: . @@ -72,7 +72,7 @@ jobs: - if: >- always() && hashFiles('trivy.sarif') != '' && github.event.pull_request.head.repo.fork != true - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@d6317709a54fd87078d323eeb0e48ec331c8e621 # v3 with: sarif_file: trivy.sarif category: trivy-fs @@ -113,7 +113,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false fetch-depth: 0 # 이력 전체를 봐야 과거 커밋의 유출을 잡는다 @@ -160,7 +160,7 @@ jobs: pull-requests: write steps: # 체크아웃하지 않는다 — 이 액션은 GitHub API 로 의존성 변화를 읽는다 - - uses: actions/dependency-review-action@v4 + - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 with: fail-on-severity: high comment-summary-in-pr: ${{ github.event.pull_request.head.repo.fork && 'never' || 'on-failure' }} diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index ef9bfc88..feba3944 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -8,7 +8,7 @@ on: workflow_call: inputs: layer: - description: unit | integration | context | chaos | mutation + description: unit | integration | context | chaos | mutation | coverage type: string required: true timeout-minutes: @@ -30,7 +30,7 @@ jobs: outputs: result: ${{ steps.run.outcome }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false @@ -48,12 +48,15 @@ jobs: context) ./gradlew --no-daemon contextTest ;; chaos) ./gradlew --no-daemon chaosTest ;; mutation) ./gradlew --no-daemon pitest ;; + # 어댑터는 단위·통합 양쪽에서 덮인다. 계층이 잡으로 갈려 exec 가 + # 한자리에 안 모이므로 여기서 둘을 함께 돌려 합쳐 잰다. + coverage) ./gradlew --no-daemon test integrationTest adapterCoverageVerification ;; *) echo "::error::알 수 없는 계층 '${{ inputs.layer }}'"; exit 1 ;; esac # 실패해도 리포트는 올린다 — 로그만으로는 어느 단언이 깨졌는지 찾기 어렵다 - if: always() && steps.setup.outputs.buildable == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: report-${{ inputs.layer }} path: | diff --git a/.github/workflows/_verify-conventions.yml b/.github/workflows/_verify-conventions.yml index bda9e04d..bcb3fe03 100644 --- a/.github/workflows/_verify-conventions.yml +++ b/.github/workflows/_verify-conventions.yml @@ -26,9 +26,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false + # 자기검증이 브랜치 리뷰 러너를 돌린다. 얕은 체크아웃이면 기준 + # 브랜치가 없어 그 케이스를 건너뛰고, 건너뛴 검사는 없는 것과 같다. + fetch-depth: 0 # 훅이 위반을 잡지 못하면 모든 코드를 통과시킨다 (TS-9) - name: 훅 자기검증 run: | @@ -124,7 +127,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false fetch-depth: 0 @@ -223,7 +226,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - name: 문서 링크 검사 diff --git a/.github/workflows/coderabbit-relay.yml b/.github/workflows/coderabbit-relay.yml index 2254c5b1..febeb5dc 100644 --- a/.github/workflows/coderabbit-relay.yml +++ b/.github/workflows/coderabbit-relay.yml @@ -2,19 +2,16 @@ name: CodeRabbit Relay # CodeRabbit 리뷰 결과를 Slack 과 Jira 로 중계한다. # -# 주의: issue_comment/pull_request_review 는 기본 브랜치의 워크플로 파일로 실행된다. +# 주의: pull_request_review 는 **기본 브랜치의 워크플로 파일**로 실행된다. # 이 파일을 고쳐도 main 에 병합되기 전까지는 반영되지 않는다. +# **코멘트 트리거를 쓰지 않는다.** 코멘트 하나마다 실행이 생기는데, 한 PR 에서 +# 60회가 돌았고 그중 22회는 봇이 아닌 사람의 코멘트가 띄운 것이라 아무 일도 +# 하지 않고 끝났다. 요약은 코멘트가 아니라 **리뷰 본문**에 실린다. on: - issue_comment: - types: [created] pull_request_review: types: [submitted] -concurrency: - group: coderabbit-${{ github.event.issue.number || github.event.pull_request.number }} - cancel-in-progress: false - permissions: contents: read pull-requests: read @@ -23,40 +20,54 @@ jobs: relay: # CodeRabbit 이 남긴 것만. skip-review 가 붙었으면 중계하지 않는다. # - # 두 라벨 배열을 `||` 로 합치지 않는다 — 빈 배열이 truthy 라 PR 라벨을 - # 영영 못 본다. 없는 쪽의 contains 는 false 이므로 따로 건다. + # **판단을 체크아웃 앞에 둔다.** 리뷰 봇은 인라인 지적 묶음마다 리뷰를 + # 하나씩 남기는데, 요약이 없는 것까지 잡을 이유가 없다. 여기서 걸러야 + # 저장소를 받아오지도 않고 끝난다. if: >- - !contains(github.event.issue.labels.*.name, 'skip-review') && !contains(github.event.pull_request.labels.*.name, 'skip-review') && + github.event.review.user.login == 'coderabbitai[bot]' && ( - (github.event_name == 'issue_comment' && - github.event.issue.pull_request != null && - github.event.comment.user.login == 'coderabbitai[bot]') || - (github.event_name == 'pull_request_review' && - github.event.review.user.login == 'coderabbitai[bot]') + contains(github.event.review.body, 'Actionable comments posted') || + contains(github.event.review.body, 'Walkthrough') || + github.event.review.state == 'changes_requested' || + github.event.review.state == 'approved' ) runs-on: ubuntu-latest timeout-minutes: 10 + + # **워크플로 수준에 두지 않는다.** 그쪽은 `if` 보다 먼저 평가되므로, + # 걸러질 실행(사람 리뷰·요약 없는 리뷰)이 **진행 중인 진짜 중계를 취소**한다. + # 취소는 이미 나간 Slack·Jira 전송을 되돌리지도 못해 중간 상태만 남는다. + # + # 그룹에 리뷰 ID 를 넣어 **같은 리뷰의 재실행만** 겹치게 한다. 서로 다른 + # 리뷰는 각각 중계돼야 한다 — 뒤엣것이 앞엣것을 지우면 지적이 사라진다. + concurrency: + group: coderabbit-${{ github.event.pull_request.number }}-${{ github.event.review.id }} + cancel-in-progress: true + steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - name: 이슈 키 추출 id: parse env: - BODY: ${{ github.event.comment.body || github.event.review.body }} - PR_NUM: ${{ github.event.issue.number || github.event.pull_request.number }} - PR_TITLE: ${{ github.event.issue.title || github.event.pull_request.title }} - PR_URL: ${{ github.event.issue.html_url || github.event.pull_request.html_url }} + BODY: ${{ github.event.review.body }} + PR_NUM: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} STATE: ${{ github.event.review.state }} run: | set -uo pipefail - # 요약 코멘트만 중계한다. 인라인 잡담까지 보내면 노이즈가 된다. - if ! grep -qiE 'walkthrough|summary|actionable comments|review' <<<"$BODY"; then + # **요약을 담은 리뷰에만 반응한다.** 이전 조건의 `review` 는 거의 모든 + # 본문에 들어 있어 사실상 필터가 아니었다 — 리뷰 16건 중 3건만 요약이다. + # 승인·변경요청은 본문이 없어도 결론이라 함께 중계한다. + if ! grep -qiE 'actionable comments posted|walkthrough' <<<"$BODY" \ + && [[ "$STATE" != "changes_requested" && "$STATE" != "approved" ]]; then echo "relay=false" >> "$GITHUB_OUTPUT" - echo "::notice::요약이 아닌 코멘트 — 건너뛴다" + echo "::notice::요약이 아닌 리뷰 — 건너뛴다" exit 0 fi echo "relay=true" >> "$GITHUB_OUTPUT" @@ -89,7 +100,7 @@ jobs: id: jira if: steps.parse.outputs.relay == 'true' env: - TITLE: ${{ github.event.issue.title || github.event.pull_request.title }} + TITLE: ${{ github.event.pull_request.title }} run: | set -uo pipefail key=$(grep -oE '(^|[^A-Za-z0-9_])CY-[0-9]+' <<<"$TITLE" | grep -oE 'CY-[0-9]+' | head -1 || true) @@ -102,14 +113,14 @@ jobs: status: ${{ steps.parse.outputs.status }} title: >- CodeRabbit — ${{ steps.parse.outputs.verdict }} - (PR #${{ github.event.issue.number || github.event.pull_request.number }}) + (PR #${{ github.event.pull_request.number }}) text: | - *${{ github.event.issue.title || github.event.pull_request.title }}* + *${{ github.event.pull_request.title }}* ${{ steps.parse.outputs.excerpt }} fields: | 지적=${{ steps.parse.outputs.count }}건 Jira=${{ steps.jira.outputs.key || '없음' }} - link-url: ${{ github.event.issue.html_url || github.event.pull_request.html_url }} + link-url: ${{ github.event.pull_request.html_url }} link-text: PR 열기 # 지적이 있을 때만. 키가 비면 부르지 않는다 — 빈 키로 부르면 실패가 @@ -118,12 +129,12 @@ jobs: steps.parse.outputs.relay == 'true' && steps.parse.outputs.count != '0' && steps.jira.outputs.key != '' - uses: coupon-yaho/cy-ci-actions/actions/jira@v1 + uses: coupon-yaho/cy-ci-actions/actions/jira@c5d6391379ae34d56894538027ff3631ea4923a2 # v1 with: issue-key: ${{ steps.jira.outputs.key }} comment: >- CodeRabbit 리뷰: ${{ steps.parse.outputs.verdict }} — - [PR #${{ github.event.issue.number || github.event.pull_request.number }}](${{ github.event.issue.html_url || github.event.pull_request.html_url }}) + [PR #${{ github.event.pull_request.number }}](${{ github.event.pull_request.html_url }}) base-url: ${{ secrets.ATLASSIAN_BASE_URL }} email: ${{ secrets.ATLASSIAN_USER_EMAIL }} token: ${{ secrets.ATLASSIAN_API_TOKEN }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b50b962f..ffb15c73 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -29,7 +29,7 @@ jobs: version: ${{ steps.meta.outputs.version }} subject-html: ${{ steps.jira.outputs.subject_html }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false fetch-depth: 0 @@ -93,6 +93,11 @@ jobs: uses: ./.github/workflows/_test.yml with: { layer: chaos, timeout-minutes: 45 } + test-coverage: + needs: build + uses: ./.github/workflows/_test.yml + with: { layer: coverage, timeout-minutes: 25 } + # PR 에서는 안 돌린다 — 느리고, 도메인이 안 바뀌면 결과도 안 바뀐다 test-mutation: needs: build @@ -121,7 +126,7 @@ jobs: # 취소를 통과로 세지 않기 위해 별도 잡으로 뺐다. 규칙은 verdict 액션에 있다. verdict: name: 판정 - needs: [context, conventions, build, test-unit, test-integration, test-context, test-chaos, test-mutation, security, load-idle] + needs: [context, conventions, build, test-unit, test-integration, test-context, test-chaos, test-coverage, test-mutation, security, load-idle] if: ${{ !cancelled() }} runs-on: ubuntu-latest timeout-minutes: 5 @@ -129,7 +134,7 @@ jobs: status: ${{ steps.v.outputs.status }} ok: ${{ steps.v.outputs.ok }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - id: v @@ -140,7 +145,7 @@ jobs: report: name: 리포트 needs: [context, conventions, build, test-unit, test-integration, test-context, - test-chaos, test-mutation, security, load-idle, verdict] + test-chaos, test-coverage, test-mutation, security, load-idle, verdict] if: ${{ !cancelled() }} uses: ./.github/workflows/_report.yml with: @@ -155,6 +160,7 @@ jobs: 단위=${{ needs.test-unit.result }} 통합=${{ needs.test-integration.result }} 카오스=${{ needs.test-chaos.result }} + 커버리지=${{ needs.test-coverage.result }} 뮤테이션=${{ needs.test-mutation.result }} 보안=${{ needs.security.result }} 부하(R1)=${{ needs.load-idle.result }} @@ -175,6 +181,7 @@ jobs: 통합 테스트${{ needs.test-integration.result }} 컨텍스트 테스트${{ needs.test-context.result }} 카오스 테스트${{ needs.test-chaos.result }} + 어댑터 커버리지${{ needs.test-coverage.result }} 뮤테이션 테스트${{ needs.test-mutation.result }} 보안 스캔${{ needs.security.result }} 부하 (R1 검증)${{ needs.load-idle.result }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ee14c030..9136d9b7 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -101,7 +101,7 @@ jobs: status: ${{ steps.v.outputs.status }} ok: ${{ steps.v.outputs.ok }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - id: v diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 82b2bccb..218f910c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -27,7 +27,7 @@ jobs: jira-key: ${{ steps.jira.outputs.key }} code: ${{ steps.changes.outputs.code }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false fetch-depth: 0 @@ -119,6 +119,17 @@ jobs: layer: chaos timeout-minutes: 40 + # 어댑터 커버리지는 단위·통합 exec 를 합쳐야 잴 수 있다. 계층 잡은 + # 각자 자기 exec 만 남기므로 여기서 둘을 함께 돌린다. + test-coverage: + name: 테스트 + needs: [context, build] + if: needs.context.outputs.code == 'true' + uses: ./.github/workflows/_test.yml + with: + layer: coverage + timeout-minutes: 25 + # ── 4단계: 보안 ──────────────────────────────────────────────────────────── # 시크릿 검사는 항상 돈다 — 문서에도 토큰을 커밋할 수 있다. # CVE 스캔만 코드가 바뀌었을 때로 좁힌다. @@ -149,7 +160,7 @@ jobs: # 취소를 통과로 세지 않기 위해 별도 잡으로 뺐다. 규칙은 verdict 액션에 있다. verdict: name: 판정 - needs: [context, conventions, build, test-unit, test-integration, test-context, test-chaos, security, load-smoke] + needs: [context, conventions, build, test-unit, test-integration, test-context, test-chaos, test-coverage, security, load-smoke] if: ${{ !cancelled() }} runs-on: ubuntu-latest timeout-minutes: 5 @@ -157,7 +168,7 @@ jobs: status: ${{ steps.v.outputs.status }} ok: ${{ steps.v.outputs.ok }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - id: v @@ -167,7 +178,7 @@ jobs: report: name: 리포트 - needs: [context, conventions, build, test-unit, test-integration, test-context, test-chaos, security, load-smoke, verdict] + needs: [context, conventions, build, test-unit, test-integration, test-context, test-chaos, test-coverage, security, load-smoke, verdict] if: ${{ !cancelled() }} uses: ./.github/workflows/_report.yml with: @@ -183,6 +194,7 @@ jobs: 단위=${{ needs.test-unit.result }} 통합=${{ needs.test-integration.result }} 카오스=${{ needs.test-chaos.result }} + 커버리지=${{ needs.test-coverage.result }} 보안=${{ needs.security.result }} 부하=${{ needs.load-smoke.result }} link-url: ${{ github.event.pull_request.html_url }} diff --git a/CLAUDE.md b/CLAUDE.md index 53317af9..4b24d2db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,8 +20,8 @@ Spring WebFlux 기반 **적응형 대기열 게이트웨이**. 뒷단은 쿠폰 | R4 | 피크 100K / 동시 대기 20,000 | | R5 | **장애 진입·유지·회복 전 구간에서 정합성과 공정성 유지** | -**현재 상태**: 제로베이스 재작성 중. **Phase 1(Foundation) 착수.** -브랜치는 `feature/CY-18-foundation` (에픽 단위 — WF-3 예외). +**현재 상태**: 제로베이스 재작성 중. **Phase 4(Control Plane) 착수.** +Phase 1·2·3 은 닫혔다. 브랜치는 태스크 단위로 딴다 (WF-3). 진행 상황은 [plan/README.md](plan/README.md) 2절, 저장소 소개는 [README.md](README.md). --- @@ -88,7 +88,7 @@ waiting/ │ ├── workflows/_*.yml ← 재사용 워크플로 (잡 단위) │ └── workflows/{pr,main,nightly}.yml ← 진입점 ├── .coderabbit.yaml ← PR 리뷰 규칙 (ai/rules/ 를 참조) -└── src/ ← (Phase 1에서 생성) +└── src/ ← 순수 도메인 (Phase 2) ``` **`ai/` 와 `.claude/` 의 분담** @@ -131,7 +131,7 @@ waiting/ 4. GREEN — 최소 구현 커밋: feat(scope): ... 5. REFACTOR (선택) 커밋: refactor(scope): ... 6. 작업 로그 기록 /journal 또는 ai/journal/ -7. 리뷰 에이전트 실행 해당 영역 에이전트 +7. 로컬 리뷰 /review ← PR 전에 끝낸다 8. PR → develop squash 금지. TDD 사이클 커밋이 이력의 목적이다 ``` @@ -153,9 +153,25 @@ waiting/ --- -## 8. 리뷰 에이전트 +## 8. 리뷰 — PR 을 올리기 전에 끝낸다 -코드를 쓴 뒤 해당 영역 에이전트를 돌린다. 사람 리뷰의 앞단이지 대체가 아니다. +**`/review` 를 돌린다.** 원격에서 지적받고 고치는 왕복은 비싸고, 그 사이 +잘못된 코드가 브랜치에 남는다. 절차 전문: [.claude/commands/review.md](.claude/commands/review.md) + +```bash +.claude/hooks/review-branch.sh # 기계 검사 — CodeRabbit 이 볼 것을 먼저 본다 +./gradlew build jacocoTestCoverageVerification pitest +``` + +`gh pr create` 는 **기계 검사가 통과해야 실행된다** (`.claude/hooks/guard-pr.sh`). +막히면 우회하지 말고 고친다. + +> **왜 브랜치 전체를 다시 보는가.** `check-java.sh`·`check-lua.sh` 는 +> `Write|Edit` 훅이라 **그 도구로 쓴 파일만** 본다. 힙독이나 스크립트로 만든 +> 파일은 통째로 지나가고, 실제로 그렇게 들어간 위반이 CodeRabbit 까지 갔다. + +기계가 통과했다고 끝이 아니다. 해당 영역 에이전트를 돌린다 — 사람 리뷰의 +앞단이지 대체가 아니다. | 에이전트 | 언제 | |---|---| diff --git a/ai/journal/2026/08/AIJ-0009-build-foundation-and-quality-gates.md b/ai/journal/2026/08/AIJ-0009-build-foundation-and-quality-gates.md new file mode 100644 index 00000000..9915b27a --- /dev/null +++ b/ai/journal/2026/08/AIJ-0009-build-foundation-and-quality-gates.md @@ -0,0 +1,87 @@ +--- +id: AIJ-0009 +date: 2026-08-18 +kind: implement +phase: 1 +plan: [1.2.1, 1.2.2, 1.2.3, 1.2.4] +jira: CY-18 +commits: [982cd03, 087c05f, 1f8f4e5, 8b44d04, 4aa524e] +agent: claude-opus-5 +confidence: high +promoted-to: +--- + +# 빌드 기반과 품질 임계 — 검사가 실제로 무는지까지 확인했다 + +## 무엇을 + +Phase 1 의 `1.2` 절을 끝냈다. Gradle 프로젝트, 기동 스모크, JaCoCo·PIT 임계, +testFixtures 소스셋. TDD 사이클(RED → GREEN)을 커밋으로 남겼다. + +빌드 파이프라인이 실제로 도는 것도 이 과정에서 처음 확인했다 — CI 를 만든 뒤 +한 번도 실행해 본 적이 없었고, 첫 실행에서 워크플로 결함 넷이 나왔다. + +## 왜 (근거) + +**툴체인 자동 provisioning.** `toolchain` 만 선언하면 JDK 21 이 없는 기기에서 +`No matching toolchain` 으로 죽는다. 러너는 `setup-java` 가 깔아주지만 개발 +기기는 아무도 안 깔아준다 — 작업 기기가 실제로 24 였고 resolver 없이는 +첫 빌드부터 실패했다. + +**커버리지 임계를 계층별로.** 도메인 분기 100%, 나머지 80%. 도메인은 순수 +계층이라 채울 수 있고, 못 채운다는 것은 도달 불가 분기가 있다는 뜻이며 그건 +설계 문제다. 뮤테이션은 `domain` 한정 — 어댑터까지 돌리면 시간이 폭발하고 +정작 지켜야 할 곳의 신호가 묻힌다. + +**픽스처를 커버리지 대상에서 제외.** 테스트를 돕는 코드까지 포함시키면 본 코드의 +미달을 픽스처가 덮는다. + +**검사의 자기검증.** 위반을 잡지 못하는 검사는 모든 코드를 통과시킨다. 그래서 +둘 다 일부러 깨뜨렸다 — 커버되지 않은 분기를 넣자 `jacocoTestCoverageVerification` +이 빌드를 실패시켰고, 픽스처를 `src/main` 으로 옮기자 `verifyFixturesExcluded` +가 잡았다. 통과하는 것만 보고 넘어갔으면 둘 다 장식이었을 수 있다. + +## 고려했으나 택하지 않은 것 + +- **의존성을 미리 넣기** — Redis·resilience4j·loadbalancer 를 지금 넣으면 + 나중에 `implementation` 한 줄이 줄지만, **어느 페이즈가 무엇을 요구하는지가 + 흐려진다.** 해당 페이즈에 도달할 때 넣는다. +- **픽스처를 `src/test` 에** — 간단하지만 다른 소스셋에서 못 쓴다. 프로덕션에 + 두면 도달 불가 상태를 만드는 생성자가 운영 코드에 노출된다 (TS-3). +- **픽스처 유출을 사람 눈으로 확인** — 한 번은 되지만 배선이 깨지는 것은 + 나중이다. `verifyFixturesExcluded` 로 빌드가 잡게 했다. +- **패키지를 `com.waiting` 으로** — 짧지만 `cy-be` 가 `com.kafkick` 아래를 + 쓰고 있고 두 저장소를 합칠 가능성이 있다. **합칠 때 패키지를 전부 옮기는 + 것이 가장 비싸다.** + +## 확신이 낮은 부분 + +- **PIT 임계 90%(생존 ≤10%)를 실측 없이 정했다.** 지금은 `domain` 패키지가 + 비어 있어 돌지 않는다. Phase 2 에서 실제 도메인이 들어와야 이 값이 타당한지 + 안다. 미달이면 낮추지 말고 결정 항목으로 올린다. +- **JaCoCo 규칙을 `PACKAGE` 요소로 걸었다.** `com.kafkick.waiting.domain.*` 에 + 클래스가 하나도 없으면 규칙이 조용히 통과한다. Phase 2 첫 도메인 클래스가 + 들어올 때 실제로 100% 를 요구하는지 다시 확인해야 한다. + +## 검증 + +- `./gradlew build` 통과 — JDK 24 기기에서 툴체인이 21 을 받아왔다 +- 커버리지 임계 위반 주입 → `branches covered ratio is 0.00, but expected minimum is 0.80` 으로 실패 +- 픽스처를 `src/main` 으로 이동 → `픽스처가 프로덕션 JAR 에 포함됐다` 로 실패 +- 부트 JAR 에 `WaitingApplication` 은 있고 `Fixtures` 는 없음 +- **main 워크플로 전체 통과** (잡 16개, 실패 0) + +## 다음 사람에게 + +**CI 는 만든 시점이 아니라 처음 돌린 시점에 검증된다.** 이 세션에서 워크플로를 +여러 번 고쳤지만 실제로 실행해 본 적이 없었고, 첫 푸시에서 넷이 나왔다. + +1. `run:` 블록 **주석 안의** `${{ ... }}` 를 GitHub 이 표현식으로 파싱해 + 워크플로 전체가 죽었다. 셸 주석이어도 치환이 먼저 일어난다 +2. 재사용 워크플로의 잡 권한은 호출부가 먼저 줘야 한다. `if` 로 안 도는 잡이어도 + **권한 검증이 `if` 평가보다 먼저** 돈다 +3. 형제 저장소를 상대 경로로 링크하면 클론한 사람에게 항상 깨진 링크다 +4. `trivy-action` 은 태그에 `v` 접두사가 붙는다. Dependabot 이 제안한 값에도 + 접두사가 없어 그대로 병합했으면 다시 깨졌다 + +`actionlint` 를 로컬에서 돌리면 1번은 커밋 전에 잡힌다. 훅에 넣을 만하다. diff --git a/ai/journal/2026/08/AIJ-0010-domain-state-and-limiter.md b/ai/journal/2026/08/AIJ-0010-domain-state-and-limiter.md new file mode 100644 index 00000000..d6921833 --- /dev/null +++ b/ai/journal/2026/08/AIJ-0010-domain-state-and-limiter.md @@ -0,0 +1,79 @@ +--- +id: AIJ-0010 +date: 2026-08-19 +kind: implement +phase: 2 +plan: [2.1.1, 2.1.2, 2.1.3, 2.1.4, 2.1.5, 2.1.6, 2.1.7, 2.2.1, 2.2.2, 2.2.3, 2.2.5] +jira: CY-40 +commits: [a8b4e5f, 8730b53, 2eb7b1e, 81ce326, 549e75a, dfd183d, 0dea2e9, f819e64] +agent: claude-opus-5 +confidence: high +promoted-to: +--- + +# 순수 도메인 — 불변식을 생성자로, 상한을 전역 크레딧으로 + +## 무엇을 + +Phase 2 의 `2.1`(쿠폰 상태·통과 상한)과 `2.2`(리미터)를 TDD 로 구현했다. +`CouponState`·`SnapshotMeta`·`SecondWindowLimiter` 와 테스트 픽스처. +프레임워크 의존이 없어 브랜치 100% 를 채운다. + +## 왜 (근거) + +**한산한 쿠폰의 상한을 그 쿠폰의 `credit` 으로 재지 않는다.** `IDLE` 이면 +`credit` 이 0 이라(I1) `credit` 으로 재는 순간 **한산할수록 반드시 큐로 가는 +역전**이 생긴다. 이전 구현이 무너진 지점이 정확히 여기다. 노드 몫의 전역 +크레딧으로 잰다 — 이 한 줄이 R1 이다. + +**나머지를 `max(1, …)` 로 올리지 않는다.** `credit` 10 을 노드 20 이 나누면 정수 +나눗셈으로 전부 0 이 되는데, 1 로 올리면 **20 이 나가 두 배**가 된다. 초과 배분은 +타협 불가라 앞쪽 노드에만 1 을 주어 총합을 가둔다. + +**리미터를 경로별로 나누지 않는다.** 정상 경로와 fail-open 경로가 각자 카운터를 +들면 회복 전이 순간 같은 초에 두 상한이 동시에 열려 1.5× 버스트가 나간다(F4). +리미터는 하나고 **상한만 인자로** 받는다. + +**두 예산을 전부-아니면-전무로 차감한다.** 순서대로 치면 앞엣것을 소비한 뒤 +뒤엣것이 거부할 때 통과하지 않은 요청이 예산을 깎는다. 그 유실은 조용해서 +부하 시험 전까지 안 보인다. + +## 고려했으나 택하지 않은 것 + +- **반납 방식 리미터** — 먼저 잡고 실패하면 돌려주는 쪽이 코드가 짧다. 그런데 + **반납 누락이 곧 조용한 예산 유실**이고, 예외 경로가 하나 늘 때마다 누락 가능성이 + 는다. 미리 확인하고 함께 차감하는 쪽을 골랐다. +- **키별 만료 시각 보관** — 정교하지만 만료 시각을 키마다 들고 있어야 해서 그 + 자체가 메모리다. 초 하나만 들고 바뀌면 통째로 버리는 쪽이 싸다. +- **맵이 가득 찼을 때 통과시키기** — 거부하면 정상 사용자가 막힐 수 있다. 그런데 + 통과시키면 **상한이 무의미해진다** — 공격자가 키를 무한히 넣어 리미터를 끄는 + 경로가 된다. 거부를 골랐다. +- **`idleCap` 을 `CouponState` 밖에** — 상태를 인자로 받는 계산기로 뺄 수도 있다. + 하지만 이 계산이 I1 과 붙어 있어서(credit 0 이라는 사실이 근거다) 같은 곳에 + 두는 편이 근거가 안 흩어진다. + +## 확신이 낮은 부분 + +- **`SecondWindowLimiter` 는 아직 스레드 안전하지 않다.** `HashMap` 을 동기화 없이 + 쓴다. `T2.2.4`(동시성)가 남아 있고 거기서 바꿔야 한다. 지금 상태로 요청 경로에 + 붙이면 카운터가 깨진다. +- **`contendedCap(int, int)` 의 `nodeIndex` 를 누가 주는지 아직 없다.** 노드마다 + 안정적인 번호가 필요한데 그 출처(하트비트 순번? instanceId 해시?)가 Phase 4 다. + 번호가 매 틱 바뀌면 앞쪽 노드가 계속 바뀌어 배분이 출렁인다. + +## 검증 + +- 도메인 분기 커버리지 **100%** — 미달 시 빌드 실패 +- 불변식 위반 조합을 생성자로 만들 수 없다 (I1~I4·I6·음수·null) +- 초과 배분 0 — credit 7종 × 노드 5종 35개 조합에서 노드 합 ≤ credit +- 거부된 요청이 어느 예산도 소비하지 않는다 (G2.12) + +## 다음 사람에게 + +**테스트가 틀렸는데 구현을 의심한 적이 한 번 있었다.** `credit == 0` 에서 큐 깊이가 +무한이 되는지 보려 했는데 `idle()` 은 `waiting` 도 0 이라 첫 분기에서 걸렸다. +**I1 과 I4 가 겹쳐 그 조합의 도달 경로는 `CLOSED` 뿐**이다 — 매진됐는데 갇힌 +사람이 있는 상태. + +불변식을 생성자로 강제하면 이렇게 **테스트가 만들 수 있는 상태 자체가 줄어든다.** +테스트가 안 통과할 때 구현을 고치기 전에 **그 상태가 도달 가능한지**를 먼저 본다. diff --git a/ai/journal/2026/08/AIJ-0011-admission-ladder-and-mutation-gaps.md b/ai/journal/2026/08/AIJ-0011-admission-ladder-and-mutation-gaps.md new file mode 100644 index 00000000..46ab451d --- /dev/null +++ b/ai/journal/2026/08/AIJ-0011-admission-ladder-and-mutation-gaps.md @@ -0,0 +1,112 @@ +--- +id: AIJ-0011 +date: 2026-08-19 +kind: implement +phase: 2 +plan: [2.3.1, 2.3.2, 2.3.3, 2.3.4, 2.3.5, 2.3.6, 2.3.7, 2.3.8, 2.3.9, 2.7.1, 2.7.2, 2.8.1] +jira: CY-42 +commits: [70bbddd, 73445b9, 815f718, 93672ed, cb66d3a, 07bff60, 17b430b, 0d8cd64] +agent: claude-opus-5 +confidence: high +promoted-to: +--- + +# 판정 사다리와, 뮤테이션이 짚어준 경계 + +## 무엇을 + +`AdmissionDecider` 10줄 사다리, `RankEstimator`, ArchUnit 도메인 순수성 검사, +그리고 뮤테이션 테스트가 지목한 경계값 테스트를 채웠다. + +## 왜 (근거) + +**사다리는 순서가 곧 정책이다.** 매진을 맨 앞에 두는 것은 취향이 아니다 — +`dataStale` 뒤에 두면 이미 끝난 쿠폰이 fail-open 상한을 갉아먹는다. +낡음 판정을 큐 검사보다 앞에 두면 **줄 선 사람을 추월시킨다**(F1). +"상태를 모른다"는 것이 추월의 사유가 되지 않는다. + +**큐 상한 검사에 `waiting > 0` 을 함께 건다.** 한산한 쿠폰은 credit 이 0 이라 +용량도 0 이고, 조건이 없으면 `waiting(0) >= 0` 이 참이 되어 **R1 경로가 통째로 +막힌다.** 계획서가 이미 "큐 상한은 `waiting > 0` 일 때만 의미가 있다"고 적어 +두었는데 구현에서 빠뜨렸다. + +**순위는 곱셈 하나다.** 앞선 사람이 샤드에 고르게 흩어져 있다고 본다. 절대 +오차는 커지지만 **앞으로 갈수록 상대 오차가 줄어** 사용자가 체감하는 자리에서 +정확하다. 이 테스트가 보증하는 것은 "입력이 단조면 출력도 단조"까지고, +`localRank` 자체의 단조성은 어댑터 몫이다. + +**도메인 순수성은 리뷰로 못 지킨다.** 한 줄만 새도 조용히 번지고, 그때는 +되돌리는 비용이 만들 때보다 크다. 스프링·레디스·리액터·시계·난수 다섯 방향을 +ArchUnit 이 막는다. + +## 고려했으나 택하지 않은 것 + +- **래치를 `waiting > 0` 관측으로 푸는 것** — 방금 큐로 보낸 사람이 곧바로 + 입장하면 래치가 **영영 안 풀려** 그 노드에서 R1 이 죽는다. 시간 기반으로 + 풀고, 시각 계산은 어댑터가 한다. +- **뮤테이션 8% 를 그냥 두는 것** — 임계 10% 라 게이트는 통과한다. 그런데 생존 + 뮤턴트가 **이미 한 번 버그가 났던 리미터에 몰려** 있었다. 통과를 근거로 + 넘기면 게이트가 눈가림이 된다. +- **판정값 도달성을 사람이 세는 것** — 13개가 우연히 다 나오고 있었을 뿐, + 강제하는 테스트가 없었다. 값이 늘거나 사다리 순서가 바뀌면 조용히 샌다. + +## 리뷰가 짚은 것 + +**리뷰 지적 6건 중 셋이 우리 자신의 MUST 규칙 위반이었다.** 정적 팩토리(JS-12)를 +안 쓴 것, 그래서 `idleCreditRatio` 검증이 사다리 10번 줄까지 밀린 것. 잘못된 +설정으로도 토큰·bypass·fail-open 은 정상으로 돌아가다가 **한산한 쿠폰 요청 +하나에서 원인과 먼 곳에서 터진다.** + +**키 슬롯 포화를 쿠폰 예산 고갈로 분류하고 있었다.** 둘은 조일 대상이 다르다 — +`maxKeys` 를 조여야 하는데 운영자는 쿠폰을 본다. `ENQUEUE_KEY_SATURATED` 를 +새로 두고 계획서 사다리 9번을 함께 고쳤다. **예산을 먼저 보고 자리를 나중에 +본다** — 예산이 말랐다면 그 키는 이미 자리를 잡고 있어 자리 문제가 아니다. + +**`globalRank` 의 곱셈이 넘치면 음수가 된다.** 순위가 뒤로 가므로 이 클래스가 +지키기로 한 "역행 0" 을 스스로 깨는 경로였다. 10만 시퀀스 테스트는 현실적인 +범위만 훑어서 못 잡았다. `CouponState.queueCapacity` 가 이미 같은 이유로 +`multiplyExact` 를 쓰고 있었는데 여기서 빠뜨렸다. + +**전역 키와 쿠폰 키에 접두사가 없었다.** 쿠폰 ID 하나가 전역 키와 같아지면 두 +예산이 한 카운터로 합쳐진다. + +거절한 것은 하나다 — 도메인 Javadoc 을 줄이라는 지적. 본문이 3~4줄로 JS-6 +한도 안이고, 내용도 JS-6 이 "쓴다" 로 규정한 *왜* 와 *재검토 조건* 이다. + +## 확신이 낮은 부분 + +- **남은 생존 뮤턴트 3건은 등가라고 판단했다.** `cap <= 0` → `cap < 0` 은 + `cap == 0` 에서 바로 다음 줄 `current >= cap` 이 같은 결과를 낸다. 논증으로 + 확인했을 뿐 도구가 보장해 주지는 않는다. +- **`RankEstimator` 는 균등 분포를 가정한다.** 샤드가 CRC16 으로 갈리므로 대체로 + 맞지만, 쿠폰 하나에 트래픽이 쏠린 순간의 편차는 Phase 3 에서 실측해야 한다. + +## 검증 + +- 도메인 분기 커버리지 **100%** · 테스트 110건 +- 뮤테이션 생존 **2.2%** (임계 10%) — 8% 에서 내림 +- 순위 역행 **0 / 10만 시퀀스** +- 크레딧 초과 배분 **0 / 10만 회**, 동시에 부족분도 0 +- 판정값 14종 전부 도달 가능 +- **종료 게이트 G2.1~G2.17 17건 전건 충족** + +게이트를 항목별로 훑다가 셋이 비어 있는 것을 찾았다 — `G2.15`(바깥 계층 의존 +0 · 루트 패키지), `G2.17`(래치 해제 시 통과 복귀), `G2.14`(토큰 보유자가 쿠폰 +상한으로 안 막힌다). 앞의 둘은 다른 테스트들이 **사실상** 덮고 있었지만 +**그 명제를 직접 주장하는 테스트는 없었다.** 셋 다 채웠다. + +## 다음 사람에게 + +**뮤테이션 테스트를 "게이트 숫자"로만 보면 손해다.** 8% 는 통과였는데, 그 8% +안에 상한이 정확히 0 일 때·키 자리가 딱 찼을 때·두 상한이 같을 때가 들어 +있었다. 전부 실제로 오는 값이다. 임계를 넘겼는지 보기 전에 **어디가 살아 +남았는지**를 본다. + +**"이미 한 번 고친 자리" 를 또 짚였다.** `multiplyExact` 는 `queueCapacity` 에 +이미 있었고, 같은-키 합산은 이전 PR 에서 이미 한 번 버그가 났었다. 같은 종류의 +결함이 새 클래스에서 되살아난다 — 새 파일을 쓸 때 **같은 패키지에서 앞서 고친 +것을 먼저 훑는다.** + +그리고 Jira 는 CI 가 부모만 전이시킨다. **하위 작업은 아무도 안 건드린다** — +부모가 완료인데 하위가 전부 `할 일` 인 상태가 Phase 1 에서 그대로 남아 있었다. +페이즈를 닫을 때 하위까지 훑는다. diff --git a/ai/journal/2026/08/AIJ-0012-allocation-polling-smoothing.md b/ai/journal/2026/08/AIJ-0012-allocation-polling-smoothing.md new file mode 100644 index 00000000..7723c35d --- /dev/null +++ b/ai/journal/2026/08/AIJ-0012-allocation-polling-smoothing.md @@ -0,0 +1,121 @@ +--- +id: AIJ-0012 +date: 2026-08-19 +kind: implement +phase: 2 +plan: [2.4.1, 2.4.2, 2.4.3, 2.4.4, 2.5.1, 2.5.2, 2.5.3, 2.5.4, 2.6.1, 2.6.2, 2.6.3, 2.7.3] +jira: CY-43 +commits: [75136fd, 5d47243, 2523b1f] +agent: claude-opus-5 +confidence: high +promoted-to: +--- + +# 배분·폴링·평활화 — "2패스" 가 계획서 자신의 목표와 부딪혔다 + +## 무엇을 + +Phase 2 의 남은 네 묶음. 공정 배분(2.4), 폴링 간격과 예산(2.5), 평활화와 +히스테리시스(2.6), ETA 와 표시 구간(2.7.3). + +## 왜 (근거) + +**배분은 기아 불가와 유휴 낭비 0 을 동시에 만족해야 한다.** 균등하게만 나누면 +한산한 쿠폰이 못 쓰고 남긴 몫이 버려지고, 요구량 비례로만 나누면 몰리는 쿠폰 +하나가 전부 가져가 나머지가 굶는다. + +**재고가 요구량의 천장이다.** 재고 3 개에 100 명을 통과시키면 97 명이 헛걸음하고, +그만큼의 크레딧을 다른 쿠폰이 못 쓴 채 버린다. `IDLE ⟹ credit == 0`(I1)의 +출처가 여기라는 것이 테스트로 드러난다 — 줄이 없으면 요구량이 0 이다. + +**폴링 부하는 큐를 훑지 않고 구한다.** 2만 명을 세면 그 계산 자체가 부하다. +배수율을 알면 각 밴드 인원은 곱셈 몇 번이다. 가장 먼 밴드가 30초라서 계획서의 +`waiting / 30` 이 성립한다 — 밴드 표가 그 식의 출처다. + +**매진 큐를 폴링 예산에서 뺀다.** 10만 명이 갇힌 죽은 큐가 예산의 83% 를 먹으면 +살아 있는 쿠폰의 간격까지 늘어난다. 배분에서 구조적으로 막아 둔 기아가 +**폴링 경로로 되살아나는** 자리다. + +**ETA 는 평활화한 값으로 나눈다.** GC 스파이크 한 번에 표시 시간이 두 배가 되면 +사용자는 그것을 서비스가 망가진 신호로 읽는다. 표시는 거친 구간으로 한다 — +초 단위로 보여 주면 1초씩 줄다 멈추는 것이 보인다. + +**"계산 중" 과 "10분 이상" 을 뭉치지 않는다.** 뭉치면 사용자가 떠날지 판단할 +근거를 잃는다. + +## 계획서를 고친 것 + +**`T2.4.2` 의 예시를 956 → 957 로 고쳤다.** "2패스 고정" 으로는 쿠폰이 많고 +요구량이 들쭉날쭉할 때 **무작위 10만 회 중 4,282 건에서 줄 수 있는 몫을 +남겼다.** 계획서 자신의 완료 조건이 "유휴 낭비 0" 이라 둘이 충돌했다. + +잔여가 마를 때까지 돌리는 쪽으로 맞췄다. 예시가 `957 + 40 + 3 = 1000` 으로 +정확히 떨어진다. **2패스는 하한이지 상한이 아니다.** + +## 고려했으나 택하지 않은 것 + +- **나머지를 앞쪽 쿠폰에 주기** — 총합은 맞지만 등록 순서가 유리해진다. + 노드마다 다른 쪽을 고르면 총합이 전역 크레딧을 넘는다. 굶주린 수보다 적게 + 남으면 멎고 다음 틱으로 넘긴다. +- **폴링 간격을 클라이언트가 정하기** — 부하를 정하는 것은 대기 인원이 아니라 + 큐의 시간 깊이이고, **개인은 그것을 모른다** (D-2). +- **지터를 도메인에서 직접 만들기** — 난수원을 직접 부르면 실패를 재현할 수 + 없다. 주입받는다 (DS-1). +- **`CreditSmoother` 를 0 에서 시작하기** — 첫 몇 틱 동안 실제보다 한참 낮은 + 값이 나가고 그 사이 표시 ETA 가 몇 배로 뛴다. 첫 관측치를 초기값으로 둔다. + +## 리뷰가 짚은 것 + +**여덟 건 중 셋이 또 우리 자신의 MUST 규칙 위반이었다** — JS-6(Javadoc 은 "왜"), +JS-12(정적 팩토리), JS-13(`private static` 헬퍼 금지). 새 패키지를 만들 때마다 +같은 자리에서 다시 샌다. + +**폴링 밴드 인원을 반올림으로 잡고 있었다.** 배수가 아주 느리면 맨 앞사람 +(ETA 0)이 첫 밴드에서 빠져 예산을 **과소 추정**한다. 그러면 `pollScale` 이 안 +올라 실제 부하가 예산을 넘는다 — 오차의 방향이 나쁜 쪽이었다. 누적 상한을 +올림으로 바꿨다. + +**`Snapshot` 의 정규 생성자가 무검증이었다.** `new Snapshot(NaN, true)` 를 +되살리면 그 순간부터 EWMA 가 영영 NaN 이고, **리더가 바뀐 뒤에야** 표시 ETA 가 +죽은 것으로 드러난다. F9 를 막으려고 만든 이월 경로가 F9 보다 나쁜 고장을 +들여올 뻔했다. + +**히스테리시스가 음수 임계를 받고 있었다.** `of(-1, -1, 3)` 이면 수요가 0 이어도 +`load(0) >= -1` 이 참이라 **아무도 안 왔는데 대기열이 켜진다.** + +**속성 테스트의 단언이 느슨했다.** 유휴 낭비를 `stillHungry × 2` 로 봤는데 +잔여 3 · 굶주림 2 인 누수가 그 사이로 빠진다. `stillHungry` 로 조였다. + +**테스트 이름이 거짓말을 하고 있었다.** `음수_크레딧은_거부한다` 가 실제로는 +`0` 만 넘기고 허용을 확인했다. 배분은 배경 작업이라 예외를 던지면 스케줄러가 +멎고 전 쿠폰의 크레딧이 낡는다 — 0 으로 보정하는 쪽이 맞아서 이름을 고쳤다. + +`FairShareAllocator.distribute` 의 `private static` 지적만 반영하지 않았다. +JS-14 의 유틸리티 클래스라 인스턴스가 없다. + +## 확신이 낮은 부분 + +- **밴드 간격 1/3/10/30 초 중 확정된 것은 30 뿐이다.** 계획서의 예산 식 + `waiting / 30` 이 마지막 밴드를 못 박았고 나머지 셋은 내가 정했다. Phase 6 + 실측에서 예산 4,000 RPS 와 맞는지 봐야 한다. +- **ETA 버킷 경계 30/90/450 초도 근거가 약하다.** "약 1분 / 약 5분 / 10분 이상" + 이라는 문구에서 역산했다. 사용자가 이탈을 결정하는 구간이 어디인지는 + 실측 없이 못 정한다. +- **히스테리시스 최소 유지 틱수의 기본값이 없다.** 진입 100% · 해제 70% 는 + 계획서에 있는데 유지 시간은 없다. 테스트는 3틱으로 뒀다. + +## 검증 + +- 테스트 **191건** · 도메인 분기 **100%** +- 뮤테이션 생존 **2.8%** (283개 중 8, 전부 등가) +- 초과 배분 **0 / 10만** · 요구량 초과 **0 / 10만** · 유휴 낭비 **0 / 10만** + +## 다음 사람에게 + +**계획서의 숫자 하나가 계획서의 다른 문장과 부딪힐 수 있다.** `T2.4.2` 는 +"956" 이라는 예시와 "유휴 낭비 0" 이라는 완료 조건을 함께 적어 뒀는데 둘이 +동시에 성립하지 않는다. **예시를 맞추면 조건이 깨지고, 조건을 맞추면 예시가 +틀린다.** 속성 테스트가 없었으면 예시를 맞추고 넘어갔을 것이다. + +구현이 계획서와 다르게 나올 때 **먼저 의심할 것은 구현이지만, 계획서가 스스로 +모순인 경우가 있다.** 그때는 어느 쪽이 목표에 가까운지로 가른다. diff --git a/ai/journal/2026/08/AIJ-0013-local-review-before-pr.md b/ai/journal/2026/08/AIJ-0013-local-review-before-pr.md new file mode 100644 index 00000000..f408c1b1 --- /dev/null +++ b/ai/journal/2026/08/AIJ-0013-local-review-before-pr.md @@ -0,0 +1,86 @@ +--- +id: AIJ-0013 +date: 2026-08-19 +kind: implement +phase: 3 +plan: [] +jira: CY-227 +commits: [81f92c2] +agent: claude-opus-5 +confidence: medium +promoted-to: +--- + +# 훅이 못 본 파일들 — PR 전에 브랜치 전체를 다시 본다 + +이전 맥락: [AIJ-0012](AIJ-0012-allocation-polling-smoothing.md) 에서 리뷰 지적 +14건을 받았고 그중 셋이 우리 자신의 MUST 규칙 위반이었다. 그때는 "새 패키지를 +만들 때마다 같은 자리에서 다시 샌다" 고만 적었는데, **원인이 사람이 아니라 +배선이었다.** + +## 무엇을 + +`review-branch.sh`(브랜치 전체 기계 검사), `guard-pr.sh`(PR 생성 차단), +`/review` 커맨드. `check-java.sh` 의 `@Nested` 오탐 수정. + +## 왜 (근거) + +**`check-java.sh` 는 `Write|Edit` PostToolUse 훅이라 그 도구로 쓴 파일만 본다.** +Phase 2 의 파일은 대부분 힙독과 스크립트로 만들었고, 그것들은 훅을 통째로 +지나갔다. 규칙 위반이 두 라운드에 걸쳐 원격 리뷰까지 간 진짜 이유가 이것이다. + +**검사 내용을 복사하지 않는다.** 러너는 기존 훅을 그 훅의 입력 형식으로 호출할 +뿐이다 — 사본이 생기면 갈라지고, 갈라진 사본은 오탐을 낸다. + +**차단이 리마인더보다 낫다.** "올리기 전에 돌려라" 는 잊힌다. 실제로 잊었다. + +## 자기검증이 세 결함을 덮고 있었다 + +러너를 만들고 자기검증 6건을 붙였는데 **전부 통과했다.** 그런데 리뷰 +에이전트가 셋을 찾았다. + +**① 상대경로.** `git diff --name-only` 는 `src/test/...` 를 준다 — 선행 슬래시가 +없다. `*/src/test/*` 가 안 맞아 **테스트 전용 검사는 한 번도 안 돌았고(미탐), +동시에 테스트가 프로덕션 규칙으로 검사됐다(오탐).** 자기검증은 프로브를 +절대경로 임시 디렉터리에 써서 **실제 호출 경로를 안 탔다.** + +**② JS-6 사본.** 러너가 `check-java.sh` 가 이미 하는 검사를 awk 로 다시 +구현해 뒀고, 한 줄 `/** … */` 처리 분기가 빠져 오탐을 냈다. 자기검증은 +`check-java.sh` 가 먼저 같은 규칙 ID 를 출력해서 **사본을 통째로 지워도 +통과했다** — 약한 단언이다. + +**③ `@Nested` 면제 범위.** 어노테이션 두 줄만 건너뛰어 `@Tag` 하나만 더 붙으면 +오탐이 났다. 그리고 훅 동작을 바꾸면서 **그 동작을 고정하는 회귀 케이스를 안 +넣었다** — 자기검증을 강화하는 커밋에서. + +셋 다 회귀 케이스로 못 박았다. 자기검증 60 → 68 건. + +## 고려했으나 택하지 않은 것 + +- **`Bash` PostToolUse 훅으로 파일 쓰기를 잡기** — 힙독 안의 경로를 파싱해야 + 하는데 형태가 무한하다. 파싱이 틀리면 오탐이고, 오탐은 우회를 부른다. + 변경 집합을 git 에게 묻는 쪽이 확실하다. +- **`gh pr create` 를 막지 않고 경고만** — 경고는 지나친다. 이 저장소가 + 차단으로 얻은 것들(커밋 규약, 경로 보호)이 이미 그 증거다. + +## 확신이 낮은 부분 + +- **`guard-pr.sh` 의 base 추출이 얕다.** `--base origin/develop` 처럼 이미 + 접두가 붙은 입력을 아직 안 다룬다. +- **러너가 보는 규칙은 `.coderabbit.yaml` 의 일부다.** 판정 순서의 타당성, + 불변식이 실제로 지켜지는지는 여전히 에이전트와 사람 몫이다. + +## 검증 + +- 러너 자기검증 8건 · 훅 자기검증 67건 전건 통과 +- 이미 병합된 Phase 2 코드에 돌려 **위반 3건 발견** — FQDN 2곳, + `private static` 2개, `@Nested` 오탐 + +## 다음 사람에게 + +**"자기검증이 통과했다" 가 "검사가 맞다" 를 뜻하지 않는다.** 이 저장소는 그 +전례를 이미 갖고 있었는데(픽스처가 도달 불가 상태를 만들 수 있어 핵심 버그가 +3개월 살아남았다) **하네스 자신에게 같은 일이 났다.** + +프로브를 실제 호출 경로로 태우지 않으면, 하네스는 자기가 시험하는 코드가 아니라 +**자기가 만든 이상적인 입력**을 시험한다. 프로브 경로 하나가 달라서 셋을 놓쳤다. diff --git a/ai/journal/2026/08/AIJ-0014-redis-wiring.md b/ai/journal/2026/08/AIJ-0014-redis-wiring.md new file mode 100644 index 00000000..8db1455e --- /dev/null +++ b/ai/journal/2026/08/AIJ-0014-redis-wiring.md @@ -0,0 +1,73 @@ +--- +id: AIJ-0014 +date: 2026-08-19 +kind: implement +phase: 3 +plan: [3.1.1, 3.1.2, 3.1.3] +jira: CY-228 +commits: [58e9f09, 72d4ad0, 0938d93, 2f79070, 92335ba] +agent: claude-opus-5 +confidence: high +promoted-to: +--- + +# 레디스 배선 — 시간 예산을 설정이 아니라 시동으로 지킨다 + +## 무엇을 + +리액티브 클라이언트 의존성, 실물 컨테이너 픽스처, 커넥션 설정과 시동 시 검증. + +## 왜 (근거) + +**요청 경로는 레디스를 치지 않지만(RD-4) 제어 평면은 쳐야 한다.** 스케줄러와 +큐가 그렇다. 배선이 없으면 Phase 4 착수 시점에야 드러난다. + +**Jedis 를 쓰지 않는다.** 블로킹이라 요청 경로가 아니라 **배경 루프가 먼저 +멎는다** (RX-1). 드라이버가 Lettuce 인지도 테스트로 고정했다 — 의존성 하나 +바뀌면 조용히 갈린다. + +**컨테이너는 JVM 당 하나만 띄운다.** 클래스마다 띄우면 전체 시험이 분 단위로 +늘어난다. 인메모리 대역을 쓰지 않는 이유는 이 페이즈가 지키려는 것이 **Lua 의 +복제 동작과 시계**이고, 대역으로는 둘 다 확인할 수 없기 때문이다 (TS-3). + +**타임아웃 기본값은 무한이다.** 명령 하나가 틱(1s)을 넘겨 붙들면 그 틱의 배분이 +통째로 밀리고, 연결이 리스(2s)보다 오래 걸리면 그 사이 리더십을 잃는다. +`timeout 500ms` · `connect-timeout 1s` 로 두었다. + +**주석 대신 시동 검증으로 막았다.** 값을 바꾸는 사람은 주석을 안 읽는다. +어긋나면 **안 뜨게** 해야 배분이 멎는 사고로 배우지 않는다. 그 검증이 실제로 +무는지도 함께 고정했다 — 통과만 하는 검사는 모든 설정을 통과시킨다. + +**풀을 켜지 않는다.** Lettuce 는 커넥션 하나를 멀티플렉싱하므로 풀을 얹어도 +처리량은 안 늘고 노드마다 커넥션 수가 달라져 레디스 쪽 한계를 예측할 수 없다. +실제 강제는 설정이 아니라 **`commons-pool2` 를 의존성에 안 넣는 것**이라, +테스트도 그 클래스가 없는지를 본다. + +## 막혔던 것 + +**Testcontainers 가 데몬을 못 찾았다.** `docker info` 는 되는데 테스트 JVM 만 +실패해서 소켓 권한을 한참 봤는데, 실제 원인은 그게 아니었다 — +`docker-java` 가 **API 1.32 로 협상하는데 데몬 최소가 1.40** 이었다. + +로그를 `-i` 로 열기 전까지 "Could not find a valid Docker environment" 라는 +문구가 권한 문제처럼 보였다. **에러 문구가 원인을 가리키지 않을 때가 있다.** + +## 확신이 낮은 부분 + +- **`DOCKER_API_VERSION` 을 1.43 으로 못 박았다.** 이미 설정돼 있으면 그대로 + 두지만, 이보다 낮은 데몬을 쓰는 사람이 있으면 거기서 막힌다. 팀 환경이 + 갈리면 다시 볼 자리다. +- **`timeout 500ms` 가 실제 부하에서 충분한지 모른다.** Phase 6 실측 전까지는 + 틱보다 짧다는 것만 근거다. + +## 검증 + +- 단위 203건 · 통합 3건 전건 통과 +- 실물 레디스에 붙어 `PING` · 쓰기·읽기 확인 +- 예산 검증이 무는지 4건으로 고정 (틱 초과 · 리스 초과 · 미설정 · 정상) + +## 다음 사람에게 + +**설정값의 근거는 주석이 아니라 코드에 둔다.** 이 프로젝트에는 "타임아웃의 +50%", "lease = 2틱" 처럼 서로 묶인 숫자가 여럿인데, 묶인 관계를 문서에만 +적으면 한쪽을 바꿀 때 다른 쪽이 조용히 어긋난다. 시동에서 깨지면 그 순간 안다. diff --git a/ai/journal/2026/08/AIJ-0015-key-scheme-and-shard-hash.md b/ai/journal/2026/08/AIJ-0015-key-scheme-and-shard-hash.md new file mode 100644 index 00000000..398c784a --- /dev/null +++ b/ai/journal/2026/08/AIJ-0015-key-scheme-and-shard-hash.md @@ -0,0 +1,74 @@ +--- +id: AIJ-0015 +date: 2026-08-19 +kind: implement +phase: 3 +plan: [3.2.1, 3.2.2, 3.2.3, 3.2.4, 3.2.5] +jira: CY-229 +commits: [6f8ef4d, a164f37, 041f470, 8643b8a, 9f5453a] +agent: claude-opus-5 +confidence: high +promoted-to: +--- + +# 키 스킴 — 한 번 정하면 못 바꾸는 것 + +## 무엇을 + +`RedisKeys`(전역·쿠폰별 키, 샤드 접미사), `ShardHash`(CRC16 배정), 분포 균등성과 +해시태그 슬롯 일치 검증. + +## 왜 (근거) + +**샤드가 하나면 접미사를 붙이지 않는다.** `{cid:0}` 으로 통일하면 깔끔해 보이지만, +그러면 **샤딩 코드를 도입하는 순간** 콜드 쿠폰 전체의 키가 바뀐다. 접미사 없는 +형식을 유지하면 S=1 쿠폰의 키는 영원히 안정적이다 (3.1절). + +**키를 한 곳에서만 만든다** (RD-3). 두 곳에서 만들어지면 샤딩을 도입할 때 +한쪽만 고쳐지고, 그때는 진행 중인 큐가 통째로 유실된다. + +**`String.hashCode()` 를 쓰지 않는다.** 판이 바뀌면 값이 달라질 수 있고, 그 +순간 **전원이 다른 샤드로 옮겨 간다.** 같은 사람이 틱마다 다른 큐에 서면 순위가 +앞뒤로 튀어 불변식 3(순번 역행 0)이 깨진다. CRC16-CCITT 를 직접 구현하고 +**알려진 값으로 못 박았다** — 구현이 바뀌면 바로 드러난다. + +**클라이언트 입력이 키 이름에 들어가는 경로를 전부 의심한다** (PK-R5). +`{`·`}`·`:` 가 섞이면 해시태그가 갈려 슬롯이 엉뚱한 곳으로 가고, 하나의 Lua 가 +원자적으로 다룰 수 없어 클러스터에서 거부된다. + +**재고 키는 만지지 않는다.** 발급 계층이 소유하고 샤드와 무관해 샤딩하면 슬롯이 +갈린다. 슬롯이 다르다는 사실 자체를 테스트로 남겼다 — 나중에 Lua 에 넣고 싶어질 +때 그 테스트가 이유를 말해 준다. + +## 고려했으나 택하지 않은 것 + +- **`String.hashCode() % shards`** — 한 줄이면 되고 지금은 잘 돈다. 그런데 + sticky 보장이 **언어 구현에 달려 있다.** 우리가 통제하지 못하는 것에 불변식을 + 걸지 않는다. +- **클러스터를 띄워 슬롯을 확인** — 사실은 그쪽이 확실하다. 다만 되먹임이 + 느려서 슬롯 계산을 직접 해 먼저 본다. 실물 확인은 `T3.8.3` 이 따로 한다. +- **샤드 범위 초과를 0 으로 보정** — 관대해 보이지만 **아무도 안 보는 키가 + 생기고 그 큐는 영영 안 빠진다.** 거부한다. + +## 확신이 낮은 부분 + +- **분포 시험이 `member-N` 과 UUID 두 형식뿐이다.** 실제 `memberId` 형식을 + 아직 모른다. 접두사가 같고 뒤만 다른 경우를 넣긴 했지만, 실제 값이 훨씬 + 치우친 형태일 수 있다. +- **편차 5% 기준의 근거가 약하다.** 계획서 수치를 그대로 썼다. 샤드가 16 보다 + 많아지면 표본당 기대값이 줄어 이 기준이 뻑뻑해진다. + +## 검증 + +- 단위 222건 전건 통과 +- CRC16 알려진 값 3종 일치 (`""`·`"123456789"`·`"A"`) +- 10만 건 × 2형식에서 샤드별 편차 5% 미만 +- 같은 샤드 다섯 키의 슬롯 일치, 샤드가 다르면 갈림 + +## 다음 사람에게 + +**"나중에 바꾸면 되지" 가 안 통하는 자리가 있다.** 키 형식이 그렇다 — 바꾸는 +순간 진행 중인 큐를 잃는데, 그건 사용자가 기다린 시간을 잃는 것이다. + +그래서 지금 안 쓰는 샤드 접미사까지 형식을 정해 뒀다. 쓸 일이 없어 보이는 코드가 +아니라, **나중에 못 고칠 것을 지금 고정해 둔 것**이다. diff --git a/ai/journal/2026/08/AIJ-0016-workflow-hygiene.md b/ai/journal/2026/08/AIJ-0016-workflow-hygiene.md new file mode 100644 index 00000000..d4deb8d4 --- /dev/null +++ b/ai/journal/2026/08/AIJ-0016-workflow-hygiene.md @@ -0,0 +1,74 @@ +--- +id: AIJ-0016 +date: 2026-08-19 +kind: implement +phase: 3 +plan: [] +jira: CY-243 +commits: [bee77cb] +agent: claude-opus-5 +confidence: high +promoted-to: +--- + +# 리뷰 중계가 코멘트마다 돌던 것 + +## 무엇을 + +리뷰 중계 워크플로의 트리거를 좁히고, 조직 액션 참조를 커밋 해시로 고정했다. + +## 왜 (근거) + +**한 PR 에서 실행이 60회 돌았고 그중 22회는 봇이 아닌 사람의 코멘트가 띄운 +것이라 아무 일도 하지 않고 끝났다.** 원인이 셋 겹쳤다. + +**① `issue_comment` 트리거.** PR 의 모든 코멘트가 실행을 만든다 — 리뷰에 다는 +답글까지. 그런데 API 로 확인해 보니 **요약은 코멘트가 아니라 리뷰 본문에 +실린다.** 리뷰 16건 중 3건이 요약을 갖고, 이슈 코멘트는 0건이었다. + +**② 필터가 필터가 아니었다.** 조건에 `review` 라는 낱말이 들어 있었는데, 그건 +거의 모든 본문에 있다. 요약이 3건인데 **16건 모두 중계돼** 같은 내용이 열세 번 +더 갔다. + +**③ 취소 없는 동시성 그룹.** 연달아 오는 리뷰가 안 뭉쳐졌다. + +처음에는 워크플로 수준에 `cancel-in-progress` 를 걸었는데 **그쪽은 `if` 보다 +먼저 평가된다.** 걸러질 실행(사람 리뷰·요약 없는 리뷰)이 **진행 중인 진짜 +중계를 취소**하고, 취소는 이미 나간 Slack·Jira 전송을 되돌리지도 못해 중간 +상태만 남는다. 잡 수준으로 내리고 그룹에 리뷰 ID 를 넣어 **같은 리뷰의 +재실행만** 겹치게 했다 — 서로 다른 리뷰는 각각 중계돼야 한다. + +**판단을 체크아웃 앞으로 옮겼다.** 요약 없는 리뷰는 저장소를 받아오지도 않고 +끝난다 — 잡 안에서 거르면 이미 비용을 치른 뒤다. + +**조직 액션도 커밋 해시로 고정했다.** 조직 소유라 위험이 낮다고 봐 왔는데, +**이 프로젝트에서 실제로 그 태그를 옮긴 적이 있다.** 움직이는 참조는 어제 +검증한 것과 오늘 도는 것이 다를 수 있다는 뜻이다. 갱신 비용이 드는 것이 +목적이다 — 무엇이 도는지 모르는 편보다 낫다. + +## 고려했으나 택하지 않은 것 + +- **`issue_comment` 를 남기고 작성자만 거르기** — 이미 그렇게 하고 있었고, + 그래도 **실행 자체는 생긴다.** 건너뛴 실행도 대기열을 먹고 이력을 어지럽힌다. +- **중계를 아예 없애기** — 지적이 있었는지를 Jira 에서 보는 것이 목적이라 + 없애면 그 고리가 끊긴다. 노이즈가 문제였지 기능이 문제가 아니다. + +## 확신이 낮은 부분 + +- **`Walkthrough` 와 `Actionable comments posted` 라는 문구에 기댄다.** 리뷰 + 도구가 문구를 바꾸면 조용히 안 돌게 된다. 문구가 아니라 구조로 가를 방법이 + 아직 없다. + +## 검증 + +| 항목 | 전 | 후 | +|---|---|---| +| PR #8 실행 생성 | 32회 | 17회 | +| PR #8 실제 중계 | 27회 | 3회 | +| PR #10 실행 생성 | 18회 | 10회 | + +## 다음 사람에게 + +**이 수정은 `main` 에 병합되기 전까지 반영되지 않는다.** `pull_request_review` +는 기본 브랜치의 워크플로 파일로 실행되기 때문이다. `develop` 에만 넣고 +"고쳤다" 고 생각하면 계속 60회가 돈다. diff --git a/ai/journal/2026/08/AIJ-0017-clock-monotonic.md b/ai/journal/2026/08/AIJ-0017-clock-monotonic.md new file mode 100644 index 00000000..379940c3 --- /dev/null +++ b/ai/journal/2026/08/AIJ-0017-clock-monotonic.md @@ -0,0 +1,84 @@ +--- +id: AIJ-0017 +date: 2026-08-19 +kind: implement +phase: 3 +plan: [3.3.1, 3.3.2, 3.3.3, 3.3.4] +jira: CY-230 +commits: [5c95705, 4702a81] +agent: claude-opus-5 +confidence: high +promoted-to: +--- + +# 시계가 뒤로 가도 추월시키지 않는다 + +## 무엇을 + +`enqueue.lua` 의 바닥값 가드, 영속성 설정, 시계 역행 관측. + +## 왜 (근거) + +**순번이 카운터가 아니라 벽시계다** (A-9). `redis.call('TIME')` 은 NTP 보정이나 +복제본 승격으로 **뒤로 갈 수 있고**, 그러면 나중에 온 사람이 앞선다 — 불변식 4 가 +깨진다. 복제본 승격이면 그 구간 전체가 한꺼번에 추월당한다. + +**바닥값은 `maxscore` 하나면 된다.** ZSET 의 마지막 원소를 읽는 방식으로는 +**큐가 빈 동안의 역행을 못 막는다** — 전원이 입장하면 읽을 원소가 없다. 그게 +이 키가 따로 있는 이유고, 그 경우를 테스트로 따로 남겼다. + +**`ZADD` 와 `SET maxscore` 는 같이 남거나 같이 사라진다.** Lua 는 효과 기반 +복제라 `maxscore` 가 ZSET 보다 뒤처지는 상태가 존재하지 않는다. 그래서 승격 후 +복원 절차가 필요 없다 — 둘 다 유실됐다면 큐가 빈 것이고, 빈 큐에는 추월할 +대상이 없다. + +**영속성이 지키려는 것은 순서이지 무결성이 아니다.** 순번이 시계라 유실이 +생겨도 중복 발번이 없다. 증발한 사람은 재등록하면 되고 **새 순번이 더 커서 +이미 줄 선 사람을 추월하지 않는다.** `always` 의 쓰기 지연을 2만 스파이크에서 +치를 이득이 아니다 (E-6). + +**`noeviction` 을 쓴다.** 대기열 항목이 eviction 으로 사라지면 그 사람은 줄에서 +증발하는데, **누가 증발할지를 메모리 정책이 정하게 두지 않는다.** + +**조용히 보정하지 않는다.** 바닥값이 적용된 사실을 반환값에 담고 횟수와 최대 +폭을 센다. 안 그러면 순서는 맞는데 **"왜 다 같은 순번인가" 를 영영 못 밝힌다.** + +## 시계를 안 돌리고 시험하는 법 + +컨테이너의 시계를 실제로 되돌리는 대신 **바닥값을 미래로 뒀다.** 가드 입장에서 +그 둘은 구분되지 않는다 — `floor >= now` 라는 같은 조건에 걸린다. + +시계 조작은 컨테이너 권한이 필요하고 다른 테스트에도 영향을 준다. **같은 조건을 +만들 수 있으면 더 싼 쪽을 고른다.** + +## 고려했으나 택하지 않은 것 + +- **`INCR` 카운터로 순번 발번** — 시계 문제가 통째로 사라진다. 그런데 유실 시 + 카운터가 되돌아가 **중복 발번**이 나고, 그건 순서가 아니라 정합성 문제다. + 시계 쪽이 실패 모드가 가볍다 (A-9). +- **`ZREVRANGE` 로 마지막 원소를 읽어 바닥값 삼기** — 키를 하나 덜 쓴다. + 그런데 **큐가 빈 동안의 역행을 못 막는다.** 그 경우가 가장 위험하다. +- **`appendfsync always`** — 유실이 0 이 된다. 대신 매 쓰기마다 fsync 라 + 스파이크에서 지연이 명령 타임아웃(500ms)을 넘겨 스케줄러를 멎게 한다. + +## 확신이 낮은 부분 + +- **`kill -9` 후 복구와 복제본 승격 시나리오가 아직 없다** (T3.3.5·T3.3.6). + 가드의 논리는 확인했지만 **실제 프로세스 사고에서** 확인한 것은 아니다. +- **`ClockSkewTracker` 가 아직 지표로 안 나간다.** 값은 정확히 세는데 내보내는 + 경로가 Phase 6 다. 그때까지는 테스트에서만 보인다. + +## 검증 + +- 단위 233건 · 통합 9건 전건 통과 +- 시계 역행·빈 큐 역행·연속 200건 단조성 확인 +- 바닥값 TTL 24시간, 적용 사실이 반환값에 실림 +- **16스레드 × 1000회 경합에서 관측 카운터 정확** — 이 저장소의 첫 실제 동시성 테스트 + +## 다음 사람에게 + +**`git add -A` 로 한 번 데였다.** 브랜치를 옮겨도 미추적 파일은 따라오는데, +그 상태로 `add -A` 하면 다른 티켓의 코드가 통째로 섞인다. 실제로 이 페이즈의 +Lua 와 테스트가 워크플로 브랜치에 딸려 가 CI 가 깨졌다. + +러너에 티켓 혼입 검사를 넣어 두었다 — 커밋 푸터의 `Refs:` 가 둘 이상이면 알린다. diff --git a/ai/journal/2026/08/AIJ-0018-enqueue.md b/ai/journal/2026/08/AIJ-0018-enqueue.md new file mode 100644 index 00000000..ee18d1cf --- /dev/null +++ b/ai/journal/2026/08/AIJ-0018-enqueue.md @@ -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)와 같은 원리다. **순서가 곧 정책이다.** diff --git a/ai/journal/2026/08/AIJ-0019-library-first.md b/ai/journal/2026/08/AIJ-0019-library-first.md new file mode 100644 index 00000000..66e36b8b --- /dev/null +++ b/ai/journal/2026/08/AIJ-0019-library-first.md @@ -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% 유지 + +## 다음 사람에게 + +**"라이브러리 있지 않아?" 라는 질문에 답할 수 있어야 한다.** 답이 "있는데 안 +썼다" 여도 괜찮다 — 근거가 있으면. 근거가 없으면 그건 검토를 안 한 것이다. + +그리고 **없는 것을 못 만드는 것과, 있는데 안 쓰는 것은 다르다.** 전자는 무지고 +후자는 판단이다. 판단이었다는 증거가 이 문서다. diff --git a/ai/journal/2026/08/AIJ-0020-queue-status.md b/ai/journal/2026/08/AIJ-0020-queue-status.md new file mode 100644 index 00000000..e7f72603 --- /dev/null +++ b/ai/journal/2026/08/AIJ-0020-queue-status.md @@ -0,0 +1,82 @@ +--- +id: AIJ-0020 +date: 2026-08-19 +kind: implement +phase: 3 +plan: [3.5.1, 3.5.2, 3.5.3, 3.5.4, 3.5.5, 3.5.6, 3.5.8] +jira: CY-232 +commits: [] +agent: claude-opus-5 +confidence: high +promoted-to: +--- + +# 순번 조회 — 한 번에 끝나야 하는 이유 + +## 무엇을 + +`queue_status.lua`. 순번 조회·하트비트·배수 판정·입장 제거가 한 스크립트 안에서 +일어난다. 유예 재입장(G3.8)과 순위 단조성(G3.11) 검증. + +## 왜 (근거) + +**나눠 치면 한쪽만 성공한 상태가 생긴다.** 조회와 하트비트가 갈리면 그때 +**성실히 새로고침하는 사람이 이탈자로 지워지고**, 배수 판정과 제거가 갈리면 +같은 사람이 두 번 입장한다. 왕복을 줄이려는 것이 아니라 **중간 상태를 없애려는** +것이다. + +**순위를 저장하지 않고 매번 센다.** 저장하면 앞사람이 빠질 때마다 전원을 +갱신해야 한다 — 2만 명 큐에서 그건 갱신 자체가 부하다. `ZCOUNT` 는 O(log N) 이라 +세는 편이 싸다. + +**없는 것과 맨 앞인 것을 구분한다.** 둘 다 0 으로 뭉치면 **유실된 사람에게 +"곧 입장" 을 보여 준다.** `NOT_QUEUED` 와 `rank 0` 은 다른 상태다. + +**입장하면 큐에서 뺀다.** 안 빼면 대기 인원이 계속 부풀고 ETA 가 전부 틀어진다. +같은 스크립트 안에서 빼므로 "판정은 됐는데 안 빠진" 상태가 없다. + +**재입장은 자리를 보관하지 않는다** (D-11). 보관하면 이탈자가 돌아올 때마다 +성실히 기다린 사람이 밀린다. 유예 기록은 **재방문자 식별용**이지 자리 +보관용이 아니다 — 그 구분을 테스트 이름에 담았다. + +## 순위 단조성은 여기가 진다 + +Phase 2 의 `RankMonotonicityTest` 는 **"입력이 단조면 출력도 단조"** 까지만 +봤다. `localRank` 자체가 단조라는 보장은 어댑터 몫이었고, 이제 그것을 +확인했다 — 등록·입장·이탈을 섞은 **10만 시퀀스에서 역행 0** 이다. + +**처음에는 표본 하나만 봤다.** 매 연산마다 전원을 세면 안 끝날 줄 알았는데, +리뷰가 짚었다 — 안 본 사람의 순위가 올랐다가 다음 표본 선택 전에 내려오면 +표본은 통째로 놓친다. + +전수로 바꾸되 **큐 전체를 한 번에 읽었다.** 사람마다 왕복하던 것을 한 번으로 +줄이니 오히려 30초에서 7초가 됐다. 느릴 거라는 짐작이 틀렸다. + +## 고려했으나 택하지 않은 것 + +- **순위를 ZSET 에 저장** — 조회가 O(1) 이 된다. 그런데 앞사람이 빠질 때마다 + 뒤 전원을 갱신해야 하고, 그 갱신이 원자적이지 않으면 **갱신 중인 순간에 + 조회한 사람이 남의 순위를 본다.** +- **입장 판정만 하고 제거는 따로** — 스크립트가 짧아진다. 그런데 그 사이에 + 같은 사람이 다시 조회하면 **입장이 두 번 난다.** +- **`grace` 에 원래 순번을 담기** — 이탈자에게 친절하다. 그런데 그건 정확히 + D-11 이 금지한 것이다. 친절이 공정성보다 앞설 수 없다. + +## 확신이 낮은 부분 + +- **`admitted` 가 없을 때 -1 로 두는 것**이 최선인지 모르겠다. 지금은 초기 + 상태와 "임계가 0" 을 구분하려고 그렇게 했는데, 0 이 유효한 임계값이라 + 헷갈릴 여지가 있다. + +## 검증 + +- 통합 42건 전건 통과 +- **G3.8 — 재입장자가 줄 맨 뒤에 서고 남은 대기자 순위가 안 늘어난다** +- **G3.11 — 등록·입장·이탈 섞인 10만 시퀀스에서 순위 역행 0** + +## 다음 사람에게 + +**"왕복을 줄이려고 Lua 를 쓴다" 가 아니다.** 그건 부수 효과고, 진짜 이유는 +**중간 상태를 없애는 것**이다. 이 스크립트를 쪼개고 싶어질 때 — 예를 들어 +하트비트만 따로 빼고 싶을 때 — 쪼갠 뒤 한쪽이 실패하면 무슨 일이 나는지를 +먼저 그려 보면 된다. 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/2026/08/AIJ-0022-leader-election.md b/ai/journal/2026/08/AIJ-0022-leader-election.md new file mode 100644 index 00000000..35052f3f --- /dev/null +++ b/ai/journal/2026/08/AIJ-0022-leader-election.md @@ -0,0 +1,69 @@ +--- +id: AIJ-0022 +date: 2026-08-20 +kind: implement +phase: 3 +plan: [3.7.1, 3.7.2, 3.7.3, 3.7.4, 3.7.5] +jira: CY-234 +commits: [261df1d, ffc7e02] +agent: claude-opus-5 +confidence: high +promoted-to: +--- + +# 리더는 한 대다 — 소유권을 값에 담는 이유 + +## 무엇을 + +`leader_acquire.lua`(획득·연장), `leader_release.lua`(해제). 10노드 동시 획득에 +정확히 1대 (G3.2). + +## 왜 (근거) + +**둘이 동시에 배분하면 총합이 전역 크레딧을 넘는다.** 그게 리더를 하나로 두는 +유일한 이유고, 나머지 설계는 전부 거기서 나온다. + +**소유자 식별자를 값에 담는다.** 안 담으면 락이 "누구 것인지" 를 알 수 없어 +**남의 락을 지울 수 있다.** 그러면 리더가 둘이 된다. + +**`GET` 과 `DEL` 을 나누지 않는다.** 나누면 그 사이에 리스가 만료되고 다른 +노드가 잡는다. 그 상태에서 `DEL` 하면 **새 리더의 락이 사라져 배분이 멎는다.** +확인과 삭제가 한 스크립트 안에 있어야 하는 이유다. + +**재진입은 연장이지 재획득이 아니다.** 매 틱 `SET NX` 로 새로 잡으려 하면 +그 틈에 남이 가져간다. 그리고 리더십이 흔들릴 때마다 평활화 상태가 +초기화된다 (F9) — ETA 가 그때마다 튄다. + +**획득 실패 시에도 지금 주인을 알려준다.** "내가 아니다" 만으로는 부족하다. +누가 리더인지 알아야 로그와 지표에서 승계를 추적할 수 있다. + +## 고려했으나 택하지 않은 것 + +- **`SETNX` + 별도 `EXPIRE`** — 두 명령 사이에 죽으면 **TTL 없는 락**이 남고 + 배분이 영영 멎는다. `SET ... NX PX` 한 번으로 끝낸다. +- **리스를 3초로** — 승계가 느려지는 대신 오탐이 준다. 그런데 계획서의 + 카오스 시나리오(STW 3초)가 리스와 같아져 **결과가 실행마다 달라진다.** + 2초(=2틱)면 그 시나리오가 결정적으로 교체를 만든다. +- **Redlock** — 노드 여러 대의 레디스를 전제한다. 우리는 한 대(+복제본)라 + 전제가 안 맞고, 그 복잡도를 살 이유가 없다. + +## 확신이 낮은 부분 + +- **`PTTL` 을 반환하는데 아직 쓰는 곳이 없다.** 승계 지연을 재려면 필요할 것 + 같아 넣었는데, Phase 4 가 실제로 쓸지는 그때 안다. +- **시계 스큐가 리스 판정에 미치는 영향을 안 봤다.** 만료는 레디스 시계가 + 판정하므로 노드 간 스큐와 무관하지만, 노드가 "내가 리더다" 라고 믿는 + 구간과는 어긋날 수 있다. + +## 검증 + +- 통합 8건 전건 통과 +- **G3.2 — 10노드 동시 획득에 성공 카운트 정확히 1** +- 자기 락 연장 · 타인 점유 거부 · 자기 락만 해제 · 리스 만료 후 승계 +- 잘못된 인자에 락 무변경 + +## 다음 사람에게 + +**만료를 고정 대기로 기다리지 않았다.** 짧으면 흔들리고 길면 시험이 느려진다. +만료를 판정하는 것은 레디스 시계라 폴링으로 확인한다 — 이 저장소가 불안정 +테스트를 금지하는 이유(TS-7)가 정확히 여기 걸린다. diff --git a/ai/journal/2026/08/AIJ-0023-cluster-mode.md b/ai/journal/2026/08/AIJ-0023-cluster-mode.md new file mode 100644 index 00000000..e3651399 --- /dev/null +++ b/ai/journal/2026/08/AIJ-0023-cluster-mode.md @@ -0,0 +1,90 @@ +--- +id: AIJ-0023 +date: 2026-08-20 +kind: implement +phase: 3 +plan: [3.8.3] +jira: CY-235 +commits: [fcf07d5, ec485a8, a7a83bc] +agent: claude-opus-5 +confidence: medium +promoted-to: +--- + +# 클러스터 모드 — 무엇을 잡고 무엇을 못 잡나 + +## 무엇을 했나 + +`ClusterModeScriptTest`. 전 스크립트(`enqueue`·`queue_status`·`sweep`· +`leader_acquire`·`leader_release`)를 `cluster-enabled yes` 인 레디스에 실제로 +태운다. 정적 검사는 **의도**를 보고 이 시험은 **사실**을 본다. + +## 왜 단독 노드인가 + +여러 노드를 띄우면 클러스터가 컨테이너 **내부 주소**를 돌려줘 밖에서 못 붙는다. +그 배선을 맞추는 데 시간을 다 쓰고 정작 검증 대상인 스크립트를 못 태운다. +슬롯 전량을 한 노드에 주면 붙는 문제가 사라진다. + +## 실측 — 무엇을 잡고 무엇을 못 잡나 + +세 가지를 직접 태워 봤다. 추정하지 않고 쳐 봤다. + +| 태운 것 | 결과 | +|---|---| +| 선언된 `KEYS` 둘이 서로 다른 슬롯 | `CROSSSLOT` 으로 **거부** | +| `KEYS` 에 없는 리터럴 키 | **통과한다** | +| 같은 슬롯 키 둘 | 통과 (정상) | + +**두 번째가 이 시험의 사각지대다.** 한 노드가 전 슬롯을 가지면 선언되지 않은 +키도 로컬이라 클러스터가 물지 않는다. 여러 노드였다면 그 키가 다른 노드로 +가면서 터졌을 것이다. + +그래서 `LuaKeysDeclarationTest` 를 버릴 수 없다. **여기서 통과한다는 것이 +여러 노드에서 통과한다는 뜻이 아니다.** 이 경계를 모르면 "클러스터에서 돌려 +봤다" 는 말이 실제보다 넓게 들린다 — 그래서 사각지대를 시험으로 못 박았다 +(`KEYS_밖의_키는_이_시험이_못_잡는다`). + +## 막혔던 것 — 정적 초기화 블록 안의 대기 + +`cluster_state:ok` 로 안 넘어가 30초 대기가 계속 터졌다. 컨테이너 안에서 +`redis-cli` 로 치면 5초 안에 서는데 시험에서는 안 섰다. + +원인은 클러스터가 아니라 **대기가 놓인 자리**였다. Awaitility 는 조건을 +**다른 스레드**에서 평가하는데, 그 스레드가 아직 초기화 중인 이 클래스의 +정적 필드를 건드리면 JVM 의 **클래스 초기화 락**에 걸려 영영 멈춘다. 조건이 +한 번도 안 돌았는데 겉으로는 그냥 시간 초과로 보인다. + +같은 스레드에서 도는 손수 만든 루프는 통과했다 — 초기화 락은 재진입이 +되기 때문이다. 그래서 한동안 "Awaitility 만 이상하다" 로 보였다. + +**컨테이너 준비를 `@BeforeAll` 로 옮겼다.** 정적 블록이 아니라 시험 스레드에서 +돌므로 락에 걸리지 않는다. + +찾는 데 오래 걸린 이유는 실패가 아무것도 안 알려줬기 때문이다. 그래서 대기 +실패 시 **마지막으로 본 `cluster info` 를 붙여** 던지게 했고, 그 값이 비어 +있다는 사실이 "조건이 한 번도 안 돌았다" 를 가리켜 원인을 좁혔다. + +슬롯 배정과 상태 확인은 둘 다 컨테이너 안 `redis-cli` 로 친다 — 클라이언트 +API 를 거치면 무엇이 실제로 나갔는지가 한 겹 가려진다. + +## 확신이 낮은 부분 + +- **여러 노드에서의 동작은 여전히 미검증이다.** Phase 10 에서 실제 클러스터를 + 세울 때 다시 봐야 한다. 그때 KEYS 미선언 접근이 있다면 여기서는 안 잡힌다. +- **인자가 손으로 적혀 있다.** 스크립트가 늘면 `keysFor`·`argsFor` 에도 + 넣어야 하고, 안 넣으면 예외로 죽는다 — 조용히 건너뛰지는 않게 해 뒀다. + +## 검증 + +- 통합 3건 전건 통과 +- 슬롯 교차가 실제로 거부됨 (자기검증) · 사각지대가 시험으로 고정됨 + +## 다음 사람에게 + +**정적 초기화 블록 안에서 다른 스레드를 기다리지 마라.** 클래스 초기화 락은 +그 스레드를 세우고, 그 사실은 시간 초과로만 보인다. 컨테이너 준비는 +`@BeforeAll` 에 둔다. + +**"클러스터에서 돌려 봤다" 는 말의 범위를 좁게 잡아라.** 단독 노드 클러스터는 +슬롯 교차는 잡지만 미선언 키는 못 잡는다. 둘을 뭉뚱그리면 정적 검사를 지워도 +된다고 착각하게 되고, 그 착각은 Phase 10 부하 시험 중에 드러난다. diff --git a/ai/journal/2026/08/AIJ-0024-adapter-coverage.md b/ai/journal/2026/08/AIJ-0024-adapter-coverage.md new file mode 100644 index 00000000..21092008 --- /dev/null +++ b/ai/journal/2026/08/AIJ-0024-adapter-coverage.md @@ -0,0 +1,84 @@ +--- +id: AIJ-0024 +date: 2026-08-20 +kind: decision +phase: 3 +plan: [3.9.1] +jira: CY-236 +commits: [64e1f18, 0252dec, f864363] +agent: claude-opus-5 +confidence: medium +promoted-to: +--- + +# 어댑터 커버리지 — 통합 exec 만으로는 엉뚱한 것을 잰다 + +## 무엇을 했나 + +`adapterCoverageVerification`. `adapter.redis` 패키지 브랜치 커버리지가 80% +미만이면 빌드가 실패한다 (G3.9). 보고용 `adapterCoverageReport` 를 같이 뒀다 — +못 넘겼을 때 **어디가 빈지** 봐야 고칠 수 있다. + +## 실측이 계획을 고쳤다 + +태스크 이름은 "통합 테스트 커버리지 임계" 였다. 그대로 통합 exec 만 보게 +만들고 재 봤더니 **27.1%** 였다. 임계를 80 으로 두면 그냥 못 넘긴다. + +| exec | 브랜치 커버리지 | +|---|---| +| 통합만 | 13/48 = **27.1%** | +| 단위 + 통합 | 44/48 = **91.7%** | + +이유는 둘이다. + +1. **JaCoCo 는 Lua 를 못 본다.** 통합 시험이 실제로 검증하는 것은 스크립트인데 + 그건 계측 대상이 아니다. 통합 exec 에 남는 것은 그 주변 Java 뿐이다. +2. **어댑터의 Java 는 대부분 순수 로직이다.** `RedisKeys`·`ShardHash`· + `ClockSkewTracker` 는 키를 만들고 해시를 돌린다. 그건 단위 시험이 도는 곳이다. + +그래서 통합 exec 만으로 재면 **낮은 숫자가 나오는 게 아니라 엉뚱한 것을 +재게 된다.** 임계를 27% 로 낮추는 것은 더 나쁘다 — 통과하는데 아무것도 안 +보는 게이트가 된다. 두 계층 exec 를 합쳐서 잰다. + +## CI 에 붙이는 방식 + +계층은 **잡으로 갈려** 있다 (TS-5 — 깨진 계층이 이름만 보고 드러나야 한다). +그래서 각 잡은 자기 exec 만 남기고 한자리에 안 모인다. + +두 갈래가 있었다. + +- **exec 를 아티팩트로 올려 합치는 잡을 하나 더 둔다** — 정석이지만 잡이 + 하나 늘고 업로드·다운로드 배선이 붙는다 +- **커버리지 잡 하나에서 두 계층을 함께 돌린다** — 단위·통합을 한 번 더 도는 + 비용이 든다 + +후자를 골랐다. 통합이 30초 안쪽이라 중복 비용이 배선 비용보다 싸다. **틀리면 +바꾸기도 쉽다** — 아티팩트 배선은 한 번 깔면 걷어내기 번거롭다. 통합이 분 +단위로 늘면 그때 전자로 옮긴다. + +## 리뷰에서 더 나온 것 + +- **보고 대상을 어댑터로 좁혔다.** `sourceSets.main.output` 전체를 넣어 + 도메인과 부트 클래스가 보고서에 섞였다 — "어댑터 보고서" 라는 이름이 + 거짓이었다. +- **보고서를 검증의 finalizer 로 걸었다.** 임계를 못 넘긴 실행에서만 보고서가 + 없었다. **못 넘겼을 때가 보고서가 가장 필요한 때다.** +- **`description` 이 "통합 시험 기준" 이었다.** 실제로는 단위·통합을 합치는데 + CI 출력이 계산 범위를 잘못 안내했다. + +## 확신이 낮은 부분 + +- **80 의 근거가 약하다.** 지금 실측이 91.7% 라 여유가 있어 잡은 값이다. + 어댑터가 커지면 이 여유가 먼저 줄고, 그때 80 이 맞는지 다시 봐야 한다. +- **Lua 는 여전히 커버리지 밖이다.** 스크립트 분기가 다 돌았는지는 이 숫자가 + 말해 주지 않는다. 그건 통합 시험의 단언이 보증할 몫이다. + +## 검증 + +- 임계를 0.95 로 올리면 실패, 0.80 이면 통과 (게이트가 실제로 문다) +- `clean` 후 `test integrationTest adapterCoverageVerification` 통과 + +## 다음 사람에게 + +**커버리지 숫자가 낮으면 먼저 "무엇을 재고 있는지" 를 의심해라.** 27% 를 +보고 테스트를 더 쓰는 것은 틀린 대응이었다. 재는 대상이 어긋나 있었다. diff --git a/ai/journal/2026/08/AIJ-0025-crash-and-promotion.md b/ai/journal/2026/08/AIJ-0025-crash-and-promotion.md new file mode 100644 index 00000000..21f39dd7 --- /dev/null +++ b/ai/journal/2026/08/AIJ-0025-crash-and-promotion.md @@ -0,0 +1,114 @@ +--- +id: AIJ-0025 +date: 2026-08-20 +kind: implement +phase: 3 +plan: [3.3.5, 3.3.6] +jira: CY-230 +commits: [4a73880, 5b5daa5, db10257, d1516da, bfefc13] +agent: claude-opus-5 +confidence: medium +promoted-to: +--- + +# 강제 종료와 승격 — 계획의 전제가 두 군데 틀렸다 + +## 무엇을 했나 + +`CrashRecoveryTest`(G3.3 · C12) 와 `ReplicaPromotionTest`(G3.10 · C13). +카오스 계층의 첫 시험이다 — 그전까지 이 계층은 **0건이었다.** + +## 왜 뒤늦게 났나 + +`CY-230` 이 `완료` 로 닫혀 있었는데 T3.3.5·T3.3.6 이 안 만들어져 있었다. +게이트 두 개(G3.3·G3.10)가 근거 없이 남아 있었고, 종료 게이트를 판정하려고 +게이트-시험 대응을 맞춰 보다가 드러났다. **티켓 상태는 코드를 안 본다.** + +## 계획의 전제 1 — `kill -9` 로는 아무것도 안 잃는다 + +2.2절은 "`appendfsync everysec` 에서 `kill -9` 가 나면 최대 1초 분량의 등록이 +사라진다" 고 적혀 있다. **실측은 아니었다.** 500건을 넣고 `SIGKILL` 로 끊은 뒤 +재기동했더니 500건이 전부 살아 있었다. + +`appendfsync` 는 **`fsync` 주기**를 정하지 `write()` 를 미루지 않는다. Redis 는 +이벤트 루프마다 AOF 버퍼를 `write()` 하므로, 프로세스를 죽여도 그 내용은 이미 +호스트 페이지 캐시에 있다. 잃으려면 **커널이 죽어야 한다.** + +그래서 둘로 나눴다. + +| 상황 | 기대 | 재현 | +|---|---|---| +| 프로세스 강제 종료 | **유실 0** | `SIGKILL` | +| 전원 단절 | 유실 허용, 역행 0 | AOF 꼬리 절단 | + +꼬리 절단은 파일이 `redis` 소유 0700 이라 호스트에서 못 건드린다 — 같은 +디렉터리를 물린 컨테이너를 root 로 띄워 자른다. + +## 계획의 전제 2 — 시계는 못 되돌린다 + +C13 은 "복제본 시계를 되돌림" 이라고 적혀 있는데, 컨테이너는 호스트 시계를 +공유해서 되돌릴 방법이 없다 (`CAP_SYS_TIME` 로 건드리면 **호스트 시각이 +바뀐다** — 하면 안 된다). + +처음 쓴 시험은 두 컨테이너를 띄워 승격만 시켰다. **통과했지만 아무것도 +검증하지 않았다** — 시계가 같으니 바닥값이 없어도 통과한다. + +되짚어 보니 승격된 복제본이 겪는 것은 "제 시계가 뒤처졌다" 라는 사정이 아니라 +관측 가능한 조건 하나다 — **`TIME < maxscore`.** 그 상태를 그대로 만들면 된다. +`maxscore` 를 한 시간 앞세워 큐를 쌓고, 복제가 따라잡은 뒤 승격시킨다. + +**바닥값이 전건 걸렸는지까지 단언한다.** 한 번도 안 걸렸다면 뒤처진 시계를 +재현하지 못한 것이고, 그러면 이 시험은 평범한 큐를 본 것이다. + +## 곁가지로 나온 결함 — `tostring` 이 score 를 접는다 + +카오스 시험이 `score` 를 파싱하다 `1.7871995069587e+15` 를 만났다. + +Lua 5.1 의 `tostring` 은 `%.14g` 다. 마이크로초 score 는 16자리라 **과학 +표기로 접히며 최대 100μs 가 반올림된다.** ZSET 에는 정확한 값이 들어가므로 +**돌려준 값과 실제 자리가 어긋난다.** + +지금은 게이트웨이가 이 값을 안 쓰지만 Phase 5 에서 토큰에 담아 요청 경로에서 +비교한다 — 내림 쪽으로 접히면 **앞사람보다 작은 score 를 쥐고 추월한다** +(불변식 4). `string.format('%d', score)` 로 고쳤다. + +`redis.call` 인자로 넘기는 수는 정확히 변환된다(실측). `maxscore` 와 `ZADD` 는 +무사했고, 명시적 `tostring` 한 곳만 문제였다. + +**아무 시험도 이걸 안 잡고 있었다.** 돌려준 score 와 ZSET 의 값을 맞춰 보는 +단언을 `EnqueueGuardTest` 에 넣고, 되돌리면 실패하는 것까지 확인했다. + +## 리뷰에서 더 나온 것 + +- **고정 시각이 시한폭탄이었다.** 바닥값을 `NOW + 3600` 으로 박아 뒀는데, + 실제 시각이 그 지점을 지나면 바닥값이 한 번도 안 걸린다. 그러면 시험은 + **조용히 아무것도 검증하지 않게 되고 실패는 그날에야 난다.** 컨테이너의 + `TIME` 에서 유도하도록 고쳤다. +- **`%d` 대신 `%.0f`.** `%d` 는 정수로 캐스팅해 32비트 런타임에서 넘친다. + `%.0f` 는 배정밀도 그대로 찍어 2^53 아래에서 정확하고 지수도 안 붙는다 + (실측으로 같은 값 확인). +- **`RedisClient` 를 안 닫고 있었다.** 제 Netty 이벤트 루프를 만들어서 + `shutdown()` 없이는 스레드가 JVM 종료까지 남는다. +- **유실 하한이 `isPositive()` 였다.** 550명 중 하나만 잃어도 통과해 + "절단이 거의 안 먹혔다" 와 "의도대로 유실됐다" 를 못 가렸다. +- **Lua 로더가 두 파일에 복제돼 있었고 상대 경로였다.** 클래스패스에서 읽는 + 공용 헬퍼(`LuaScripts`)로 합쳤다. + +## 확신이 낮은 부분 + +- **꼬리를 절반에서 자른다.** 실제 전원 단절이 어디서 끊길지는 모른다. 비율을 + 바꿔 가며 도는 것이 나을 수 있는데, 그러면 시험이 느려진다. +- **승격을 `REPLICAOF NO ONE` 으로 한다.** Sentinel 이 하는 승격과 같은지는 + 안 봤다. Phase 4 에서 Sentinel 을 세울 때 다시 봐야 한다. + +## 검증 + +- 카오스 3건 전건 통과 (강제 종료 · 전원 단절 · 승격) +- 바닥값 가드를 빼면 승격 시험이 실패 (자기검증) +- `tostring` 으로 되돌리면 score 시험이 실패 (자기검증) + +## 다음 사람에게 + +**계획서에 적힌 장애 모형을 그대로 믿지 마라.** `kill -9` 가 데이터를 잃는다는 +것도, 컨테이너 시계를 되돌릴 수 있다는 것도 안 맞았다. 둘 다 그럴듯해서 +아무도 안 쳐 봤을 뿐이다. 재현할 수 없으면 **그 게이트는 없는 것과 같다.** diff --git a/ai/journal/2026/08/AIJ-0026-dependency-refresh.md b/ai/journal/2026/08/AIJ-0026-dependency-refresh.md new file mode 100644 index 00000000..e8e2a40f --- /dev/null +++ b/ai/journal/2026/08/AIJ-0026-dependency-refresh.md @@ -0,0 +1,90 @@ +--- +id: AIJ-0026 +date: 2026-08-20 +kind: decision +phase: 3 +plan: [] +jira: CY-265 +commits: [7a5736a] +agent: claude-opus-5 +confidence: high +promoted-to: +--- + +# 버전은 실측한다 — 검색 색인은 최신을 모른다 + +## 무엇을 했나 + +손으로 박은 버전 넷을 올렸다. + +| 아티팩트 | 전 | 후 | +|---|---|---| +| jacoco | 0.8.13 | 0.8.15 | +| archunit | 1.3.2 | 1.5.0 | +| testcontainers-bom | 1.21.3 | 2.0.5 | +| pitest-junit5-plugin | 1.2.2 | 1.2.3 | +| pitest 코어 | 1.15.0 (플러그인 기본값) | 1.25.9 | + +Spring Boot 4.1.0 · Spring Cloud 2025.1.2 · pitest 플러그인 1.15.0 은 이미 최신이라 +그대로 뒀다. + +## 검색 색인을 믿으면 안 된다 + +`search.maven.org/solrsearch` 는 jacoco 최신을 **0.8.13** 이라고 답했다. 그런데 +`maven-metadata.xml` 에는 0.8.14·0.8.15 가 있다. 색인이 뒤처진 것이다. + +**`maven-metadata.xml` 이 정답이다.** 저장소가 직접 들고 있는 목록이라 색인 +갱신 주기와 무관하다. + +``` +https://repo1.maven.org/maven2///maven-metadata.xml +``` + +## BOM 이 먼저다 + +버전을 손으로 박는 것은 **BOM 이 없을 때만** 한다. 이 저장소는 Spring Cloud 와 +Testcontainers 를 이미 `mavenBom` 으로 받는데, 그래서 Testcontainers 를 2.0.5 로 +올릴 때 **한 줄만 고쳐도 63개 아티팩트가 정합적으로 따라왔다.** + +## Testcontainers 2.x 는 아티팩트 이름이 바뀌었다 + +`org.testcontainers:junit-jupiter` → `org.testcontainers:testcontainers-junit-jupiter`. +옛 이름으로 두면 해석 단계에서 죽는다 — 조용히 넘어가지 않아서 다행이다. +build.gradle 에 이유를 적어 뒀다. 안 적으면 다음 사람이 "오타" 로 보고 되돌린다. + +## 왜 별도 티켓으로 뺐나 + +이 실측은 Phase 3 작업(CY-230) 중에 나왔다. 그 PR 에 얹으면 **범위가 흐려져** +리뷰가 "카오스 시험을 보는 일" 과 "의존성을 보는 일" 로 갈린다. 메이저 승격이 +섞이면 더 그렇다 — 되돌릴 때도 같이 되돌아간다. + +## 코어와 플러그인은 같이 봐야 한다 + +`pitest-junit5-plugin` 을 올리고 나서야 **PIT 코어가 그래들 플러그인 기본값인 +1.15.0 에 머물러 있는 것**을 봤다. 플러그인은 PIT 을 `provided` 로 잡아서 +코어가 뒤처져도 해석 단계에서 안 죽는다 — 조용히 돌다가 엉뚱한 데서 깨진다. + +코어를 1.25.9 로 올리니 **뮤턴트가 44개 늘었다.** + +| 조합 | 총 뮤턴트 | KILLED | SURVIVED | TIMED_OUT | +|---|---|---|---|---| +| 1.15.0 + 1.2.2 | 285 | 273 | 8 | 4 | +| 1.25.9 + 1.2.3 | **329** | 316 | 9 | 4 | + +같은 코드인데 게이트가 보던 범위가 좁았던 것이다. 생존률은 4.2% → 4.0% 로 +비슷하지만 **재는 대상이 15% 넓어졌다.** + +리뷰에서는 "1.2.3 이 PIT 1.19.4 이상을 요구한다" 고 했는데 1.2.3 의 pom 은 +**1.15.2** 를 잡는다. 수치는 틀렸지만 **버전 어긋남이 있다는 지적 자체는 +맞았고**, 그걸 따라가다 이게 나왔다. + +## 검증 + +- 단위·통합·카오스 전 계층 통과 (clean 후) +- 뮤테이션 커버리지 미달 0 · 강도 97% · 뮤턴트 329개 (전 285개) + +## 다음 사람에게 + +**버전을 올릴 땐 어디서 읽었는지도 같이 남겨라.** "최신으로 올렸다" 는 6개월 +뒤에 아무 의미가 없고, 어느 출처를 봤는지가 다음 사람이 같은 실수를 안 하게 +한다. 이번에는 검색 API 를 믿었다면 0.8.13 이 최신인 줄 알고 넘어갔을 것이다. diff --git a/ai/journal/index.md b/ai/journal/index.md index aba0b1c9..4c66aacb 100644 --- a/ai/journal/index.md +++ b/ai/journal/index.md @@ -8,6 +8,24 @@ | ID | 날짜 | 종류 | 제목 | 확신 | 승격 | |---|---|---|---|---|---| +| [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 | — | +| [AIJ-0023](2026/08/AIJ-0023-cluster-mode.md) | 2026-08-20 | implement | 클러스터 모드 — 무엇을 잡고 무엇을 못 잡나 | medium | — | +| [AIJ-0022](2026/08/AIJ-0022-leader-election.md) | 2026-08-20 | implement | 리더는 한 대다 — 소유권을 값에 담는 이유 | high | — | +| [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 | — | +| [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 | — | +| [AIJ-0014](2026/08/AIJ-0014-redis-wiring.md) | 2026-08-19 | implement | 레디스 배선 — 시간 예산을 시동으로 지킨다 | high | — | +| [AIJ-0013](2026/08/AIJ-0013-local-review-before-pr.md) | 2026-08-19 | implement | 훅이 못 본 파일들 — PR 전 브랜치 전체 검사 | medium | — | +| [AIJ-0012](2026/08/AIJ-0012-allocation-polling-smoothing.md) | 2026-08-19 | implement | 배분·폴링·평활화 — 계획서 예시와 완료 조건의 충돌 | high | — | +| [AIJ-0011](2026/08/AIJ-0011-admission-ladder-and-mutation-gaps.md) | 2026-08-19 | implement | 판정 사다리 · 순위 추정 · 뮤테이션이 짚은 경계 | high | — | +| [AIJ-0010](2026/08/AIJ-0010-domain-state-and-limiter.md) | 2026-08-19 | implement | 순수 도메인 — 불변식·통과 상한·리미터 | high | — | +| [AIJ-0009](2026/08/AIJ-0009-build-foundation-and-quality-gates.md) | 2026-08-18 | implement | 빌드 기반과 품질 임계 · CI 첫 실행 결함 4건 | high | — | | [AIJ-0008](2026/08/AIJ-0008-header-identity-and-front-lb.md) | 2026-08-18 | decision | 인증을 헤더 식별자로 · 락 소유권 · 앞단 LB 부재 | medium | D-A1 | | [AIJ-0007](2026/08/AIJ-0007-queue-order-by-timestamp.md) | 2026-08-14 | decision | 큐 순서를 timestamp 로 · 차단 5건 해소 | medium | 90-decisions 2.7·2.8 | | [AIJ-0006](2026/08/AIJ-0006-repository-layout.md) | 2026-08-14 | decide | 저장소 배치 — cy-be 와 분리, 게이트웨이는 하나로 | high | O-1~O-3 | @@ -43,6 +61,18 @@ | AIJ-0008 | 앞단 LB 한 대가 1GbE 에서 목표 RPS 를 감당한다 (PPS 가 먼저 막힐 수 있다) | 10.7.5 기준선 측정 | | AIJ-0008 | `queueToken` 을 게이트웨이가 발급한다는 데 발급 계층이 동의한다 | 계약표(00-req 8절)에 항목 없음. 협의 필요 | | AIJ-0008 | IP 리미터가 시험 환경(NAT·출발지 IP 8개)에서 부하 생성기를 막지 않는다 | 시험 프로파일 상한 별도 지정 | +| ~~AIJ-0009~~ | ~~PIT 임계 90%(생존 ≤10%)가 타당하다~~ | ✅ 도메인 125 뮤턴트에서 생존 2.4%. 임계가 느슨해 8% 도 통과했다 — **숫자보다 어디가 살았는지를 본다** (AIJ-0011) | +| ~~AIJ-0009~~ | ~~JaCoCo PACKAGE 규칙이 빈 패키지에서 조용히 통과하지 않는다~~ | ✅ 도메인 분기 100% 미달 시 빌드 실패 확인 | +| AIJ-0010 | `SecondWindowLimiter` 가 요청 경로의 동시성을 견딘다 | ⚠️ `synchronized` 는 넣었으나 `AtomicAcquireTest` 는 **단일 스레드**다. 한 번 ✅ 로 닫았던 것을 되돌린다 (AIJ-0014) | +| AIJ-0010 | 노드 번호(`nodeIndex`)가 틱마다 안정적이다 | Phase 4 하트비트 설계 시 확인. 바뀌면 배분이 출렁인다 | +| AIJ-0011 | 남은 생존 뮤턴트 3건이 정말 등가다 | 논증으로만 확인했다. 도구가 보장하지 않는다 | +| AIJ-0011 | 샤드 균등 분포 가정이 쏠린 순간에도 성립한다 | Phase 3 에서 실측 | +| AIJ-0014 | `DOCKER_API_VERSION` 1.43 이 팀 전원의 데몬과 맞는다 | 더 낮은 데몬을 쓰는 사람이 있으면 막힌다 | +| AIJ-0014 | `timeout 500ms` 가 실제 부하에서 충분하다 | Phase 6 실측 | +| AIJ-0013 | `guard-pr.sh` 의 base 추출이 모든 입력 형태를 다룬다 | `--base origin/develop` 같은 중복 접두 미처리 | +| AIJ-0012 | 폴링 밴드 간격 1/3/10 초가 예산 4,000 RPS 와 맞는다 | 30 초만 계획서가 못 박았다. Phase 6 실측 | +| AIJ-0012 | ETA 버킷 경계 30/90/450 초가 이탈 판단 구간과 맞는다 | 문구에서 역산했다. 실측 필요 | +| AIJ-0012 | 히스테리시스 최소 유지 3틱이 적절하다 | 계획서에 기본값이 없다 | | AIJ-0007 | `everysec` 유실 1초가 재등록으로 덮인다 | Phase 6 부하 하네스 (6.6) | | AIJ-0007 | `TIME` 을 쓰는 Lua 가 복제에서 안전하다 | Phase 3 | | AIJ-0007 | 폴링마다 붙는 `ZCOUNT` 부하가 감당된다 | Phase 3 | diff --git a/ai/rules/00-index.md b/ai/rules/00-index.md index fa34f541..75a59ddb 100644 --- a/ai/rules/00-index.md +++ b/ai/rules/00-index.md @@ -41,6 +41,16 @@ 아직 없으면 언제 생기는지를 적는다. 나머지는 리뷰 에이전트와 사람이 본다. +| 표기 | 무엇이 막는가 | 언제 | +|---|---|---| +| ✅ | `check-java.sh`·`check-lua.sh` | 파일을 쓰는 순간 | +| **PR** | `review-branch.sh` → `guard-pr.sh` | `gh pr create` 시점 | +| CI | 워크플로 | 푸시 이후 | + +> **✅ 는 `Write`·`Edit` 로 쓴 파일만 본다.** 힙독이나 스크립트로 만든 파일은 +> 그 훅을 지나가므로, 브랜치 전체를 다시 보는 **PR** 단이 필요하다 +> ([AIJ-0013](../journal/2026/08/AIJ-0013-local-review-before-pr.md)). + --- ## MUST 목록 (전체) @@ -53,11 +63,12 @@ | JS-2 | 와일드카드 import 금지 | ✅ | | JS-6 | Javadoc 5줄 이하 | ✅ | | JS-9 | `@Data` 금지 | ✅ | -| JS-12 | 생성자 대신 정적 팩토리 | — | +| JS-12 | 생성자 대신 정적 팩토리 | **PR** | | JS-13 | `private static` 메서드 금지 | ✅ | | JS-14 | 유틸리티·중첩 클래스는 `static` | ✅ | | DS-1 | 도메인은 Spring·Redis·시계를 참조하지 않는다 | ✅ | | DS-2 | 도달 불가 상태를 만들 수 있는 public 생성자 금지 | — | +| DS-8 | 직접 만들기 전에 라이브러리부터 찾는다. 판은 실측해 고른다 | — | | RX-1 | 블로킹 호출 금지 | ✅ | | RX-4 | `subscribe()` 결과를 버리지 않는다 | — | | TS-1 | 구현보다 테스트를 먼저 커밋한다 | — | @@ -73,20 +84,21 @@ | LG-4 | Loki 라벨은 저카디널리티만 | 6.5.2 예정 | | LG-5 | 리액티브에서 MDC 금지 | ✅ | | LG-6 | 개인정보·비밀 로깅 금지 | ✅ | -| EX-1 | 정상 실패는 예외가 아니다 (판정값으로) | — | +| EX-1 | 정상 실패는 예외가 아니다 (판정값으로) | **PR** | | EX-2 | 모든 예외는 `WaitingException` 상속 | — | | EX-7 | 내부 정보를 응답에 담지 않는다 | — | | EX-12 | 전역 처리는 `ErrorWebExceptionHandler` | — | | JS-5 | 필드는 `final` | — | -| RX-2 | 주기 루프는 `repeatWhen`, `Flux.interval` 아님 | — | +| RX-2 | 주기 루프는 `repeatWhen`, `Flux.interval` 아님 | **PR** | | RX-3 | 배경 루프에 스케줄러를 명시한다 | — | | RX-5 | 에러가 루프를 죽이지 않게 한다 | — | | RX-6 | 실패해도 마지막 좋은 상태를 지운다 | — | | RX-7 | 요청 바디를 읽지 않는다 | — | | RX-11 | 공유 가변 상태에는 메모리 가시성을 명시한다 | — | | TS-2 | 테스트 이름은 한글 문장 | — | -| TS-4 | 시계를 고정한다 | — | -| TS-7 | 불안정 테스트는 격리하지 말고 고친다 | — | +| TS-4 | 시계를 고정한다 | **PR** | +| TS-7 | 불안정 테스트는 격리하지 말고 고친다 | **PR** | +| TS-11 | 약한 단언을 쓰지 않는다 | **PR** | | TS-8 | 카오스도 TDD로 쓴다 | — | | TS-9 | 하네스를 자기검증한다 | CI | | RD-2 | 한 스크립트는 한 슬롯만 만진다 | — | diff --git a/ai/rules/10-java-style.md b/ai/rules/10-java-style.md index 102e878d..51d1483a 100644 --- a/ai/rules/10-java-style.md +++ b/ai/rules/10-java-style.md @@ -298,6 +298,10 @@ private class Accumulator { } private static final class Accumulator { } ``` +**예외는 JUnit 5 의 `@Nested` 하나다.** 그쪽은 static 이면 아예 실행되지 +않는다 — 규칙과 프레임워크가 충돌하는 자리라 규칙이 진다. 훅도 면제하므로 +`RULE-EXCEPTION` 주석을 달 필요가 없다. + non-static 내부 클래스는 바깥 인스턴스를 잡고 있어, 그 참조가 배경 루프나 컬렉션에 실려 나가면 **바깥 객체 전체가 GC되지 않는다.** 리액티브 체인처럼 객체가 스레드를 넘나드는 곳에서 특히 위험하다. diff --git a/ai/rules/20-design.md b/ai/rules/20-design.md index 114cac50..ccc38ad0 100644 --- a/ai/rules/20-design.md +++ b/ai/rules/20-design.md @@ -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) diff --git a/ai/rules/60-workflow.md b/ai/rules/60-workflow.md index c5a2631e..23b4070b 100644 --- a/ai/rules/60-workflow.md +++ b/ai/rules/60-workflow.md @@ -52,6 +52,7 @@ type·scope 를 영문으로 두는 이유는 Conventional Commits 도구가 그 ``` domain admission allocation snapshot queue capacity token redis routing config health chaos plan rules +hooks ci ``` 여러 패키지에 걸치면 생략한다. diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..730239a6 --- /dev/null +++ b/build.gradle @@ -0,0 +1,239 @@ +plugins { + id 'java' + id 'jacoco' + // 도메인 한정. 어댑터까지 돌리면 시간이 폭발하고, 정작 지켜야 할 곳의 + // 신호가 묻힌다 (Phase 2 Goal — 생존 ≤ 10%) + id 'info.solidsoft.pitest' version '1.15.0' + // 픽스처를 src/test 에 두면 다른 소스셋에서 못 쓰고, + // 프로덕션에 두면 도달 불가 상태를 만드는 생성자가 운영 코드에 노출된다 (TS-3). + id 'java-test-fixtures' + id 'org.springframework.boot' version '4.1.0' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.kafkick' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +// **BOM 이 있으면 BOM 을 쓴다.** 손으로 박은 버전은 낡고, 낡았다는 사실이 +// 드러나지 않는다. 버전은 maven-metadata.xml 로 실측한다 — +// search.maven.org 색인은 뒤처져 있어 최신을 못 알려준다. +ext { + springCloudVersion = '2025.1.2' + testcontainersVersion = '2.0.5' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + // 부트 BOM 은 testcontainers 판을 관리하지 않는다. 손으로 적으면 + // 부트가 기대하는 판과 어긋나 런타임에야 드러난다. + mavenBom "org.testcontainers:testcontainers-bom:${testcontainersVersion}" + } +} + +// 버전을 손으로 적지 않는다 — 부트가 관리하는 판과 어긋나면 런타임에야 드러난다. + +// 해당 페이즈에 도달하기 전에는 넣지 않는다. +// resilience4j·loadbalancer 를 미리 넣으면 쓰지 않는 의존성이 빌드를 무겁게 하고, +// 어느 페이즈가 무엇을 요구하는지가 흐려진다. +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webflux' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // 요청 경로는 레디스를 치지 않는다 (RD-4). 그래도 제어 평면 — 스케줄러와 + // 큐 — 은 쳐야 한다. reactive 쪽만 쓴다. Jedis 는 블로킹이라 배경 루프를 + // 멎게 한다 (RX-1). + implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + // 도메인 순수성은 리뷰로 못 지킨다. 한 번 깨지면 조용히 번진다. + testImplementation 'com.tngtech.archunit:archunit-junit5:1.5.0' + // 인메모리 대역으로는 Lua 의 복제 동작도 시계도 확인할 수 없다 (TS-3). + // 이 페이즈가 지키려는 것이 정확히 그 둘이라 실물로 붙는다. + // + // 2.x 에서 junit-jupiter 가 testcontainers-junit-jupiter 로 바뀌었다. + // 옛 이름으로 되돌리면 해석 자체가 안 된다. + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +// ── 테스트 계층 ────────────────────────────────────────────────────────────── +// CI 가 계층별로 나눠 돌린다 (_test.yml). 한 태스크에 다 넣으면 어느 계층이 +// 왜 느린지·왜 깨졌는지가 안 보이고, 페이즈별로 켜고 끌 수도 없다. +// +// 태그로 가른다. 태그가 없는 테스트는 unit 으로 본다 — 새 테스트를 쓰면서 +// 태그를 잊어도 어딘가에서는 돈다. + +// docker-java 는 API 1.32 로 협상하는데 최신 데몬은 1.40 이상만 받는다. 데몬을 +// 낮출 수는 없으니 클라이언트 판을 올려 준다. 이미 설정돼 있으면 그대로 둔다 — +// 사람마다 데몬 판이 다르고, 우리가 그걸 다 알 수는 없다. +tasks.withType(Test).configureEach { + def apiVersion = System.getenv('DOCKER_API_VERSION') ?: '1.43' + environment 'DOCKER_API_VERSION', apiVersion + systemProperty 'api.version', apiVersion +} + +tasks.named('test') { + useJUnitPlatform { + excludeTags 'integration', 'context', 'chaos' + } + finalizedBy jacocoTestReport +} + +['integration', 'context', 'chaos'].each { layer -> + tasks.register("${layer}Test", Test) { + description = "${layer} 계층 테스트" + group = 'verification' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { + includeTags layer + } + // 해당 태그가 하나도 없어도 실패하지 않는다. 계층은 페이즈가 + // 진행되며 채워지고, 빈 계층 때문에 파이프라인이 막히면 안 된다. + filter { + failOnNoMatchingTests = false + } + } +} + +// ── 품질 임계 ──────────────────────────────────────────────────────────────── +// 임계 미달이면 build 가 실패한다. "확인했다" 는 통과가 아니다. + +jacoco { + toolVersion = '0.8.15' +} + +tasks.named('jacocoTestReport') { + dependsOn test + reports { + xml.required = true + html.required = true + } + // 픽스처는 커버리지 대상이 아니다. 테스트를 돕는 코드라 + // 포함시키면 본 코드의 미달을 픽스처가 덮는다. + classDirectories.setFrom(sourceSets.main.output) +} + +tasks.named('jacocoTestCoverageVerification') { + dependsOn test + classDirectories.setFrom(sourceSets.main.output) + violationRules { + // 도메인은 순수해서 100% 가 가능하다. 못 채운다는 것은 + // 도달 불가 분기가 있다는 뜻이고, 그건 설계 문제다. + rule { + element = 'PACKAGE' + includes = ['com.kafkick.waiting.domain.*'] + limit { + counter = 'BRANCH' + minimum = 1.00 + } + } + rule { + element = 'BUNDLE' + limit { + counter = 'BRANCH' + minimum = 0.80 + } + } + } +} + +// 어댑터는 단위·통합 양쪽에서 덮인다. JaCoCo 는 Lua 를 못 보고 어댑터의 +// Java 는 대부분 단위 시험이 도는 순수 로직이라, 통합 exec 만 보면 엉뚱한 +// 것을 잰다 (AIJ-0024). 계층이 잡으로 갈려 exec 가 한자리에 안 모이므로 +// 두 exec 를 합쳐 어댑터만 따로 잰다. +// +// **보고 대상도 어댑터로 좁힌다.** main 전체를 넣으면 도메인과 부트 클래스가 +// 섞여 들어가 "어댑터 보고서" 라는 이름이 거짓이 된다. +def 어댑터클래스 = { -> + sourceSets.main.output.classesDirs.asFileTree.matching { + include 'com/kafkick/waiting/adapter/redis/**' + } +} + +tasks.register('adapterCoverageReport', JacocoReport) { + description = '어댑터 커버리지 보고 — 단위·통합 결합 기준' + group = 'verification' + dependsOn test, integrationTest + executionData test, integrationTest + sourceDirectories.setFrom(sourceSets.main.java.sourceDirectories) + classDirectories.setFrom(어댑터클래스()) + reports { + xml.required = true + html.required = true + } +} + +tasks.register('adapterCoverageVerification', JacocoCoverageVerification) { + description = '어댑터 커버리지 임계 — 단위·통합 결합 기준' + group = 'verification' + dependsOn test, integrationTest + executionData test, integrationTest + sourceDirectories.setFrom(sourceSets.main.java.sourceDirectories) + classDirectories.setFrom(어댑터클래스()) + // **못 넘겼을 때가 보고서가 가장 필요한 때다.** finalizer 로 걸지 않으면 + // 실패한 실행에서만 보고서가 없어 어디가 빈지 볼 수 없다. + finalizedBy 'adapterCoverageReport' + violationRules { + rule { + element = 'PACKAGE' + includes = ['com.kafkick.waiting.adapter.redis'] + limit { + counter = 'BRANCH' + minimum = 0.80 + } + } + } +} + +tasks.named('check') { + dependsOn jacocoTestCoverageVerification +} + +// 픽스처가 프로덕션 아티팩트에 새면 도달 불가 상태를 만드는 생성자가 +// 운영 코드에 노출된다 (TS-3). 사람 눈으로 보지 않고 빌드가 잡는다. +tasks.register('verifyFixturesExcluded') { + dependsOn tasks.named('bootJar') + doLast { + def jar = tasks.named('bootJar').get().archiveFile.get().asFile + def leaked = new java.util.zip.ZipFile(jar).entries().toList() + .findAll { it.name.contains('testFixtures') || it.name.endsWith('Fixtures.class') } + if (leaked) { + throw new GradleException("픽스처가 프로덕션 JAR 에 포함됐다: ${leaked*.name}") + } + } +} + +tasks.named('check') { + dependsOn 'verifyFixturesExcluded' +} + +pitest { + targetClasses = ['com.kafkick.waiting.domain.*'] + // domain 은 Phase 2 에서 채워진다. 그전까지 대상이 0건인데, 이걸 실패로 + // 두면 파이프라인이 페이즈 진행을 막는다. 클래스가 들어오면 임계가 문다. + failWhenNoMutations = false + excludedClasses = ['com.kafkick.waiting.*Application*'] + mutationThreshold = 90 // 생존 ≤ 10% + timestampedReports = false + // XML 이 있어야 생존 뮤턴트를 기계로 짚는다. HTML 만 두면 사람이 눈으로 센다. + outputFormats = ['HTML', 'XML'] + // **코어와 플러그인을 함께 본다.** junit5-plugin 은 PIT 을 provided 로 + // 잡아서, 코어가 뒤처져도 조용히 돌다가 엉뚱한 데서 깨진다. + // 1.25.9 는 1.15.0 보다 뮤턴트를 44개 더 만든다 (285 → 329, 실측). + pitestVersion = '1.25.9' + junit5PluginVersion = '1.2.3' +} diff --git a/docker/redis.conf b/docker/redis.conf new file mode 100644 index 00000000..83c976a4 --- /dev/null +++ b/docker/redis.conf @@ -0,0 +1,31 @@ +# 대기열 레디스. 이 설정이 지키려는 것은 **순서**이지 무결성이 아니다. +# +# 순번이 카운터가 아니라 시계라(A-9) 유실이 생겨도 중복 발번이 나지 않는다. +# 남는 위험은 대기자 증발인데, 그 사람들은 재등록하면 되고 **새 순번이 더 커서 +# 이미 줄 선 사람을 추월하지 않는다.** + +appendonly yes + +# always 가 아니다. 20,000 스파이크에서 매 쓰기마다 fsync 를 치를 이득이 +# 아니다 (E-6). 최대 1초 분량이 사라지고, 그건 위 이유로 허용한다. +appendfsync everysec + +# 다시 쓰는 동안 fsync 를 멈추지 않는다. 멈추면 그 구간의 지연이 튀고, +# 튄 지연은 명령 타임아웃(500ms)을 넘겨 스케줄러를 멎게 한다. +no-appendfsync-on-rewrite no + +# 메모리가 차면 조용히 지우지 않는다. 대기열 항목이 사라지면 그 사람은 +# 줄에서 증발하는데, 그걸 eviction 정책으로 결정하게 두지 않는다. +maxmemory-policy noeviction + +# **상한이 없으면 noeviction 은 아무것도 안 막는다.** 상한에 닿아야 쓰기를 +# 거부하는데, 기본값 0 은 무제한이라 그 지점이 오지 않는다 — 대신 호스트가 +# OOM 으로 죽는다. 거부는 복구할 수 있고 OOM 은 못 한다. +# +# 2만 대기 × 쿠폰 수에 AOF 재작성 여유를 더한 값이다. 노드 메모리가 바뀌면 +# 여기도 함께 본다 (Phase 6 실측). +maxmemory 1gb + +# 시계는 slew 로만 맞춘다는 운영 전제(2.4절)를 여기 적어 둔다 — 설정으로 +# 강제할 수 없어서, 읽는 사람이 알게 하는 것이 최선이다. +# chrony: makestep 0 0 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..8bdaf60c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..ca025c83 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..c4bdd3ab --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/plan/01-foundation.md b/plan/01-foundation.md index b3c638a0..f3accc7c 100644 --- a/plan/01-foundation.md +++ b/plan/01-foundation.md @@ -166,15 +166,17 @@ Phase 2 의 첫 테스트를 쓰는 순간부터 필요하다. ### 2.5 공개 저장소 표면 -**공개 저장소다.** 지금 첫 화면은 빈 파일 목록이고, 라이선스가 없으면 기본값이 -**전권 유보**라 아무도 합법적으로 쓰거나 기여할 수 없다. +**공개 저장소다.** 지금 첫 화면은 빈 파일 목록이라 처음 온 사람이 무엇인지 알 +방법이 없다. | ID | 작업 | 완료 | |---|---|---| | T1.5.1 | 루트 `README.md` — 무엇을·왜·어떻게 실행 | 처음 온 사람이 **10줄 안에** 무엇인지 안다 | -| T1.5.2 | `LICENSE` | 선택한 라이선스가 파일로 있다 | -| T1.5.3 | `CONTRIBUTING.md` — **외부 기여를 받는지** 명시 | 밖에서 온 사람이 헛수고하지 않는다 | -| T1.5.4 | GitHub 이슈 처리 — 끄거나 Jira 로 안내 | 추적이 두 곳으로 갈리지 않는다 | + +**라이선스·기여 정책·이슈 트래커는 PoC 범위 밖이다.** 셋 다 *밖에서 오는 +사람*을 전제로 하는데, PoC 단계에서는 그런 사람이 없다. 없는 독자를 위해 +문서를 쓰면 유지 대상만 늘고, 정작 정할 때가 오면 그때의 조건으로 다시 +정하게 된다. **공개 시점에 정한다.** > **`CLAUDE.md` 를 README 로 쓰지 않는다.** 그건 작업 규범이라 바깥 사람이 읽을 > 문서가 아니다. 서로 링크만 건다. @@ -210,7 +212,7 @@ Phase 2 의 첫 테스트를 쓰는 순간부터 필요하다. | G1.12 | journal 형식·색인 동기화가 CI 에서 검증 | T1.3.3 | | G1.13 | 서드파티 액션이 전부 SHA 로 고정됨 | T1.3.7 | | G1.14 | `main`·`develop` 에 필수 체크 + squash 비활성 | T1.3.8 | -| G1.15 | 루트 `README.md`·`LICENSE`·`CONTRIBUTING.md` 가 있다 | T1.5.1~T1.5.3 | +| G1.15 | 루트 `README.md` 가 있다 | T1.5.1 | ### 게이트 커밋 diff --git a/plan/02-domain-core.md b/plan/02-domain-core.md index ae1b468a..c3f7e81a 100644 --- a/plan/02-domain-core.md +++ b/plan/02-domain-core.md @@ -106,8 +106,13 @@ tryAcquireAll(tier1, tier2): 둘 다 여유 있음 → 둘 다 차감, PASS_UNDER_CAP tier1 부족 → 아무것도 안 깎음, ENQUEUE_RATE_COUPON tier2 부족 → 아무것도 안 깎음, ENQUEUE_RATE_GLOBAL + 키 자리 없음 → 아무것도 안 깎음, ENQUEUE_KEY_SATURATED ``` +**키 자리 부족은 예산 고갈과 다른 값이다.** 같이 묶으면 운영자가 쿠폰이나 +노드를 조이는데, 조여야 할 것은 리미터의 키 상한이다. 예산을 먼저 보고 자리를 +나중에 본다 — 예산이 말랐다면 그 키는 이미 자리를 잡고 있어 자리 문제가 아니다. + 전부-아니면-전무다. 부분 획득 뒤 반납하는 형태로 만들지 않는다 — 반납 누락이 곧 예산 유실이고, 그건 조용히 통과량을 갉아먹는다. @@ -176,8 +181,8 @@ tryAcquireAll(tier1, tier2): 7. mode == ALWAYS → ENQUEUE_ALWAYS 8. runtime != IDLE || justEnqueued → ENQUEUE_BACKLOG (새치기 방지) ───────────── 여기부터 한산한 쿠폰 ───────────── - 9. tryAcquireAll(tier1, tier2) → 부족한 쪽에 따라 - ENQUEUE_RATE_COUPON / ENQUEUE_RATE_GLOBAL + 9. tryAcquireAll(tier1, tier2) → 부족한 쪽에 따라 ENQUEUE_RATE_COUPON / + ENQUEUE_RATE_GLOBAL / ENQUEUE_KEY_SATURATED 10. → PASS_UNDER_CAP ``` @@ -192,6 +197,11 @@ tryAcquireAll(tier1, tier2): 어느 쪽이 부족했는지는 판정값으로 구분한다 — `ENQUEUE_RATE_COUPON` 은 **그 한산한 쿠폰이 유휴 몫을 다 쓴 것**이고, `ENQUEUE_RATE_GLOBAL` 은 **노드가 다 쓴 것**이라 대응이 다르다. 전자는 그 쿠폰만 조이면 되고, 후자는 노드를 늘려야 한다. +`ENQUEUE_KEY_SATURATED` 는 셋째다 — 예산은 남았는데 리미터가 키를 더 못 들고 +있는 것이라 조일 것은 쿠폰도 노드도 아니다. + +**두 예산의 키는 접두사로 갈라 둔다.** 쿠폰 ID 하나가 전역 키와 같아지는 순간 +두 예산이 한 카운터로 합쳐져, 다른 쿠폰의 전역 트래픽이 그 쿠폰 몫을 먹는다. **8번의 `justEnqueued` 가 전이 구멍을 막는다.** `runtime` 은 스냅샷 값이라 이 노드가 @@ -561,12 +571,16 @@ CouponStates.unknown() - **선행** T2.4.1 1. **RED** `균등_배분_후_남은_몫을_재배분한다` - — globalCredit 1000, 핫 20만/콜드 40/콜드 3 → 956/40/3 -2. **GREEN** 1패스 균등 + 2패스 잔여 + — globalCredit 1000, 핫 20만/콜드 40/콜드 3 → 957/40/3 +2. **GREEN** 굶주린 쿠폰에 균등 배분, 못 쓴 몫을 다시 굶주린 쪽으로. + **2패스는 하한이지 상한이 아니다** — 쿠폰이 많고 요구량이 들쭉날쭉하면 + 두 번으로 못 채우고, 남긴 만큼 대기자가 이유 없이 기다린다 3. **RED** `핫에_20만이_밀려도_콜드는_첫_틱에_전부_빠진다` 4. **GREEN** (1패스가 이미 보장) 5. **RED** `정수_나눗셈_나머지는_다음_틱으로_넘긴다` -6. **GREEN** 나머지를 배분하지 않는다 +6. **GREEN** 굶주린 수보다 적게 남으면 멎는다 — 균등하게 나눌 방법이 없다. + 나머지를 누구에게 주면 그 쿠폰만 이득이고, 노드마다 다른 쪽을 고르면 + 총합이 전역 크레딧을 넘는다 7. **완료** 기아 불가 + 유휴 낭비 0 이 동시에 성립 #### T2.4.3 · 크레딧 초과 배분 속성 테스트 ★ diff --git a/plan/03-redis-adapter.md b/plan/03-redis-adapter.md index 6016da16..2d083790 100644 --- a/plan/03-redis-adapter.md +++ b/plan/03-redis-adapter.md @@ -4,7 +4,7 @@ > 이 페이즈에서 정한 키 형식은 나중에 바꾸면 진행 중인 큐가 유실되므로, > Phase 10(샤딩)까지 내다보고 한 번에 확정한다. -**선행**: Phase 1 · **상세 수준**: 제목 + 근거 (진입 시 완전 명세로 승격) +**선행**: Phase 1 · Phase 2 · **상세 수준**: **완전 명세** (2026-08-19 승격) > ## ✅ 착수 차단 해소 — D-P1 · D-S1 (2026-08-14) > @@ -179,106 +179,571 @@ coupon:policy JSON. `pcall(cjson.decode, …)` 로 읽고 실패는 기본값 ( ## 5. Task +> **태스크 ID 는 페이즈를 따른다** — `3.N.M`. 절 번호(`5.N`)는 이 문서 안에서만 +> 의미가 있다 ([README 5절](README.md)). + ### 5.1 인프라 배선 -| ID | 작업 | 커밋 | -|---|---|---| -| 3.1.1 | `spring-boot-starter-data-redis-reactive` 추가 | `build: 리액티브 redis 의존성 추가` | -| 3.1.2 | Testcontainers Redis 픽스처 | `test(redis): testcontainers 픽스처 추가` | -| 3.1.3 | 커넥션 설정 — 풀 없음(Lettuce 멀티플렉싱), 타임아웃 | `feat(redis): 연결 설정` | -| 3.1.4 | 커넥션 설정 근거를 주석으로 | 동 | +#### T3.1.1 · 리액티브 Redis 의존성 + +- **산출물** `build.gradle` +- **근거** RD-4 (요청 경로에서 Redis 를 치지 않는다 — 그래도 제어 평면은 쳐야 한다) + +1. **RED** `애플리케이션_컨텍스트가_뜬다` 가 Redis 자동설정과 함께 통과하는지 +2. **GREEN** `spring-boot-starter-data-redis-reactive` 추가 +3. **완료** 컨텍스트 기동 시 Lettuce 커넥션 팩토리가 뜬다 + +#### T3.1.2 · Testcontainers Redis 픽스처 + +- **산출물** `test/.../redis/RedisContainerSupport.java` +- **근거** TS-3 (인프라 동작은 실물로 검증한다) · 2.4절 (Redis 7.x 이상) +- **선행** T3.1.1 + +1. **RED** `컨테이너가_뜨고_PING에_응답한다` +2. **GREEN** `@Testcontainers` 기반 공용 베이스. **컨테이너는 클래스당 하나가 + 아니라 JVM 당 하나** — 태스크마다 띄우면 전체 테스트가 분 단위로 늘어난다 +3. **RED** `이미지_태그가_7_이상이다` — `TIME` 과 효과 기반 복제가 필요하다 (2.4절) +4. **GREEN** 태그 고정 +5. **완료** `integrationTest` 로 분리돼 단위 테스트 시간이 늘지 않는다. 이미지 태그가 7 이상으로 고정 + +#### T3.1.3 · 커넥션 설정 + +- **산출물** `adapter/redis/RedisConfig.java` +- **근거** 2.4절 · [06-protection.md](06-protection.md) (타임아웃 예산) +- **선행** T3.1.2 + +1. **RED** `커넥션_풀을_쓰지_않는다` — Lettuce 는 멀티플렉싱이라 풀이 해롭다 +2. **GREEN** 풀 비활성, 단일 커넥션 공유 +3. **RED** `명령_타임아웃이_설정되어_있다` — 무한 대기는 스케줄러를 멎게 한다 +4. **GREEN** 타임아웃 설정 +5. **완료** 풀 비활성·타임아웃이 설정에 있고, 각 값의 근거가 주석으로 남는다 (JS-6) + +--- ### 5.2 키 스킴 -| ID | 작업 | 주요 테스트 | -|---|---|---| -| 3.2.1 | `RedisKeys` — 전역 키 | 문자열 고정 | -| 3.2.2 | 쿠폰별 키 + 샤드 파라미터 | S=1에서 접미사 없음 | -| 3.2.3 | `shardOf(memberId, shards)` — 결정적 해시 | 같은 입력 → 같은 출력, JVM 재기동 후에도 | -| 3.2.4 | 샤드 분포 균등성 | 10만 memberId → 샤드별 편차 < 5% | -| 3.2.5 | 해시태그 슬롯 일치 검증 | 같은 샤드 키가 같은 슬롯 | +#### T3.2.1 · `RedisKeys` — 전역 키 ★ + +- **산출물** `adapter/redis/RedisKeys.java` +- **근거** 3절 · RD-3 (키 이름은 한 곳에서만) · PK-R1 + +1. **RED** `전역_키_문자열이_고정되어_있다` — `gw:snapshot`·`gw:instances`· + `scheduler:leader`·`gw:tunables`·`coupons:active`·`coupon:policy` +2. **GREEN** 상수와 접근자 +3. **완료** 키 문자열 리터럴이 이 클래스 **밖에 하나도 없다** (T3.8.2 가 검사) + +#### T3.2.2 · 쿠폰별 키와 샤드 접미사 ★ + +- **산출물** `RedisKeys.java` +- **근거** 3.1절 (S==1 을 접미사 없이 두는 이유) · G3.6 +- **선행** T3.2.1 + +1. **RED** `샤드가_하나면_접미사가_붙지_않는다` — `queue:{c1}` +2. **GREEN** `shards == 1` 분기 +3. **RED** `샤드가_여럿이면_접미사가_붙는다` — `queue:{c1:3}` +4. **GREEN** 접미사 형식 +5. **RED** `같은_쿠폰의_키가_모두_같은_해시태그를_쓴다` + — `queue`·`maxscore`·`admitted`·`grace`·`alive` 다섯 +6. **GREEN** 해시태그 공통화 +7. **완료** `queue:{c1}` 형식이 테스트로 고정된다. 다섯 키가 같은 해시태그를 쓴다 + +> **S=1 쿠폰의 키는 영원히 안 바뀐다.** 지금 못 박지 않으면 샤딩을 도입하는 +> 순간 콜드 쿠폰 전체의 키가 갈린다. + +#### T3.2.3 · `shardOf` — 결정적 해시 ★ + +- **산출물** `RedisKeys.java` 또는 `adapter/redis/ShardHash.java` +- **근거** 3절 (E-7 CRC16) · **sticky 여야 한다** +- **선행** T3.2.2 + +1. **RED** `같은_memberId는_항상_같은_샤드로_간다` +2. **GREEN** CRC16 기반 `crc16(memberId) % shards` +3. **RED** `JVM_해시에_의존하지_않는다` — 문자열 해시 시드가 바뀌어도 같은 값 +4. **GREEN** 자체 CRC16 구현. `String.hashCode()` 를 **쓰지 않는다** +5. **RED** `샤드_수가_1이면_항상_0이다` +6. **GREEN** 분기 +7. **완료** 같은 `memberId` 가 JVM 재기동 후에도 같은 샤드로 간다. `String.hashCode()` 미사용 + +> **sticky 하지 않으면 같은 사람이 틱마다 다른 큐에 선다.** 순위가 앞뒤로 +> 튀어 불변식 3(순번 역행 0)이 깨진다. + +#### T3.2.4 · 샤드 분포 균등성 + +- **산출물** `test/.../redis/ShardDistributionTest.java` +- **근거** G3.7 · TS-6 (속성 테스트) +- **선행** T3.2.3 + +1. **RED** `10만_memberId의_샤드별_편차가_5퍼센트_미만이다` — S=16 +2. **GREEN** (T3.2.3 이 통과시켜야 한다. 실패하면 해시를 고친다) +3. **완료** 10만 `memberId` · S=16 에서 샤드별 편차 < 5% + +#### T3.2.5 · 해시태그 슬롯 일치 검증 + +- **산출물** `test/.../redis/HashTagSlotTest.java` +- **근거** 3.2절 · RD-2 · G3.4 +- **선행** T3.2.2 + +1. **RED** `같은_샤드의_키들이_같은_슬롯에_들어간다` — CRC16 슬롯 계산으로 직접 확인 +2. **GREEN** (T3.2.2 가 통과시켜야 한다) +3. **RED** `stock_키는_Lua가_만지지_않는다` — 샤딩 시 슬롯이 갈린다 +4. **GREEN** 정적 검사로 위임 (T3.8.2) +5. **완료** 다섯 키의 CRC16 슬롯이 서로 같다 — 클러스터를 안 띄우고 확인한다 + +--- ### 5.3 영속성 정책 (F2) -| ID | 작업 | 주요 테스트 | -|---|---|---| -| 3.3.1 | Redis 설정 — `appendfsync` 정책 확정 | 설정 파일 + 문서 | -| 3.3.2 | 시계 점프 폭 관측 지표 — `maxscore` 가 실제 시각을 얼마나 앞서는가 | 되돌린 폭이 지표로 보인다 | -| 3.3.3 | `enqueue.lua`에 **시계 역행 바닥값**(`maxscore`) | 시계를 되돌린 뒤 순서 역행 0 | -| 3.3.4 | 점프 시 경고 로그 + 메트릭 | | -| 3.3.5 | **kill -9 후 복구 시나리오** | 순서 역행 0 (C12) | -| 3.3.6 | **복제본 승격 + 시계 역행 시나리오** | 순서 역행 0 (C13) | +#### T3.3.1 · Redis 영속성 설정 + +- **산출물** `docker/redis.conf` · `docs/operations` 항목 +- **근거** [90-decisions.md](90-decisions.md) E-6 (`everysec` + 복제본) · 2.2절 + +1. **RED** `설정_파일이_everysec를_지정한다` — 설정 파싱 테스트 +2. **GREEN** `appendonly yes` · `appendfsync everysec` +3. **완료** 설정 파일이 `appendonly yes` · `appendfsync everysec` 를 담고, `always` 를 안 쓴 근거가 문서에 있다 + +#### T3.3.2 · 시계 점프 관측 지표 + +- **산출물** `adapter/redis/ClockSkewGauge.java` +- **근거** 2.1절 · 2.4절 (slew 보정만) +- **선행** T3.1.3 + +1. **RED** `maxscore가_실제_시각을_앞선_폭이_지표로_보인다` +2. **GREEN** `maxscore - TIME` 을 게이지로 +3. **완료** `maxscore - TIME` 이 게이지로 노출된다 + +#### T3.3.3 · `enqueue.lua` 시계 역행 바닥값 ★★ + +- **산출물** `resources/redis/enqueue.lua` +- **근거** 2.1절 · 불변식 4 · G3.10 +- **선행** T3.2.2 + +1. **RED** `시계를_되돌려도_뒤에_온_사람이_앞서지_않는다` + — `TIME` 을 조작한 뒤 A·B 순으로 등록 → `score(B) > score(A)` +2. **GREEN** `maxscore` 를 읽어 `floor >= t` 면 `t = floor + 1` +3. **RED** `큐가_빈_동안_시계가_되돌아가도_막힌다` — 전원 입장 후 시계 역행 +4. **GREEN** `maxscore` 를 ZSET 과 **독립적으로** 유지. ZSET 의 마지막 원소를 + 읽는 방식으로는 이 경우를 못 막는다 +5. **RED** `maxscore가_enqueue마다_24시간_갱신된다` +6. **GREEN** `SET ... EX 86400` +7. **완료** 시계를 되돌린 뒤 등록해도 `score` 가 단조 증가. `maxscore` TTL 이 매 등록마다 24시간으로 갱신 + +> `ZADD` 와 `SET maxscore` 는 **같이 남거나 같이 사라진다** — Lua 는 효과 기반 +> 복제라 `maxscore` 가 ZSET 보다 뒤처지는 상태가 존재하지 않는다. + +#### T3.3.4 · 점프 경고 + +- **산출물** `enqueue.lua` 반환값 + 어댑터 로깅 +- **근거** 2.4절 +- **선행** T3.3.3 + +1. **RED** `바닥값이_적용되면_그_사실이_반환된다` +2. **GREEN** 반환 튜플에 플래그 추가 +3. **완료** 바닥값이 적용된 등록의 반환값에 플래그가 서고, 경고 로그와 메트릭이 함께 오른다 + +#### T3.3.5 · `kill -9` 후 복구 시나리오 ★ + +- **산출물** `test/.../redis/CrashRecoveryTest.java` +- **근거** G3.3 · C12 · 2.2절 +- **선행** T3.3.3 + +1. **RED** `kill_9_후_재기동해도_순서가_역행하지_않는다` + — 등록 → 프로세스 강제 종료 → 재기동 → 추가 등록 → 전 구간 score 단조 +2. **GREEN** (T3.3.3 이 통과시켜야 한다) +3. **RED** `유실된_사람은_NOT_QUEUED를_받는다` — 증발이 조용한 오류가 되면 안 된다 +4. **GREEN** 조회 경로에서 부재를 명시적으로 낸다 +5. **완료** 강제 종료·재기동 전후 전 구간 `score` 단조. 유실된 사람은 `NOT_QUEUED` + +> **유실은 허용하고 역행은 허용하지 않는다.** 재등록하면 새 score 가 유실 +> 구간보다 크므로 이미 줄 선 사람을 추월하지 않는다. + +#### T3.3.6 · 복제본 승격 + 시계 역행 시나리오 ★★ + +- **산출물** `test/.../redis/ReplicaPromotionTest.java` +- **근거** G3.10 · C13 · 2.1절 +- **선행** T3.3.5 + +1. **RED** `시계가_뒤처진_복제본을_승격해도_순서가_역행하지_않는다` + — 주-복제 구성 → 복제본 시계를 되돌림 → 승격 → 등록 → score 단조 +2. **GREEN** (T3.3.3 이 통과시켜야 한다) +3. **완료** 시계를 되돌린 복제본을 승격한 뒤 등록해도 `score` 단조 + +--- ### 5.4 enqueue -| ID | 작업 | 주요 테스트 | -|---|---|---| -| 3.4.1 | `enqueue.lua` — 기본 등록 | TIME 조회, 단조 가드, ZADD | -| 3.4.2 | 재등록 시 원래 순번 유지 | 같은 사용자 반복 호출 | -| 3.4.3 | **동시 등록** — 같은 사용자 100 동시 | 자리 정확히 1개 | -| 3.4.4 | 하트비트 TTL 갱신 | | -| 3.4.5 | 반환 형식 파싱 | | -| 3.4.6 | 큐 길이 상한 (2차 방어) | 상한 초과 시 거부 신호 | +> 이 경우가 가장 위험하다 — **구간 전체가 한꺼번에 추월당한다.** 여기서 안 +> 잡으면 Phase 8 에서 게이트웨이 전체를 세우고서야 드러난다. + +#### T3.4.1 · `enqueue.lua` 기본 등록 ★ + +- **산출물** `resources/redis/enqueue.lua` · `adapter/redis/EnqueueScript.java` +- **근거** 4절 (조회-후-등록을 나누면 항목이 둘 생긴다) · RD-10 +- **선행** T3.3.3 + +1. **RED** `등록하면_큐에_자리가_생긴다` +2. **GREEN** `TIME` → 단조 가드 → `ZADD` +3. **RED** `KEYS와_ARGV_계약이_스크립트_상단에_적혀_있다` — 주석 검사 +4. **GREEN** 계약 주석 (RD-10) +5. **완료** 등록 후 `ZCARD` 가 1 증가. 상단에 KEYS/ARGV 계약 주석이 있다 (RD-10) + +#### T3.4.2 · 재등록 시 순번 유지 ★ + +- **산출물** `enqueue.lua` +- **근거** 불변식 3 (순번 역행 0) +- **선행** T3.4.1 + +1. **RED** `같은_사용자가_다시_등록해도_원래_순번을_지킨다` + — 새로고침 연타가 자기 자리를 뒤로 미는 일이 없어야 한다 +2. **GREEN** `ZSCORE` 로 기존 score 를 먼저 본다. 있으면 그대로 돌려준다 +3. **완료** 같은 사용자가 N 번 등록해도 `ZSCORE` 가 첫 값과 같다 + +> **자기 자신에게 추월당하지 않는다.** 이게 깨지면 사용자는 기다릴수록 +> 손해라고 배운다. + +#### T3.4.3 · 동시 등록 ★★ + +- **산출물** `test/.../redis/ConcurrentEnqueueTest.java` +- **근거** G3.1 · 4절 +- **선행** T3.4.2 + +1. **RED** `같은_사용자_100_동시_등록에서_자리가_정확히_1개다` +2. **GREEN** (T3.4.2 가 통과시켜야 한다. Lua 원자성이 보장한다) +3. **완료** `ZCARD` 가 정확히 1 + +> 자리가 2개가 되면 대기 인원이 부풀고 **ETA 가 전부 틀어진다.** + +#### T3.4.4 · 하트비트 TTL + +- **산출물** `enqueue.lua` +- **근거** [07-queue-lifecycle.md](07-queue-lifecycle.md) (`alive` TTL 30s) +- **선행** T3.4.1 + +1. **RED** `등록하면_생존_키가_TTL과_함께_생긴다` +2. **GREEN** `SET alive:{cid}:{member} 1 EX ` +3. **완료** 생존 키가 주입된 TTL 로 생긴다 — 스크립트에 값을 박지 않는다 + +#### T3.4.5 · 반환 형식 + +- **산출물** `EnqueueScript.java` +- **근거** JS-6 · 4절 +- **선행** T3.4.4 + +1. **RED** `반환값을_타입으로_파싱한다` — score·순위·바닥값 적용 여부 +2. **GREEN** Lua 배열 → record 매핑 +3. **RED** `형식이_어긋나면_예외로_드러난다` +4. **GREEN** 길이·타입 검증 +5. **완료** 형식이 어긋나면 예외가 난다. 기본값으로 삼키지 않는다 + +#### T3.4.6 · 큐 길이 상한 (2차 방어) + +- **산출물** `enqueue.lua` +- **근거** [02-domain-core.md](02-domain-core.md) `REJECT_QUEUE_FULL` (1차는 도메인) +- **선행** T3.4.1 + +1. **RED** `상한을_넘으면_등록하지_않고_거부_신호를_낸다` +2. **GREEN** `ZCARD` 확인 후 분기 +3. **완료** 상한 초과 시 `ZCARD` 가 늘지 않고 거부 신호가 온다. 상한은 인자로 받는다 + +--- ### 5.5 queue_status -| ID | 작업 | 주요 테스트 | -|---|---|---| -| 3.5.1 | 순번 조회 기본 | | -| 3.5.2 | 큐에 없는 사람 | `NOT_QUEUED` | -| 3.5.3 | **유예 재입장** — 새 score 로 재등록 (D-11) | 남은 대기자의 순위가 늘지 않음 | -| 3.5.4 | 하트비트 갱신 | TTL 연장 확인 | -| 3.5.5 | 배수 판정 — `score <= admitted` | | -| 3.5.6 | 입장 확정 시 ZREM | 대기 인원 정확 | -| 3.5.7 | **동시 폴링** — 같은 사용자 병렬 | 상태 일관 | -| 3.5.8 | **순위 단조성 속성 테스트** ★ | 등록·입장·이탈을 섞은 10만 시퀀스에서 순위 증가 0 | +#### T3.5.1 · 순번 조회 ★ + +- **산출물** `resources/redis/queue_status.lua` · `adapter/redis/QueueStatusScript.java` +- **근거** 4절 · [02-domain-core.md](02-domain-core.md) `RankEstimator` +- **선행** T3.4.1 + +1. **RED** `내_앞에_몇_명인지_돌려준다` — `ZCOUNT queue -inf (myScore` +2. **GREEN** 스크립트 +3. **완료** `ZCOUNT` 결과가 앞선 인원과 같다. 순위를 저장하지 않는다 + +#### T3.5.2 · 큐에 없는 사람 + +- **산출물** `queue_status.lua` +- **근거** T3.3.5 (유실은 조용한 오류가 되면 안 된다) +- **선행** T3.5.1 + +1. **RED** `큐에_없으면_NOT_QUEUED를_받는다` +2. **GREEN** `ZSCORE` 부재 분기 +3. **완료** 큐에 없는 사용자가 `NOT_QUEUED` 를 받는다 — 0번째와 구분된다 + +#### T3.5.3 · 유예 재입장 ★★ + +- **산출물** `queue_status.lua` +- **근거** [90-decisions.md](90-decisions.md) D-11 · G3.8 · + [07-queue-lifecycle.md](07-queue-lifecycle.md) +- **선행** T3.5.2 + +1. **RED** `유예_기록이_있으면_재방문자로_식별한다` +2. **GREEN** `grace` 해시 조회 +3. **RED** `재입장은_새_score로_등록된다` — 원래 자리를 **보관하지 않는다** (D-11) +4. **GREEN** 새 score 로 `ZADD` +5. **RED** `재입장이_남은_대기자의_순위를_늘리지_않는다` + — 재입장 전후로 뒤에 선 사람의 `ZCOUNT` 가 같거나 줄어든다 +6. **GREEN** (5번이 성립하려면 새 score 가 **뒤**여야 한다) +7. **완료** 재입장 전후로 뒤에 선 사람의 `ZCOUNT` 가 같거나 줄어든다 + +> 자리를 보관하면 이탈자가 돌아올 때마다 **성실히 기다린 사람이 밀린다.** +> 불변식 4 는 장애 중에도 적용된다 (D-11). + +#### T3.5.4 · 하트비트 갱신 + +- **산출물** `queue_status.lua` +- **근거** [07-queue-lifecycle.md](07-queue-lifecycle.md) (폴링이 곧 생존 신호) +- **선행** T3.5.1 + +1. **RED** `조회하면_생존_TTL이_연장된다` +2. **GREEN** 같은 스크립트 안에서 `EXPIRE` +3. **완료** 조회 한 번에 TTL 이 연장된다 — 왕복이 늘지 않는다 + +#### T3.5.5 · 배수 판정 + +- **산출물** `queue_status.lua` +- **근거** 2.3절 (`admitted` 는 개수가 아니라 score) · D-8 +- **선행** T3.5.1 + +1. **RED** `내_score가_임계_이하면_입장이다` +2. **GREEN** `admitted` 와 비교 +3. **RED** `임계가_없으면_아무도_입장하지_않는다` — 초기 상태 +4. **GREEN** 부재 시 0 으로 간주 +5. **완료** `score <= admitted` 가 입장. `admitted` 부재 시 아무도 입장하지 않는다 + +#### T3.5.6 · 입장 확정 시 제거 + +- **산출물** `queue_status.lua` +- **근거** T3.5.5 · 대기 인원 정확도 +- **선행** T3.5.5 + +1. **RED** `입장이_확정되면_큐에서_빠진다` +2. **GREEN** `ZREM` +3. **RED** `이미_빠진_사람이_다시_조회해도_망가지지_않는다` +4. **GREEN** 멱등 처리 +5. **완료** 입장 확정 후 `ZCARD` 가 1 줄고, 재조회해도 값이 안 바뀐다 + +#### T3.5.7 · 동시 폴링 + +- **산출물** `test/.../redis/ConcurrentStatusTest.java` +- **근거** 4절 (원자성이 필요한 이유) +- **선행** T3.5.6 + +1. **RED** `같은_사용자가_병렬로_조회해도_상태가_일관된다` + — 입장 확정이 두 번 일어나지 않는다 +2. **GREEN** (Lua 원자성이 보장한다) +3. **완료** 병렬 조회에서 입장 확정이 정확히 한 번 + +#### T3.5.8 · 순위 단조성 속성 테스트 ★★ + +- **산출물** `test/.../redis/RankMonotonicityIntegrationTest.java` +- **근거** G3.11 · TS-6 · [02-domain-core.md](02-domain-core.md) T2.7.2 +- **선행** T3.5.7 + +1. **RED** `등록_입장_이탈을_섞은_10만_시퀀스에서_순위가_증가하지_않는다` +2. **GREEN** (앞 태스크들이 통과시켜야 한다) +3. **완료** 등록·입장·이탈을 섞은 10만 시퀀스에서 순위 증가 0건 + +--- ### 5.6 sweep -| ID | 작업 | 주요 테스트 | -|---|---|---| -| 3.6.1 | `sweep.lua` — alive 없는 앞부분 항목 제거 | | -| 3.6.2 | grace로 이력 이동 | HSET 확인 | -| 3.6.3 | grace 항목 만료 정책 | | -| 3.6.4 | 검사 범위 K 파라미터화 | | +> Phase 2 의 단조성 테스트는 **"입력이 단조면 출력도 단조"** 까지만 봤다. +> `localRank` 자체가 단조라는 보장은 여기가 진다. + +#### T3.6.1 · `sweep.lua` — 이탈자 제거 + +- **산출물** `resources/redis/sweep.lua` · `adapter/redis/SweepScript.java` +- **근거** 4절 · [07-queue-lifecycle.md](07-queue-lifecycle.md) 4절 +- **선행** T3.4.4 + +1. **RED** `생존_키가_없는_앞부분_항목이_제거된다` +2. **GREEN** 앞에서 K 개를 훑어 `alive` 부재면 `ZREM` +3. **RED** `생존_키가_있으면_건드리지_않는다` +4. **GREEN** 존재 확인 +5. **완료** 생존 키 없는 앞부분 K 개만 제거된다. 있는 항목은 그대로 + +#### T3.6.2 · grace 이동 + +- **산출물** `sweep.lua` +- **근거** D-11 · T3.5.3 +- **선행** T3.6.1 + +1. **RED** `제거된_사람이_유예_기록에_남는다` +2. **GREEN** 같은 스크립트에서 `HSET grace` +3. **완료** 제거된 사용자가 `grace` 해시에 남는다 — 같은 스크립트 안에서 + +#### T3.6.3 · grace 만료 + +- **산출물** `sweep.lua` +- **근거** [07-queue-lifecycle.md](07-queue-lifecycle.md) (grace 5m) · RD-7 +- **선행** T3.6.2 + +1. **RED** `유예_기록이_무한히_쌓이지_않는다` +2. **GREEN** 필드별 타임스탬프 + 만료 정리 +3. **완료** 만료 지난 유예 기록이 정리된다. 해시 크기가 단조 증가하지 않는다 + +#### T3.6.4 · 검사 범위 K + +- **산출물** `sweep.lua` +- **근거** T3.6.1 +- **선행** T3.6.1 + +1. **RED** `검사_범위가_인자로_주어진다` +2. **GREEN** `ARGV` 로 K +3. **완료** K 가 `ARGV` 로 주어져 배포 없이 바뀐다 + +--- ### 5.7 leader -| ID | 작업 | 주요 테스트 | -|---|---|---| -| 3.7.1 | `leader_acquire.lua` — 획득 | | -| 3.7.2 | 재진입 시 TTL 연장 | 리더십이 흔들리지 않음 | -| 3.7.3 | 다른 노드 점유 시 거부 | | -| 3.7.4 | **동시 획득 시도** — 10노드 | 정확히 1대만 성공 | -| 3.7.5 | 해제 — 자기 락만 삭제 | 남의 락 삭제 불가 | +#### T3.7.1 · `leader_acquire.lua` ★ + +- **산출물** `resources/redis/leader_acquire.lua` · `adapter/redis/LeaderScript.java` +- **근거** 4절 · [04-control-plane.md](04-control-plane.md) (lease 2s) + +1. **RED** `아무도_안_잡았으면_획득한다` +2. **GREEN** `SET key NX PX ` +3. **완료** 빈 키에서 획득에 성공하고, 값에 소유자 ID 가 들어 있다 + +#### T3.7.2 · 재진입 연장 + +- **산출물** `leader_acquire.lua` +- **근거** [04-control-plane.md](04-control-plane.md) (renew 500ms) +- **선행** T3.7.1 + +1. **RED** `자기가_잡은_락은_연장된다` +2. **GREEN** `GET == 내 ID` 확인 후 `PEXPIRE` +3. **완료** 자기 락의 PTTL 이 연장된다 + +#### T3.7.3 · 타인 점유 시 거부 + +- **산출물** `leader_acquire.lua` +- **근거** [04-control-plane.md](04-control-plane.md) · G3.2 +- **선행** T3.7.1 + +1. **RED** `남이_잡고_있으면_획득하지_못한다` +2. **GREEN** `NX` 실패 분기 +3. **완료** 타인이 점유한 키에서 획득이 실패한다 + +#### T3.7.4 · 동시 획득 ★★ + +- **산출물** `test/.../redis/ConcurrentLeaderTest.java` +- **근거** G3.2 +- **선행** T3.7.3 + +1. **RED** `10노드가_동시에_시도하면_정확히_1대만_성공한다` +2. **GREEN** (T3.7.1 이 통과시켜야 한다) +3. **완료** 성공 카운트가 정확히 1 + +#### T3.7.5 · 해제 ★ + +- **산출물** `leader_acquire.lua` 또는 `leader_release.lua` +- **근거** T3.7.1 +- **선행** T3.7.4 + +1. **RED** `자기_락만_지울_수_있다` +2. **GREEN** Lua 안에서 `GET == 내 ID` 확인 후 `DEL` +3. **RED** `남의_락은_지워지지_않는다` — 리스가 만료돼 다른 노드가 잡은 뒤 늦게 + 도착한 해제 요청 +4. **GREEN** 소유권 확인 +5. **완료** 자기 락은 지워지고 타인 락은 남는다 — 확인과 삭제가 한 스크립트 안에서 + +--- ### 5.8 클러스터 호환성 검사 -| ID | 작업 | 커밋 | -|---|---|---| -| 3.8.1 | 모든 Lua의 KEYS 규칙 준수 정적 검사 | `test(redis): lua KEYS 선언 검증` | -| 3.8.2 | 하드코딩된 키 접근 탐지 | 동 | -| 3.8.3 | 클러스터 모드 Testcontainers로 전 스크립트 실행 | `test(redis): 클러스터 모드 스크립트 실행 검증` | +> **지금 해야 한다.** Phase 10 에서 발견하면 Lua 를 전부 다시 쓴다. + +> `GET` 과 `DEL` 을 나누면 **그 사이에 리스가 만료되고 다른 노드가 잡는다.** +> 그 상태에서 `DEL` 하면 리더가 없어지고 배분이 멎는다. + +#### T3.8.1 · KEYS 규칙 정적 검사 ★★ + +- **산출물** `test/.../redis/LuaKeysDeclarationTest.java` +- **근거** RD-1 · PK-R2 · G3.5 +- **선행** T3.7.5 + +1. **RED** `모든_Lua가_KEYS에_선언된_키만_만진다` + — 스크립트를 파싱해 `redis.call` 의 첫 인자가 `KEYS[n]` 파생인지 확인 +2. **GREEN** 위반 스크립트를 고친다 +3. **RED** `검사가_실제로_문다` — 위반 스크립트를 일부러 넣으면 실패한다 +4. **GREEN** (자기검증 — 통과만 하는 검사는 검사가 아니다) +5. **완료** 위반 스크립트를 넣으면 검사가 실패한다 (자기검증) + +> 클러스터에서 KEYS 미선언 키 접근은 **런타임 오류**다. 여기서 못 잡으면 +> 부하 시험 중에 터진다. + +#### T3.8.2 · 하드코딩 키 탐지 ★ + +- **산출물** `test/.../redis/HardcodedKeyTest.java` +- **근거** RD-3 · PK-R1 · T3.2.1 +- **선행** T3.2.1 + +1. **RED** `키_문자열_리터럴이_RedisKeys_밖에_없다` + — 소스와 Lua 를 훑어 `queue:`·`admitted:` 같은 접두사를 찾는다 +2. **GREEN** 발견된 것을 `RedisKeys` 로 옮긴다 +3. **RED** `검사가_실제로_문다` +4. **GREEN** 자기검증 +5. **완료** 키 접두사 리터럴이 `RedisKeys` 밖에 0건. 위반을 넣으면 검사가 실패한다 + +#### T3.8.3 · 클러스터 모드 실행 ★★ + +- **산출물** `test/.../redis/ClusterModeScriptTest.java` +- **근거** G3.4 · RD-2 · 3.2절 +- **선행** T3.8.1 + +1. **RED** `모든_스크립트가_클러스터_모드에서_오류_없이_실행된다` + — Testcontainers 클러스터에 전 스크립트를 실제로 태운다 +2. **GREEN** 슬롯 교차를 없앤다 +3. **완료** 클러스터 모드에서 전 스크립트가 오류 0 으로 실행된다 + +--- + +### 5.9 통합 커버리지 + +> 정적 검사는 **의도**를 보고 이 테스트는 **사실**을 본다. 정적 검사가 놓치는 +> 동적 키 구성이 있어 둘 다 필요하다. + +#### T3.9.1 · 통합 테스트 커버리지 임계 + +- **산출물** `build.gradle` (JaCoCo 규칙) +- **근거** G3.9 +- **선행** T3.8.3 -> 3.8.1~3.8.3을 **지금** 해야 한다. Phase 10에서 발견하면 Lua를 전부 다시 쓴다. +1. **RED** `adapter_redis_패키지_브랜치_커버리지가_80퍼센트_이상이다` +2. **GREEN** 미달 분기에 테스트 추가 +3. **완료** `adapter/redis` 패키지 브랜치 커버리지 ≥ 80%, 미달 시 빌드 실패 --- ## 6. Exit Gate -| ID | 기준 | 검증 | +| 게이트 | 기준 | 증명 | |---|---|---| -| G3.1 | 같은 사용자 100 동시 등록 → 자리 **정확히 1개** | 3.4.3 | -| G3.2 | 10노드 동시 리더 획득 → 정확히 1대 | 3.7.4 | -| G3.3 | **`kill -9` 후 복구에서 순서 역행 0** | 3.3.5 / C12 | -| G3.4 | 모든 Lua가 클러스터 모드에서 오류 없이 실행 | 3.8.3 | -| G3.5 | KEYS 미선언 키 접근 **0건** | 3.8.1 정적 검사 | -| G3.6 | S=1 키 문자열이 접미사 없는 형식 | 3.2.2 | -| G3.7 | 10만 memberId 샤드 분포 편차 < 5% | 3.2.4 | -| G3.8 | 유예 재입장이 남은 대기자를 추월하지 않는다 | 3.5.3 | -| G3.9 | 통합 테스트 브랜치 커버리지 ≥ 80% | JaCoCo | -| G3.10 | **시계가 뒤처진 복제본을 승격해도 순서 역행 0** | 3.3.6 / C13 | -| G3.11 | **`ZCOUNT` 로 구한 순위가 단조 감소** — 무작위 10만 시퀀스 | 3.5.8 | +| G3.1 | 같은 사용자 100 동시 등록 → 자리 **정확히 1개** | ✅ `ConcurrentEnqueueTest` (통합 4) | +| G3.2 | 10노드 동시 리더 획득 → 정확히 1대 | ✅ `LeaderElectionTest` (통합 8) | +| G3.3 | **`kill -9` 후 복구에서 순서 역행 0** | ✅ `CrashRecoveryTest` (카오스 2) | +| G3.4 | 모든 Lua가 클러스터 모드에서 오류 없이 실행 | ✅ `ClusterModeScriptTest` (통합 3) | +| G3.5 | KEYS 미선언 키 접근 **0건** | ✅ `LuaKeysDeclarationTest` (단위 6) | +| G3.6 | S=1 키 문자열이 접미사 없는 형식 | ✅ `RedisKeysTest` (단위 8) | +| G3.7 | 10만 memberId 샤드 분포 편차 < 5% | ✅ `ShardDistributionTest` (단위 2) | +| G3.8 | 유예 재입장이 남은 대기자를 추월하지 않는다 | ✅ `GraceReentryTest` (통합 4) | +| G3.9 | 어댑터 브랜치 커버리지 ≥ 80% | ✅ `adapterCoverageVerification` — 91.7% | +| G3.10 | **시계가 뒤처진 복제본을 승격해도 순서 역행 0** | ✅ `ReplicaPromotionTest` (카오스 1) | +| G3.11 | **`ZCOUNT` 로 구한 순위가 단조 감소** | ✅ `RankMonotonicityIntegrationTest` (통합·단위 7) | + +> **판정 2026-08-20 — 전건 통과.** 주장이 아니라 실행으로 판정했다. +> 단위 248 · 통합 68 · 카오스 3 건이 돌았고, 각 게이트가 어느 시험에서 +> 증명되는지는 위 표의 마지막 열이다. + +**계획의 전제 두 개를 실측이 고쳤다.** `kill -9` 는 유실을 만들지 않고 +(`appendfsync` 는 `fsync` 주기를 정할 뿐이다), 컨테이너 시계는 되돌릴 수 없다 +(호스트와 공유). 전자는 전원 단절을 AOF 꼬리 절단으로, 후자는 `TIME < maxscore` +조건을 직접 만들어 재현했다 → AIJ-0025. + +**G3.9 의 정의도 실측이 고쳤다.** "통합 테스트 커버리지" 를 통합 exec 로만 +재면 27.1% 다 — JaCoCo 는 Lua 를 못 보고 어댑터 Java 는 단위 시험이 도는 +곳이라 **엉뚱한 것을 재게 된다.** 두 계층을 합쳐 잰다 → AIJ-0024. ### 게이트 커밋 @@ -289,7 +754,7 @@ chore(gate): Phase 3 게이트 통과 kill -9 · 복제본 승격 후 순서 역행 0 (maxscore 바닥값) 전 Lua 를 클러스터 모드에서 검증 -Refs: CY- +Refs: CY-237 ``` --- @@ -303,8 +768,8 @@ Refs: CY- | HTTP 계층 | Phase 5 | | 전체 카오스 시나리오 | Phase 8. 여기서는 G3.3·G3.10 에 필요한 것만 | -> **여기서 만드는 것은 Redis 프로세스 조작뿐이다** — `kill -9` 후 재기동(3.3.5), -> 시계가 뒤처진 복제본 승격(3.3.6). 어댑터가 **순서 역행 0** 을 지키는지만 본다. +> **여기서 만드는 것은 Redis 프로세스 조작뿐이다** — `kill -9` 후 재기동(T3.3.5), +> 시계가 뒤처진 복제본 승격(T3.3.6). 어댑터가 **순서 역행 0** 을 지키는지만 본다. > > Phase 8 의 `C12`·`C13` 은 같은 사건을 **진입·유지·회복 3단계로 판정**하고 > `RC1`~`RC6`(초과 발급 0 · 자리 유지 · 회복 버스트 ≤1.2배 …)을 얹는다. @@ -314,4 +779,5 @@ Refs: CY- ## 지라 티켓 -아직 발번하지 않았다. 이 페이즈에 진입할 때 만든다. +에픽과 태스크는 발번했다. 대응표는 [91-jira-map.md](91-jira-map.md) 9절에 있다 — +여기 옮겨 적지 않는다. 사본이 생기면 둘이 갈라진다. diff --git a/plan/04-control-plane.md b/plan/04-control-plane.md index 4f0ef165..5f5349c3 100644 --- a/plan/04-control-plane.md +++ b/plan/04-control-plane.md @@ -352,4 +352,16 @@ Refs: CY- ## 지라 티켓 -아직 발번하지 않았다. 이 페이즈에 진입할 때 만든다. +에픽 **CY-267** — 제어 평면 (스냅샷·가용량·배분) + +| 절 | 티켓 | 제목 | +|---|---|---| +| 6.0 | CY-268 | 최소 장애 주입 픽스처 | +| 6.1 | CY-269 | 스냅샷 홀더 | +| 6.2 | CY-270 | 스냅샷 갱신 루프 | +| 6.3 | CY-271 | 게이트웨이 레지스트리 | +| 6.4 | CY-272 | 가용량 수집 | +| 6.5 | CY-273 | 리더 선출 배선 | +| 6.6 | CY-274 | 배분 스케줄러 | +| 6.7 | CY-275 | 헬스체크와 관리 포트 | +| 7 | CY-276 | 제어 평면 종료 게이트 | diff --git a/plan/91-jira-map.md b/plan/91-jira-map.md index 2c243a4d..1abf5c32 100644 --- a/plan/91-jira-map.md +++ b/plan/91-jira-map.md @@ -57,7 +57,7 @@ Refs: CY-145 ← Jira |---|---|---| | 1 | 검증 기반 구축 | `CY-18` | | 2 | 입장 판정 도메인 | `CY-19` | -| 3 | 레디스 키와 스크립트 | — | +| 3 | 레디스 키와 스크립트 | [`CY-227`](https://shseol.atlassian.net/browse/CY-227) | | 4 | 배분 스케줄러와 스냅샷 | — | | 5 | 요청 경로 | `CY-22` | | 6 | 보호 장치와 실측 | — | @@ -203,6 +203,7 @@ CI 가 브랜치명에서 CY-42 를 뽑아 |---|---| | `phase-1` | [`CY-18`](https://shseol.atlassian.net/browse/CY-18) | | `phase-2` | [`CY-19`](https://shseol.atlassian.net/browse/CY-19) | +| `phase-3` | [`CY-227`](https://shseol.atlassian.net/browse/CY-227) | | `phase-5` | [`CY-22`](https://shseol.atlassian.net/browse/CY-22) | | `phase-8` | [`CY-24`](https://shseol.atlassian.net/browse/CY-24) | | `phase-9` | [`CY-25`](https://shseol.atlassian.net/browse/CY-25) | @@ -278,3 +279,13 @@ CI 가 브랜치명에서 CY-42 를 뽑아 | `plan-8.0.5` | [`CY-118`](https://shseol.atlassian.net/browse/CY-118) | | `gate-1` | [`CY-39`](https://shseol.atlassian.net/browse/CY-39) | | `gate-2` | [`CY-48`](https://shseol.atlassian.net/browse/CY-48) | +| `plan-3.1` | [`CY-228`](https://shseol.atlassian.net/browse/CY-228) | +| `plan-3.2` | [`CY-229`](https://shseol.atlassian.net/browse/CY-229) | +| `plan-3.3` | [`CY-230`](https://shseol.atlassian.net/browse/CY-230) | +| `plan-3.4` | [`CY-231`](https://shseol.atlassian.net/browse/CY-231) | +| `plan-3.5` | [`CY-232`](https://shseol.atlassian.net/browse/CY-232) | +| `plan-3.6` | [`CY-233`](https://shseol.atlassian.net/browse/CY-233) | +| `plan-3.7` | [`CY-234`](https://shseol.atlassian.net/browse/CY-234) | +| `plan-3.8` | [`CY-235`](https://shseol.atlassian.net/browse/CY-235) | +| `plan-3.9` | [`CY-236`](https://shseol.atlassian.net/browse/CY-236) | +| `gate-3` | [`CY-237`](https://shseol.atlassian.net/browse/CY-237) | diff --git a/plan/README.md b/plan/README.md index c516f226..cf37ea05 100644 --- a/plan/README.md +++ b/plan/README.md @@ -41,15 +41,15 @@ R5가 무게중심이다. 장애 중 동작만이 아니라 **회복 전이**까 **각 페이즈는 아래 수치를 넘겨야 닫힌다.** 못 넘기면 다음 페이즈를 시작하지 않는다. -> **지금은 Phase 1 착수.** 브랜치는 `feature/CY-18-foundation` — -> 이 페이즈만 에픽 단위로 딴다 (WF-3 예외, [01-foundation.md](01-foundation.md)). +> **지금은 Phase 4 착수.** Phase 1·2·3 은 닫혔다. +> Phase 3 은 게이트 11개 전건 통과로 2026-08-20 에 닫혔다. | # | 페이즈 | Goal (한 문장) | 통과 수치 | |---|---|---|---| -| **1** ◀ | [Foundation](01-foundation.md) | 코드를 쓰기 전에 갖춰야 할 것만 세운다 | 빌드 CI 통과 · 규약 위반 커밋 거부 · 품질 임계 미달 시 빌드 실패 · 훅 자기검증 전건 통과 | -| **2** | [Domain Core](02-domain-core.md) | **한산한 쿠폰이 대기열 없이 통과한다 (R1)** | 브랜치 100% · 뮤테이션 생존 ≤10% · 크레딧 초과 배분 0/10만회 · 순위 역행 0/10만회 | -| **3** | [Redis Adapter](03-redis-adapter.md) | 큐 순서의 유일성과 단조성이 장애에서도 깨지지 않는다 | 동시 등록 100 → 자리 1개 · `kill -9`·승격 후 순서 역행 0 · 클러스터 모드 전 스크립트 실행 | -| **4** | [Control Plane](04-control-plane.md) | 배분이 리더 하나에서만 돌고 낡음을 두 종류로 구분한다 | 10노드 → 배분 1대 · 리더 승계 ≤3틱 · 틱 지연 p99 <100ms | +| **1** ✅ | [Foundation](01-foundation.md) | 코드를 쓰기 전에 갖춰야 할 것만 세운다 | 빌드 CI 통과 · 규약 위반 커밋 거부 · 품질 임계 미달 시 빌드 실패 · 훅 자기검증 전건 통과 | +| **2** ✅ | [Domain Core](02-domain-core.md) | **한산한 쿠폰이 대기열 없이 통과한다 (R1)** | 브랜치 100% · 뮤테이션 생존 ≤10% · 크레딧 초과 배분 0/10만회 · 순위 역행 0/10만회 | +| **3** ✅ | [Redis Adapter](03-redis-adapter.md) | 큐 순서의 유일성과 단조성이 장애에서도 깨지지 않는다 | 동시 등록 100 → 자리 1개 · `kill -9`·승격 후 순서 역행 0 · 클러스터 모드 전 스크립트 실행 | +| **4** ◀ | [Control Plane](04-control-plane.md) | 배분이 리더 하나에서만 돌고 낡음을 두 종류로 구분한다 | 10노드 → 배분 1대 · 리더 승계 ≤3틱 · 틱 지연 p99 <100ms | | **5** | [Data Plane](05-data-plane.md) | **판정 경로의 Redis 왕복이 0이다** | 통과 경로 Redis 명령 0건(계측) · 미지 쿠폰 1만건 → 키 증가 0 · 위조 토큰 403 · 503 재시도 분산 ≥0.5s | | **6** | [Protection](06-protection.md) | **요청 경로가 뒷단 장애에 버티고, 그것이 실측으로 증명된다** | 오버헤드 p99 <5ms · 한산한 쿠폰 큐 등록 0건(실부하) · 뒷단 정지 시 물리지 않음 · 쿠폰 2,000개 격벽 메모리 유계 · 종료 중 5xx 0 · 동시 조회 1만 → 뒷단 1건 | | **7** | [Queue Lifecycle](07-queue-lifecycle.md) | 큐에서 나가야 할 사람이 반드시 나간다 (R3) | 매진 후 1폴링 내 종료 · 이탈 30%에서 크레딧 낭비 <5% | @@ -146,7 +146,7 @@ R5가 무게중심이다. 장애 중 동작만이 아니라 **회복 전이**까 | 그 이후 | 태스크 제목 + 근거 링크 | 앞 페이즈 결과에 따라 바뀐다. 지금 쓰면 버려진다 | **페이즈 진입 시 그 페이즈를 완전 명세로 승격**하는 것이 첫 작업이다. -현재 완전 명세: **Phase 1, Phase 2**. +현재 완전 명세: **Phase 1, Phase 2, Phase 3**. --- diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..d3e336bc --- /dev/null +++ b/settings.gradle @@ -0,0 +1,9 @@ +// 툴체인 자동 provisioning. +// toolchain 만 선언하고 이 resolver 를 빼면 JDK 21 이 없는 기기에서 +// "No matching toolchain" 으로 빌드가 죽는다 — 러너는 setup-java 가 깔아주지만 +// 개발 기기는 아무도 안 깔아준다. +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.9.0' +} + +rootProject.name = 'waiting' diff --git a/src/main/java/com/kafkick/waiting/WaitingApplication.java b/src/main/java/com/kafkick/waiting/WaitingApplication.java new file mode 100644 index 00000000..eb21cefb --- /dev/null +++ b/src/main/java/com/kafkick/waiting/WaitingApplication.java @@ -0,0 +1,18 @@ +package com.kafkick.waiting; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 적응형 대기열 게이트웨이 진입점. + * + *

이 게이트웨이는 입장(admission)만 소유한다. 발급과 재고 차감은 + * 쿠폰 서비스가 한다. + */ +@SpringBootApplication +public class WaitingApplication { + + public static void main(String[] args) { + SpringApplication.run(WaitingApplication.class, args); + } +} diff --git a/src/main/java/com/kafkick/waiting/adapter/redis/ClockSkewTracker.java b/src/main/java/com/kafkick/waiting/adapter/redis/ClockSkewTracker.java new file mode 100644 index 00000000..59e0607c --- /dev/null +++ b/src/main/java/com/kafkick/waiting/adapter/redis/ClockSkewTracker.java @@ -0,0 +1,53 @@ +package com.kafkick.waiting.adapter.redis; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * 시계가 뒤로 간 사실을 남긴다. + * + *

바닥값이 조용히 보정하면 순서는 맞지만 "왜 다 같은 순번인가" 를 영영 + * 못 밝힌다. 보정한 횟수와 폭을 들고 있다가 지표로 내보낸다. + * + *

여러 요청이 동시에 들어오므로 원자 갱신이 필요하다 — 세는 값이 틀리면 + * 있었는지 없었는지도 못 믿는다. + */ +public class ClockSkewTracker { + + private final AtomicLong appliedCount = new AtomicLong(); + private final AtomicLong maxSkewMicros = new AtomicLong(); + + private ClockSkewTracker() { + } + + public static ClockSkewTracker create() { + return new ClockSkewTracker(); + } + + /** + * 등록 결과를 기록한다. + * + * @param floorApplied 바닥값이 적용됐는가 + * @param skewMicros 바닥값이 실제 시각을 앞선 폭. 음수면 정상이다 + */ + public void record(boolean floorApplied, long skewMicros) { + if (!floorApplied) { + return; + } + appliedCount.incrementAndGet(); + maxSkewMicros.accumulateAndGet(Math.max(0, skewMicros), Math::max); + } + + /** 바닥값이 적용된 횟수. 0 이 아니면 시계가 뒤로 간 적이 있다는 뜻이다. */ + public long appliedCount() { + return appliedCount.get(); + } + + /** + * 관측된 최대 역행 폭(마이크로초). + * + *

이 값이 크면 `slew` 가 아니라 `step` 보정이 걸렸다는 신호다 (2.4절). + */ + public long maxSkewMicros() { + return maxSkewMicros.get(); + } +} diff --git a/src/main/java/com/kafkick/waiting/adapter/redis/RedisConfig.java b/src/main/java/com/kafkick/waiting/adapter/redis/RedisConfig.java new file mode 100644 index 00000000..92561a7b --- /dev/null +++ b/src/main/java/com/kafkick/waiting/adapter/redis/RedisConfig.java @@ -0,0 +1,22 @@ +package com.kafkick.waiting.adapter.redis; + +import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 레디스 어댑터 배선. + * + *

시간 예산 검증을 빈으로 올린다 — 기동 시 한 번 돌고 어긋나면 컨텍스트가 + * 안 뜬다. + */ +@Configuration +public class RedisConfig { + + @Bean + RedisTimeBudget redisTimeBudget(DataRedisProperties properties) { + RedisTimeBudget budget = RedisTimeBudget.of(properties); + budget.verify(); + return budget; + } +} diff --git a/src/main/java/com/kafkick/waiting/adapter/redis/RedisKeys.java b/src/main/java/com/kafkick/waiting/adapter/redis/RedisKeys.java new file mode 100644 index 00000000..5ef7cebb --- /dev/null +++ b/src/main/java/com/kafkick/waiting/adapter/redis/RedisKeys.java @@ -0,0 +1,123 @@ +package com.kafkick.waiting.adapter.redis; + +/** + * 레디스 키를 한 곳에서만 만든다 (RD-3). + * + *

키가 두 곳에서 만들어지면 샤딩을 도입할 때 한쪽만 고쳐진다. 그때는 + * 진행 중인 큐가 통째로 유실되고, 되돌릴 방법이 없다. + */ +public final class RedisKeys { + + /** 리더가 발행하는 판정 재료. 각 노드가 주기적으로 받아 간다. */ + public static final String SNAPSHOT = "gw:snapshot"; + + /** 살아 있는 게이트웨이 목록. 배분의 분모가 여기서 나온다. */ + public static final String INSTANCES = "gw:instances"; + + /** 배분을 도는 노드 하나를 정하는 락. */ + public static final String LEADER = "scheduler:leader"; + + /** 운영자가 배포 없이 고치는 값 (P-1). 밖에서 쓰는 키다. */ + public static final String TUNABLES = "gw:tunables"; + + /** 배분 대상 쿠폰. 여기 없는 쿠폰은 스케줄러가 보지 않는다. */ + public static final String ACTIVE_COUPONS = "coupons:active"; + + /** 쿠폰별 정책 JSON. 밖에서 쓰는 키라 파싱 실패를 전제한다 (E-12). */ + public static final String COUPON_POLICY = "coupon:policy"; + + /** 해시태그를 깨뜨리는 문자. 클라이언트 입력이 키에 들어가는 경로를 막는다. */ + private static final String FORBIDDEN = "{}:"; + + private RedisKeys() { + } + + /** 대기열 ZSET. score 는 Redis {@code TIME} 의 마이크로초다 (A-9). */ + public static String queue(String couponId, int shards, int shard) { + return "queue:{" + tag(couponId, shards, shard) + "}"; + } + + /** 시계 역행 방어용 바닥값. ZSET 이 비어도 남아 있어야 한다. */ + public static String maxScore(String couponId, int shards, int shard) { + return "maxscore:{" + tag(couponId, shards, shard) + "}"; + } + + /** 입장 임계. 개수가 아니라 score 값이다 (D-8). */ + public static String admitted(String couponId, int shards, int shard) { + return "admitted:{" + tag(couponId, shards, shard) + "}"; + } + + /** 이탈자 기록. 재방문자를 식별하되 자리는 보관하지 않는다 (D-11). */ + public static String grace(String couponId, int shards, int shard) { + return "grace:{" + tag(couponId, shards, shard) + "}"; + } + + /** + * 생존 신호. 폴링이 곧 하트비트다. + * + *

사람마다 키를 만들지 않는다. 그러면 청소 스크립트가 KEYS 에 + * 선언되지 않은 키를 만지게 되고 클러스터가 거부한다 (RD-1). 쿠폰당 ZSET + * 하나에 만료 시각을 score 로 담는다 — 개별 TTL 은 잃지만 청소가 + * 어차피 만료를 보므로 잃는 것이 없다. + */ + public static String alive(String couponId, int shards, int shard) { + return "alive:{" + tag(couponId, shards, shard) + "}"; + } + + /** + * 남은 재고. 발급 계층이 소유하고 샤드와 무관하다. + * + *

샤딩하면 슬롯이 갈리므로 Lua 에서 만지지 않는다 — 별도로 읽는다. + */ + public static String stock(String couponId) { + return "stock:{" + validated(couponId, "couponId") + "}"; + } + + /** 키에서 해시태그를 꺼낸다. 같은 슬롯에 모이는지 확인하는 데 쓴다. */ + public static String hashTagOf(String key) { + int open = key.indexOf('{'); + int close = key.indexOf('}', open + 1); + if (open < 0 || close < 0) { + throw new IllegalArgumentException("해시태그가 없는 키다: " + key); + } + return key.substring(open + 1, close); + } + + /** + * 해시태그 본문. + * + *

샤드가 하나면 접미사를 붙이지 않는다. 붙였다 떼는 순간 콜드 쿠폰 + * 전체의 키가 갈리므로, 운영 중 샤딩 도입이 불가능해진다 (3.1절). + */ + // RULE-EXCEPTION(JS-13): JS-14 가 RedisKeys 를 유틸리티 클래스로 명시한다. + // 인스턴스가 없어 인스턴스 메서드로 둘 수 없다 (AIJ-0014). + private static String tag(String couponId, int shards, int shard) { + String id = validated(couponId, "couponId"); + if (shards < 1) { + throw new IllegalArgumentException("shards 는 1 이상이어야 한다: " + shards); + } + if (shard < 0 || shard >= shards) { + // 범위를 넘으면 아무도 안 보는 키가 생기고 그 큐는 영영 안 빠진다. + throw new IllegalArgumentException( + "shard 는 [0, %d) 안이어야 한다: %d".formatted(shards, shard)); + } + return shards == 1 ? id : id + ":" + shard; + } + + /** 클라이언트 입력이 키 이름에 들어가는 경로는 전부 의심한다 (PK-R5). */ + // RULE-EXCEPTION(JS-13): JS-14 가 RedisKeys 를 유틸리티 클래스로 명시한다. + // 인스턴스가 없어 인스턴스 메서드로 둘 수 없다 (AIJ-0014). + private static String validated(String value, String what) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(what + " 는 필수다"); + } + for (int i = 0; i < FORBIDDEN.length(); i++) { + if (value.indexOf(FORBIDDEN.charAt(i)) >= 0) { + throw new IllegalArgumentException( + "%s 에 '%c' 가 들어갈 수 없다 — 슬롯이 갈린다: %s" + .formatted(what, FORBIDDEN.charAt(i), value)); + } + } + return value; + } +} diff --git a/src/main/java/com/kafkick/waiting/adapter/redis/RedisTimeBudget.java b/src/main/java/com/kafkick/waiting/adapter/redis/RedisTimeBudget.java new file mode 100644 index 00000000..e51becbf --- /dev/null +++ b/src/main/java/com/kafkick/waiting/adapter/redis/RedisTimeBudget.java @@ -0,0 +1,53 @@ +package com.kafkick.waiting.adapter.redis; + +import java.time.Duration; +import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties; + +/** + * 커넥션 설정이 제어 평면의 시간 예산 안에 있는지 확인한다. + * + *

주석으로만 적어 두면 값을 바꾸는 사람이 안 읽는다. 어긋나면 안 뜨게 + * 해야 배분이 멎는 사고로 배우지 않는다. + */ +public final class RedisTimeBudget { + + /** 스케줄러 틱. 명령이 이보다 오래 붙들면 그 틱의 배분이 밀린다. */ + static final Duration TICK = Duration.ofSeconds(1); + + /** 리더 리스. 연결이 이보다 오래 걸리면 그 사이 리더십을 잃는다. */ + static final Duration LEASE = Duration.ofSeconds(2); + + private final Duration commandTimeout; + private final Duration connectTimeout; + + private RedisTimeBudget(Duration commandTimeout, Duration connectTimeout) { + this.commandTimeout = commandTimeout; + this.connectTimeout = connectTimeout; + } + + /** 설정에서 만든다. 값이 없으면 그대로 담는다 — 검증은 {@link #verify()} 가 한다. */ + public static RedisTimeBudget of(DataRedisProperties properties) { + return new RedisTimeBudget(properties.getTimeout(), properties.getConnectTimeout()); + } + + /** 예산을 벗어나면 {@link IllegalStateException}. 기동을 막는 것이 목적이다. */ + public void verify() { + require(commandTimeout, TICK, "timeout", "틱"); + require(connectTimeout, LEASE, "connect-timeout", "리스"); + } + + private void require(Duration actual, Duration budget, String key, String what) { + // 0 이나 음수는 예산 안에 들어오지만 값으로는 성립하지 않는다. + // 0 은 즉시 실패, 음수는 드라이버에 따라 무한 대기가 된다 — 둘 다 + // "타임아웃을 설정했다" 는 착각만 남기고 아무것도 안 막는다. + if (actual == null || actual.isZero() || actual.isNegative()) { + throw new IllegalStateException( + "spring.data.redis.%s 는 양수여야 한다: %s".formatted(key, actual)); + } + if (actual.compareTo(budget) >= 0) { + throw new IllegalStateException( + "spring.data.redis.%s 는 %s(%s)보다 짧아야 한다: %s" + .formatted(key, what, budget, actual)); + } + } +} diff --git a/src/main/java/com/kafkick/waiting/adapter/redis/ShardHash.java b/src/main/java/com/kafkick/waiting/adapter/redis/ShardHash.java new file mode 100644 index 00000000..d059f385 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/adapter/redis/ShardHash.java @@ -0,0 +1,51 @@ +package com.kafkick.waiting.adapter.redis; + +import java.nio.charset.StandardCharsets; + +/** + * 큐 샤드를 정한다. 반드시 sticky 여야 한다 (E-7). + * + *

같은 사람이 틱마다 다른 큐에 서면 순위가 앞뒤로 튀고 불변식 3(순번 역행 0)이 + * 깨진다. 그래서 {@code String.hashCode()} 를 쓰지 않는다 — 판이 바뀌면 값이 + * 달라질 수 있고, 그 순간 전원이 다른 샤드로 옮겨 간다. + */ +public final class ShardHash { + + /** CRC16-CCITT (XMODEM). 레디스 클러스터가 슬롯을 정할 때 쓰는 것과 같다. */ + private static final int POLYNOMIAL = 0x1021; + + private ShardHash() { + } + + /** 이 사람이 설 큐. 같은 입력은 언제 어디서나 같은 값을 낸다. */ + public static int shardOf(String memberId, int shards) { + if (shards < 1) { + throw new IllegalArgumentException("shards 는 1 이상이어야 한다: " + shards); + } + if (memberId == null || memberId.isBlank()) { + throw new IllegalArgumentException("memberId 는 필수다"); + } + return shards == 1 ? 0 : crc16(memberId) % shards; + } + + /** 바이트 단위로 돈다 — 문자열 인코딩이 갈리면 값도 갈린다. */ + public static int crc16(String value) { + if (value == null) { + throw new IllegalArgumentException("memberId 는 필수다"); + } + if (value.isEmpty()) { + // 빈 문자열의 CRC 는 0 이다. 유효성은 호출부가 본다. + return 0; + } + + int crc = 0; + for (byte b : value.getBytes(StandardCharsets.UTF_8)) { + crc ^= (b & 0xFF) << 8; + for (int bit = 0; bit < 8; bit++) { + crc = (crc & 0x8000) != 0 ? (crc << 1) ^ POLYNOMIAL : crc << 1; + crc &= 0xFFFF; + } + } + return crc; + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/admission/AdmissionDecider.java b/src/main/java/com/kafkick/waiting/domain/admission/AdmissionDecider.java new file mode 100644 index 00000000..635a295a --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/admission/AdmissionDecider.java @@ -0,0 +1,132 @@ +package com.kafkick.waiting.domain.admission; + +import com.kafkick.waiting.domain.admission.SecondWindowLimiter.AcquireResult; +import com.kafkick.waiting.domain.coupon.CouponState; +import com.kafkick.waiting.domain.coupon.QueueMode; +import com.kafkick.waiting.domain.coupon.RuntimeState; + +/** + * 입장 판정. 순서가 곧 정책이다. + * + *

각 줄에는 앞줄보다 먼저 와야 하는 이유가 있다. 이전 구현은 {@code dataStale} + * 을 맨 앞에 두는 바람에 이미 줄 선 사람이 있는 쿠폰에도 신규 유입을 통과시켰다. + */ +public class AdmissionDecider { + + /** + * 노드 전역 예산의 키. + * + *

쿠폰 키에는 {@link #couponBudgetKey} 가 다른 접두사를 붙인다. 접두사가 + * 없으면 쿠폰 ID 하나가 이 값과 같아지는 순간 두 예산이 한 카운터로 합쳐진다. + */ + private static final String GLOBAL_KEY = "node:"; + + private static final String COUPON_KEY_PREFIX = "coupon:"; + + private final SecondWindowLimiter limiter; + private final double idleCreditRatio; + + private AdmissionDecider(SecondWindowLimiter limiter, double idleCreditRatio) { + this.limiter = limiter; + this.idleCreditRatio = idleCreditRatio; + } + + /** + * 설정을 검증하고 만든다. + * + *

비율은 10번 줄에서만 쓰이므로, 여기서 안 막으면 잘못된 설정으로도 + * 토큰·bypass·fail-open 판정이 정상으로 돌아간다. 그러다 한산한 쿠폰 + * 요청 하나가 들어오는 순간 터진다 — 원인에서 먼 곳에서. + */ + public static AdmissionDecider of(SecondWindowLimiter limiter, double idleCreditRatio) { + if (limiter == null) { + throw new IllegalArgumentException("limiter 는 필수다"); + } + if (!Double.isFinite(idleCreditRatio) || idleCreditRatio < 0) { + throw new IllegalArgumentException( + "idleCreditRatio 는 0 이상 유한값이어야 한다: %s".formatted(idleCreditRatio)); + } + return new AdmissionDecider(limiter, idleCreditRatio); + } + + /** 판정 사다리 10줄. 위에서부터 처음 걸리는 줄이 답이다. */ + public AdmissionDecision decide(AdmissionRequest req) { + CouponState s = req.state(); + + // 1 — 재고가 없으면 나머지를 볼 필요가 없다. 맨 앞이어야 매진 쿠폰이 + // fail-open 상한을 갉아먹지 않는다. + if (s.remainingStock() <= 0) { + return AdmissionDecision.REJECT_SOLD_OUT; + } + + // 2 — 차례가 온 사람. 다시 세우지 않는다. 쿠폰별 상한은 걸지 않고 + // 노드 상한만 본다 — 이미 배분 시점에 크레딧을 썼다 (B-14). + if (req.validToken()) { + return limiter.tryAcquire(GLOBAL_KEY, globalCap(req), req.epochSecond()) + ? AdmissionDecision.PASS_TOKEN + : AdmissionDecision.RETRY_TOKEN; + } + + // 3 — 운영자가 껐다. 붐비든 말든 줄을 안 세운다. + if (s.mode() == QueueMode.OFF) { + return AdmissionDecision.PASS_BYPASS; + } + + boolean hasQueue = s.waiting() > 0 || req.justEnqueued(); + + // 4 — 낡았지만 줄이 비었다. 밀어낼 사람이 없으니 상한 안에서 통과. + if (req.dataStale() && !hasQueue) { + return limiter.tryAcquire(GLOBAL_KEY, globalCap(req), req.epochSecond()) + ? AdmissionDecision.PASS_FAIL_OPEN + : AdmissionDecision.REJECT_OVERLOAD; + } + + // 5 — 줄 자체가 꽉 찼다. 큐로 보내는 모든 줄보다 앞에 있어야 한다. + // **줄이 있을 때만 의미가 있다.** 한산한 쿠폰은 credit 이 0 이라 + // 용량도 0 이고, 조건을 안 걸면 waiting(0) >= 0 이 참이 되어 + // R1 경로가 통째로 막힌다. + if (s.waiting() > 0 && s.waiting() >= s.queueCapacity(req.maxEtaSec())) { + return AdmissionDecision.REJECT_QUEUE_FULL; + } + + // 6 — 낡았는데 줄에 사람이 있다. 모른다는 것이 추월의 사유가 아니다 (F1). + if (req.dataStale()) { + return AdmissionDecision.ENQUEUE_STALE; + } + + // 7 — 운영자가 무조건 세우기로 했다. + if (s.mode() == QueueMode.ALWAYS) { + return AdmissionDecision.ENQUEUE_ALWAYS; + } + + // 8 — 이미 붐빈다. 래치는 스냅샷이 따라잡기 전의 한 틱을 메운다. + if (s.runtime() != RuntimeState.IDLE || req.justEnqueued()) { + return AdmissionDecision.ENQUEUE_BACKLOG; + } + + // ── 여기까지 왔다면 안 몰리는 쿠폰이다 ── + + // 9 — 안 몰려도 무제한은 아니다. 두 예산을 함께 차감한다. + AcquireResult acquired = limiter.tryAcquireAll( + couponBudgetKey(req.couponKey()), s.idleCap(req.meta(), idleCreditRatio), + GLOBAL_KEY, globalCap(req), req.epochSecond()); + + return switch (acquired) { + // 10 — 줄도 토큰도 없이 뒷단으로. 이 경로가 R1 이다. + case ACQUIRED -> AdmissionDecision.PASS_UNDER_CAP; + case COUPON_EXHAUSTED -> AdmissionDecision.ENQUEUE_RATE_COUPON; + case GLOBAL_EXHAUSTED -> AdmissionDecision.ENQUEUE_RATE_GLOBAL; + case KEY_SATURATED -> AdmissionDecision.ENQUEUE_KEY_SATURATED; + }; + } + + /** 이 노드가 초당 감당할 양. 쿠폰과 무관한 노드 전체의 상한이다. */ + private long globalCap(AdmissionRequest req) { + return req.meta().globalCredit() / req.meta().effectiveGatewayCount(); + } + + /** 접두사를 붙여 전역 키와 절대 겹치지 않게 한다. */ + private String couponBudgetKey(String couponKey) { + return COUPON_KEY_PREFIX + couponKey; + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/admission/AdmissionDecision.java b/src/main/java/com/kafkick/waiting/domain/admission/AdmissionDecision.java new file mode 100644 index 00000000..0448bdf5 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/admission/AdmissionDecision.java @@ -0,0 +1,88 @@ +package com.kafkick.waiting.domain.admission; + +/** + * 입장 판정 결과. 사용자가 받는 응답은 셋 중 하나다 — 통과·큐·종결. + * + *

값을 나눠 두는 이유는 대응이 다르기 때문이다. 큐로 보내는 이유가 + * 쿠폰 상한인지 노드 상한인지에 따라 조일 대상이 달라진다. + */ +public enum AdmissionDecision { + + /** 차례가 와서 토큰을 받은 사람. 다시 세우지 않는다. */ + PASS_TOKEN, + + /** 운영자가 이 쿠폰의 대기열을 꺼뒀다. */ + PASS_BYPASS, + + /** 판정 재료가 낡았지만 줄이 비어 있다. 상한 안에서 통과시킨다. */ + PASS_FAIL_OPEN, + + /** 안 몰리는 쿠폰이 상한 안에서 통과한다. 이 경로가 R1 이다. */ + PASS_UNDER_CAP, + + /** 낡았는데 줄에 사람이 있다. 모른다는 것이 추월의 사유가 되지 않는다. */ + ENQUEUE_STALE, + + /** 운영자가 무조건 줄을 세우기로 한 쿠폰. */ + ENQUEUE_ALWAYS, + + /** 이미 붐빈다. 앞사람이 있으면 뒤에 선다. */ + ENQUEUE_BACKLOG, + + /** 그 쿠폰이 유휴 몫을 다 썼다. 그 쿠폰만 조이면 된다. */ + ENQUEUE_RATE_COUPON, + + /** 이 노드가 초당 감당량을 다 썼다. 노드를 늘려야 한다. */ + ENQUEUE_RATE_GLOBAL, + + /** + * 예산은 남았는데 리미터가 키를 더 못 들고 있다. + * + *

상한 고갈과 같이 묶으면 운영자가 쿠폰이나 노드를 조인다 — 여기서 + * 조일 것은 {@code maxKeys} 다. + */ + ENQUEUE_KEY_SATURATED, + + /** 재고가 없다. Redis 도 뒷단도 치지 않고 여기서 끝낸다. */ + REJECT_SOLD_OUT, + + /** 줄 자체가 꽉 찼다. */ + REJECT_QUEUE_FULL, + + /** fail-open 상한마저 넘었다. */ + REJECT_OVERLOAD, + + /** + * 토큰을 들고 왔지만 노드 상한을 넘었다. + * + *

큐 뒤로 보내지 않는다 — 이미 차례가 온 사람을 되돌리면 허가가 + * "아마도" 가 된다. 짧은 재시도를 안내한다 (F8). + */ + RETRY_TOKEN; + + /** 뒷단으로 흘려보낸다. */ + public boolean isPass() { + return this == PASS_TOKEN + || this == PASS_BYPASS + || this == PASS_FAIL_OPEN + || this == PASS_UNDER_CAP; + } + + /** 줄을 세운다. */ + public boolean isEnqueue() { + return this == ENQUEUE_STALE + || this == ENQUEUE_ALWAYS + || this == ENQUEUE_BACKLOG + || this == ENQUEUE_RATE_COUPON + || this == ENQUEUE_RATE_GLOBAL + || this == ENQUEUE_KEY_SATURATED; + } + + /** 여기서 끝낸다. 줄도 뒷단도 없다. */ + public boolean isReject() { + return this == REJECT_SOLD_OUT + || this == REJECT_QUEUE_FULL + || this == REJECT_OVERLOAD + || this == RETRY_TOKEN; + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/admission/AdmissionRequest.java b/src/main/java/com/kafkick/waiting/domain/admission/AdmissionRequest.java new file mode 100644 index 00000000..4e91806b --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/admission/AdmissionRequest.java @@ -0,0 +1,45 @@ +package com.kafkick.waiting.domain.admission; + +import com.kafkick.waiting.domain.coupon.CouponState; +import com.kafkick.waiting.domain.coupon.SnapshotMeta; + +/** + * 판정에 필요한 재료 전부. 도메인은 이것 말고 아무것도 안 본다. + * + *

{@code dataStale} 과 {@code justEnqueued} 는 주입받는 값이다. 도메인은 + * 시계도 노드 로컬 상태도 참조하지 않는다 (DS-1). + * + * @param couponKey 예산을 나누는 단위 + * @param state 스냅샷에서 읽은 쿠폰 상태 + * @param meta 전역 값 + * @param dataStale 스케줄러가 멎어 판정 재료가 오래됐는가 + * @param validToken 차례가 와서 받은 토큰을 들고 왔는가 + * @param justEnqueued 이 노드가 이 쿠폰을 방금 큐로 보냈는가 + * @param epochSecond 리미터 윈도우를 가르는 시각 + * @param maxEtaSec 받아도 되는 최대 대기 시간 + */ +public record AdmissionRequest( + String couponKey, + CouponState state, + SnapshotMeta meta, + boolean dataStale, + boolean validToken, + boolean justEnqueued, + long epochSecond, + long maxEtaSec) { + + public AdmissionRequest withDataStale(boolean value) { + return new AdmissionRequest( + couponKey, state, meta, value, validToken, justEnqueued, epochSecond, maxEtaSec); + } + + public AdmissionRequest withValidToken(boolean value) { + return new AdmissionRequest( + couponKey, state, meta, dataStale, value, justEnqueued, epochSecond, maxEtaSec); + } + + public AdmissionRequest withJustEnqueued(boolean value) { + return new AdmissionRequest( + couponKey, state, meta, dataStale, validToken, value, epochSecond, maxEtaSec); + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/admission/SecondWindowLimiter.java b/src/main/java/com/kafkick/waiting/domain/admission/SecondWindowLimiter.java new file mode 100644 index 00000000..4a0b64d8 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/admission/SecondWindowLimiter.java @@ -0,0 +1,152 @@ +package com.kafkick.waiting.domain.admission; + +import java.util.HashMap; +import java.util.Map; + +/** + * 초 단위 고정 윈도우 리미터. + * + *

경로별로 나누지 않는다. 각자 카운터를 들면 회복 전이 순간 두 상한이 + * 동시에 열려 1.5× 버스트가 나간다(F4). 리미터는 하나고 상한만 인자로 받는다. + */ +public class SecondWindowLimiter { + + /** 키가 클라이언트 입력에서 오므로 상한이 없으면 메모리가 무한히 는다. */ + private final int maxKeys; + + private final Map used = new HashMap<>(); + private long windowSecond = Long.MIN_VALUE; + + /** 키 상한을 정해 만든다. 0 이하는 1 로 올린다 — 상한이 없는 리미터는 없다. */ + public static SecondWindowLimiter withMaxKeys(int maxKeys) { + return new SecondWindowLimiter(maxKeys); + } + + SecondWindowLimiter(int maxKeys) { + this.maxKeys = Math.max(1, maxKeys); + } + + /** + * 상한 안이면 차감하고 {@code true}. + * + * @param key 예산을 나누는 단위. 쿠폰 ID 또는 노드 전역 키 + * @param cap 이번 판정에 적용할 상한. 경로마다 다른 값이 온다 + * @param epochSecond 주입받은 시각. 도메인은 시계를 부르지 않는다 (DS-1) + */ + public synchronized boolean tryAcquire(String key, long cap, long epochSecond) { + if (cap <= 0) { + return false; + } + rollWindow(epochSecond); + + long current = used.getOrDefault(key, 0L); + if (current >= cap) { + return false; + } + if (current == 0 && used.size() >= maxKeys) { + // 새 키를 받을 자리가 없다. 통과시키면 상한이 무의미해지므로 거부한다. + return false; + } + used.put(key, current + 1); + return true; + } + + /** + * 두 예산을 전부-아니면-전무로 획득한다. + * + *

순서대로 치면 앞엣것을 소비한 뒤 뒤엣것이 거부할 때 통과하지 않은 요청이 + * 예산을 깎는다. 반납 방식도 쓰지 않는다 — 반납 누락이 조용한 예산 유실이다. + * + * @return 획득 결과. 실패면 어느 쪽이 부족했는지 담는다 + */ + public synchronized AcquireResult tryAcquireAll( + String couponKey, long couponCap, String globalKey, long globalCap, long epochSecond) { + + rollWindow(epochSecond); + + // 두 키가 같으면 예산도 하나다. 따로 차감하면 요청 하나가 2 를 소비해 + // 상한의 절반만 통과시킨다. + if (couponKey.equals(globalKey)) { + long cap = Math.min(couponCap, globalCap); + if (!hasRoom(couponKey, cap)) { + return couponCap <= globalCap + ? AcquireResult.COUPON_EXHAUSTED + : AcquireResult.GLOBAL_EXHAUSTED; + } + if (!hasSlots(used.containsKey(couponKey) ? 0 : 1)) { + return AcquireResult.KEY_SATURATED; + } + used.merge(couponKey, 1L, Long::sum); + return AcquireResult.ACQUIRED; + } + + // 신규 키가 몇 개 들어오는지 먼저 센다. 하나씩 검사하면 마지막 슬롯 + // 하나를 두 키가 함께 차지해 상한을 넘긴다. + int incoming = (used.containsKey(couponKey) ? 0 : 1) + + (used.containsKey(globalKey) ? 0 : 1); + + // 예산을 먼저 본다. 예산이 마른 것과 자리가 없는 것은 대응이 다르고, + // 예산이 말랐으면 그 키는 이미 자리를 잡고 있어 자리 문제가 아니다. + if (!hasRoom(couponKey, couponCap)) { + return AcquireResult.COUPON_EXHAUSTED; + } + if (!hasRoom(globalKey, globalCap)) { + return AcquireResult.GLOBAL_EXHAUSTED; + } + if (!hasSlots(incoming)) { + return AcquireResult.KEY_SATURATED; + } + + used.merge(couponKey, 1L, Long::sum); + used.merge(globalKey, 1L, Long::sum); + return AcquireResult.ACQUIRED; + } + + /** 지금 들고 있는 키 수. 상한이 지켜지는지 시험하려고 노출한다. */ + public synchronized int size() { + return used.size(); + } + + private boolean hasRoom(String key, long cap) { + return cap > 0 && used.getOrDefault(key, 0L) < cap; + } + + /** 새로 들어올 키 {@code incomingKeys} 개를 받을 자리가 남았는가. */ + private boolean hasSlots(int incomingKeys) { + return used.size() + incomingKeys <= maxKeys; + } + + /** + * 초가 바뀌면 윈도우를 통째로 버린다. + * + *

키별로 만료시키지 않는다 — 만료 시각을 키마다 들고 있어야 해서 그 자체가 + * 메모리다. 초 하나만 들고 바뀌면 전부 버리는 쪽이 싸다. + */ + private void rollWindow(long epochSecond) { + // == 이 아니라 > 다. 노드 간 시계 스큐나 NTP 보정으로 과거 초가 들어오면 + // == 비교로는 현재 윈도우를 통째로 날려 예산이 리셋된다. 뒤로 가지 않는다. + if (epochSecond <= windowSecond) { + return; + } + windowSecond = epochSecond; + used.clear(); + } + + /** 획득 실패 시 어느 예산이 부족했는지. 대응이 다르므로 구분한다. */ + public enum AcquireResult { + /** 둘 다 여유가 있어 함께 차감했다. */ + ACQUIRED, + /** 그 쿠폰이 유휴 몫을 다 썼다. 그 쿠폰만 조이면 된다. */ + COUPON_EXHAUSTED, + /** 이 노드가 초당 감당량을 다 썼다. 노드를 늘려야 한다. */ + GLOBAL_EXHAUSTED, + /** + * 예산은 남았는데 키를 더 못 들고 있다. + * + *

예산 고갈로 뭉뚱그리면 운영자가 엉뚱한 데를 조인다. 여기를 보면 + * 조일 것은 쿠폰도 노드도 아니고 {@code maxKeys} 다. + */ + KEY_SATURATED + } + +} diff --git a/src/main/java/com/kafkick/waiting/domain/allocation/CouponDemand.java b/src/main/java/com/kafkick/waiting/domain/allocation/CouponDemand.java new file mode 100644 index 00000000..7fdc64f6 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/allocation/CouponDemand.java @@ -0,0 +1,36 @@ +package com.kafkick.waiting.domain.allocation; + +/** + * 이 쿠폰이 이번 틱에 받고 싶은 양. + * + *

재고가 천장이다(C-2). 재고 3 개에 100 명을 통과시키면 97 명이 + * 헛걸음하고, 그만큼의 크레딧은 다른 쿠폰이 못 쓴 채 버려진다. + * + * @param couponId 예산을 나누는 단위 + * @param waiting 줄 선 사람 수 + * @param stock 남은 재고 + */ +public record CouponDemand(String couponId, long waiting, long stock) { + + public CouponDemand { + if (couponId == null || couponId.isBlank()) { + throw new IllegalArgumentException("couponId 는 필수다"); + } + if (waiting < 0) { + throw new IllegalArgumentException("waiting 은 0 이상이어야 한다: " + waiting); + } + if (stock < 0) { + throw new IllegalArgumentException("stock 은 0 이상이어야 한다: " + stock); + } + } + + /** 재고를 넘겨 주면 그 몫은 뒷단이 거절하고, 다른 쿠폰이 못 쓴 채 사라진다. */ + public long want() { + return Math.min(waiting, stock); + } + + /** 여기가 {@code IDLE ⟹ credit == 0}(I1)의 출처다 — 요구량이 0 이면 못 받는다. */ + public boolean isActive() { + return want() > 0; + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/allocation/CreditSmoother.java b/src/main/java/com/kafkick/waiting/domain/allocation/CreditSmoother.java new file mode 100644 index 00000000..819d4e1b --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/allocation/CreditSmoother.java @@ -0,0 +1,82 @@ +package com.kafkick.waiting.domain.allocation; + +/** + * 여유 값을 지수 이동평균으로 다듬는다. + * + *

순간값을 그대로 쓰면 GC 스파이크 한 번이 표시 ETA 를 두 배로 만든다. + * ETA 오차의 지배항이 배수율의 흔들림이다 (Phase 4 F9). + */ +public class CreditSmoother { + + private final double alpha; + + private double value; + private boolean seeded; + + private CreditSmoother(double alpha, Snapshot snapshot) { + this.alpha = alpha; + this.value = snapshot.value(); + this.seeded = snapshot.seeded(); + } + + /** {@code alpha} 가 클수록 최근 값을 빨리 따라간다. */ + public static CreditSmoother of(double alpha) { + return restore(alpha, Snapshot.empty()); + } + + /** + * 이월받은 상태로 시작한다. + * + *

리더가 바뀔 때마다 0 에서 다시 시작하면 그 순간 ETA 가 튄다 (F9). + */ + public static CreditSmoother restore(double alpha, Snapshot snapshot) { + if (!Double.isFinite(alpha) || alpha <= 0 || alpha > 1) { + throw new IllegalArgumentException("alpha 는 (0, 1] 이어야 한다: " + alpha); + } + return new CreditSmoother(alpha, snapshot); + } + + /** + * 관측치를 넣고 다듬어진 값을 돌려준다. + * + *

첫 관측치는 그대로 초기값이 된다. 0 에서 시작하면 첫 몇 틱 동안 + * 실제보다 한참 낮은 값이 나가고 그 사이 표시 ETA 가 몇 배로 뛴다. + */ + public double observe(double credit) { + if (!Double.isFinite(credit) || credit < 0) { + throw new IllegalArgumentException("credit 은 0 이상 유한값이어야 한다: " + credit); + } + value = seeded ? alpha * credit + (1 - alpha) * value : credit; + seeded = true; + return value; + } + + /** Phase 4 가 스냅샷 메타에 실어 다음 리더에게 넘긴다. */ + public Snapshot snapshot() { + return new Snapshot(value, seeded); + } + + /** + * 이월 가능한 평활화 상태. + * + * @param value 지금까지 다듬어진 값 + * @param seeded 관측을 한 번이라도 했는가. 안 했으면 다음 값이 초기값이 된다 + */ + public record Snapshot(double value, boolean seeded) { + + public Snapshot { + // 이월받은 값이 NaN 이면 그 순간부터 EWMA 가 영영 NaN 이고, + // 표시 ETA 도 함께 죽는다 — 리더가 바뀐 뒤에야 드러난다. + if (!Double.isFinite(value) || value < 0) { + throw new IllegalArgumentException("value 는 0 이상 유한값이어야 한다: " + value); + } + if (!seeded && value != 0) { + throw new IllegalArgumentException("관측 전 상태의 value 는 0 이어야 한다: " + value); + } + } + + public static Snapshot empty() { + return new Snapshot(0, false); + } + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/allocation/FairShareAllocator.java b/src/main/java/com/kafkick/waiting/domain/allocation/FairShareAllocator.java new file mode 100644 index 00000000..e15d550e --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/allocation/FairShareAllocator.java @@ -0,0 +1,94 @@ +package com.kafkick.waiting.domain.allocation; + +import java.util.ArrayList; +import java.util.List; + +/** + * 전역 크레딧을 쿠폰에 나눈다. 기아 불가와 유휴 낭비 0 을 함께 만족시킨다. + * + *

균등하게만 나누면 한산한 쿠폰이 못 쓰고 남긴 몫이 버려지고, 요구량 비례로만 + * 나누면 몰리는 쿠폰 하나가 전부 가져가 나머지가 굶는다 (C-1·C-3). + */ +public class FairShareAllocator { + + private FairShareAllocator() { + } + + /** + * 배분기를 만든다. + * + *

상태가 없어 static 으로 둘 수도 있지만 배분은 도메인 규칙이다 + * (JS-14). 두 번째 정책이 생길 때 호출부를 안 고치려면 인스턴스여야 한다. + */ + public static FairShareAllocator create() { + return new FairShareAllocator(); + } + + /** + * 굶주린 쿠폰에게 균등하게 나누고, 못 쓴 몫을 다시 굶주린 쪽으로 돌린다. + * + *

정수 나눗셈의 나머지는 배분하지 않는다. 누구에게 주든 그 쿠폰만 + * 이득이고, 노드마다 다른 쪽을 고르면 총합이 전역 크레딧을 넘는다. 남긴 + * 나머지는 다음 틱 배분에 다시 들어간다. + */ + public List allocate(long globalCredit, List demands) { + List active = demands.stream().filter(CouponDemand::isActive).toList(); + if (active.isEmpty()) { + return List.of(); + } + + long[] granted = new long[active.size()]; + long pool = Math.max(0, globalCredit); + // 2패스가 하한이지 상한이 아니다. 쿠폰이 많고 요구량이 들쭉날쭉하면 + // 두 번으로는 못 채우고, 남긴 몫만큼 대기자가 이유 없이 기다린다. + // 몫이 굶주린 수보다 적어지면 distribute 가 0 을 돌려주므로 반드시 멎는다. + while (pool > 0) { + pool = distribute(active, granted, pool); + } + + List result = new ArrayList<>(active.size()); + for (int i = 0; i < active.size(); i++) { + result.add(new Grant(active.get(i).couponId(), granted[i])); + } + return result; + } + + /** + * 굶주린 쿠폰에게 균등하게 나눠 주고 다음 패스로 넘길 몫을 돌려준다. + * + *

못 쓴 몫은 전부 넘긴다. 나눗셈 나머지를 그 자리에서 버리면 + * 패스마다 조금씩 새어 그만큼 대기자가 이유 없이 기다린다. 넘긴 나머지는 + * 다음 패스에서 더 적은 수로 다시 나뉘고, 결국 굶주린 수보다 작아지면 + * 멎는다 — 그때는 균등하게 나눌 방법이 없어서 다음 틱 몫이 된다. + */ + private long distribute(List active, long[] granted, long pool) { + // 호출부가 pool > 0 을 보장한다. 0 이면 애초에 돌 이유가 없다. + int hungry = 0; + for (int i = 0; i < active.size(); i++) { + if (granted[i] < active.get(i).want()) { + hungry++; + } + } + if (hungry == 0) { + return 0; + } + + long share = pool / hungry; + if (share == 0) { + // 나머지만 남았다. 나눠 주면 앞쪽 쿠폰이 유리해진다. + return 0; + } + + long spent = 0; + for (int i = 0; i < active.size(); i++) { + long room = active.get(i).want() - granted[i]; + if (room <= 0) { + continue; + } + long give = Math.min(room, share); + granted[i] += give; + spent += give; + } + return pool - spent; + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/allocation/Grant.java b/src/main/java/com/kafkick/waiting/domain/allocation/Grant.java new file mode 100644 index 00000000..97bfbae4 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/allocation/Grant.java @@ -0,0 +1,19 @@ +package com.kafkick.waiting.domain.allocation; + +/** + * 배분 결과. + * + *

인원이 아니라 초당 속도다. 인원으로 주면 노드가 그것을 언제 쓸지 + * 각자 정하게 되고, 같은 초에 몰리면 뒷단이 받는 순간 부하가 배분과 무관해진다. + */ +public record Grant(String couponId, long credit) { + + public Grant { + if (couponId == null || couponId.isBlank()) { + throw new IllegalArgumentException("couponId 는 필수다"); + } + if (credit < 0) { + throw new IllegalArgumentException("credit 은 0 이상이어야 한다: " + credit); + } + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/allocation/QueueingHysteresis.java b/src/main/java/com/kafkick/waiting/domain/allocation/QueueingHysteresis.java new file mode 100644 index 00000000..3edcf381 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/allocation/QueueingHysteresis.java @@ -0,0 +1,75 @@ +package com.kafkick.waiting.domain.allocation; + +/** + * 대기열을 켜고 끄는 임계를 비대칭으로 둔다. + * + *

같은 임계를 쓰면 유입이 임계선 근처에서 흔들릴 때 사용자에게 + * "대기 없음 → 500명 → 대기 없음" 이 반복해서 보인다. + */ +public class QueueingHysteresis { + + private final double enterRatio; + private final double exitRatio; + private final int minHoldTicks; + + private boolean queueing; + private int belowExitTicks; + + private QueueingHysteresis(double enterRatio, double exitRatio, int minHoldTicks) { + this.enterRatio = enterRatio; + this.exitRatio = exitRatio; + this.minHoldTicks = minHoldTicks; + } + + /** 해제 임계가 진입 임계보다 크면 히스테리시스가 아니라 진동 증폭기가 된다. */ + public static QueueingHysteresis of(double enterRatio, double exitRatio, int minHoldTicks) { + if (!Double.isFinite(enterRatio) || enterRatio < 0 + || !Double.isFinite(exitRatio) || exitRatio < 0) { + // 음수를 허용하면 수요가 0 이어도 load(0) >= enterRatio 가 참이라 + // 아무도 안 왔는데 대기열이 켜진다. + throw new IllegalArgumentException( + "임계는 0 이상 유한값이어야 한다: %s / %s".formatted(enterRatio, exitRatio)); + } + if (exitRatio > enterRatio) { + throw new IllegalArgumentException( + "해제 임계가 진입 임계보다 클 수 없다: %s > %s".formatted(exitRatio, enterRatio)); + } + return new QueueingHysteresis(enterRatio, exitRatio, Math.max(0, minHoldTicks)); + } + + /** + * 이번 틱에 줄을 세울 것인가. + * + *

켤 때는 {@code enterRatio} 를 넘어야 하고, 끌 때는 {@code exitRatio} + * 아래로 연속해서 {@code minHoldTicks} 만큼 머물러야 한다. + */ + public boolean shouldQueue(long demand, long capacity) { + double load = load(demand, capacity); + + if (!queueing) { + queueing = load >= enterRatio; + belowExitTicks = 0; + return queueing; + } + + if (load >= exitRatio) { + belowExitTicks = 0; + return true; + } + + belowExitTicks++; + if (belowExitTicks >= minHoldTicks) { + queueing = false; + belowExitTicks = 0; + } + return queueing; + } + + /** 배수할 수 없는데(용량 0) 수요가 있으면 줄이 맞다. 없으면 부하도 0 이다. */ + private double load(long demand, long capacity) { + if (capacity <= 0) { + return demand > 0 ? Double.POSITIVE_INFINITY : 0; + } + return (double) demand / capacity; + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/allocation/ShardGrant.java b/src/main/java/com/kafkick/waiting/domain/allocation/ShardGrant.java new file mode 100644 index 00000000..204e0ac9 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/allocation/ShardGrant.java @@ -0,0 +1,22 @@ +package com.kafkick.waiting.domain.allocation; + +/** + * 샤드 하나에 배정된 몫. + * + *

샤드가 하나뿐인 지금도 번호를 들고 다닌다. 나중에 붙이면 이 값을 실어 + * 나르는 경로를 전부 다시 고쳐야 한다 (DS-7). + */ +public record ShardGrant(String couponId, int shardIndex, long credit) { + + public ShardGrant { + if (couponId == null || couponId.isBlank()) { + throw new IllegalArgumentException("couponId 는 필수다"); + } + if (shardIndex < 0) { + throw new IllegalArgumentException("shardIndex 는 0 이상이어야 한다: " + shardIndex); + } + if (credit < 0) { + throw new IllegalArgumentException("credit 은 0 이상이어야 한다: " + credit); + } + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/allocation/ShardSplitter.java b/src/main/java/com/kafkick/waiting/domain/allocation/ShardSplitter.java new file mode 100644 index 00000000..3bd60e0f --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/allocation/ShardSplitter.java @@ -0,0 +1,19 @@ +package com.kafkick.waiting.domain.allocation; + +import java.util.List; + +/** + * 쿠폰 몫을 큐 샤드에 나눈다. + * + *

지금 구현은 하나뿐이지만 두 번째 사례가 Phase 10 에 예정돼 있어 + * 미리 가른다 (DS-7). 나중에 가르려면 배분 로직까지 함께 건드려야 한다. + */ +public interface ShardSplitter { + + /** 빈 목록을 돌려주지 않는다 — 호출부가 "이 쿠폰은 없다" 와 구분하지 못한다. */ + List split(Grant grant); + + default List splitAll(List grants) { + return grants.stream().flatMap(g -> split(g).stream()).toList(); + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/allocation/SingleShardSplitter.java b/src/main/java/com/kafkick/waiting/domain/allocation/SingleShardSplitter.java new file mode 100644 index 00000000..191b7647 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/allocation/SingleShardSplitter.java @@ -0,0 +1,26 @@ +package com.kafkick.waiting.domain.allocation; + +import java.util.List; + +/** + * 샤드가 하나인 동안의 구현. 전량을 0번 샤드에 준다. + * + *

몫이 0 이어도 목록을 비우지 않는다 — 비우면 호출부가 "이 쿠폰은 없다" 와 + * 구분하지 못한다. + */ +public class SingleShardSplitter implements ShardSplitter { + + private static final int ONLY_SHARD = 0; + + private SingleShardSplitter() { + } + + public static SingleShardSplitter create() { + return new SingleShardSplitter(); + } + + @Override + public List split(Grant grant) { + return List.of(new ShardGrant(grant.couponId(), ONLY_SHARD, grant.credit())); + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/coupon/CouponState.java b/src/main/java/com/kafkick/waiting/domain/coupon/CouponState.java new file mode 100644 index 00000000..05c3f81e --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/coupon/CouponState.java @@ -0,0 +1,194 @@ +package com.kafkick.waiting.domain.coupon; + +/** + * 판정에 쓰는 쿠폰 하나의 상태. 스냅샷에서 읽어 온 값이다. + * + *

불변식을 문서가 아니라 생성자가 지킨다. 픽스처가 존재할 수 없는 상태를 + * 만들 수 있으면 테스트가 버그를 증명하지 못한다. 도달 가능한 상태만 만들려면 + * {@code CouponStates} 팩토리를 쓴다. + * + * @param mode 운영자가 정한 대기열 정책 + * @param runtime 기계가 관측한 현재 상태 + * @param credit 이 쿠폰에 배분된 초당 통과 몫 + * @param remainingStock 남은 재고. 발급 계층이 소유하고 게이트웨이는 읽기만 한다 + * @param waiting 줄 서 있는 사람 수 + * @param pollScale 폴링 간격 배수. 예산이 빠듯하면 커진다 + */ +public record CouponState( + QueueMode mode, + RuntimeState runtime, + long credit, + long remainingStock, + long waiting, + double pollScale) { + + public CouponState { + if (mode == null || runtime == null) { + throw new IllegalArgumentException("mode 와 runtime 은 필수다"); + } + if (credit < 0 || remainingStock < 0 || waiting < 0) { + throw new IllegalArgumentException( + "음수가 될 수 없다: credit=%d, remainingStock=%d, waiting=%d" + .formatted(credit, remainingStock, waiting)); + } + + // I1 — IDLE 은 배분을 못 받았다는 뜻이다. 이 둘은 독립 값이 아니라 + // 같은 원인에서 나온다. 갈라지면 "한산한 쿠폰일수록 큐로 간다"는 + // 역전이 생기고, 그게 이전 구현의 핵심 버그였다. + if (runtime == RuntimeState.IDLE && credit != 0) { + throw new IllegalArgumentException( + "IDLE 이면 credit 이 0 이어야 한다: credit=%d".formatted(credit)); + } + + // I2 — 재고가 남았는데 종결됐다면 그건 종결이 아니다. + if (runtime == RuntimeState.CLOSED && remainingStock != 0) { + throw new IllegalArgumentException( + "CLOSED 면 remainingStock 이 0 이어야 한다: remainingStock=%d" + .formatted(remainingStock)); + } + + // I3 — DRAINING 은 "이번 틱에 남은 대기자를 다 뺄 수 있다"는 뜻이다. + // 몫이 대기자보다 적으면 그건 아직 QUEUEING 이다. + if (runtime == RuntimeState.DRAINING && credit < waiting) { + throw new IllegalArgumentException( + "DRAINING 이면 credit >= waiting 이어야 한다: credit=%d, waiting=%d" + .formatted(credit, waiting)); + } + + // I1' — IDLE 인데 줄이 서 있으면 판정 8번(runtime != IDLE)이 통과시켜 + // 줄 선 사람을 추월한다. I4 의 대우로는 이 조합이 막히지 않는다. + if (runtime == RuntimeState.IDLE && waiting != 0) { + throw new IllegalArgumentException( + "IDLE 이면 waiting 이 0 이어야 한다: waiting=%d".formatted(waiting)); + } + + // I4 — 줄이 비었는데 큐 상태라는 것은 유령이다. 판정 사다리 9번이 + // IDLE 쿠폰만 받는다는 논증이 이 불변식의 대우에 걸려 있다. + if (waiting == 0 + && runtime != RuntimeState.IDLE + && runtime != RuntimeState.CLOSED) { + throw new IllegalArgumentException( + "waiting 이 0 이면 IDLE 또는 CLOSED 여야 한다: runtime=%s".formatted(runtime)); + } + + // NaN 은 비교가 전부 false 라 Math.max 를 그냥 통과한다. 그대로 두면 + // 폴링 간격 계산이 조용히 NaN 이 되어 대기자가 폴링을 멈춘다. + if (Double.isNaN(pollScale)) { + throw new IllegalArgumentException("pollScale 이 NaN 이다"); + } + + // I6 — 거부가 아니라 정규화다. 1 미만은 폴링을 더 자주 하라는 뜻이 + // 되는데 그건 예산을 늘리는 방향이라 의미가 없다. + pollScale = Math.max(1.0, pollScale); + } + + /** + * 경합 쿠폰이 이 노드에서 쓸 수 있는 몫. 노드 번호를 모를 때 쓴다. + * + *

나머지를 버리므로 총합이 {@code credit} 을 넘지 않는다. 대신 나머지만큼 + * 덜 나간다 — 초과는 장애고 미달은 지연이다. + */ + public long contendedCap(int gatewayCount) { + return credit / Math.max(1, gatewayCount); + } + + /** + * 경합 쿠폰이 이 노드에서 쓸 수 있는 몫. 나머지를 노드 번호로 나눠 갖는다. + * + *

{@code credit} 이 노드 수보다 작으면 정수 나눗셈으로 전 노드가 0 이 된다. + * 그렇다고 {@code max(1, …)} 로 올리면 노드 수만큼 나가 초과 배분이다 — + * credit 10 에 노드 20 이면 20 이 나간다. 앞쪽 노드에만 1 을 준다. + */ + public long contendedCap(int gatewayCount, int nodeIndex) { + int n = Math.max(1, gatewayCount); + long base = credit / n; + long remainder = credit % n; + return base + (nodeIndex < remainder ? 1 : 0); + } + + /** + * 한산한 쿠폰이 이 노드에서 쓸 수 있는 상한. + * + *

이 쿠폰의 credit 으로 재지 않는다. IDLE 이면 credit 이 0 이라(I1) + * 한산한 쿠폰일수록 반드시 큐로 가는 역전이 생긴다 — 이전 구현의 핵심 버그다. + * 노드 몫의 전역 크레딧으로 잰다. + */ + public long idleCap(SnapshotMeta meta, double idleCreditRatio) { + if (!Double.isFinite(idleCreditRatio) || idleCreditRatio < 0) { + throw new IllegalArgumentException( + "idleCreditRatio 는 0 이상 유한값이어야 한다: %s".formatted(idleCreditRatio)); + } + long perNode = meta.globalCredit() / meta.effectiveGatewayCount(); + return (long) (perNode * idleCreditRatio); + } + + /** + * 지금 줄이 빠지는 데 걸리는 시간(초). + * + *

{@code credit} 이 0 이면 영원히 안 빠진다 — 예외가 아니라 무한이 맞다. + * 한산한 쿠폰이 정확히 그 상태이므로(I1) 방어가 없으면 R1 경로가 터진다. + */ + public double queueDepthSec() { + if (waiting == 0) { + return 0.0; + } + return credit == 0 ? Double.POSITIVE_INFINITY : (double) waiting / credit; + } + + /** + * 받아도 되는 줄의 최대 길이. + * + *

배수할 수 없는데(credit 0) 줄을 받으면 갇힌 사람만 늘어난다. + */ + public long queueCapacity(long maxEtaSec) { + if (maxEtaSec <= 0) { + return 0; + } + // 곱셈이 넘치면 음수가 되어 큐 상한이 사실상 0 이 된다 — 전원 거절이다. + try { + return Math.multiplyExact(credit, maxEtaSec); + } catch (ArithmeticException e) { + return Long.MAX_VALUE; + } + } + + /** 아무도 줄을 서지 않았다. 배분을 못 받았으므로 credit 은 0 이다. */ + public static CouponState idle(long remainingStock) { + return new CouponState(QueueMode.ADAPTIVE, RuntimeState.IDLE, 0, remainingStock, 0, 1.0); + } + + /** 줄이 생겼다. 상한을 넘은 초과분이 큐로 들어가면서 이 상태가 된다. */ + public static CouponState queueing(long credit, long remainingStock, long waiting) { + return new CouponState( + QueueMode.ADAPTIVE, RuntimeState.QUEUEING, credit, remainingStock, waiting, 1.0); + } + + /** 이번 틱에 남은 대기자를 다 빼줄 수 있다. 배분이 대기자를 따라잡으면 여기로 온다. */ + public static CouponState draining(long credit, long remainingStock, long waiting) { + return new CouponState( + QueueMode.ADAPTIVE, RuntimeState.DRAINING, credit, remainingStock, waiting, 1.0); + } + + /** 재고가 소진됐는데 대기자가 남았다. 스케줄러가 이 전이를 만든다. */ + public static CouponState closed(long waiting) { + return new CouponState(QueueMode.ADAPTIVE, RuntimeState.CLOSED, 0, 0, waiting, 1.0); + } + + /** 운영자가 무조건 줄을 세우기로 했다. 한산해도 대기열을 태운다. */ + public static CouponState always(long remainingStock) { + return new CouponState(QueueMode.ALWAYS, RuntimeState.IDLE, 0, remainingStock, 0, 1.0); + } + + /** 운영자가 대기열을 껐다. 붐비든 말든 줄을 세우지 않는다. */ + public static CouponState off(long remainingStock) { + return new CouponState(QueueMode.OFF, RuntimeState.IDLE, 0, remainingStock, 0, 1.0); + } + + /** + * 스냅샷에 없는 쿠폰. 판정이 {@code null} 을 다루지 않게 하려는 것이지 + * 통과시키려는 게 아니다 — 미지 쿠폰은 요청 경로에서 404 로 끊는다. + */ + public static CouponState unknown() { + return new CouponState(QueueMode.ADAPTIVE, RuntimeState.CLOSED, 0, 0, 0, 1.0); + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/coupon/QueueMode.java b/src/main/java/com/kafkick/waiting/domain/coupon/QueueMode.java new file mode 100644 index 00000000..b0356fe5 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/coupon/QueueMode.java @@ -0,0 +1,18 @@ +package com.kafkick.waiting.domain.coupon; + +/** + * 운영자가 정한 대기열 정책. 사람이 고르는 값이다. + * + *

지금 붐비는지({@link RuntimeState})와 무관하게 적용된다. + */ +public enum QueueMode { + + /** 대기열을 쓰지 않는다. 붐벼도 줄을 세우지 않는다. */ + OFF, + + /** 몰릴 때만 줄을 세운다. 상한을 넘은 초과분만 큐로 간다. */ + ADAPTIVE, + + /** 한산해도 무조건 줄을 세운다. */ + ALWAYS +} diff --git a/src/main/java/com/kafkick/waiting/domain/coupon/RuntimeState.java b/src/main/java/com/kafkick/waiting/domain/coupon/RuntimeState.java new file mode 100644 index 00000000..88bf1191 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/coupon/RuntimeState.java @@ -0,0 +1,22 @@ +package com.kafkick.waiting.domain.coupon; + +/** + * 쿠폰이 지금 실제로 어떤 상태인가. 기계가 관측한 값이다. + * + *

정책({@link QueueMode})과 섞지 않는다 — 섞으면 "붐빈다"와 "줄을 세우기로 + * 했다"를 구분할 수 없다. + */ +public enum RuntimeState { + + /** 줄이 없다. 상한 안이면 대기열 없이 통과시킨다 — 이 제품의 존재 이유(R1). */ + IDLE, + + /** 줄이 서 있다. 신규 유입은 뒤에 선다. */ + QUEUEING, + + /** 남은 대기자를 이번 틱에 다 빼줄 수 있다. 줄이 곧 없어지는 구간. */ + DRAINING, + + /** 재고가 소진됐는데 대기자가 남아 있다. 배분 대상에서 빠진다. */ + CLOSED +} diff --git a/src/main/java/com/kafkick/waiting/domain/coupon/SnapshotMeta.java b/src/main/java/com/kafkick/waiting/domain/coupon/SnapshotMeta.java new file mode 100644 index 00000000..5fbeab87 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/coupon/SnapshotMeta.java @@ -0,0 +1,29 @@ +package com.kafkick.waiting.domain.coupon; + +/** + * 쿠폰별 상태와 함께 오는 전역 값. + * + *

{@code gatewayCount} 는 분모로 쓰인다. 0 이 들어오면 판정 전체가 터지므로 + * 읽는 쪽이 아니라 여기서 한 번만 방어한다. + * + * @param globalCredit 전 쿠폰 합산 초당 통과 몫 + * @param gatewayCount 신선한 게이트웨이 수. 스케줄러가 하트비트로 센다 + */ +public record SnapshotMeta(long globalCredit, int gatewayCount) { + + public SnapshotMeta { + if (globalCredit < 0) { + throw new IllegalArgumentException("globalCredit 은 음수가 될 수 없다: %d".formatted(globalCredit)); + } + } + + /** + * 분모로 쓸 수 있는 노드 수. + * + *

0 이나 음수는 관측 실패지 "노드가 없다"가 아니다. 노드가 정말 없으면 + * 이 코드가 돌지 않는다 — 자기 자신이 노드이기 때문이다. + */ + public int effectiveGatewayCount() { + return Math.max(1, gatewayCount); + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/queue/EtaDisplay.java b/src/main/java/com/kafkick/waiting/domain/queue/EtaDisplay.java new file mode 100644 index 00000000..3756b44b --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/queue/EtaDisplay.java @@ -0,0 +1,24 @@ +package com.kafkick.waiting.domain.queue; + +/** + * 사용자에게 보여 줄 대기 시간 구간. + * + *

초 단위로 보여 주면 1초씩 줄다 멈추는 것이 보이고, 그때마다 신뢰를 잃는다. + */ +public enum EtaDisplay { + + /** 아직 배수율을 모른다. "10분 이상" 과 뭉치면 떠날지 판단할 수 없다. */ + CALCULATING, + + /** 30초 미만. */ + ALMOST_THERE, + + /** 30초 이상 90초 미만. */ + ABOUT_A_MINUTE, + + /** 90초 이상 450초 미만. */ + ABOUT_FIVE_MINUTES, + + /** 450초 이상. */ + OVER_TEN_MINUTES +} diff --git a/src/main/java/com/kafkick/waiting/domain/queue/EtaPolicy.java b/src/main/java/com/kafkick/waiting/domain/queue/EtaPolicy.java new file mode 100644 index 00000000..e6e1cb3d --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/queue/EtaPolicy.java @@ -0,0 +1,56 @@ +package com.kafkick.waiting.domain.queue; + +/** + * 예상 대기 시간과 그 표시 구간. + * + *

순간 배수율로 나누지 않는다. 평활화한 값을 쓴다 — GC 스파이크 한 번에 + * 표시 시간이 두 배가 되면 사용자는 서비스가 망가진 신호로 읽는다. + */ +public final class EtaPolicy { + + /** 배수율을 아직 모른다. 무한을 그대로 내보내면 표시 계층이 터진다. */ + public static final double UNKNOWN = -1; + + private static final double[] BUCKET_EDGES = {30, 90, 450}; + + private static final EtaDisplay[] BUCKETS = { + EtaDisplay.ALMOST_THERE, + EtaDisplay.ABOUT_A_MINUTE, + EtaDisplay.ABOUT_FIVE_MINUTES, + EtaDisplay.OVER_TEN_MINUTES + }; + + /** + * 앞선 {@code rank} 명이 빠지는 데 걸리는 시간(초). + * + * @param smoothedCredit 평활화한 초당 배수율. 순간값이 아니다 + */ + public static double etaSec(long rank, double smoothedCredit) { + if (rank < 0) { + throw new IllegalArgumentException("rank 는 0 이상이어야 한다: " + rank); + } + if (rank == 0) { + return 0; + } + if (!(smoothedCredit > 0)) { + return UNKNOWN; + } + return rank / smoothedCredit; + } + + /** 표시할 구간. */ + public static EtaDisplay bucket(double etaSec) { + if (etaSec < 0) { + return EtaDisplay.CALCULATING; + } + for (int i = 0; i < BUCKET_EDGES.length; i++) { + if (etaSec < BUCKET_EDGES[i]) { + return BUCKETS[i]; + } + } + return BUCKETS[BUCKETS.length - 1]; + } + + private EtaPolicy() { + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/queue/PollBudgetPlanner.java b/src/main/java/com/kafkick/waiting/domain/queue/PollBudgetPlanner.java new file mode 100644 index 00000000..5fe8f44e --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/queue/PollBudgetPlanner.java @@ -0,0 +1,80 @@ +package com.kafkick.waiting.domain.queue; + +import com.kafkick.waiting.domain.allocation.CouponDemand; +import java.util.List; +import java.util.function.ToDoubleFunction; + +/** + * 폴링 부하를 큐를 훑지 않고 구한다. + * + *

2만 명을 세면 그 계산 자체가 부하다. 배수율을 알면 각 밴드에 몇 명이 있는지는 + * 곱셈 몇 번으로 나온다 — 앞에서 {@code drainRate × 밴드폭} 명씩 채워 나간다. + */ +public final class PollBudgetPlanner { + + /** 밴드 상한(초). 마지막 밴드는 상한이 없다. */ + private static final double[] BAND_EDGES = {5, 30, 120}; + + /** 밴드별 폴링 간격(초). {@link PollIntervalPolicy} 와 같은 표를 본다. */ + private static final double[] BAND_INTERVALS = {1, 3, 10, 30}; + + /** + * 이 큐가 만드는 초당 폴링 수. + * + *

{@code drainRate} 가 0 이면 전원의 ETA 가 무한이라 가장 먼 밴드다. + * 방어가 없으면 0 으로 나눠 터진다. + */ + public static double expectedPollRps(long waiting, double drainRate) { + if (waiting <= 0) { + return 0; + } + if (!(drainRate > 0)) { + return waiting / BAND_INTERVALS[BAND_INTERVALS.length - 1]; + } + + double rps = 0; + long placed = 0; + for (int i = 0; i < BAND_EDGES.length && placed < waiting; i++) { + // 누적 상한을 올림으로 잡는다. 반올림하면 배수가 아주 느릴 때 + // 맨 앞사람(ETA 0)이 첫 밴드에서 빠져 예산을 과소 추정하고, + // pollScale 이 안 올라 실제 부하가 예산을 넘는다. + long cumulative = Math.min(waiting, (long) Math.ceil(BAND_EDGES[i] * drainRate)); + long inBand = Math.max(0, cumulative - placed); + rps += inBand / BAND_INTERVALS[i]; + placed += inBand; + } + rps += (waiting - placed) / BAND_INTERVALS[BAND_INTERVALS.length - 1]; + return rps; + } + + /** + * 살아 있는 쿠폰만 합산한다. + * + *

매진 큐를 빼지 않으면 죽은 큐 10만 명이 예산의 대부분을 먹고, + * 배분에서 막아 둔 기아가 폴링 경로로 되살아난다 (Phase 7 3.3절). + */ + public static double expectedPollRps( + List demands, ToDoubleFunction drainRateOf) { + return demands.stream() + .filter(CouponDemand::isActive) + .mapToDouble(d -> expectedPollRps( + d.waiting(), drainRateOf.applyAsDouble(d.couponId()))) + .sum(); + } + + /** + * 예산을 넘은 비율. 이 배수만큼 모두의 간격을 함께 늘린다. + * + *

1 미만으로 내려가지 않는다. 한산하다고 더 자주 두드리게 하면 + * 한산할 때 없던 부하를 만든다. + */ + public static double pollScale(double expectedRps, double budgetRps) { + if (!(budgetRps > 0)) { + return 1.0; + } + return Math.max(1.0, expectedRps / budgetRps); + } + + private PollBudgetPlanner() { + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/queue/PollIntervalPolicy.java b/src/main/java/com/kafkick/waiting/domain/queue/PollIntervalPolicy.java new file mode 100644 index 00000000..95d689b7 --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/queue/PollIntervalPolicy.java @@ -0,0 +1,83 @@ +package com.kafkick.waiting.domain.queue; + +import java.util.function.DoubleSupplier; + +/** + * 폴링 간격을 서버가 정한다 (D-2). + * + *

부하를 정하는 것은 대기 인원이 아니라 큐의 시간 깊이다. 개인은 + * 자기가 얼마나 기다릴지 모르므로 클라이언트에 맡길 수 없다. + */ +public class PollIntervalPolicy { + + /** ETA 밴드 경계(초). 위 경계는 다음 밴드에 속한다. */ + private static final double[] BAND_EDGES = {5, 30, 120}; + + /** 밴드별 기본 간격(초). 가장 먼 밴드가 30 이라 예산 식이 {@code waiting/30} 이다. */ + private static final long[] BAND_INTERVALS = {1, 3, 10, 30}; + + private static final long MIN_INTERVAL_SEC = 1; + private static final long MAX_INTERVAL_SEC = 60; + + /** 백그라운드 탭이 분당 1회로 스로틀돼도 살아 있다고 봐야 한다. */ + private static final long MIN_ALIVE_TTL_SEC = 30; + + private static final long ALIVE_TTL_FACTOR = 3; + + private final double jitterRatio; + + private PollIntervalPolicy(double jitterRatio) { + this.jitterRatio = jitterRatio; + } + + /** {@code jitterRatio} 는 기본 간격에 곱해지는 흔들림 폭이다. */ + public static PollIntervalPolicy of(double jitterRatio) { + if (!Double.isFinite(jitterRatio) || jitterRatio < 0) { + throw new IllegalArgumentException( + "jitterRatio 는 0 이상 유한값이어야 한다: %s".formatted(jitterRatio)); + } + return new PollIntervalPolicy(jitterRatio); + } + + public long intervalSec(double etaSec, DoubleSupplier random) { + return intervalSec(etaSec, random, 1.0); + } + + /** + * 이 사람의 폴링 간격. + * + *

{@code random} 은 {@code [0,1]} 을 낸다. 주입받는다 — 도메인이 + * 난수원을 직접 부르면 실패를 재현할 수 없다 (DS-1). + * + * @param pollScale 전역 예산이 모자랄 때 모두의 간격을 함께 늘리는 배수 + */ + public long intervalSec(double etaSec, DoubleSupplier random, double pollScale) { + long base = bandInterval(etaSec); + double scaled = base * Math.max(1.0, pollScale); + // [-jitter, +jitter] 로 흔들어 같은 밴드가 동시에 두드리지 않게 한다 + double jittered = scaled * (1 + jitterRatio * (2 * random.getAsDouble() - 1)); + return Math.clamp(Math.round(jittered), MIN_INTERVAL_SEC, MAX_INTERVAL_SEC); + } + + /** + * 이 간격으로 폴링하는 사람의 생존 TTL. + * + *

간격만 보고 잡으면 백그라운드 탭이 스로틀된 사람이 이탈자로 지워진다. + */ + public long aliveTtlSec(long intervalSec) { + return Math.max(MIN_ALIVE_TTL_SEC, intervalSec * ALIVE_TTL_FACTOR); + } + + /** ETA 를 모르면 가장 먼 밴드다 — 모를수록 자주 묻게 하면 안 된다. */ + private long bandInterval(double etaSec) { + if (Double.isNaN(etaSec)) { + return BAND_INTERVALS[BAND_INTERVALS.length - 1]; + } + for (int i = 0; i < BAND_EDGES.length; i++) { + if (etaSec < BAND_EDGES[i]) { + return BAND_INTERVALS[i]; + } + } + return BAND_INTERVALS[BAND_INTERVALS.length - 1]; + } +} diff --git a/src/main/java/com/kafkick/waiting/domain/queue/RankEstimator.java b/src/main/java/com/kafkick/waiting/domain/queue/RankEstimator.java new file mode 100644 index 00000000..9bfd962f --- /dev/null +++ b/src/main/java/com/kafkick/waiting/domain/queue/RankEstimator.java @@ -0,0 +1,35 @@ +package com.kafkick.waiting.domain.queue; + +/** + * 샤드 안 순위를 전역 순위로 환산한다. + * + *

{@code localRank} 는 도메인이 계산하지 않고 주입받는 값이다 — 어댑터의 + * {@code ZCOUNT} 결과이고, 그 값이 단조라는 보장은 Phase 3 이 진다 (G3.11). + */ +public final class RankEstimator { + + /** + * 표시할 전역 순위. + * + *

내 앞의 사람들이 각 샤드에 고르게 흩어져 있다고 보고 곱한다. 오차는 + * 커지지만 상대 오차는 앞으로 갈수록 작아지고, 사용자가 체감하는 것은 + * 자기 앞의 절대값이라 정확해야 할 자리에서 정확하다. + */ + public static long globalRank(long localRank, int shards) { + if (localRank < 0) { + // ZCOUNT 는 음수를 못 낸다. 여기 음수가 오면 어댑터가 깨진 것이고, + // 조용히 통과시키면 음수 순위나 포화한 MAX_VALUE 로 둔갑한다. + throw new IllegalArgumentException("localRank 는 0 이상이어야 한다: " + localRank); + } + try { + return Math.multiplyExact(localRank, Math.max(1, shards)); + } catch (ArithmeticException overflow) { + // 넘치면 음수가 되어 순위가 뒤로 간다. 포화가 거짓말이긴 해도 + // "역행 0" 을 깨는 것보다는 낫다 — 그 크기의 줄은 이미 비정상이다. + return Long.MAX_VALUE; + } + } + + private RankEstimator() { + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..0f99074e --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,27 @@ +spring: + application: + name: waiting + + data: + redis: + host: localhost + port: 6379 + # 스케줄러 틱(1s)보다 짧아야 한다. 명령 하나가 틱을 넘겨 붙들면 + # 그 틱의 배분이 통째로 밀린다. + timeout: 500ms + # 리더 리스(2s)보다 짧아야 한다. 연결에 그보다 오래 걸리면 + # 그 사이 리더십을 잃는다. + connect-timeout: 1s + # 풀을 켜지 않는다. Lettuce 는 커넥션 하나를 멀티플렉싱하므로 + # 풀을 얹어도 처리량은 안 늘고 커넥션 수만 예측 불가가 된다. + # commons-pool2 를 의존성에 넣지 않는 것이 실제 강제다. + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + probes: + enabled: true diff --git a/src/main/resources/redis/enqueue.lua b/src/main/resources/redis/enqueue.lua new file mode 100644 index 00000000..dfa6cff4 --- /dev/null +++ b/src/main/resources/redis/enqueue.lua @@ -0,0 +1,98 @@ +-- 큐 등록. 조회와 등록을 나누면 새로고침 연타에 항목이 둘 생긴다. +-- +-- KEYS[1] queue:{cid} ZSET. score = Redis TIME 의 마이크로초 +-- KEYS[2] maxscore:{cid} 시계 역행 방어용 바닥값 +-- 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' +-- floorApplied 바닥값이 적용됐는가. 1 이면 시계가 뒤로 갔다는 뜻이다 +-- alreadyQueued 이미 줄에 있었는가. 1 이면 순번을 그대로 돌려준 것이다 +-- +-- 순번이 카운터가 아니라 **벽시계**다 (A-9). NTP 보정이나 복제본 승격으로 +-- 시계가 뒤로 가면 나중에 온 사람이 앞선다 — 불변식 4 가 깨진다. +-- +-- 바닥값은 maxscore 하나면 된다. ZSET 의 마지막 원소를 읽는 방식으로는 +-- **큐가 빈 동안의 역행**을 못 막는다. 그게 이 키의 존재 이유다. + +-- **쓰기 전에 인자를 검증한다.** Lua 는 중간 오류를 되돌리지 않는다 — +-- ZADD 뒤에서 SET 이 터지면 "같이 남거나 같이 사라진다" 는 계약이 깨지고 +-- maxscore 없는 ZSET 이 남는다. +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 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])) +end + +-- **이미 줄에 있으면 그 순번을 지킨다.** 덮어쓰면 새로고침 연타가 자기 +-- 자신을 뒤로 민다 — 사용자는 기다릴수록 손해라고 배운다. +-- +-- 상한 검사보다 앞이다. 이미 선 사람을 상한 때문에 쫓아내면, 줄이 길어진 +-- 것이 그 사람 잘못이 아닌데 그가 자리를 잃는다. +local existing = redis.call('ZSCORE', KEYS[1], ARGV[1]) +if existing then + redis.call('ZADD', KEYS[3], now + aliveTtl, ARGV[1]) + return {existing, 0, 1} +end + +-- 2차 방어다. 1차는 도메인이 낡은 스냅샷으로 판정하므로 여기서 한 번 더 본다. +if maxLen > 0 and redis.call('ZCARD', KEYS[1]) >= maxLen then + return {'-1', 0, 0} +end + +-- 이름을 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 +if floor >= score then + score = floor + 1 + applied = 1 +end + +-- **복제 단위로는 함께 움직인다.** Lua 는 효과 기반 복제라 이 스크립트가 +-- 남긴 쓰기는 복제본과 AOF 에 통째로 가거나 통째로 안 간다 — maxscore 가 +-- ZSET 보다 뒤처진 채 복제되는 상태는 없다. +-- +-- **다만 스크립트 안의 롤백은 없다.** 아래 세 명령 중 하나가 런타임 오류를 +-- 내면 앞의 것은 그대로 남는다. 그래서 실패할 수 있는 것(인자 검증)을 전부 +-- 위로 올려 뒀다 — 여기 도달하면 남는 실패 경로는 메모리 부족뿐이고, +-- 그건 maxmemory 로 막는다. +redis.call('ZADD', KEYS[1], score, ARGV[1]) +redis.call('SET', KEYS[2], score, 'EX', scoreTtl) +redis.call('ZADD', KEYS[3], now + aliveTtl, ARGV[1]) + +-- **tostring 을 쓰지 않는다.** Lua 5.1 은 수를 %.14g 로 문자열화하는데 +-- 마이크로초 score 는 16자리라 과학 표기로 접히며 최대 100μs 가 반올림된다. +-- ZSET 에는 정확한 값이 들어가므로 **돌려준 값과 실제 자리가 어긋난다** — +-- 내림 쪽으로 접히면 앞사람보다 작은 score 를 쥐고 추월한다 (불변식 4). +-- +-- %d 가 아니라 %.0f 다. %d 는 정수로 캐스팅해 32비트 런타임에서 넘친다. +-- %.0f 는 배정밀도 그대로 찍으므로 2^53 아래에서 정확하고 지수도 안 붙는다. +return {string.format('%.0f', score), applied, 0} diff --git a/src/main/resources/redis/leader_acquire.lua b/src/main/resources/redis/leader_acquire.lua new file mode 100644 index 00000000..0c02fe7d --- /dev/null +++ b/src/main/resources/redis/leader_acquire.lua @@ -0,0 +1,44 @@ +-- 리더 획득·연장. **획득과 확인이 갈리면 두 리더가 생긴다.** +-- +-- KEYS[1] scheduler:leader +-- ARGV[1] ownerId. 이 노드를 가리키는 값 +-- ARGV[2] 리스(밀리초). 양의 정수 +-- +-- 반환 {acquired, owner, ttlMillis} +-- acquired 1 이면 내가 리더다 (새로 잡았거나 연장했다) +-- owner 지금 락을 쥔 노드 +-- ttlMillis 남은 리스 +-- +-- **소유자 ID 를 값에 담는다.** 안 담으면 남의 락을 지울 수 있고, 그러면 +-- 리더가 둘이 되어 배분 총합이 전역 크레딧을 넘는다. +-- +-- 재진입은 연장이다. 매 틱 새로 잡으려 하면 리더십이 흔들리고, 그때마다 +-- 평활화 상태가 초기화된다 (F9). + +local lease = tonumber(ARGV[2]) +if lease == nil or lease < 1 or lease ~= math.floor(lease) then + return redis.error_reply('리스는 양의 정수여야 한다: ' .. tostring(ARGV[2])) +end +if ARGV[1] == nil or ARGV[1] == '' then + return redis.error_reply('ownerId 는 필수다') +end + +local current = redis.call('GET', KEYS[1]) + +if not current then + -- 아무도 안 잡았다. NX 로 잡아 **경합에서 하나만 이기게** 한다. + if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', lease) then + return {1, ARGV[1], lease} + end + -- 그 사이 다른 노드가 잡았다. 다시 읽어 사실대로 알린다. + current = redis.call('GET', KEYS[1]) + return {0, current or '', redis.call('PTTL', KEYS[1])} +end + +if current == ARGV[1] then + -- 내 락이다. 연장한다 — 새로 잡으려 하면 그 틈에 남이 가져간다. + redis.call('PEXPIRE', KEYS[1], lease) + return {1, ARGV[1], lease} +end + +return {0, current, redis.call('PTTL', KEYS[1])} diff --git a/src/main/resources/redis/leader_release.lua b/src/main/resources/redis/leader_release.lua new file mode 100644 index 00000000..b91730f3 --- /dev/null +++ b/src/main/resources/redis/leader_release.lua @@ -0,0 +1,15 @@ +-- 리더 해제. **자기 락만 지운다.** +-- +-- KEYS[1] scheduler:leader +-- ARGV[1] ownerId +-- +-- 반환 1 이면 지웠다. 0 이면 내 락이 아니었다 +-- +-- GET 과 DEL 을 나누면 **그 사이에 리스가 만료되고 다른 노드가 잡는다.** +-- 그 상태에서 DEL 하면 새 리더의 락이 사라져 배분이 멎는다. 확인과 삭제가 +-- 한 스크립트 안에 있어야 하는 이유다. + +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0 diff --git a/src/main/resources/redis/queue_status.lua b/src/main/resources/redis/queue_status.lua new file mode 100644 index 00000000..d4aa35e8 --- /dev/null +++ b/src/main/resources/redis/queue_status.lua @@ -0,0 +1,52 @@ +-- 순번 조회. 조회·하트비트·배수 판정이 **한 번에** 일어나야 한다. +-- +-- KEYS[1] queue:{cid} ZSET +-- KEYS[2] admitted:{cid} 입장 임계. 개수가 아니라 score 값이다 (D-8) +-- 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' +-- rank 내 앞의 인원. 큐에 없으면 -1 +-- score 내 순번. 큐에 없으면 '-1' +-- +-- **나눠 치면 한쪽만 성공한 상태가 생긴다.** 조회와 하트비트가 갈리면 그때 +-- 성실히 새로고침하는 사람이 이탈자로 지워지고, 배수 판정이 갈리면 같은 +-- 사람이 두 번 입장한다. + +local ttl = tonumber(ARGV[2]) +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번째와 구분한다.** 없는 것과 맨 앞인 것은 다르다. 뭉치면 유실된 + -- 사람에게 "곧 입장" 을 보여 주게 된다. + return {'NOT_QUEUED', -1, '-1'} +end + +-- 폴링이 곧 생존 신호다. 조회한 김에 갱신한다 — 왕복을 늘리지 않는다. +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 + +-- **개수를 세지 순위를 저장하지 않는다.** 저장하면 앞사람이 빠질 때마다 +-- 전원을 갱신해야 한다. +local rank = redis.call('ZCOUNT', KEYS[1], '-inf', '(' .. score) +return {'WAITING', rank, score} 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/FixturesWiringTest.java b/src/test/java/com/kafkick/waiting/FixturesWiringTest.java new file mode 100644 index 00000000..c9a228de --- /dev/null +++ b/src/test/java/com/kafkick/waiting/FixturesWiringTest.java @@ -0,0 +1,16 @@ +package com.kafkick.waiting; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** testFixtures 소스셋이 테스트에서 보이는지 확인한다. */ +class FixturesWiringTest { + + @Test + @DisplayName("픽스처_소스셋을_테스트에서_쓸_수_있다") + void 픽스처_소스셋을_테스트에서_쓸_수_있다() { + assertThat(Fixtures.소스셋이_연결되었다()).isEqualTo("testFixtures"); + } +} diff --git a/src/test/java/com/kafkick/waiting/WaitingApplicationTest.java b/src/test/java/com/kafkick/waiting/WaitingApplicationTest.java new file mode 100644 index 00000000..4071e757 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/WaitingApplicationTest.java @@ -0,0 +1,22 @@ +package com.kafkick.waiting; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * 기동 스모크. + * + *

컨텍스트가 뜨는지만 본다. 빈 배선이 깨지면 여기서 먼저 걸리고, + * 그러지 않으면 부하 시험 전까지 아무도 모른다. + */ +@SpringBootTest +class WaitingApplicationTest { + + @Test + @DisplayName("애플리케이션_컨텍스트가_뜬다") + void 애플리케이션_컨텍스트가_뜬다() { + // 컨텍스트 로딩 실패 시 이 테스트가 실행되기 전에 예외가 난다. + // 단언이 없는 것이 아니라, 로딩 자체가 단언이다. + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/ClockMonotonicTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/ClockMonotonicTest.java new file mode 100644 index 00000000..c10816bc --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/ClockMonotonicTest.java @@ -0,0 +1,199 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +/** + * 시계가 뒤로 가도 줄 선 사람을 추월시키지 않는다 (F2 · 불변식 4). + * + *

순번이 카운터가 아니라 벽시계라(A-9) NTP 보정이나 복제본 승격으로 + * 뒤로 갈 수 있다. 실제로 시계를 돌리지 않고 시험한다 — 바닥값을 미래로 두는 + * 것이 시계가 그만큼 뒤처진 것과 같다. + */ +@Tag("integration") +@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); + + private static final String COUPON = "c1"; + private static final String QUEUE = RedisKeys.queue(COUPON, 1, 0); + private static final String MAX_SCORE = RedisKeys.maxScore(COUPON, 1, 0); + private static final String ALIVE_TTL = "30"; + private static final String NO_CAP = "0"; + + @Autowired + private ReactiveStringRedisTemplate redis; + + private RedisScript script; + + @BeforeEach + void 준비() { + script = RedisScript.of(new ClassPathResource("redis/enqueue.lua"), List.class); + redis.delete(QUEUE, MAX_SCORE).block(WAIT); + for (int i = 0; i < 200; i++) { + redis.delete(alive("m" + i)).block(WAIT); + } + redis.delete(alive("m1"), alive("A"), alive("B")).block(WAIT); + } + + @SuppressWarnings("unchecked") + private List enqueue(String memberId) { + return (List) redis.execute( + script, + List.of(QUEUE, MAX_SCORE, alive(memberId)), + List.of(memberId, String.valueOf(TTL_SECONDS), ALIVE_TTL, NO_CAP, NOW)) + .blockFirst(WAIT); + } + + private String alive(String memberId) { + return RedisKeys.alive(COUPON, 1, 0); + } + + private long scoreOf(String memberId) { + return redis.opsForZSet().score(QUEUE, memberId).block(WAIT).longValue(); + } + + private long appliedFlag(List result) { + return Long.parseLong(String.valueOf(result.get(1))); + } + + private long alreadyQueued(List result) { + return Long.parseLong(String.valueOf(result.get(2))); + } + + @Test + @DisplayName("정상_등록은_바닥값을_적용하지_않는다") + void 정상_등록은_바닥값을_적용하지_않는다() { + List result = enqueue("m1"); + + assertThat(result).hasSize(3); + assertThat(appliedFlag(result)).isZero(); + assertThat(alreadyQueued(result)).isZero(); + assertThat(scoreOf("m1")).isPositive(); + } + + @Test + @DisplayName("시계를_되돌려도_뒤에_온_사람이_앞서지_않는다") + void 시계를_되돌려도_뒤에_온_사람이_앞서지_않는다() { + enqueue("A"); + long scoreA = scoreOf("A"); + + // 바닥값을 A 보다 한참 앞에 둔다 — 시계가 그만큼 뒤처진 상태와 같다. + redis.opsForValue().set(MAX_SCORE, String.valueOf(scoreA + 10_000_000)).block(WAIT); + enqueue("B"); + + assertThat(scoreOf("B")).isGreaterThan(scoreA); + } + + @Test + @DisplayName("큐가_빈_동안_시계가_되돌아가도_막힌다") + void 큐가_빈_동안_시계가_되돌아가도_막힌다() { + enqueue("A"); + long scoreA = scoreOf("A"); + + // 전원 입장 — ZSET 은 비지만 바닥값은 남는다. ZSET 의 마지막 원소를 + // 읽는 방식으로는 이 경우를 못 막는다. 그게 이 키의 존재 이유다. + redis.delete(QUEUE).block(WAIT); + redis.opsForValue().set(MAX_SCORE, String.valueOf(scoreA + 10_000_000)).block(WAIT); + enqueue("B"); + + assertThat(scoreOf("B")).isGreaterThan(scoreA); + } + + @Test + @DisplayName("바닥값이_적용되면_그_사실이_반환된다") + void 바닥값이_적용되면_그_사실이_반환된다() { + // 조용히 보정하지 않는다. 시계가 뒤로 간 사실을 알 수 있어야 + // "순서는 맞는데 왜 다 같은 score 인가" 를 나중에 밝힐 수 있다. + enqueue("A"); + redis.opsForValue().set(MAX_SCORE, String.valueOf(scoreOf("A") + 60_000_000)).block(WAIT); + + assertThat(appliedFlag(enqueue("B"))).isOne(); + } + + @Test + @DisplayName("연속_등록에서_순번이_단조_증가한다") + void 연속_등록에서_순번이_단조_증가한다() { + // 같은 마이크로초에 둘이 들어와도 뒤엣것이 앞서면 안 된다. + long previous = 0; + for (int i = 0; i < 200; i++) { + enqueue("m" + i); + long score = scoreOf("m" + i); + assertThat(score).isGreaterThan(previous); + previous = score; + } + } + + @Test + @DisplayName("바닥값에_TTL이_걸린다") + void 바닥값에_TTL이_걸린다() { + // 오픈 중에는 등록마다 밀려나 안 사라지고, 끝난 쿠폰은 하루 뒤 사라진다. + // 쿠폰 일정을 몰라도 성립한다. + enqueue("m1"); + + assertThat(redis.getExpire(MAX_SCORE).block(WAIT)) + .isBetween(Duration.ofSeconds(TTL_SECONDS - 10), Duration.ofSeconds(TTL_SECONDS)); + } + + @Test + @DisplayName("이미_줄에_있으면_원래_순번을_지킨다") + void 이미_줄에_있으면_원래_순번을_지킨다() { + // 덮어쓰면 새로고침 연타가 자기 자신을 뒤로 민다 — 사용자는 + // 기다릴수록 손해라고 배운다. + enqueue("m1"); + long first = scoreOf("m1"); + + for (int i = 0; i < 10; i++) { + List again = enqueue("m1"); + assertThat(alreadyQueued(again)).isOne(); + assertThat(scoreOf("m1")).isEqualTo(first); + } + } + + @Test + @DisplayName("재등록은_뒤에_선_사람을_밀어내지_않는다") + void 재등록은_뒤에_선_사람을_밀어내지_않는다() { + enqueue("A"); + enqueue("B"); + long scoreB = scoreOf("B"); + + enqueue("A"); + + assertThat(scoreOf("A")).isLessThan(scoreB); + assertThat(scoreOf("B")).isEqualTo(scoreB); + } + + @Test + @DisplayName("TTL이_양의_정수가_아니면_아무것도_쓰지_않는다") + void TTL이_양의_정수가_아니면_아무것도_쓰지_않는다() { + // Lua 는 중간 오류를 되돌리지 않는다. 쓰기 전에 막지 않으면 + // maxscore 없는 ZSET 이 남아 "같이 남거나 같이 사라진다" 가 깨진다. + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + redis.execute(script, List.of(QUEUE, MAX_SCORE, alive("m1")), + List.of("m1", "0", ALIVE_TTL, NO_CAP, NOW)) + .blockFirst(WAIT)) + .rootCause() + .hasMessageContaining("TTL"); + + assertThat(redis.opsForZSet().size(QUEUE).block(WAIT)).isZero(); + assertThat(redis.hasKey(MAX_SCORE).block(WAIT)).isFalse(); + // 셋 중 하나만 생기는 회귀를 잡는다 — 검증이 첫 쓰기 앞에 있어야 + // 한다는 계약은 세 키 전부에 걸린다. + assertThat(redis.hasKey(alive("m1")).block(WAIT)).isFalse(); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/ClockSkewTrackerTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/ClockSkewTrackerTest.java new file mode 100644 index 00000000..a4d6e510 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/ClockSkewTrackerTest.java @@ -0,0 +1,89 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 시계 역행 관측. + * + *

세는 값이 틀리면 있었는지 없었는지도 못 믿는다. 그래서 동시 갱신에서 + * 정확한지까지 본다. + */ +class ClockSkewTrackerTest { + + @Test + @DisplayName("보정이_없으면_아무것도_세지_않는다") + void 보정이_없으면_아무것도_세지_않는다() { + ClockSkewTracker tracker = ClockSkewTracker.create(); + tracker.record(false, 0); + tracker.record(false, 999); + + assertThat(tracker.appliedCount()).isZero(); + assertThat(tracker.maxSkewMicros()).isZero(); + } + + @Test + @DisplayName("보정하면_횟수와_최대_폭을_남긴다") + void 보정하면_횟수와_최대_폭을_남긴다() { + ClockSkewTracker tracker = ClockSkewTracker.create(); + tracker.record(true, 300); + tracker.record(true, 1200); + tracker.record(true, 500); + + assertThat(tracker.appliedCount()).isEqualTo(3); + assertThat(tracker.maxSkewMicros()).isEqualTo(1200); + } + + @Test + @DisplayName("음수_폭은_0으로_본다") + void 음수_폭은_0으로_본다() { + // 바닥값이 실제 시각보다 뒤면 역행이 아니다. 음수를 그대로 두면 + // 최대값이 음수가 되어 지표가 말이 안 된다. + ClockSkewTracker tracker = ClockSkewTracker.create(); + tracker.record(true, -500); + + assertThat(tracker.appliedCount()).isOne(); + assertThat(tracker.maxSkewMicros()).isZero(); + } + + @Test + @DisplayName("동시에_기록해도_횟수가_정확하다") + void 동시에_기록해도_횟수가_정확하다() throws InterruptedException { + // 요청 경로에서 여러 스레드가 동시에 부른다. 세는 값이 틀리면 + // 시계가 뒤로 갔는지조차 못 믿는다. + ClockSkewTracker tracker = ClockSkewTracker.create(); + int threads = 16; + int perThread = 1000; + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + + try (ExecutorService pool = Executors.newFixedThreadPool(threads)) { + for (int t = 0; t < threads; t++) { + int base = t; + pool.execute(() -> { + try { + start.await(); + for (int i = 0; i < perThread; i++) { + tracker.record(true, base * 1000L + i); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertThat(done.await(30, TimeUnit.SECONDS)).isTrue(); + } + + assertThat(tracker.appliedCount()).isEqualTo((long) threads * perThread); + assertThat(tracker.maxSkewMicros()).isEqualTo(15 * 1000L + (perThread - 1)); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/ClusterModeScriptTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/ClusterModeScriptTest.java new file mode 100644 index 00000000..17439225 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/ClusterModeScriptTest.java @@ -0,0 +1,215 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.ScriptOutputType; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.api.sync.RedisCommands; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionTimeoutException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; + +/** + * 전 스크립트를 클러스터 모드에서 실제로 태운다 (G3.4 · RD-2). + * + *

정적 검사({@link LuaKeysDeclarationTest})는 의도를 보고 이 시험은 사실을 + * 본다. 슬롯 교차는 단독 모드에서 조용히 통과하고 Phase 10 에서 터진다. + */ +@Tag("integration") +class ClusterModeScriptTest { + + private static final Path SCRIPTS = Path.of("src/main/resources/redis"); + + /** + * 슬롯 전량을 한 노드에 준다. 여러 노드를 띄우면 클러스터가 내부 + * 주소를 돌려줘 컨테이너 밖에서 못 붙는다 — 그 배선을 맞추느라 정작 + * 검증 대상인 스크립트를 못 태운다. + */ + @SuppressWarnings("resource") // JVM 종료까지 살려 둔다 + private static final GenericContainer CLUSTER = + new GenericContainer<>(RedisContainerSupport.IMAGE) + .withExposedPorts(6379) + .withCommand("redis-server", "--cluster-enabled", "yes", + "--cluster-require-full-coverage", "no", "--appendonly", "no"); + + private static RedisClient client; + private static StatefulRedisConnection connection; + + /** + * 정적 초기화 블록에 두지 않는다. 아래 대기가 조건을 다른 스레드에서 + * 평가하는데, 그 스레드가 초기화 중인 이 클래스를 건드리면 JVM 의 클래스 + * 초기화 락에 걸려 영영 멈춘다 — 대기는 그저 시간 초과로만 보인다. + */ + @BeforeAll + static void 클러스터를_세운다() { + CLUSTER.start(); + 슬롯을_전부_준다(); + 클러스터가_설_때까지_기다린다(); + client = RedisClient.create( + "redis://%s:%d".formatted(CLUSTER.getHost(), CLUSTER.getMappedPort(6379))); + connection = client.connect(); + } + + @AfterAll + static void 닫는다() { + connection.close(); + client.shutdown(); + } + + /** + * 슬롯 전량을 이 노드에 준다. 컨테이너 안의 {@code redis-cli} 로 친다 — + * 클라이언트 API 를 거치면 무엇이 실제로 나갔는지가 한 겹 가려진다. + */ + private static void 슬롯을_전부_준다() { + String out = 컨테이너에서("cluster", "addslotsrange", "0", "16383"); + if (!out.contains("OK")) { + throw new IllegalStateException("슬롯 배정 실패: " + out); + } + } + + /** + * 슬롯을 받아도 상태가 {@code ok} 가 되기까지 클러스터 크론이 몇 바퀴 돈다. + * + *

못 서면 마지막으로 본 상태를 붙여 던진다. "안 떴다" 만 남으면 + * 슬롯이 안 붙은 것인지 크론이 늦은 것인지 가릴 수 없다. + */ + private static void 클러스터가_설_때까지_기다린다() { + AtomicReference last = new AtomicReference<>(""); + try { + Awaitility.await().atMost(Duration.ofSeconds(30)) + .pollInterval(Duration.ofMillis(500)) + .until(() -> { + last.set(컨테이너에서("cluster", "info")); + return last.get().contains("cluster_state:ok"); + }); + } catch (ConditionTimeoutException e) { + throw new IllegalStateException("클러스터가 서지 않았다: " + last.get(), e); + } + } + + private static String 컨테이너에서(String... args) { + String[] command = new String[args.length + 1]; + command[0] = "redis-cli"; + System.arraycopy(args, 0, command, 1, args.length); + try { + var result = CLUSTER.execInContainer(command); + return result.getStdout() + result.getStderr(); + } catch (IOException | InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("컨테이너 명령 실패", e); + } + } + + /** 쿠폰 하나가 쓰는 키 묶음. 해시 태그가 같아 한 슬롯에 모인다. */ + private static List keysFor(String script) { + return switch (script) { + case "enqueue.lua" -> List.of( + RedisKeys.queue("c1", 1, 0), + RedisKeys.maxScore("c1", 1, 0), + RedisKeys.alive("c1", 1, 0)); + case "queue_status.lua" -> List.of( + RedisKeys.queue("c1", 1, 0), + RedisKeys.admitted("c1", 1, 0), + RedisKeys.alive("c1", 1, 0), + RedisKeys.grace("c1", 1, 0)); + case "sweep.lua" -> List.of( + RedisKeys.queue("c1", 1, 0), + RedisKeys.grace("c1", 1, 0), + RedisKeys.alive("c1", 1, 0)); + case "leader_acquire.lua", "leader_release.lua" -> List.of(RedisKeys.LEADER); + default -> throw new IllegalStateException("인자를 안 정한 스크립트: " + script); + }; + } + + private static List argsFor(String script) { + return switch (script) { + case "enqueue.lua" -> List.of("m1", "60", "30", "0", "1000"); + case "queue_status.lua" -> List.of("m1", "30", "1000"); + case "sweep.lua" -> List.of("10", "1000", "300", "50", "0"); + case "leader_acquire.lua" -> List.of("node-1", "2000"); + case "leader_release.lua" -> List.of("node-1"); + default -> throw new IllegalStateException("인자를 안 정한 스크립트: " + script); + }; + } + + private static List 스크립트들() { + try (Stream paths = Files.list(SCRIPTS)) { + return paths.filter(p -> p.toString().endsWith(".lua")).sorted().toList(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Test + @DisplayName("모든_스크립트가_클러스터_모드에서_오류_없이_실행된다") + void 모든_스크립트가_클러스터_모드에서_오류_없이_실행된다() throws IOException { + RedisCommands redis = connection.sync(); + List failures = new ArrayList<>(); + + List scripts = 스크립트들(); + for (Path script : scripts) { + String name = script.getFileName().toString(); + String body = Files.readString(script, StandardCharsets.UTF_8); + String[] keys = keysFor(name).toArray(String[]::new); + String[] args = argsFor(name).toArray(String[]::new); + try { + redis.eval(body, ScriptOutputType.MULTI, keys, args); + } catch (RuntimeException e) { + // 무엇이 터졌는지까지 남긴다 — "실패" 만 보면 어느 것인지 모른다. + failures.add("%s → %s".formatted(name, e.getMessage())); + } + } + + assertThat(failures) + .withFailMessage("클러스터에서 실패한 스크립트 %d 건%n%s", + failures.size(), String.join("\n", failures)) + .isEmpty(); + // 한 건도 안 태우고 통과하면 검사가 아니다 (TS-9). + assertThat(scripts).hasSizeGreaterThanOrEqualTo(5); + } + + @Test + @DisplayName("슬롯을_넘는_스크립트는_거부된다") + void 슬롯을_넘는_스크립트는_거부된다() { + // 통과만 하는 검사는 모든 스크립트를 통과시킨다 (TS-9). + // 해시 태그가 다른 키 둘을 만지면 클러스터가 물어야 한다. + String rogue = "redis.call('SET', KEYS[1], '1') " + + "redis.call('SET', KEYS[2], '1') return 1"; + + assertThatThrownBy(() -> connection.sync().eval(rogue, ScriptOutputType.INTEGER, + new String[] {"{a}k", "{b}k"}, new String[0])) + .hasMessageContaining("CROSSSLOT"); + } + + @Test + @DisplayName("KEYS_밖의_키는_이_시험이_못_잡는다") + void KEYS_밖의_키는_이_시험이_못_잡는다() { + // **사각지대를 못 박아 둔다.** 한 노드가 전 슬롯을 가지면 선언되지 + // 않은 키도 로컬이라 클러스터가 안 문다. 그래서 정적 검사 + // (LuaKeysDeclarationTest) 를 버릴 수 없다 — 여기서 통과한다는 것이 + // 여러 노드에서 통과한다는 뜻이 아니다. + String undeclared = "redis.call('SET', 'literal:key', '1') return 1"; + + Object result = connection.sync().eval(undeclared, ScriptOutputType.INTEGER, + new String[] {"{a}k"}, new String[0]); + + assertThat(result).isEqualTo(1L); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/ConcurrentEnqueueTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/ConcurrentEnqueueTest.java new file mode 100644 index 00000000..fe15a8f1 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/ConcurrentEnqueueTest.java @@ -0,0 +1,150 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.IntConsumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.domain.Range; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +/** + * 새로고침 연타에도 자리는 하나다 (G3.1). + * + *

자리가 둘이 되면 대기 인원이 부풀고 ETA 가 전부 틀어진다. 조회와 + * 등록을 나누면 그 사이에 다른 요청이 끼어들어 실제로 그렇게 된다 — Lua 로 + * 묶는 이유가 이것이다. + */ +@Tag("integration") +@SpringBootTest +class ConcurrentEnqueueTest extends RedisContainerSupport { + + private static final String NOW = "1800000000"; + + private static final long TTL_SECONDS = 86_400; + private static final Duration WAIT = Duration.ofSeconds(10); + + private static final String COUPON = "c1"; + private static final String QUEUE = RedisKeys.queue(COUPON, 1, 0); + private static final String MAX_SCORE = RedisKeys.maxScore(COUPON, 1, 0); + private static final String ALIVE_TTL = "30"; + private static final String NO_CAP = "0"; + + @Autowired + private ReactiveStringRedisTemplate redis; + + private RedisScript script; + + @BeforeEach + void 준비() { + script = RedisScript.of(new ClassPathResource("redis/enqueue.lua"), List.class); + redis.delete(QUEUE, MAX_SCORE).block(WAIT); + } + + private String alive(String memberId) { + return RedisKeys.alive(COUPON, 1, 0); + } + + private void enqueue(String memberId) { + redis.execute(script, List.of(QUEUE, MAX_SCORE, alive(memberId)), + List.of(memberId, String.valueOf(TTL_SECONDS), ALIVE_TTL, NO_CAP, NOW)) + .blockFirst(WAIT); + } + + /** + * 스레드를 동시에 풀어 실제 경합을 만든다. 순차 반복은 이 결함을 못 잡는다. + * + *

워커의 예외를 삼키지 않는다. executor 안에서 죽으면 테스트 + * 스레드는 모르고, 요청 절반이 실패해도 통과한다. + */ + private void 동시에(int threads, IntConsumer body) throws InterruptedException { + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + List failures = new CopyOnWriteArrayList<>(); + + try (ExecutorService pool = Executors.newFixedThreadPool(threads)) { + for (int i = 0; i < threads; i++) { + int index = i; + pool.execute(() -> { + try { + start.await(); + body.accept(index); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + failures.add(e); + } catch (RuntimeException e) { + failures.add(e); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertThat(done.await(60, TimeUnit.SECONDS)).isTrue(); + } + + assertThat(failures) + .withFailMessage("워커 %d개가 실패했다: %s", failures.size(), failures) + .isEmpty(); + } + + @Test + @DisplayName("같은_사용자_100_동시_등록에서_자리가_정확히_1개다") + void 같은_사용자_100_동시_등록에서_자리가_정확히_1개다() throws InterruptedException { + 동시에(100, i -> enqueue("same-member")); + + assertThat(redis.opsForZSet().size(QUEUE).block(WAIT)).isEqualTo(1); + } + + @Test + @DisplayName("동시_등록에서도_순번이_겹치지_않는다") + void 동시_등록에서도_순번이_겹치지_않는다() throws InterruptedException { + // 같은 마이크로초에 여럿이 들어오면 score 가 겹칠 수 있다. 겹치면 + // ZSET 이 사전순으로 재정렬해 **등록 순서와 다른 줄**이 된다. + int people = 200; + 동시에(people, i -> enqueue("m" + i)); + + Set scores = ConcurrentHashMap.newKeySet(); + redis.opsForZSet().rangeWithScores(QUEUE, Range.closed(0L, (long) people)) + .doOnNext(tuple -> scores.add(tuple.getScore())) + .blockLast(WAIT); + + assertThat(redis.opsForZSet().size(QUEUE).block(WAIT)).isEqualTo(people); + assertThat(scores).hasSize(people); + } + + @Test + @DisplayName("서로_다른_사용자가_동시에_와도_전원_자리를_받는다") + void 서로_다른_사용자가_동시에_와도_전원_자리를_받는다() throws InterruptedException { + 동시에(100, i -> enqueue("member-" + i)); + + assertThat(redis.opsForZSet().size(QUEUE).block(WAIT)).isEqualTo(100); + } + + @Test + @DisplayName("동시_재등록이_원래_순번을_흔들지_않는다") + void 동시_재등록이_원래_순번을_흔들지_않는다() throws InterruptedException { + enqueue("m1"); + double first = redis.opsForZSet().score(QUEUE, "m1").block(WAIT); + + 동시에(50, i -> enqueue("m1")); + + assertThat(redis.opsForZSet().score(QUEUE, "m1").block(WAIT)).isEqualTo(first); + assertThat(redis.opsForZSet().size(QUEUE).block(WAIT)).isEqualTo(1); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/CrashRecoveryTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/CrashRecoveryTest.java new file mode 100644 index 00000000..db7c9949 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/CrashRecoveryTest.java @@ -0,0 +1,281 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.ScriptOutputType; +import io.lettuce.core.api.StatefulRedisConnection; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import com.github.dockerjava.api.model.Bind; +import com.github.dockerjava.api.model.Volume; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; + +/** + * 잃어도 줄 선 사람을 추월시키지 않는다 (G3.3 · C12). + * + *

{@code kill -9} 만으로는 아무것도 안 잃는다. {@code appendfsync} 는 + * {@code fsync} 주기를 정할 뿐이라 잃으려면 커널이 죽어야 한다 — 계획 2.2절의 + * 전제와 다르다. 그래서 강제 종료(유실 0)와 전원 단절(유실 허용, 역행 0)을 + * 나눠 본다. 근거는 AIJ-0025. + */ +@Tag("chaos") +class CrashRecoveryTest { + + private static final String COUPON = "c1"; + private static final String QUEUE = RedisKeys.queue(COUPON, 1, 0); + private static final String MAX_SCORE = RedisKeys.maxScore(COUPON, 1, 0); + private static final String ALIVE = RedisKeys.alive(COUPON, 1, 0); + private static final String ADMITTED = RedisKeys.admitted(COUPON, 1, 0); + private static final String GRACE = RedisKeys.grace(COUPON, 1, 0); + + /** + * 먼저 확실히 남길 사람들. {@code appendfsync always} 로 넣어 + * 강제 종료해도 살아남는 것이 보장된다 — 살아남은 사람이 없으면 단조를 + * 검증할 대상 자체가 없어 시험이 헛돈다. + */ + private static final int DURABLE = 50; + + /** + * 잃힐 사람들. {@code everysec} 로 돌린 뒤 곧바로 끊으므로 아직 + * 디스크에 안 내려간 구간이다. 이걸 안 만들면 {@code NOT_QUEUED} 경로가 + * 한 번도 안 돌고, 그래도 시험은 통과한다. + */ + private static final int VOLATILE = 500; + + private static final int AFTER_CRASH = 20; + + private static final long NOW = 1_800_000_000L; + + private final List> 띄운것 = new ArrayList<>(); + private final List 클라이언트 = new ArrayList<>(); + private final List> 연결 = new ArrayList<>(); + /** + * 호스트 바인드 마운트를 안 쓴다. redis 엔트리포인트가 {@code /data} 를 + * {@code chown} 해 버려서 호스트 사용자가 디렉터리를 열지도 못하게 된다 — + * 시험이 남긴 AOF 가 임시 저장소에 그대로 쌓인다. 이름 있는 볼륨은 도커가 + * 지운다. + */ + private String volume; + + /** + * 연결 → 클라이언트 → 컨테이너 순으로 닫는다. {@link RedisClient} 는 + * 제 Netty 이벤트 루프를 만들어서, {@code shutdown()} 없이는 그 스레드가 + * JVM 종료까지 남는다. 같은 워커에서 여러 시험이 돌면 계속 쌓인다. + */ + @AfterEach + void 정리() { + 연결.forEach(StatefulRedisConnection::close); + 클라이언트.forEach(RedisClient::shutdown); + 띄운것.forEach(GenericContainer::stop); + 연결.clear(); + 클라이언트.clear(); + 띄운것.clear(); + // 컨테이너를 내려도 볼륨은 남는다. 안 지우면 돌릴 때마다 쌓인다. + if (volume != null) { + DockerClientFactory.instance().client().removeVolumeCmd(volume).exec(); + volume = null; + } + } + + /** 같은 데이터 디렉터리를 물린 레디스. 컨테이너가 바뀌어도 AOF 는 남는다. */ + private StatefulRedisConnection 레디스를_띄운다() { + GenericContainer container = new GenericContainer<>(RedisContainerSupport.IMAGE) + .withExposedPorts(6379) + .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig() + .withBinds(new Bind(volume, new Volume("/data")))) + .withCommand("redis-server", "--appendonly", "yes", + "--appendfsync", "everysec", "--dir", "/data"); + container.start(); + 띄운것.add(container); + RedisClient client = RedisClient.create( + "redis://%s:%d".formatted(container.getHost(), container.getMappedPort(6379))); + 클라이언트.add(client); + StatefulRedisConnection connection = client.connect(); + 연결.add(connection); + return connection; + } + + private void 강제종료한다(GenericContainer container) { + // stop() 은 곱게 내려 종료 절차를 다 밟는다. 그 경로가 아니라 + // 끊긴 경로를 봐야 하므로 SIGKILL 로 끊는다. + container.getDockerClient() + .killContainerCmd(container.getContainerId()) + .withSignal("KILL") + .exec(); + } + + + /** + * 전원이 끊긴 상태를 재현한다 — 아직 {@code fsync} 안 된 AOF 꼬리가 날아간다. + * + *

파일이 {@code redis} 소유 0700 이라 밖에서 못 건드린다. 같은 볼륨을 + * 물린 컨테이너를 root 로 띄워 자른다. + */ + private void AOF_꼬리를_자른다(double 남길비율) { + try (GenericContainer helper = new GenericContainer<>(RedisContainerSupport.IMAGE) + .withCreateContainerCmdModifier(cmd -> { + cmd.withUser("root"); + cmd.getHostConfig().withBinds(new Bind(volume, new Volume("/data"))); + }) + .withCommand("tail", "-f", "/dev/null")) { + helper.start(); + // AOF 재작성이 일어나면 incr 파일이 여럿이다. 그때 아래 명령들은 + // 엉뚱한 파일을 대상으로 삼거나 조용히 실패한다. + List files = 도구로(helper, "ls /data/appendonlydir/*.incr.aof") + .lines().filter(line -> !line.isBlank()).toList(); + assertThat(files) + .withFailMessage("incr AOF 가 하나가 아니다: %s", files) + .hasSize(1); + String path = files.get(0); + long size = Long.parseLong(도구로(helper, "wc -c < " + path).trim()); + 도구로(helper, "truncate -s %d %s".formatted((long) (size * 남길비율), path)); + } + } + + private static String 도구로(GenericContainer container, String command) { + try { + var result = container.execInContainer("sh", "-c", command); + if (result.getExitCode() != 0) { + throw new IllegalStateException("명령 실패: " + command + " → " + result.getStderr()); + } + return result.getStdout(); + } catch (IOException | InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("명령 실패: " + command, e); + } + } + + /** score 는 문자열로 온다 — Lua 수는 2^53 위에서 정밀도를 잃는다. */ + private static long score(Object raw) { + return Long.parseLong(raw.toString()); + } + + @SuppressWarnings("unchecked") + private static List 등록한다( + StatefulRedisConnection redis, String member, long nowSec) { + return (List) redis.sync().eval(LuaScripts.of("enqueue.lua"), ScriptOutputType.MULTI, + new String[] {QUEUE, MAX_SCORE, ALIVE}, + member, "86400", "30", "0", String.valueOf(nowSec)); + } + + @SuppressWarnings("unchecked") + private static List 조회한다( + StatefulRedisConnection redis, String member, long nowSec) { + return (List) redis.sync().eval(LuaScripts.of("queue_status.lua"), + ScriptOutputType.MULTI, + new String[] {QUEUE, ADMITTED, ALIVE, GRACE}, + member, "30", String.valueOf(nowSec)); + } + + /** + * 등록한 사람들. 앞 {@link #DURABLE} 명은 반드시 살아남고, 뒤 + * {@link #VOLATILE} 명은 잃힐 수 있다. + */ + private Map 등록한다(StatefulRedisConnection redis) { + Map 등록한score = new LinkedHashMap<>(); + // 운영은 everysec 이다 (docker/redis.conf). 여기서만 잠깐 always 로 + // 돌리는 것은 **살아남는 쪽을 결정적으로 만들기 위한 픽스처**다. + redis.sync().configSet("appendfsync", "always"); + for (int i = 0; i < DURABLE; i++) { + 등록한score.put("keep" + i, score(등록한다(redis, "keep" + i, NOW).get(0))); + } + redis.sync().configSet("appendfsync", "everysec"); + for (int i = 0; i < VOLATILE; i++) { + 등록한score.put("lose" + i, score(등록한다(redis, "lose" + i, NOW).get(0))); + } + return 등록한score; + } + + @Test + @DisplayName("kill_9_후_재기동해도_순서가_역행하지_않는다") + void kill_9_후_재기동해도_순서가_역행하지_않는다() { + volume = "cy-crash-" + UUID.randomUUID(); + DockerClientFactory.instance().client().createVolumeCmd().withName(volume).exec(); + + var before = 레디스를_띄운다(); + 등록한다(before); + 강제종료한다(띄운것.get(띄운것.size() - 1)); + + var after = 레디스를_띄운다(); + List 살아남은 = after.sync().zrange(QUEUE, 0, -1); + // **강제 종료만으로는 아무도 안 잃는다.** 하나라도 비면 write() 된 + // 것이 사라졌다는 뜻이고, 그건 전제가 무너진 것이다. + assertThat(살아남은).hasSize(DURABLE + VOLATILE); + + long 살아남은최대 = 살아남은.stream() + .mapToLong(m -> after.sync().zscore(QUEUE, m).longValue()) + .max() + .orElseThrow(); + + for (int i = 0; i < AFTER_CRASH; i++) { + long score = score(등록한다(after, "post" + i, NOW).get(0)); + assertThat(score) + .withFailMessage("재기동 후 등록이 살아남은 사람을 추월했다: %d ≤ %d", + score, 살아남은최대) + .isGreaterThan(살아남은최대); + 살아남은최대 = score; + } + } + + @Test + @DisplayName("전원이_끊겨_유실된_사람은_NOT_QUEUED를_받는다") + void 전원이_끊겨_유실된_사람은_NOT_QUEUED를_받는다() { + volume = "cy-crash-" + UUID.randomUUID(); + DockerClientFactory.instance().client().createVolumeCmd().withName(volume).exec(); + + var before = 레디스를_띄운다(); + Map 등록한score = 등록한다(before); + 강제종료한다(띄운것.get(띄운것.size() - 1)); + AOF_꼬리를_자른다(0.5); + + var after = 레디스를_띄운다(); + List 이상한결과 = new ArrayList<>(); + int 잃음수 = 0; + + for (var entry : 등록한score.entrySet()) { + List status = 조회한다(after, entry.getKey(), NOW); + String state = status.get(0).toString(); + long score = score(status.get(2)); + + // 둘 중 하나여야 한다. 남았으면 원래 score 그대로, 잃었으면 + // NOT_QUEUED. **조용히 다른 자리에 서 있는 경우가 없어야 한다.** + boolean 남음 = "WAITING".equals(state) && score == entry.getValue(); + boolean 잃음 = "NOT_QUEUED".equals(state); + if (잃음) { + 잃음수++; + } else if (!남음) { + 이상한결과.add("%s → %s score=%d (등록시 %d)" + .formatted(entry.getKey(), state, score, entry.getValue())); + } + } + + assertThat(이상한결과) + .withFailMessage("남지도 잃지도 않은 항목 %d 건%n%s", + 이상한결과.size(), String.join("\n", 이상한결과)) + .isEmpty(); + // **몇 명 잃었는지가 중요하다.** 550 명 중 하나만 잃어도 통과하면 + // "꼬리 절단이 거의 안 먹혔다" 와 "의도대로 대량 유실됐다" 를 못 가린다. + // 정확한 수는 비결정적이므로 의미 있는 하한만 둔다. + assertThat(잃음수) + .withFailMessage("유실이 %d 명뿐이다 — 꼬리 절단이 의도한 구간에 안 닿았다", 잃음수) + .isGreaterThan(VOLATILE / 4); + // **절단 지점이 의도한 구간인지 본다.** fsync 된 앞쪽까지 잘렸다면 + // 유실 수만 보고는 "많이 잘렸다" 로 읽혀 구분이 안 된다. + assertThat(after.sync().zrange(QUEUE, 0, -1)) + .withFailMessage("확실히 남겼어야 할 구간까지 잘렸다") + .contains("keep0", "keep" + (DURABLE - 1)) + .hasSizeGreaterThanOrEqualTo(DURABLE); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/EnqueueGuardTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/EnqueueGuardTest.java new file mode 100644 index 00000000..bb1ae561 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/EnqueueGuardTest.java @@ -0,0 +1,164 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +/** + * 등록의 생존 신호와 길이 상한. + * + *

상한은 2차 방어다. 1차는 도메인이 보지만 낡은 스냅샷으로 판정하므로 + * 여기서 한 번 더 본다. + */ +@Tag("integration") +@SpringBootTest +class EnqueueGuardTest extends RedisContainerSupport { + + private static final String NOW = "1800000000"; + + private static final Duration WAIT = Duration.ofSeconds(5); + private static final String COUPON = "guard"; + private static final String QUEUE = RedisKeys.queue(COUPON, 1, 0); + private static final String MAX_SCORE = RedisKeys.maxScore(COUPON, 1, 0); + private static final String ALIVE = RedisKeys.alive(COUPON, 1, 0); + + @Autowired + private ReactiveStringRedisTemplate redis; + + private RedisScript script; + + @BeforeEach + void 준비() { + script = RedisScript.of(new ClassPathResource("redis/enqueue.lua"), List.class); + redis.delete(QUEUE, MAX_SCORE).block(WAIT); + for (int i = 0; i < 10; i++) { + redis.delete(RedisKeys.alive(COUPON, 1, 0)).block(WAIT); + } + } + + @SuppressWarnings("unchecked") + private List enqueue(String memberId, String aliveTtl, String cap) { + return (List) redis.execute( + script, + List.of(QUEUE, MAX_SCORE, RedisKeys.alive(COUPON, 1, 0)), + List.of(memberId, "86400", aliveTtl, cap, NOW)) + .blockFirst(WAIT); + } + + @Test + @DisplayName("돌려준_score가_ZSET에_든_값과_정확히_같다") + void 돌려준_score가_ZSET에_든_값과_정확히_같다() { + // **tostring 은 못 쓴다.** Lua 5.1 은 수를 %.14g 로 접는데 마이크로초 + // score 는 16자리다. 접히면 돌려준 값과 실제 자리가 어긋나고, 그 값을 + // 토큰에 담아 요청 경로에서 비교하는 순간 앞사람을 추월한다. + String returned = enqueue("m1", "30", "0").get(0).toString(); + + Double stored = redis.opsForZSet().score(QUEUE, "m1").block(WAIT); + + assertThat(returned).doesNotContain("e+"); + // **한 문장으로 단언한다.** null 검사를 따로 두면 약한 단언이 되고, + // 서식 인자에 stored 를 넣으면 미등록일 때 NPE 가 진짜 원인을 덮는다. + assertThat(stored) + .withFailMessage("돌려준 score 와 ZSET 의 값이 다르다 (미등록이면 null): " + + returned) + .isEqualTo((double) Long.parseLong(returned)); + } + + @Test + @DisplayName("등록하면_생존_신호에_만료_시각이_찍힌다") + void 등록하면_생존_신호에_만료_시각이_찍힌다() { + // ZSET 의 score 가 만료 시각이다. 사람마다 키를 만들면 청소가 + // KEYS 에 없는 키를 만지게 되고 클러스터가 거부한다 (RD-1). + enqueue("m1", "30", "0"); + + assertThat(redis.opsForZSet().score(ALIVE, "m1").block(WAIT)) + .isEqualTo(Long.parseLong(NOW) + 30); + } + + @Test + @DisplayName("생존_TTL은_주입받는다") + void 생존_TTL은_주입받는다() { + // 폴링 간격에서 나오는 값이라 스크립트에 박으면 둘이 갈라진다. + enqueue("m2", "90", "0"); + + assertThat(redis.opsForZSet().score(ALIVE, "m2").block(WAIT)) + .isEqualTo(Long.parseLong(NOW) + 90); + } + + @Test + @DisplayName("재등록도_생존_신호를_갱신한다") + void 재등록도_생존_신호를_갱신한다() { + // 순번은 그대로지만 살아 있다는 신호는 새로 찍혀야 한다. + // 안 그러면 성실히 새로고침하는 사람이 이탈자로 지워진다. + enqueue("m3", "30", "0"); + redis.opsForZSet().add(ALIVE, "m3", Long.parseLong(NOW) - 100).block(WAIT); + + enqueue("m3", "30", "0"); + + assertThat(redis.opsForZSet().score(ALIVE, "m3").block(WAIT)) + .isEqualTo(Long.parseLong(NOW) + 30); + } + + @Test + @DisplayName("상한을_넘으면_등록하지_않고_거부_신호를_낸다") + void 상한을_넘으면_등록하지_않고_거부_신호를_낸다() { + enqueue("m1", "30", "2"); + enqueue("m2", "30", "2"); + + List rejected = enqueue("m3", "30", "2"); + + assertThat(String.valueOf(rejected.get(0))).isEqualTo("-1"); + assertThat(redis.opsForZSet().size(QUEUE).block(WAIT)).isEqualTo(2); + } + + @Test + @DisplayName("이미_선_사람은_상한과_무관하게_자리를_지킨다") + void 이미_선_사람은_상한과_무관하게_자리를_지킨다() { + // 줄이 길어진 것이 그 사람 잘못이 아닌데 자리를 잃으면 안 된다. + enqueue("m1", "30", "2"); + enqueue("m2", "30", "2"); + double first = redis.opsForZSet().score(QUEUE, "m1").block(WAIT); + + List again = enqueue("m1", "30", "1"); + + assertThat(String.valueOf(again.get(2))).isEqualTo("1"); + assertThat(redis.opsForZSet().score(QUEUE, "m1").block(WAIT)).isEqualTo(first); + } + + @Test + @DisplayName("상한이_0이면_제한하지_않는다") + void 상한이_0이면_제한하지_않는다() { + for (int i = 0; i < 5; i++) { + enqueue("m" + i, "30", "0"); + } + + assertThat(redis.opsForZSet().size(QUEUE).block(WAIT)).isEqualTo(5); + } + + @Test + @DisplayName("잘못된_인자는_아무것도_쓰지_않는다") + void 잘못된_인자는_아무것도_쓰지_않는다() { + // Lua 는 중간 오류를 되돌리지 않는다. 쓰기 전에 전부 검증한다. + assertThatThrownBy(() -> enqueue("m1", "0", "0")).rootCause() + .hasMessageContaining("alive TTL"); + assertThatThrownBy(() -> enqueue("m1", "30", "-1")).rootCause() + .hasMessageContaining("큐 길이 상한"); + + assertThat(redis.opsForZSet().size(QUEUE).block(WAIT)).isZero(); + assertThat(redis.hasKey(MAX_SCORE).block(WAIT)).isFalse(); + // alive 만 생기는 회귀를 잡는다. 검증이 첫 쓰기 앞이라는 계약은 + // 세 키 전부에 걸린다 — 하나라도 새면 계약이 아니다. + assertThat(redis.hasKey(RedisKeys.alive(COUPON, 1, 0)).block(WAIT)).isFalse(); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/GraceReentryTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/GraceReentryTest.java new file mode 100644 index 00000000..4e6e1f75 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/GraceReentryTest.java @@ -0,0 +1,127 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.domain.Range; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +/** + * 유예 재입장은 자리를 보관하지 않는다 (D-11 · G3.8). + * + *

보관하면 이탈자가 돌아올 때마다 성실히 기다린 사람이 밀린다. + * 불변식 4 는 장애 중에도 적용된다. + */ +@Tag("integration") +@SpringBootTest +class GraceReentryTest extends RedisContainerSupport { + + private static final String NOW = "1800000000"; + + private static final Duration WAIT = Duration.ofSeconds(5); + private static final String COUPON = "grace"; + private static final String QUEUE = RedisKeys.queue(COUPON, 1, 0); + private static final String MAX_SCORE = RedisKeys.maxScore(COUPON, 1, 0); + private static final String GRACE = RedisKeys.grace(COUPON, 1, 0); + + @Autowired + private ReactiveStringRedisTemplate redis; + + private RedisScript enqueueScript; + + @BeforeEach + void 준비() { + enqueueScript = RedisScript.of(new ClassPathResource("redis/enqueue.lua"), List.class); + redis.delete(QUEUE, MAX_SCORE, GRACE).block(WAIT); + } + + private void enqueue(String memberId) { + redis.execute(enqueueScript, + List.of(QUEUE, MAX_SCORE, RedisKeys.alive(COUPON, 1, 0)), + List.of(memberId, "86400", "30", "0", NOW)) + .blockFirst(WAIT); + } + + private long rankOf(String memberId) { + Double score = redis.opsForZSet().score(QUEUE, memberId).block(WAIT); + return redis.opsForZSet() + .count(QUEUE, Range.leftUnbounded(Range.Bound.exclusive(score))) + .block(WAIT); + } + + @Test + @DisplayName("재입장은_새_순번으로_등록된다") + void 재입장은_새_순번으로_등록된다() { + enqueue("leaver"); + double original = redis.opsForZSet().score(QUEUE, "leaver").block(WAIT); + + // 이탈 — 자리를 잃고 유예 기록만 남는다 + redis.opsForZSet().remove(QUEUE, "leaver").block(WAIT); + redis.opsForHash().put(GRACE, "leaver", "left").block(WAIT); + enqueue("waiter"); + + enqueue("leaver"); + + assertThat(redis.opsForZSet().score(QUEUE, "leaver").block(WAIT)) + .isGreaterThan(original); + } + + @Test + @DisplayName("재입장이_남은_대기자의_순위를_늘리지_않는다") + void 재입장이_남은_대기자의_순위를_늘리지_않는다() { + enqueue("leaver"); + enqueue("waiter1"); + enqueue("waiter2"); + + redis.opsForZSet().remove(QUEUE, "leaver").block(WAIT); + redis.opsForHash().put(GRACE, "leaver", "left").block(WAIT); + long before1 = rankOf("waiter1"); + long before2 = rankOf("waiter2"); + + enqueue("leaver"); + + // 이 시퀀스에서는 아무도 빠지지 않으므로 순위는 **그대로**여야 한다. + // 이하로 두면 예기치 않은 삭제나 재정렬도 통과시킨다. + assertThat(rankOf("waiter1")).isEqualTo(before1); + assertThat(rankOf("waiter2")).isEqualTo(before2); + } + + @Test + @DisplayName("재입장자는_줄_맨_뒤에_선다") + void 재입장자는_줄_맨_뒤에_선다() { + // 자리를 보관하면 성실히 기다린 사람이 밀린다. + enqueue("leaver"); + redis.opsForZSet().remove(QUEUE, "leaver").block(WAIT); + for (int i = 0; i < 5; i++) { + enqueue("w" + i); + } + + enqueue("leaver"); + + assertThat(rankOf("leaver")).isEqualTo(5); + } + + @Test + @DisplayName("유예_기록이_있어도_자리를_돌려주지_않는다") + void 유예_기록이_있어도_자리를_돌려주지_않는다() { + // 기록은 재방문자 식별용이지 자리 보관용이 아니다. + enqueue("leaver"); + redis.opsForZSet().remove(QUEUE, "leaver").block(WAIT); + redis.opsForHash().put(GRACE, "leaver", "left").block(WAIT); + enqueue("newcomer"); + + enqueue("leaver"); + + assertThat(rankOf("leaver")).isEqualTo(1); + assertThat(redis.opsForHash().get(GRACE, "leaver").block(WAIT)).isEqualTo("left"); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/HardcodedKeyTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/HardcodedKeyTest.java new file mode 100644 index 00000000..d421a6bb --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/HardcodedKeyTest.java @@ -0,0 +1,243 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 키 문자열은 {@link RedisKeys} 에서만 만든다 (RD-3 · PK-R1). + * + *

두 곳에서 만들어지면 샤딩을 도입할 때 한쪽만 고쳐지고, 그때는 + * 진행 중인 큐가 통째로 유실된다. + */ +class HardcodedKeyTest { + + /** + * 어댑터만 본다. 도메인은 레디스를 모르므로(DS-1) 거기 있는 + * {@code "coupon:"} 같은 문자열은 리미터 예산 키지 레디스 키가 아니다. + * 그것까지 잡으면 오탐이고, 오탐이 나면 사람은 검사를 우회한다. + */ + private static final Path ADAPTER = Path.of("src/main/java/com/kafkick/waiting/adapter"); + private static final Path KEYS_CLASS = + Path.of("src/main/java/com/kafkick/waiting/adapter/redis/RedisKeys.java"); + + /** 이 저장소가 쓰는 키 접두사. 새 키를 만들면 여기도 함께 는다. */ + private static final Pattern KEY_LITERAL = Pattern.compile( + "\"(queue|maxscore|admitted|grace|alive|stock|gw|scheduler|coupons|coupon):"); + + private List violationsIn(Path root) throws IOException { + List violations = new ArrayList<>(); + try (Stream paths = Files.walk(root)) { + for (Path file : paths.filter(p -> p.toString().endsWith(".java")).toList()) { + if (file.equals(KEYS_CLASS)) { + continue; // 여기가 유일하게 허용되는 자리다 + } + // 주석 속 예시는 위반이 아니다. 오탐이 나면 사람은 + // 검사를 고치는 대신 우회한다. + String source = 주석을_지운다( + Files.readString(file, StandardCharsets.UTF_8)); + String[] lines = source.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (KEY_LITERAL.matcher(lines[i]).find()) { + violations.add("%s:%d".formatted(file.getFileName(), i + 1)); + } + } + } + } + return violations; + } + + /** + * 주석만 지우고 문자열은 남긴다. 정규식으로는 못 한다 — 리터럴에 담긴 + * 주석 기호가 진짜 주석의 시작으로 잡히면 검사가 눈이 먼다. + * + *

지울 자리는 공백으로 덮어 줄 번호를 그대로 둔다. 통째로 + * 지우면 뒤따르는 위반이 엉뚱한 줄로 보고돼 엉뚱한 곳을 보게 된다. + */ + private static String 주석을_지운다(String source) { + char[] out = source.toCharArray(); + int n = out.length; + int i = 0; + while (i < n) { + char c = out[i]; + if (c == '/' && i + 1 < n && out[i + 1] == '/') { + while (i < n && out[i] != '\n') { + out[i++] = ' '; + } + } else if (c == '/' && i + 1 < n && out[i + 1] == '*') { + out[i++] = ' '; + out[i++] = ' '; + // 닫히지 않은 주석은 파일 끝까지가 주석이다 + while (i < n && !(out[i] == '*' && i + 1 < n && out[i + 1] == '/')) { + if (out[i] != '\n') { + out[i] = ' '; + } + i++; + } + i = Math.min(i + 2, n); + } else if (c == '"' && i + 2 < n && out[i + 1] == '"' && out[i + 2] == '"') { + i += 3; // 텍스트 블록. 안의 주석 기호는 코드가 아니다 + while (i < n && !(out[i] == '"' && i + 2 < n + && out[i + 1] == '"' && out[i + 2] == '"')) { + i += out[i] == '\\' ? 2 : 1; + } + i = Math.min(i + 3, n); + } else if (c == '"' || c == '\'') { + i++; // 리터럴 안은 건드리지 않는다. 키를 찾는 곳이 여기다 + while (i < n && out[i] != c && out[i] != '\n') { + i += out[i] == '\\' ? 2 : 1; + } + i++; + } else { + i++; + } + } + return new String(out); + } + + @Test + @DisplayName("키_문자열_리터럴이_RedisKeys_밖에_없다") + void 키_문자열_리터럴이_RedisKeys_밖에_없다() throws IOException { + List violations = violationsIn(ADAPTER); + + assertThat(violations) + .withFailMessage("RedisKeys 밖의 키 리터럴 %d 건%n%s", + violations.size(), String.join("\n", violations)) + .isEmpty(); + } + + @Test + @DisplayName("검사가_실제로_문다") + void 검사가_실제로_문다() throws IOException { + // 통과만 하는 검사는 모든 코드를 통과시킨다 (TS-9). + Path dir = Files.createTempDirectory("probe"); + Path probe = dir.resolve("Leak.java"); + Files.writeString(probe, + "class Leak {\n String key = \"queue:{c1}\";\n}\n", StandardCharsets.UTF_8); + + try { + // 무엇을 잡았는지까지 본다 — 엉뚱한 것을 잡아도 통과하면 안 된다. + assertThat(violationsIn(dir)).containsExactly("Leak.java:2"); + } finally { + Files.deleteIfExists(probe); + Files.deleteIfExists(dir); + } + } + + @Test + @DisplayName("블록_주석_속_예시는_위반이_아니다") + void 블록_주석_속_예시는_위반이_아니다() throws IOException { + Path dir = Files.createTempDirectory("probe"); + Path probe = dir.resolve("Doc.java"); + Files.writeString(probe, + "/**\n * 예: \"queue:{c1}\" 처럼 쓰면 안 된다\n */\nclass Doc {\n}\n", + StandardCharsets.UTF_8); + + try { + assertThat(violationsIn(dir)).isEmpty(); + } finally { + Files.deleteIfExists(probe); + Files.deleteIfExists(dir); + } + } + + @Test + @DisplayName("주석_속_예시는_위반이_아니다") + void 주석_속_예시는_위반이_아니다() throws IOException { + Path dir = Files.createTempDirectory("probe"); + Path probe = dir.resolve("Fine.java"); + Files.writeString(probe, + "class Fine {\n // 예: \"queue:{c1}\" 처럼 쓰면 안 된다\n" + + " String key = RedisKeys.queue(\"c1\", 1, 0);\n}\n", + StandardCharsets.UTF_8); + + try { + assertThat(violationsIn(dir)).isEmpty(); + } finally { + Files.deleteIfExists(probe); + Files.deleteIfExists(dir); + } + } + + @Test + @DisplayName("문자열_속_주석_기호가_검사를_눈멀게_하지_않는다") + void 문자열_속_주석_기호가_검사를_눈멀게_하지_않는다() throws IOException { + // 주석을 정규식으로 지우면 여기서 검사가 통째로 죽는다 — 리터럴 + // 안의 "/*" 가 주석의 시작으로 잡혀 뒤따르는 진짜 위반이 주석 + // 속으로 사라진다. **못 잡는데 통과하니 없는 것보다 나쁘다.** + Path dir = Files.createTempDirectory("probe"); + Path probe = dir.resolve("Blind.java"); + Files.writeString(probe, + "class Blind {\n" + + " String block = \"/*\";\n" + + " String line = \"//\";\n" + + " String key = \"queue:{c1}\";\n" + + " String close = \"*/\";\n}\n", + StandardCharsets.UTF_8); + + try { + assertThat(violationsIn(dir)).containsExactly("Blind.java:4"); + } finally { + Files.deleteIfExists(probe); + Files.deleteIfExists(dir); + } + } + + @Test + @DisplayName("텍스트_블록_속_주석_기호도_코드가_아니다") + void 텍스트_블록_속_주석_기호도_코드가_아니다() throws IOException { + Path dir = Files.createTempDirectory("probe"); + Path probe = dir.resolve("Doc.java"); + Files.writeString(probe, + "class Doc {\n" + + " String sample = \"\"\"\n" + + " /* 여기는 문자열이다\n" + + " \"\"\";\n" + + " String key = \"queue:{c1}\";\n}\n", + StandardCharsets.UTF_8); + + try { + assertThat(violationsIn(dir)).containsExactly("Doc.java:5"); + } finally { + Files.deleteIfExists(probe); + Files.deleteIfExists(dir); + } + } + + @Test + @DisplayName("도메인의_예산_키는_대상이_아니다") + void 도메인의_예산_키는_대상이_아니다() throws IOException { + // 도메인은 레디스를 모른다 (DS-1). 거기 있는 "coupon:" 은 리미터 + // 예산을 가르는 값이지 레디스 키가 아니다 — 잡으면 오탐이다. + assertThat(ADAPTER.toString()).endsWith("adapter"); + assertThat(violationsIn(ADAPTER)).isEmpty(); + } + + @Test + @DisplayName("블록_주석_뒤의_위반은_원본_줄_번호로_보고된다") + void 블록_주석_뒤의_위반은_원본_줄_번호로_보고된다() throws IOException { + // 주석을 통째로 지우면 줄 번호가 앞으로 밀려 엉뚱한 곳을 보게 된다. + Path dir = Files.createTempDirectory("probe"); + Path probe = dir.resolve("Shifted.java"); + Files.writeString(probe, + "/**\n * 여러\n * 줄\n * 주석\n */\nclass Shifted {\n" + + " String key = \"queue:{c1}\";\n}\n", + StandardCharsets.UTF_8); + + try { + assertThat(violationsIn(dir)).containsExactly("Shifted.java:7"); + } finally { + Files.deleteIfExists(probe); + Files.deleteIfExists(dir); + } + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/HashTagSlotTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/HashTagSlotTest.java new file mode 100644 index 00000000..69b0602b --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/HashTagSlotTest.java @@ -0,0 +1,66 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 같은 샤드의 키는 같은 슬롯에 모여야 한다 (RD-2). + * + *

흩어지면 하나의 Lua 가 원자적으로 다룰 수 없어 클러스터에서 거부된다. + * 클러스터를 띄우지 않고 확인한다 — 빠른 되먹임이 목적이다. + */ +class HashTagSlotTest { + + /** 레디스 클러스터의 슬롯 수. */ + private static final int SLOTS = 16_384; + + private int slotOf(String key) { + return ShardHash.crc16(RedisKeys.hashTagOf(key)) % SLOTS; + } + + @Test + @DisplayName("같은_샤드의_키들이_같은_슬롯에_들어간다") + void 같은_샤드의_키들이_같은_슬롯에_들어간다() { + List keys = List.of( + RedisKeys.queue("c1", 4, 3), + RedisKeys.maxScore("c1", 4, 3), + RedisKeys.admitted("c1", 4, 3), + RedisKeys.grace("c1", 4, 3), + RedisKeys.alive("c1", 4, 3)); + + assertThat(keys).extracting(this::slotOf).containsOnly(slotOf(keys.get(0))); + } + + @Test + @DisplayName("샤드가_다르면_슬롯도_갈린다") + void 샤드가_다르면_슬롯도_갈린다() { + // 갈려야 부하가 퍼진다. 안 갈리면 샤딩이 이름뿐이다. + int distinct = (int) IntStream.range(0, 16) + .mapToObj(s -> RedisKeys.queue("c1", 16, s)) + .map(this::slotOf) + .distinct() + .count(); + + assertThat(distinct).isGreaterThan(8); + } + + @Test + @DisplayName("샤드가_하나여도_쿠폰별로_슬롯이_갈린다") + void 샤드가_하나여도_쿠폰별로_슬롯이_갈린다() { + assertThat(slotOf(RedisKeys.queue("c1", 1, 0))) + .isNotEqualTo(slotOf(RedisKeys.queue("c2", 1, 0))); + } + + @Test + @DisplayName("재고_키는_큐와_같은_슬롯이_아닐_수_있다") + void 재고_키는_큐와_같은_슬롯이_아닐_수_있다() { + // 발급 계층이 소유하고 샤드 무관이다. 샤딩하면 슬롯이 갈리므로 + // Lua 에서 만지지 않고 별도로 읽는다 — 그 사실을 여기에 못 박는다. + assertThat(slotOf(RedisKeys.stock("c1"))) + .isNotEqualTo(slotOf(RedisKeys.queue("c1", 4, 3))); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/LeaderElectionTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/LeaderElectionTest.java new file mode 100644 index 00000000..ae294405 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/LeaderElectionTest.java @@ -0,0 +1,209 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +/** + * 리더는 한 대다 (G3.2). + * + *

둘이 동시에 배분하면 총합이 전역 크레딧을 넘는다. 그리고 확인과 + * 삭제가 갈리면 그 사이 리스가 만료돼 남의 락을 지운다. + */ +@Tag("integration") +@SpringBootTest +class LeaderElectionTest extends RedisContainerSupport { + + private static final Duration WAIT = Duration.ofSeconds(10); + /** + * 폴링 한 번의 상한. {@link #WAIT} 보다 짧아야 한다. + * + *

폴링 안에서 {@code WAIT} 를 쓰면 조건 한 번이 바깥 제한보다 오래 걸려 + * 상한이 상한 노릇을 못 한다. 그러면 늦어졌다는 사실이 시험 실패가 + * 아니라 그냥 느린 시험으로 보인다. + */ + private static final Duration POLL = Duration.ofSeconds(1); + private static final String LEADER = RedisKeys.LEADER; + private static final String LEASE = "2000"; + + @Autowired + private ReactiveStringRedisTemplate redis; + + private RedisScript acquire; + private RedisScript release; + + @BeforeEach + void 준비() { + acquire = RedisScript.of(new ClassPathResource("redis/leader_acquire.lua"), List.class); + release = RedisScript.of(new ClassPathResource("redis/leader_release.lua"), Long.class); + redis.delete(LEADER).block(WAIT); + } + + @SuppressWarnings("unchecked") + private List tryAcquire(String owner) { + return (List) redis.execute(acquire, List.of(LEADER), List.of(owner, LEASE)) + .blockFirst(WAIT); + } + + private long releaseBy(String owner) { + return redis.execute(release, List.of(LEADER), List.of(owner)).blockFirst(WAIT); + } + + private boolean acquired(List r) { + return Long.parseLong(String.valueOf(r.get(0))) == 1; + } + + private String owner(List r) { + return String.valueOf(r.get(1)); + } + + @Test + @DisplayName("아무도_안_잡았으면_획득한다") + void 아무도_안_잡았으면_획득한다() { + List result = tryAcquire("node-1"); + + assertThat(acquired(result)).isTrue(); + assertThat(owner(result)).isEqualTo("node-1"); + assertThat(redis.opsForValue().get(LEADER).block(WAIT)).isEqualTo("node-1"); + } + + @Test + @DisplayName("자기가_잡은_락은_연장된다") + void 자기가_잡은_락은_연장된다() { + tryAcquire("node-1"); + redis.expire(LEADER, Duration.ofMillis(300)).block(WAIT); + + assertThat(acquired(tryAcquire("node-1"))).isTrue(); + assertThat(redis.getExpire(LEADER).block(WAIT)) + .isGreaterThan(Duration.ofMillis(1000)); + } + + @Test + @DisplayName("남이_잡고_있으면_획득하지_못한다") + void 남이_잡고_있으면_획득하지_못한다() { + tryAcquire("node-1"); + + List result = tryAcquire("node-2"); + + assertThat(acquired(result)).isFalse(); + assertThat(owner(result)).isEqualTo("node-1"); + assertThat(redis.opsForValue().get(LEADER).block(WAIT)).isEqualTo("node-1"); + } + + @Test + @DisplayName("10노드가_동시에_시도하면_정확히_1대만_성공한다") + void 노드_열이_동시에_시도하면_정확히_한_대만_성공한다() throws InterruptedException { + // 둘이 동시에 배분하면 총합이 전역 크레딧을 넘는다. + int nodes = 10; + AtomicInteger winners = new AtomicInteger(); + List failures = new CopyOnWriteArrayList<>(); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(nodes); + + try (ExecutorService pool = Executors.newFixedThreadPool(nodes)) { + for (int i = 0; i < nodes; i++) { + String owner = "node-" + i; + pool.execute(() -> { + try { + start.await(); + if (acquired(tryAcquire(owner))) { + winners.incrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + failures.add(e); + } catch (RuntimeException e) { + failures.add(e); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertThat(done.await(30, TimeUnit.SECONDS)).isTrue(); + } + + assertThat(failures).isEmpty(); + assertThat(winners.get()).isEqualTo(1); + } + + @Test + @DisplayName("자기_락만_지울_수_있다") + void 자기_락만_지울_수_있다() { + tryAcquire("node-1"); + + assertThat(releaseBy("node-1")).isOne(); + assertThat(redis.hasKey(LEADER).block(WAIT)).isFalse(); + } + + @Test + @DisplayName("남의_락은_지워지지_않는다") + void 남의_락은_지워지지_않는다() { + // 리스가 만료돼 다른 노드가 잡은 뒤 늦게 도착한 해제 요청이다. + // 지워지면 새 리더의 락이 사라져 배분이 멎는다. + tryAcquire("node-2"); + + assertThat(releaseBy("node-1")).isZero(); + assertThat(redis.opsForValue().get(LEADER).block(WAIT)).isEqualTo("node-2"); + } + + @Test + @DisplayName("리스가_만료되면_다른_노드가_잡는다") + void 리스가_만료되면_다른_노드가_잡는다() { + // 리더가 죽으면 이만큼 뒤 승계된다. 안 풀리면 배분이 영영 멎는다. + redis.opsForValue().set(LEADER, "dead-node", Duration.ofMillis(200)).block(WAIT); + + 리스_만료를_기다린다(); + + assertThat(acquired(tryAcquire("node-2"))).isTrue(); + } + + /** + * 리스가 풀릴 때까지 기다린다. + * + *

고정 대기를 쓰지 않는다 — 짧으면 흔들리고 길면 시험이 느려진다. + * 만료를 판정하는 것은 Redis 시계라 폴링으로 확인한다. + */ + private void 리스_만료를_기다린다() { + await().atMost(Duration.ofSeconds(5)) + .until(() -> Boolean.FALSE.equals(redis.hasKey(LEADER).block(POLL))); + } + + @Test + @DisplayName("잘못된_인자는_락을_건드리지_않는다") + void 잘못된_인자는_락을_건드리지_않는다() { + tryAcquire("node-1"); + + assertThatThrownBy(() -> + redis.execute(acquire, List.of(LEADER), List.of("node-2", "0")).blockFirst(WAIT)) + .rootCause() + .hasMessageContaining("리스"); + + // 빈 ownerId 도 막는다. 빈 값으로 잡히면 해제 때 누구의 락인지 + // 가릴 수 없어 남의 락을 지운다. + assertThatThrownBy(() -> + redis.execute(acquire, List.of(LEADER), List.of("", LEASE)).blockFirst(WAIT)) + .rootCause() + .hasMessageContaining("ownerId"); + + assertThat(redis.opsForValue().get(LEADER).block(WAIT)).isEqualTo("node-1"); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/LuaKeysDeclarationTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/LuaKeysDeclarationTest.java new file mode 100644 index 00000000..9c70406d --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/LuaKeysDeclarationTest.java @@ -0,0 +1,183 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Lua 는 {@code KEYS} 에 선언되지 않은 키를 만지지 않는다 (RD-1 · G3.5). + * + *

클러스터에서 미선언 키 접근은 런타임 오류다. 여기서 못 잡으면 + * 부하 시험 중에 터진다 — 그때는 스크립트를 전부 다시 써야 한다. + */ +class LuaKeysDeclarationTest { + + private static final Path SCRIPTS = Path.of("src/main/resources/redis"); + + /** {@code redis.call('CMD', <키>` — 명령 뒤 첫 인자가 키 자리다. */ + private static final Pattern KEY_ARGUMENT = Pattern.compile( + "redis\\.(?:call|pcall)\\(\\s*'[A-Za-z]+'\\s*,\\s*([^,)]+)"); + + private List scripts() throws IOException { + try (Stream paths = Files.list(SCRIPTS)) { + return paths.filter(p -> p.toString().endsWith(".lua")).sorted().toList(); + } + } + + /** 주석을 걷어낸다 — 주석 속 예시가 위반으로 잡히면 검사를 안 믿게 된다. */ + private String codeOf(Path script) throws IOException { + return Files.readAllLines(script, StandardCharsets.UTF_8).stream() + .map(line -> line.replaceAll("--.*", "")) + .reduce("", (a, b) -> a + "\n" + b); + } + + /** + * {@code KEYS[n]} 에서 온 값만 키 자리에 올 수 있다. + * + *

지역 변수를 무조건 허용하면 {@code local rogue = 'queue:{c1}'} 를 + * 거쳐 들어오는 것을 못 막는다 — 검사가 있는데 아무것도 안 보는 상태다. + */ + private List violationsIn(Path script) throws IOException { + String code = codeOf(script); + + // KEYS[n] 이 대입된 지역 변수만 모은다. 문자열 조립은 안 받는다. + Set derived = new HashSet<>(); + Matcher assignment = Pattern + .compile("local\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*KEYS\\[") + .matcher(code); + while (assignment.find()) { + derived.add(assignment.group(1)); + } + + List violations = new ArrayList<>(); + Matcher matcher = KEY_ARGUMENT.matcher(code); + while (matcher.find()) { + String argument = matcher.group(1).strip(); + boolean declared = argument.startsWith("KEYS[") || derived.contains(argument); + if (!declared) { + violations.add("%s: %s".formatted(script.getFileName(), argument)); + } + } + return violations; + } + + @Test + @DisplayName("모든_Lua가_KEYS에_선언된_키만_만진다") + void 모든_Lua가_KEYS에_선언된_키만_만진다() throws IOException { + List violations = new ArrayList<>(); + for (Path script : scripts()) { + violations.addAll(violationsIn(script)); + } + + assertThat(violations) + .withFailMessage("KEYS 미선언 키 접근 %d 건%n%s", + violations.size(), String.join("\n", violations)) + .isEmpty(); + } + + @Test + @DisplayName("모든_Lua에_KEYS와_ARGV_계약_주석이_있다") + void 모든_Lua에_KEYS와_ARGV_계약_주석이_있다() throws IOException { + // 계약이 없으면 호출부가 인자 순서를 추측하게 된다 (RD-10). + // **상단 주석 블록만 본다** — 코드에서 KEYS[1] 을 쓰는 것과 + // 계약을 적어 둔 것은 다르다. + for (Path script : scripts()) { + String header = headerOf(script); + assertThat(header) + .withFailMessage("%s 상단에 KEYS 계약이 없다", script.getFileName()) + .contains("KEYS[1]"); + assertThat(header) + .withFailMessage("%s 상단에 ARGV 계약이 없다", script.getFileName()) + .contains("ARGV[1]"); + } + } + + /** 첫 코드 줄 전까지의 주석 블록. */ + private String headerOf(Path script) throws IOException { + StringBuilder header = new StringBuilder(); + for (String line : Files.readAllLines(script, StandardCharsets.UTF_8)) { + String trimmed = line.strip(); + if (!trimmed.isEmpty() && !trimmed.startsWith("--")) { + break; + } + header.append(line).append('\n'); + } + return header.toString(); + } + + @Test + @DisplayName("문자열_조립으로_만든_키는_위반이다") + void 문자열_조립으로_만든_키는_위반이다() throws IOException { + // 지역 변수를 무조건 허용하면 이 경로로 다 새어 나간다. + Path probe = Files.createTempFile("probe", ".lua"); + Files.writeString(probe, + "local rogue = 'queue:{c1}'\nredis.call('ZCARD', rogue)\n", + StandardCharsets.UTF_8); + + try { + assertThat(violationsIn(probe)).singleElement().asString().contains("rogue"); + } finally { + Files.deleteIfExists(probe); + } + } + + @Test + @DisplayName("KEYS에서_온_지역_변수는_위반이_아니다") + void KEYS에서_온_지역_변수는_위반이_아니다() throws IOException { + Path probe = Files.createTempFile("probe", ".lua"); + Files.writeString(probe, + "local queue = KEYS[1]\nredis.call('ZCARD', queue)\n", + StandardCharsets.UTF_8); + + try { + assertThat(violationsIn(probe)).isEmpty(); + } finally { + Files.deleteIfExists(probe); + } + } + + @Test + @DisplayName("검사가_실제로_문다") + void 검사가_실제로_문다() throws IOException { + // 통과만 하는 검사는 모든 스크립트를 통과시킨다 (TS-9). + Path probe = Files.createTempFile("probe", ".lua"); + Files.writeString(probe, "redis.call('ZCARD', 'queue:{c1}')\n", StandardCharsets.UTF_8); + + try { + // 무엇을 잡았는지까지 본다 — 엉뚱한 것을 잡아도 통과하면 안 된다. + assertThat(violationsIn(probe)) + .singleElement().asString().contains("'queue:{c1}'"); + } finally { + Files.deleteIfExists(probe); + } + } + + @Test + @DisplayName("주석_속_예시는_위반이_아니다") + void 주석_속_예시는_위반이_아니다() throws IOException { + // 오탐이 나면 사람은 검사를 고치는 대신 우회한다. + Path probe = Files.createTempFile("probe", ".lua"); + Files.writeString(probe, + "-- redis.call('ZCARD', 'queue:{c1}') 처럼 쓰면 안 된다\n" + + "redis.call('ZCARD', KEYS[1])\n", + StandardCharsets.UTF_8); + + try { + assertThat(violationsIn(probe)).isEmpty(); + } finally { + Files.deleteIfExists(probe); + } + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/LuaScripts.java b/src/test/java/com/kafkick/waiting/adapter/redis/LuaScripts.java new file mode 100644 index 00000000..0a1ca06e --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/LuaScripts.java @@ -0,0 +1,31 @@ +package com.kafkick.waiting.adapter.redis; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * Lua 스크립트를 클래스패스에서 읽는다. + * + *

파일 경로로 읽으면 작업 디렉터리가 모듈 루트일 때만 동작한다. 스크립트가 + * 옮겨지면 읽는 쪽을 전부 따로 고쳐야 하므로 한 곳에 둔다. + */ +final class LuaScripts { + + private LuaScripts() { + } + + /** @param name {@code enqueue.lua} 처럼 {@code redis/} 아래의 이름 */ + static String of(String name) { + String path = "redis/" + name; + try (InputStream in = LuaScripts.class.getClassLoader().getResourceAsStream(path)) { + if (in == null) { + throw new IllegalArgumentException("클래스패스에 없다: " + path); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/PersistencePolicyTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/PersistencePolicyTest.java new file mode 100644 index 00000000..ce973e7d --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/PersistencePolicyTest.java @@ -0,0 +1,70 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 영속성 정책이 문서가 아니라 설정 파일에 있는지 본다. + * + *

이 설정이 지키려는 것은 순서이지 무결성이 아니다. 순번이 시계라 + * 유실이 생겨도 중복 발번이 없고, 증발한 사람은 재등록하면 새 순번이 더 커서 + * 이미 줄 선 사람을 추월하지 않는다 (E-6). + */ +class PersistencePolicyTest { + + private Map config() throws IOException { + return Files.readAllLines(Path.of("docker/redis.conf"), StandardCharsets.UTF_8).stream() + .map(String::trim) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .map(line -> line.split("\\s+", 2)) + .collect(Collectors.toMap(parts -> parts[0], parts -> parts[1])); + } + + @Test + @DisplayName("AOF가_켜져_있고_초당_동기화다") + void AOF가_켜져_있고_초당_동기화다() throws IOException { + assertThat(config()).containsEntry("appendonly", "yes"); + assertThat(config()).containsEntry("appendfsync", "everysec"); + } + + @Test + @DisplayName("always를_쓰지_않는다") + void always를_쓰지_않는다() throws IOException { + // 매 쓰기마다 fsync 를 치르면 스파이크에서 지연이 명령 타임아웃을 넘긴다. + assertThat(config().get("appendfsync")).isNotEqualTo("always"); + } + + @Test + @DisplayName("메모리가_차도_조용히_지우지_않는다") + void 메모리가_차도_조용히_지우지_않는다() throws IOException { + // 대기열 항목이 eviction 으로 사라지면 그 사람은 줄에서 증발한다. + // 누가 증발할지를 메모리 정책이 정하게 두지 않는다. + assertThat(config()).containsEntry("maxmemory-policy", "noeviction"); + } + + @Test + @DisplayName("메모리_상한이_설정되어_있다") + void 메모리_상한이_설정되어_있다() throws IOException { + // 상한이 없으면 noeviction 은 아무것도 안 막는다. 상한에 닿아야 + // 쓰기를 거부하는데 기본값 0 은 무제한이라 그 지점이 안 온다 — + // 대신 호스트가 OOM 으로 죽는다. 거부는 복구할 수 있고 OOM 은 못 한다. + assertThat(config()).containsKey("maxmemory"); + assertThat(config().get("maxmemory")).isNotEqualTo("0"); + } + + @Test + @DisplayName("다시_쓰는_동안에도_동기화를_멈추지_않는다") + void 다시_쓰는_동안에도_동기화를_멈추지_않는다() throws IOException { + // 멈추면 그 구간의 지연이 튀고, 튄 지연은 명령 타임아웃을 넘겨 + // 스케줄러를 멎게 한다. + assertThat(config()).containsEntry("no-appendfsync-on-rewrite", "no"); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/QueueStatusTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/QueueStatusTest.java new file mode 100644 index 00000000..ce4d1c93 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/QueueStatusTest.java @@ -0,0 +1,216 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +/** + * 순번 조회. + * + *

조회·하트비트·배수 판정이 한 번에 일어나야 한다. 나눠 치면 한쪽만 + * 성공한 상태가 생기고, 그때 성실히 새로고침하는 사람이 이탈자로 지워진다. + */ +@Tag("integration") +@SpringBootTest +class QueueStatusTest extends RedisContainerSupport { + + private static final String NOW = "1800000000"; + + private static final Duration WAIT = Duration.ofSeconds(5); + private static final String COUPON = "status"; + private static final String QUEUE = RedisKeys.queue(COUPON, 1, 0); + private static final String MAX_SCORE = RedisKeys.maxScore(COUPON, 1, 0); + private static final String ADMITTED = RedisKeys.admitted(COUPON, 1, 0); + private static final String GRACE = RedisKeys.grace(COUPON, 1, 0); + private static final String ALIVE_TTL = "30"; + + @Autowired + private ReactiveStringRedisTemplate redis; + + private RedisScript enqueueScript; + private RedisScript statusScript; + + @BeforeEach + void 준비() { + enqueueScript = RedisScript.of(new ClassPathResource("redis/enqueue.lua"), List.class); + statusScript = RedisScript.of(new ClassPathResource("redis/queue_status.lua"), List.class); + redis.delete(QUEUE, MAX_SCORE, ADMITTED, GRACE).block(WAIT); + for (int i = 0; i < 10; i++) { + redis.delete(alive("m" + i)).block(WAIT); + } + } + + private String alive(String memberId) { + return RedisKeys.alive(COUPON, 1, 0); + } + + private void enqueue(String memberId) { + redis.execute(enqueueScript, List.of(QUEUE, MAX_SCORE, alive(memberId)), + List.of(memberId, "86400", ALIVE_TTL, "0", NOW)) + .blockFirst(WAIT); + } + + @SuppressWarnings("unchecked") + private List status(String memberId) { + return (List) redis.execute( + statusScript, + List.of(QUEUE, ADMITTED, alive(memberId), GRACE), + List.of(memberId, ALIVE_TTL, NOW)) + .blockFirst(WAIT); + } + + private String state(List r) { + return String.valueOf(r.get(0)); + } + + private long rank(List r) { + return Long.parseLong(String.valueOf(r.get(1))); + } + + @Test + @DisplayName("내_앞에_몇_명인지_돌려준다") + void 내_앞에_몇_명인지_돌려준다() { + enqueue("m0"); + enqueue("m1"); + enqueue("m2"); + + assertThat(rank(status("m0"))).isZero(); + assertThat(rank(status("m1"))).isEqualTo(1); + assertThat(rank(status("m2"))).isEqualTo(2); + } + + @Test + @DisplayName("큐에_없으면_NOT_QUEUED를_받는다") + void 큐에_없으면_NOT_QUEUED를_받는다() { + // 0번째와 구분한다. 없는 것과 맨 앞인 것은 다르다 — 뭉치면 + // 유실된 사람에게 "곧 입장" 을 보여 주게 된다. + List result = status("ghost"); + + assertThat(state(result)).isEqualTo("NOT_QUEUED"); + assertThat(rank(result)).isEqualTo(-1); + } + + @Test + @DisplayName("조회하면_생존_TTL이_연장된다") + void 조회하면_생존_TTL이_연장된다() { + // **경계에 붙이지 않는다.** 2초로 줄여 두면 CI 지연 한 번에 키가 + // 먼저 사라져 갱신이 아예 안 돌고, 그러면 이 시험이 불안정해진다. + enqueue("m0"); + redis.opsForZSet().add(alive("m0"), "m0", Long.parseLong(NOW) - 100).block(WAIT); + + assertThat(state(status("m0"))).isEqualTo("WAITING"); + + // score 가 만료 시각이다. 조회 한 번에 미래로 밀려야 한다. + assertThat(redis.opsForZSet().score(alive("m0"), "m0").block(WAIT)) + .isEqualTo(Long.parseLong(NOW) + 30); + } + + @Test + @DisplayName("임계가_없으면_아무도_입장하지_않는다") + void 임계가_없으면_아무도_입장하지_않는다() { + enqueue("m0"); + + assertThat(state(status("m0"))).isEqualTo("WAITING"); + } + + @Test + @DisplayName("내_순번이_임계_이하면_입장이다") + void 내_순번이_임계_이하면_입장이다() { + enqueue("m0"); + enqueue("m1"); + double scoreM0 = redis.opsForZSet().score(QUEUE, "m0").block(WAIT); + redis.opsForValue().set(ADMITTED, String.valueOf((long) scoreM0)).block(WAIT); + + assertThat(state(status("m0"))).isEqualTo("ADMITTED"); + assertThat(state(status("m1"))).isEqualTo("WAITING"); + } + + @Test + @DisplayName("입장이_확정되면_큐에서_빠진다") + void 입장이_확정되면_큐에서_빠진다() { + // 안 빼면 대기 인원이 계속 부풀고 ETA 가 전부 틀어진다. + enqueue("m0"); + enqueue("m1"); + double scoreM0 = redis.opsForZSet().score(QUEUE, "m0").block(WAIT); + redis.opsForValue().set(ADMITTED, String.valueOf((long) scoreM0)).block(WAIT); + + status("m0"); + + assertThat(redis.opsForZSet().size(QUEUE).block(WAIT)).isEqualTo(1); + assertThat(rank(status("m1"))).isZero(); + } + + @Test + @DisplayName("이미_빠진_사람이_다시_조회해도_망가지지_않는다") + void 이미_빠진_사람이_다시_조회해도_망가지지_않는다() { + enqueue("m0"); + redis.opsForValue() + .set(ADMITTED, String.valueOf(redis.opsForZSet().score(QUEUE, "m0").block(WAIT).longValue())) + .block(WAIT); + status("m0"); + + // 두 번째 조회는 큐에 없으니 NOT_QUEUED 다. 입장이 두 번 나지 않는다. + assertThat(state(status("m0"))).isEqualTo("NOT_QUEUED"); + } + + @Test + @DisplayName("입장한_사람은_유예_기록에_남는다") + void 입장한_사람은_유예_기록에_남는다() { + enqueue("m0"); + redis.opsForValue() + .set(ADMITTED, String.valueOf(redis.opsForZSet().score(QUEUE, "m0").block(WAIT).longValue())) + .block(WAIT); + + status("m0"); + + assertThat(redis.opsForHash().get(GRACE, "m0").block(WAIT)).isEqualTo("admitted"); + } + + @Test + @DisplayName("앞사람이_빠지면_내_순번이_줄어든다") + void 앞사람이_빠지면_내_순번이_줄어든다() { + // 순위를 저장하지 않고 매번 센다. 저장하면 앞사람이 빠질 때마다 + // 전원을 갱신해야 한다. + for (int i = 0; i < 5; i++) { + enqueue("m" + i); + } + assertThat(rank(status("m4"))).isEqualTo(4); + + redis.opsForZSet().remove(QUEUE, "m0", "m1").block(WAIT); + + assertThat(rank(status("m4"))).isEqualTo(2); + } + + @Test + @DisplayName("잘못된_TTL은_아무것도_쓰지_않는다") + void 잘못된_TTL은_아무것도_쓰지_않는다() { + enqueue("m0"); + double before = redis.opsForZSet().score(QUEUE, "m0").block(WAIT); + redis.delete(alive("m0")).block(WAIT); + + assertThatThrownBy(() -> + redis.execute(statusScript, List.of(QUEUE, ADMITTED, alive("m0"), GRACE), + List.of("m0", "0", NOW)) + .blockFirst(WAIT)) + .rootCause() + .hasMessageContaining("alive TTL"); + + // 쓰기 대상 전부를 본다. 하나만 보면 ZREM 이나 HSET 이 먼저 + // 일어난 회귀를 통과시킨다. 순번은 **그대로**여야 한다 — + // 있기만 하면 되는 게 아니라 값이 안 바뀌어야 한다. + assertThat(redis.opsForZSet().score(alive("m0"), "m0").block(WAIT)).isNull(); + assertThat(redis.opsForZSet().score(QUEUE, "m0").block(WAIT)).isEqualTo(before); + assertThat(redis.opsForHash().hasKey(GRACE, "m0").block(WAIT)).isFalse(); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/RankMonotonicityIntegrationTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/RankMonotonicityIntegrationTest.java new file mode 100644 index 00000000..dbc36f55 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/RankMonotonicityIntegrationTest.java @@ -0,0 +1,122 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +/** + * 순위는 뒤로 가지 않는다 (G3.11 · 불변식 3). + * + *

Phase 2 의 단조성 테스트는 "입력이 단조면 출력도 단조" 까지만 봤다. + * {@code localRank} 자체가 단조라는 보장은 여기가 진다. + * + *

매 연산마다 전원을 본다. 표본 하나만 보면 안 본 사람의 순위가 + * 올랐다가 다음 표본 선택 전에 내려오는 경우를 통째로 놓친다. + */ +@Tag("integration") +@SpringBootTest +class RankMonotonicityIntegrationTest extends RedisContainerSupport { + + private static final String NOW = "1800000000"; + + private static final long SEED = 20260819L; + private static final int OPERATIONS = 3_000; + private static final int PEOPLE = 200; + private static final Duration WAIT = Duration.ofSeconds(10); + + private static final String COUPON = "rank"; + private static final String QUEUE = RedisKeys.queue(COUPON, 1, 0); + private static final String MAX_SCORE = RedisKeys.maxScore(COUPON, 1, 0); + + @Autowired + private ReactiveStringRedisTemplate redis; + + private RedisScript enqueueScript; + + @BeforeEach + void 준비() { + enqueueScript = RedisScript.of(new ClassPathResource("redis/enqueue.lua"), List.class); + redis.delete(QUEUE, MAX_SCORE).block(WAIT); + } + + private void enqueue(String memberId) { + redis.execute(enqueueScript, + List.of(QUEUE, MAX_SCORE, RedisKeys.alive(COUPON, 1, 0)), + List.of(memberId, "86400", "30", "0", NOW)) + .blockFirst(WAIT); + } + + /** 큐 전체를 순서대로 한 번에 읽는다. 사람마다 왕복하면 10만 회가 안 끝난다. */ + private List queueOrder() { + return redis.opsForZSet().range(QUEUE, org.springframework.data.domain.Range.closed(0L, -1L)) + .collectList() + .block(WAIT); + } + + @Test + @DisplayName("등록_입장_이탈을_섞은_시퀀스에서_순위가_증가하지_않는다") + void 등록_입장_이탈을_섞은_시퀀스에서_순위가_증가하지_않는다() { + Random rnd = new Random(SEED); + List expected = new ArrayList<>(); + Map lastRank = new HashMap<>(); + List violations = new ArrayList<>(); + + for (int op = 0; op < OPERATIONS; op++) { + int action = rnd.nextInt(100); + + if (action < 50 && expected.size() < PEOPLE) { + String id = "m" + op; + enqueue(id); + expected.add(id); + } else if (action < 80 && !expected.isEmpty()) { + // 입장 — 맨 앞이 빠진다 + String front = expected.remove(0); + redis.opsForZSet().remove(QUEUE, front).block(WAIT); + lastRank.remove(front); + } else if (!expected.isEmpty()) { + // 이탈 — 중간에서 빠진다 + String gone = expected.remove(rnd.nextInt(expected.size())); + redis.opsForZSet().remove(QUEUE, gone).block(WAIT); + lastRank.remove(gone); + } + + List actual = queueOrder(); + + // **기대한 줄과 실제 줄이 같아야 한다.** 다르면 사라졌거나 순서가 + // 뒤집힌 것이고, 둘 다 그냥 넘기면 안 되는 사고다. + if (!actual.equals(expected)) { + violations.add("op %d: 줄이 어긋났다 — 기대 %s / 실제 %s" + .formatted(op, expected, actual)); + break; + } + + // 매 연산마다 **전원**의 순위를 본다 + for (int rank = 0; rank < actual.size(); rank++) { + String member = actual.get(rank); + Integer previous = lastRank.put(member, rank); + if (previous != null && rank > previous) { + violations.add("op %d: %s 순위 %d → %d".formatted(op, member, previous, rank)); + } + } + } + + assertThat(violations) + .withFailMessage("순위 역행 %d 건 (시드 %d)%n%s", + violations.size(), SEED, String.join("\n", violations)) + .isEmpty(); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/RedisBudgetGuardTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/RedisBudgetGuardTest.java new file mode 100644 index 00000000..80eac375 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/RedisBudgetGuardTest.java @@ -0,0 +1,100 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties; + +/** + * 시간 예산 검증이 실제로 무는가. + * + *

통과만 하는 검사는 모든 설정을 통과시킨다. 값을 바꾸는 사람이 주석을 + * 안 읽어도 안 뜨게 만드는 것이 이 검증의 목적이라, 그 동작을 고정한다. + */ +class RedisBudgetGuardTest { + + private DataRedisProperties props(Duration timeout, Duration connect) { + DataRedisProperties p = new DataRedisProperties(); + p.setTimeout(timeout); + p.setConnectTimeout(connect); + return p; + } + + @Test + @DisplayName("예산_안이면_뜬다") + void 예산_안이면_뜬다() { + RedisTimeBudget budget = + RedisTimeBudget.of(props(Duration.ofMillis(500), Duration.ofSeconds(1))); + + // 예외가 안 나는 것이 단언이다. 경계 바로 안쪽 값을 쓴다. + assertThatCode(budget::verify).doesNotThrowAnyException(); + } + + @Test + @DisplayName("명령_타임아웃이_틱_이상이면_안_뜬다") + void 명령_타임아웃이_틱_이상이면_안_뜬다() { + RedisTimeBudget budget = + RedisTimeBudget.of(props(Duration.ofSeconds(1), Duration.ofSeconds(1))); + + assertThatThrownBy(budget::verify) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("timeout"); + } + + @Test + @DisplayName("연결_타임아웃이_리스_이상이면_안_뜬다") + void 연결_타임아웃이_리스_이상이면_안_뜬다() { + RedisTimeBudget budget = + RedisTimeBudget.of(props(Duration.ofMillis(500), Duration.ofSeconds(2))); + + assertThatThrownBy(budget::verify) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("connect-timeout"); + } + + @Test + @DisplayName("설정이_없으면_안_뜬다") + void 설정이_없으면_안_뜬다() { + // 기본값은 무한이다. 무한 대기는 스케줄러를 멎게 한다. + assertThatThrownBy(() -> RedisTimeBudget.of(props(null, null)).verify()) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("타임아웃이_0이면_안_뜬다") + void 타임아웃이_0이면_안_뜬다() { + // 0 은 예산 안이지만 즉시 실패라 아무것도 못 한다. + assertThatThrownBy(() -> RedisTimeBudget.of(props(Duration.ZERO, Duration.ofSeconds(1))).verify()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("양수"); + assertThatThrownBy(() -> RedisTimeBudget.of(props(Duration.ofMillis(500), Duration.ZERO)).verify()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("양수"); + } + + @Test + @DisplayName("타임아웃이_음수면_안_뜬다") + void 타임아웃이_음수면_안_뜬다() { + // 음수는 드라이버에 따라 무한 대기가 된다 — 막으려던 것이 그대로 난다. + assertThatThrownBy(() -> + RedisTimeBudget.of(props(Duration.ofMillis(-1), Duration.ofSeconds(1))).verify()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("양수"); + assertThatThrownBy(() -> + RedisTimeBudget.of(props(Duration.ofMillis(500), Duration.ofMillis(-1))).verify()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("양수"); + } + + @Test + @DisplayName("예산_경계_바로_아래는_뜬다") + void 예산_경계_바로_아래는_뜬다() { + // 경계를 초과로만 잡으면 딱 틱만큼 걸리는 명령이 통과한다. + assertThatCode(() -> RedisTimeBudget.of( + props(Duration.ofMillis(999), Duration.ofMillis(1999))).verify()) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/RedisConnectionSettingsTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/RedisConnectionSettingsTest.java new file mode 100644 index 00000000..a748a0c8 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/RedisConnectionSettingsTest.java @@ -0,0 +1,58 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * 커넥션 설정. + * + *

제어 평면은 틱 안에 끝나야 한다. 명령 하나가 틱보다 오래 붙들면 + * 스케줄러가 그 틱을 통째로 놓치고, 그동안 배분이 멎는다. + */ +@SpringBootTest +class RedisConnectionSettingsTest { + + /** 스케줄러 틱. 명령 타임아웃은 이보다 짧아야 한다. */ + private static final Duration TICK = Duration.ofSeconds(1); + + /** 리더 리스. 연결 타임아웃은 이보다 짧아야 한다. */ + private static final Duration LEASE = Duration.ofSeconds(2); + + @Autowired + private DataRedisProperties properties; + + @Test + @DisplayName("커넥션_풀을_쓰지_않는다") + void 커넥션_풀을_쓰지_않는다() { + // Lettuce 는 커넥션 하나를 멀티플렉싱한다. 풀을 얹으면 커넥션이 늘 뿐 + // 처리량은 안 늘고, 노드마다 커넥션 수가 달라져 레디스 쪽 한계를 + // 예측할 수 없게 된다. commons-pool2 가 없으면 풀은 애초에 안 켜진다. + assertThatThrownBy(() -> Class.forName("org.apache.commons.pool2.ObjectPool")) + .isInstanceOf(ClassNotFoundException.class); + } + + @Test + @DisplayName("명령_타임아웃이_틱보다_짧다") + void 명령_타임아웃이_틱보다_짧다() { + // 기본값은 무한이다. 무한 대기는 스케줄러를 멎게 한다. + assertThat(properties.getTimeout()) + .isPositive() + .isLessThan(TICK); + } + + @Test + @DisplayName("연결_타임아웃이_리스보다_짧다") + void 연결_타임아웃이_리스보다_짧다() { + // 연결이 리스(2초)보다 오래 걸리면 그 사이 리더십을 잃는다. + assertThat(properties.getConnectTimeout()) + .isPositive() + .isLessThan(LEASE); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/RedisContainerSupport.java b/src/test/java/com/kafkick/waiting/adapter/redis/RedisContainerSupport.java new file mode 100644 index 00000000..f03cf9d7 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/RedisContainerSupport.java @@ -0,0 +1,45 @@ +package com.kafkick.waiting.adapter.redis; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +/** + * 통합 시험용 레디스. JVM 당 하나만 띄운다. + * + *

클래스마다 띄우면 전체 시험이 분 단위로 늘어난다. 컨테이너를 정적으로 두고 + * 종료를 JVM 에 맡기면 재사용된다 — Testcontainers 의 ryuk 이 회수한다. + */ +public abstract class RedisContainerSupport { + + /** + * 7.x 이상이어야 한다. + * + *

Lua 의 {@code TIME} 과 효과 기반 복제(5+), {@code ZRANDMEMBER}(6.2+) 를 쓴다. + * 낮은 판에서는 스크립트가 조용히 다르게 동작한다. + */ + static final DockerImageName IMAGE = DockerImageName.parse("redis:7.4-alpine"); + + /** + * 운영과 같은 설정으로 띄운다. 기본 설정으로 띄우면 파일만 검사하는 + * 테스트가 되고, 실제로 도는 레디스의 정책은 아무도 안 본다. + */ + @SuppressWarnings("resource") // JVM 종료까지 살려 둔다 — 재사용이 목적이다 + static final GenericContainer REDIS = new GenericContainer<>(IMAGE) + .withExposedPorts(6379) + .withCopyFileToContainer( + MountableFile.forHostPath("docker/redis.conf"), "/etc/redis/redis.conf") + .withCommand("redis-server", "/etc/redis/redis.conf"); + + static { + REDIS.start(); + } + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS::getHost); + registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379)); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/RedisContainerTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/RedisContainerTest.java new file mode 100644 index 00000000..894b2866 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/RedisContainerTest.java @@ -0,0 +1,52 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; + +/** + * 실물 레디스에 붙는다. + * + *

인메모리 대역으로는 Lua 의 복제 동작도 시계도 확인할 수 없다 (TS-3). + * 이 페이즈가 지키려는 것이 정확히 그 둘이라 대역을 쓰지 않는다. + */ +@Tag("integration") +@SpringBootTest +class RedisContainerTest extends RedisContainerSupport { + + @Autowired + private ReactiveStringRedisTemplate redis; + + @Test + @DisplayName("컨테이너가_뜨고_PING에_응답한다") + void 컨테이너가_뜨고_PING에_응답한다() { + String pong = redis.getConnectionFactory().getReactiveConnection() + .ping().block(Duration.ofSeconds(5)); + + assertThat(pong).isEqualTo("PONG"); + } + + @Test + @DisplayName("이미지_태그가_7_이상이다") + void 이미지_태그가_7_이상이다() { + // TIME 과 효과 기반 복제를 쓴다. 낮은 판에서는 조용히 다르게 동작한다. + String tag = IMAGE.getVersionPart(); + int major = Integer.parseInt(tag.split("[.-]")[0]); + + assertThat(major).isGreaterThanOrEqualTo(7); + } + + @Test + @DisplayName("쓰고_읽으면_같은_값이_나온다") + void 쓰고_읽으면_같은_값이_나온다() { + redis.opsForValue().set("probe", "1").block(Duration.ofSeconds(5)); + + assertThat(redis.opsForValue().get("probe").block(Duration.ofSeconds(5))).isEqualTo("1"); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/RedisKeysTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/RedisKeysTest.java new file mode 100644 index 00000000..b80547ef --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/RedisKeysTest.java @@ -0,0 +1,103 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 키 이름은 여기서만 만든다 (RD-3). + * + *

키가 두 곳에서 만들어지면 샤딩을 도입할 때 한쪽만 고쳐진다. 그때는 + * 진행 중인 큐가 통째로 유실된다. + */ +class RedisKeysTest { + + @Test + @DisplayName("전역_키_문자열이_고정되어_있다") + void 전역_키_문자열이_고정되어_있다() { + // 이름이 바뀌면 배포 중 두 판이 서로 다른 키를 본다. + assertThat(RedisKeys.SNAPSHOT).isEqualTo("gw:snapshot"); + assertThat(RedisKeys.INSTANCES).isEqualTo("gw:instances"); + assertThat(RedisKeys.LEADER).isEqualTo("scheduler:leader"); + assertThat(RedisKeys.TUNABLES).isEqualTo("gw:tunables"); + assertThat(RedisKeys.ACTIVE_COUPONS).isEqualTo("coupons:active"); + assertThat(RedisKeys.COUPON_POLICY).isEqualTo("coupon:policy"); + } + + @Test + @DisplayName("샤드가_하나면_접미사가_붙지_않는다") + void 샤드가_하나면_접미사가_붙지_않는다() { + // 붙였다 떼면 샤딩 도입 순간 콜드 쿠폰 전체의 키가 갈린다. + assertThat(RedisKeys.queue("c1", 1, 0)).isEqualTo("queue:{c1}"); + assertThat(RedisKeys.maxScore("c1", 1, 0)).isEqualTo("maxscore:{c1}"); + assertThat(RedisKeys.admitted("c1", 1, 0)).isEqualTo("admitted:{c1}"); + assertThat(RedisKeys.grace("c1", 1, 0)).isEqualTo("grace:{c1}"); + assertThat(RedisKeys.alive("c1", 1, 0)).isEqualTo("alive:{c1}"); + } + + @Test + @DisplayName("샤드가_여럿이면_접미사가_붙는다") + void 샤드가_여럿이면_접미사가_붙는다() { + assertThat(RedisKeys.queue("c1", 4, 3)).isEqualTo("queue:{c1:3}"); + assertThat(RedisKeys.maxScore("c1", 4, 3)).isEqualTo("maxscore:{c1:3}"); + assertThat(RedisKeys.alive("c1", 4, 3)).isEqualTo("alive:{c1:3}"); + } + + @Test + @DisplayName("같은_쿠폰의_키가_모두_같은_해시태그를_쓴다") + void 같은_쿠폰의_키가_모두_같은_해시태그를_쓴다() { + // 같은 샤드의 키가 같은 슬롯에 모여야 하나의 Lua 가 원자적으로 다룬다 (RD-2). + List keys = List.of( + RedisKeys.queue("c1", 4, 3), + RedisKeys.maxScore("c1", 4, 3), + RedisKeys.admitted("c1", 4, 3), + RedisKeys.grace("c1", 4, 3), + RedisKeys.alive("c1", 4, 3)); + + assertThat(keys).allMatch(k -> RedisKeys.hashTagOf(k).equals("c1:3")); + } + + @Test + @DisplayName("재고_키는_샤드와_무관하다") + void 재고_키는_샤드와_무관하다() { + // 발급 계층이 소유한다. 샤딩 시 슬롯이 갈리므로 Lua 에서 만지지 않는다. + assertThat(RedisKeys.stock("c1")).isEqualTo("stock:{c1}"); + } + + @Test + @DisplayName("쿠폰_식별자에_해시태그_문자가_있으면_거부한다") + void 쿠폰_식별자에_해시태그_문자가_있으면_거부한다() { + // 클라이언트 입력이 키 이름에 들어가는 경로는 전부 의심한다 (PK-R5). + // 중괄호가 섞이면 슬롯이 엉뚱한 곳으로 가고 Lua 가 거부된다. + assertThatThrownBy(() -> RedisKeys.queue("c{1}", 1, 0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> RedisKeys.queue("c:1", 1, 0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> RedisKeys.alive("c{1}", 1, 0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> RedisKeys.stock("c:1")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("빈_식별자는_거부한다") + void 빈_식별자는_거부한다() { + assertThatThrownBy(() -> RedisKeys.queue("", 1, 0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> RedisKeys.queue(null, 1, 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("샤드_번호가_범위를_벗어나면_거부한다") + void 샤드_번호가_범위를_벗어나면_거부한다() { + // 범위를 넘으면 아무도 안 보는 키가 생기고 그 큐는 영영 안 빠진다. + assertThatThrownBy(() -> RedisKeys.queue("c1", 4, 4)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> RedisKeys.queue("c1", 4, -1)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/RedisWiringTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/RedisWiringTest.java new file mode 100644 index 00000000..b959139f --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/RedisWiringTest.java @@ -0,0 +1,44 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.lettuce.core.RedisClient; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; + +/** + * 제어 평면이 레디스를 칠 수 있는지 본다. + * + *

요청 경로는 레디스를 치지 않지만(RD-4) 스케줄러와 큐는 쳐야 한다. + * 배선이 없으면 Phase 4 착수 시점에야 드러난다. + */ +@SpringBootTest +class RedisWiringTest { + + @Autowired + private ReactiveRedisConnectionFactory connectionFactory; + + @Test + @DisplayName("리액티브_커넥션_팩토리가_설정된_주소를_갖는다") + void 리액티브_커넥션_팩토리가_설정된_주소를_갖는다() { + // 빈이 있다는 것만으로는 부족하다. 주소가 안 잡히면 첫 명령에서야 터진다. + assertThat(connectionFactory).isInstanceOf(LettuceConnectionFactory.class); + + LettuceConnectionFactory lettuce = (LettuceConnectionFactory) connectionFactory; + assertThat(lettuce.getHostName()).isNotBlank(); + assertThat(lettuce.getPort()).isPositive(); + } + + @Test + @DisplayName("드라이버는_Lettuce다") + void 드라이버는_Lettuce다() { + // Jedis 는 블로킹이라 이 프로젝트에서 쓸 수 없다 (RX-1). + // 실수로 바뀌면 요청 경로가 아니라 스케줄러가 먼저 멎는다. + assertThat(connectionFactory.getClass().getName()).contains("Lettuce"); + assertThat(RedisClient.class.getPackageName()).isEqualTo("io.lettuce.core"); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/ReplicaPromotionTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/ReplicaPromotionTest.java new file mode 100644 index 00000000..1d9c263d --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/ReplicaPromotionTest.java @@ -0,0 +1,177 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.ScriptOutputType; +import io.lettuce.core.api.StatefulRedisConnection; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; + +/** + * 시계가 뒤처진 복제본을 승격해도 순서가 역행하지 않는다 (G3.10 · C13). + * + *

score 가 벽시계라(A-9) 승격된 복제본의 시계가 뒤처져 있으면 그 구간 + * 전체가 한꺼번에 추월당한다. {@code maxscore} 바닥값이 이걸 막는다 — + * 같은 Lua 안에서 갱신되므로 ZSET 과 함께 복제된다 (2.1절). + */ +@Tag("chaos") +class ReplicaPromotionTest { + + private static final String COUPON = "c1"; + private static final String QUEUE = RedisKeys.queue(COUPON, 1, 0); + private static final String MAX_SCORE = RedisKeys.maxScore(COUPON, 1, 0); + private static final String ALIVE = RedisKeys.alive(COUPON, 1, 0); + + private static final long NOW = 1_800_000_000L; + private static final int BEFORE = 100; + private static final int AFTER = 50; + + private final List> 띄운것 = new ArrayList<>(); + private final List 클라이언트 = new ArrayList<>(); + private final List> 연결 = new ArrayList<>(); + private Network network; + + /** + * 연결 → 클라이언트 → 컨테이너 순으로 닫는다. {@link RedisClient} 는 + * 제 Netty 이벤트 루프를 만들어서, {@code shutdown()} 없이는 그 스레드가 + * JVM 종료까지 남는다. + */ + @AfterEach + void 정리() { + 연결.forEach(StatefulRedisConnection::close); + 클라이언트.forEach(RedisClient::shutdown); + 띄운것.forEach(GenericContainer::stop); + 연결.clear(); + 클라이언트.clear(); + 띄운것.clear(); + if (network != null) { + network.close(); + } + } + + @SuppressWarnings("resource") // 정리()가 닫는다 + private GenericContainer 레디스(String alias) { + GenericContainer container = new GenericContainer<>(RedisContainerSupport.IMAGE) + .withExposedPorts(6379) + .withNetwork(network) + .withNetworkAliases(alias) + .withCommand("redis-server", "--appendonly", "no"); + container.start(); + 띄운것.add(container); + return container; + } + + private StatefulRedisConnection 붙는다(GenericContainer c) { + RedisClient client = RedisClient.create( + "redis://%s:%d".formatted(c.getHost(), c.getMappedPort(6379))); + 클라이언트.add(client); + StatefulRedisConnection connection = client.connect(); + 연결.add(connection); + return connection; + } + + /** {@code {score, floorApplied, alreadyQueued}}. score 는 문자열로 온다. */ + @SuppressWarnings("unchecked") + private static List 등록한다( + StatefulRedisConnection redis, String member) { + return (List) redis.sync().eval(LuaScripts.of("enqueue.lua"), + ScriptOutputType.MULTI, + new String[] {QUEUE, MAX_SCORE, ALIVE}, + member, "86400", "30", "0", String.valueOf(NOW)); + } + + /** 컨테이너의 시계. 호스트 시계를 못 돌리므로 여기서 기준을 얻는다. */ + private static long 지금_마이크로초(StatefulRedisConnection redis) { + List time = redis.sync().time(); + return Long.parseLong(time.get(0)) * 1_000_000L + Long.parseLong(time.get(1)); + } + + private static long score(List result) { + return Long.parseLong(result.get(0).toString()); + } + + private static boolean 바닥값이_적용됐나(List result) { + return ((Long) result.get(1)) == 1L; + } + + @Test + @DisplayName("시계가_뒤처진_복제본을_승격해도_순서가_역행하지_않는다") + void 시계가_뒤처진_복제본을_승격해도_순서가_역행하지_않는다() { + network = Network.newNetwork(); + GenericContainer primary = 레디스("primary"); + GenericContainer replica = 레디스("replica"); + + var 주 = 붙는다(primary); + var 복제 = 붙는다(replica); + 복제.sync().replicaof("primary", 6379); + + // **시계를 한 시간 앞세운다.** 컨테이너 시계는 호스트와 공유라 못 + // 돌린다. 하지만 승격된 복제본이 겪는 상태는 "제 시계가 뒤처졌다" 가 + // 아니라 관측 가능한 조건 하나다 — `TIME < maxscore`. 주가 앞선 + // 시계로 쌓은 큐를 물려받은 것과 같은 상태를 그대로 만든다. + // + // **컨테이너의 TIME 에서 유도한다.** 고정 시각을 박으면 실제 시각이 + // 그걸 지나는 날 바닥값이 안 걸리고, 시험은 조용히 아무것도 검증하지 + // 않게 된다 — 실패도 그날에야 난다. + long 미래 = 지금_마이크로초(주) + 3600L * 1_000_000L; + 주.sync().set(MAX_SCORE, String.valueOf(미래)); + + long 마지막 = 0; + for (int i = 0; i < BEFORE; i++) { + 마지막 = score(등록한다(주, "m" + i)); + } + // 앞세운 시계로 쌓였는지 확인한다. 아니면 이 시험은 평범한 큐를 본다. + assertThat(마지막).isGreaterThan(미래); + + // 복제가 따라잡아야 승격 후의 상태가 의미를 갖는다. + long 기대 = 마지막; + Awaitility.await().atMost(Duration.ofSeconds(20)) + .pollInterval(Duration.ofMillis(200)) + .until(() -> { + String floor = 복제.sync().get(MAX_SCORE); + return floor != null && Long.parseLong(floor) == 기대; + }); + + 복제.sync().replicaofNoOne(); + + List 역행 = new ArrayList<>(); + AtomicReference 직전 = new AtomicReference<>(마지막); + int 바닥값적용 = 0; + for (int i = 0; i < AFTER; i++) { + List result = 등록한다(복제, "post" + i); + long score = score(result); + if (바닥값이_적용됐나(result)) { + 바닥값적용++; + } + if (score <= 직전.get()) { + 역행.add("post%d score=%d ≤ 직전 %d".formatted(i, score, 직전.get())); + } + 직전.set(score); + } + + assertThat(역행) + .withFailMessage("승격 후 순서가 역행했다 %d 건%n%s", + 역행.size(), String.join("\n", 역행)) + .isEmpty(); + // 바닥값이 한 번도 안 걸렸다면 시계가 뒤처진 상태가 아니었던 것이고, + // 그러면 이 시험은 아무것도 검증하지 않았다. + assertThat(바닥값적용) + .withFailMessage("바닥값이 한 번도 안 걸렸다 — 뒤처진 시계를 재현하지 못했다") + .isEqualTo(AFTER); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/RuntimePersistenceTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/RuntimePersistenceTest.java new file mode 100644 index 00000000..ed3a144b --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/RuntimePersistenceTest.java @@ -0,0 +1,73 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.Container; + +/** + * 도는 레디스의 정책을 본다. + * + *

설정 파일만 검사하면 그 파일이 실제로 적용됐는지는 아무도 안 본다 — + * 파일은 맞는데 컨테이너가 기본 설정으로 뜨는 일이 실제로 있었다. + */ +@Tag("integration") +class RuntimePersistenceTest extends RedisContainerSupport { + + private String configOf(String key) throws IOException, InterruptedException { + Container.ExecResult result = REDIS.execInContainer("redis-cli", "CONFIG", "GET", key); + String[] lines = result.getStdout().strip().split("\\R"); + // CONFIG GET 은 이름과 값을 줄로 번갈아 낸다 + return lines.length >= 2 ? lines[1].strip() : ""; + } + + @Test + @DisplayName("도는_레디스가_초당_동기화다") + void 도는_레디스가_초당_동기화다() throws IOException, InterruptedException { + assertThat(configOf("appendonly")).isEqualTo("yes"); + assertThat(configOf("appendfsync")).isEqualTo("everysec"); + } + + @Test + @DisplayName("도는_레디스가_조용히_지우지_않는다") + void 도는_레디스가_조용히_지우지_않는다() throws IOException, InterruptedException { + assertThat(configOf("maxmemory-policy")).isEqualTo("noeviction"); + } + + @Test + @DisplayName("도는_레디스에_메모리_상한이_있다") + void 도는_레디스에_메모리_상한이_있다() throws IOException, InterruptedException { + // 0 이면 무제한이라 noeviction 이 아무것도 안 막는다. + assertThat(Long.parseLong(configOf("maxmemory"))).isPositive(); + } + + @Test + @DisplayName("도는_레디스가_재작성_중에도_동기화를_멈추지_않는다") + void 도는_레디스가_재작성_중에도_동기화를_멈추지_않는다() + throws IOException, InterruptedException { + assertThat(configOf("no-appendfsync-on-rewrite")).isEqualTo("no"); + } + + @Test + @DisplayName("설정_파일과_도는_값이_일치한다") + void 설정_파일과_도는_값이_일치한다() throws IOException, InterruptedException { + // 둘이 갈라지면 어느 쪽을 믿어야 할지 알 수 없다. + Map expected = Map.of( + "appendonly", "yes", + "appendfsync", "everysec", + "maxmemory-policy", "noeviction", + "no-appendfsync-on-rewrite", "no"); + + for (Map.Entry entry : expected.entrySet()) { + assertThat(configOf(entry.getKey())) + .withFailMessage( + "%s: 파일은 %s 인데 도는 값은 %s", + entry.getKey(), entry.getValue(), configOf(entry.getKey())) + .isEqualTo(entry.getValue()); + } + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/ShardDistributionTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/ShardDistributionTest.java new file mode 100644 index 00000000..7eedeab1 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/ShardDistributionTest.java @@ -0,0 +1,53 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 샤드 분포가 쏠리면 샤딩의 이득이 사라진다. + * + *

한 샤드만 붐비면 그 큐의 Lua 가 병목이 되고, 나머지 샤드는 놀면서 + * 레디스 커넥션만 차지한다. + */ +class ShardDistributionTest { + + private static final int SHARDS = 16; + private static final int SAMPLES = 100_000; + + @Test + @DisplayName("무작위_10만건에서_샤드별_편차가_5퍼센트_미만이다") + void 무작위_10만건에서_샤드별_편차가_5퍼센트_미만이다() { + int[] counts = new int[SHARDS]; + for (int i = 0; i < SAMPLES; i++) { + counts[ShardHash.shardOf("member-" + i, SHARDS)]++; + } + + double expected = (double) SAMPLES / SHARDS; + for (int shard = 0; shard < SHARDS; shard++) { + double deviation = Math.abs(counts[shard] - expected) / expected; + assertThat(deviation) + .withFailMessage( + "샤드 %d 편차 %.2f%% (기대 %.0f · 실제 %d)", + shard, deviation * 100, expected, counts[shard]) + .isLessThan(0.05); + } + } + + @Test + @DisplayName("UUID_형식_식별자에서도_고르게_퍼진다") + void UUID_형식_식별자에서도_고르게_퍼진다() { + // 실제 memberId 가 어떤 형식일지 모른다. 접두사가 같고 뒤만 다른 + // 경우가 가장 쏠리기 쉬워서 그 형태로도 본다. + int[] counts = new int[SHARDS]; + for (int i = 0; i < SAMPLES; i++) { + counts[ShardHash.shardOf("550e8400-e29b-41d4-a716-%012d".formatted(i), SHARDS)]++; + } + + double expected = (double) SAMPLES / SHARDS; + for (int shard = 0; shard < SHARDS; shard++) { + assertThat(Math.abs(counts[shard] - expected) / expected).isLessThan(0.05); + } + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/ShardHashTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/ShardHashTest.java new file mode 100644 index 00000000..5e4b90bd --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/ShardHashTest.java @@ -0,0 +1,69 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 샤드 배정은 반드시 sticky 여야 한다 (E-7). + * + *

같은 사람이 틱마다 다른 큐에 서면 순위가 앞뒤로 튀고, 불변식 3(순번 역행 0)이 + * 깨진다. 그래서 JVM 해시에 기대지 않고 CRC16 을 직접 구현한다. + */ +class ShardHashTest { + + @Test + @DisplayName("같은_memberId는_항상_같은_샤드로_간다") + void 같은_memberId는_항상_같은_샤드로_간다() { + int first = ShardHash.shardOf("member-42", 16); + + for (int i = 0; i < 1000; i++) { + assertThat(ShardHash.shardOf("member-42", 16)).isEqualTo(first); + } + } + + @Test + @DisplayName("JVM_해시에_의존하지_않는다") + void JVM_해시에_의존하지_않는다() { + // String.hashCode() 는 판이 바뀌면 값이 달라질 수 있다. 그 순간 + // 전원이 다른 샤드로 옮겨 가고 진행 중인 큐가 통째로 어긋난다. + // CRC16-CCITT(XMODEM) 의 알려진 값으로 못 박는다. + assertThat(ShardHash.crc16("")).isZero(); + assertThat(ShardHash.crc16("123456789")).isEqualTo(0x31C3); + assertThat(ShardHash.crc16("A")).isEqualTo(0x58E5); + } + + @Test + @DisplayName("샤드_수가_1이면_항상_0이다") + void 샤드_수가_1이면_항상_0이다() { + assertThat(ShardHash.shardOf("무엇이든", 1)).isZero(); + assertThat(ShardHash.shardOf("다른값", 1)).isZero(); + } + + @Test + @DisplayName("결과는_항상_범위_안이다") + void 결과는_항상_범위_안이다() { + // 범위를 벗어나면 아무도 안 보는 키가 생기고 그 큐는 영영 안 빠진다. + for (int i = 0; i < 5000; i++) { + assertThat(ShardHash.shardOf("m" + i, 16)).isBetween(0, 15); + } + } + + @Test + @DisplayName("샤드_수가_1미만이면_거부한다") + void 샤드_수가_1미만이면_거부한다() { + assertThatThrownBy(() -> ShardHash.shardOf("m1", 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("빈_식별자는_거부한다") + void 빈_식별자는_거부한다() { + assertThatThrownBy(() -> ShardHash.shardOf(null, 4)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ShardHash.shardOf("", 4)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/kafkick/waiting/adapter/redis/SweepTest.java b/src/test/java/com/kafkick/waiting/adapter/redis/SweepTest.java new file mode 100644 index 00000000..5af8ba83 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/adapter/redis/SweepTest.java @@ -0,0 +1,271 @@ +package com.kafkick.waiting.adapter.redis; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +/** + * 이탈자 청소. + * + *

앞부분만 훑는다. 2만 명 큐에서 전체를 보면 청소 자체가 부하다. + * 뒤엣사람은 아직 폴링할 차례가 안 왔을 뿐 죽은 것이 아니다. + */ +@Tag("integration") +@SpringBootTest +class SweepTest extends RedisContainerSupport { + + private static final Duration WAIT = Duration.ofSeconds(5); + private static final String COUPON = "sweep"; + private static final String QUEUE = RedisKeys.queue(COUPON, 1, 0); + private static final String MAX_SCORE = RedisKeys.maxScore(COUPON, 1, 0); + private static final String GRACE = RedisKeys.grace(COUPON, 1, 0); + private static final String ALIVE = RedisKeys.alive(COUPON, 1, 0); + + /** 시각을 주입한다 — 실제 시계에 기대면 만료 시험이 흔들린다 (TS-4). */ + private static final long NOW = 1_800_000_000L; + private static final String RETENTION = "300"; + private static final String BUDGET = "1000"; + + @Autowired + private ReactiveStringRedisTemplate redis; + + private RedisScript enqueueScript; + private RedisScript sweepScript; + + @BeforeEach + void 준비() { + enqueueScript = RedisScript.of(new ClassPathResource("redis/enqueue.lua"), List.class); + sweepScript = RedisScript.of(new ClassPathResource("redis/sweep.lua"), List.class); + redis.delete(QUEUE, MAX_SCORE, GRACE, ALIVE).block(WAIT); + } + + private void enqueue(String memberId) { + redis.execute(enqueueScript, + List.of(QUEUE, MAX_SCORE, ALIVE), + List.of(memberId, "86400", "3600", "0", String.valueOf(NOW))) + .blockFirst(WAIT); + } + + @SuppressWarnings("unchecked") + private List sweep(String limit, String budget, String cursor) { + return (List) redis.execute( + sweepScript, + List.of(QUEUE, GRACE, ALIVE), + List.of(limit, String.valueOf(NOW), RETENTION, budget, cursor)) + .blockFirst(WAIT); + } + + private List sweep(String limit) { + return sweep(limit, BUDGET, "0"); + } + + private String nextCursor(List r) { + return String.valueOf(r.get(3)); + } + + private long swept(List r) { + return Long.parseLong(String.valueOf(r.get(0))); + } + + /** 유예 기록 정리 수. 반환값 두 번째는 생존 신호 정리 수다. */ + private long expired(List r) { + return Long.parseLong(String.valueOf(r.get(2))); + } + + @Test + @DisplayName("생존_키가_없는_앞부분_항목이_제거된다") + void 생존_키가_없는_앞부분_항목이_제거된다() { + enqueue("m0"); + enqueue("m1"); + enqueue("m2"); + double keptScore = redis.opsForZSet().score(QUEUE, "m2").block(WAIT); + redis.opsForZSet().remove(ALIVE, "m0", "m1").block(WAIT); + + assertThat(swept(sweep("10"))).isEqualTo(2); + + // 크기만 보면 엉뚱한 사람을 지운 구현도 통과한다. 누가 빠지고 + // 누가 남았는지를 직접 본다. + assertThat(redis.opsForZSet().score(QUEUE, "m0").block(WAIT)).isNull(); + assertThat(redis.opsForZSet().score(QUEUE, "m1").block(WAIT)).isNull(); + assertThat(redis.opsForZSet().score(QUEUE, "m2").block(WAIT)).isEqualTo(keptScore); + } + + @Test + @DisplayName("생존_키가_있으면_건드리지_않는다") + void 생존_키가_있으면_건드리지_않는다() { + enqueue("m0"); + enqueue("m1"); + + assertThat(swept(sweep("10"))).isZero(); + assertThat(redis.opsForZSet().size(QUEUE).block(WAIT)).isEqualTo(2); + } + + @Test + @DisplayName("검사_범위_밖은_보지_않는다") + void 검사_범위_밖은_보지_않는다() { + for (int i = 0; i < 5; i++) { + enqueue("m" + i); + redis.opsForZSet().remove(ALIVE, "m" + i).block(WAIT); + } + + double kept2 = redis.opsForZSet().score(QUEUE, "m2").block(WAIT); + double kept4 = redis.opsForZSet().score(QUEUE, "m4").block(WAIT); + + assertThat(swept(sweep("2"))).isEqualTo(2); + + // 앞 둘만 빠지고 범위 밖은 **순번까지 그대로** 남는다 + assertThat(redis.opsForZSet().score(QUEUE, "m0").block(WAIT)).isNull(); + assertThat(redis.opsForZSet().score(QUEUE, "m1").block(WAIT)).isNull(); + assertThat(redis.opsForZSet().score(QUEUE, "m2").block(WAIT)).isEqualTo(kept2); + assertThat(redis.opsForZSet().score(QUEUE, "m4").block(WAIT)).isEqualTo(kept4); + } + + @Test + @DisplayName("검사_범위가_인자로_주어진다") + void 검사_범위가_인자로_주어진다() { + // 부하와 정확도의 맞바꿈이라 배포 없이 조절할 수 있어야 한다 (P-1). + for (int i = 0; i < 5; i++) { + enqueue("m" + i); + redis.opsForZSet().remove(ALIVE, "m" + i).block(WAIT); + } + + assertThat(swept(sweep("1"))).isOne(); + assertThat(swept(sweep("4"))).isEqualTo(4); + } + + @Test + @DisplayName("제거된_사람이_유예_기록에_남는다") + void 제거된_사람이_유예_기록에_남는다() { + // 제거와 기록이 갈리면 자리도 잃고 재방문자로도 식별 안 되는 + // 사람이 생긴다. 같은 스크립트 안에서 한다. + enqueue("m0"); + redis.opsForZSet().remove(ALIVE, "m0").block(WAIT); + + sweep("10"); + + assertThat(redis.opsForHash().get(GRACE, "m0").block(WAIT)) + .isEqualTo(String.valueOf(NOW)); + } + + @Test + @DisplayName("만료된_유예_기록이_정리된다") + void 만료된_유예_기록이_정리된다() { + redis.opsForHash().put(GRACE, "old", String.valueOf(NOW - 400)).block(WAIT); + redis.opsForHash().put(GRACE, "fresh", String.valueOf(NOW - 100)).block(WAIT); + + assertThat(expired(sweep("10"))).isOne(); + assertThat(redis.opsForHash().hasKey(GRACE, "old").block(WAIT)).isFalse(); + assertThat(redis.opsForHash().hasKey(GRACE, "fresh").block(WAIT)).isTrue(); + } + + @Test + @DisplayName("유예_기록이_무한히_쌓이지_않는다") + void 유예_기록이_무한히_쌓이지_않는다() { + // 만료가 없으면 이 해시가 영원히 자란다 (RD-7). + for (int i = 0; i < 50; i++) { + redis.opsForHash().put(GRACE, "old" + i, String.valueOf(NOW - 1000)).block(WAIT); + } + + sweep("10"); + + assertThat(redis.opsForHash().size(GRACE).block(WAIT)).isZero(); + } + + @Test + @DisplayName("값이_깨진_유예_기록도_정리된다") + void 값이_깨진_유예_기록도_정리된다() { + // 숫자가 아니면 언제 것인지 알 수 없다. 남겨 두면 영원히 안 지워진다. + redis.opsForHash().put(GRACE, "broken", "언제인지모름").block(WAIT); + + assertThat(expired(sweep("10"))).isOne(); + assertThat(redis.opsForHash().hasKey(GRACE, "broken").block(WAIT)).isFalse(); + } + + @Test + @DisplayName("잘못된_인자는_아무것도_바꾸지_않는다") + void 잘못된_인자는_아무것도_바꾸지_않는다() { + enqueue("m0"); + double before = redis.opsForZSet().score(QUEUE, "m0").block(WAIT); + redis.opsForZSet().remove(ALIVE, "m0").block(WAIT); + + assertThatThrownBy(() -> sweep("0")).rootCause().hasMessageContaining("검사 범위"); + + assertThat(redis.opsForZSet().score(QUEUE, "m0").block(WAIT)).isEqualTo(before); + assertThat(redis.opsForHash().size(GRACE).block(WAIT)).isZero(); + } + + @Test + @DisplayName("정리_예산이_한_번의_실행을_묶는다") + void 정리_예산이_한_번의_실행을_묶는다() { + // COUNT 는 힌트지 상한이 아니다. 받은 것 중 예산만큼만 지워야 + // 한 번의 실행이 유계다. + for (int i = 0; i < 40; i++) { + redis.opsForHash().put(GRACE, "old" + i, String.valueOf(NOW - 1000)).block(WAIT); + } + + assertThat(expired(sweep("10", "5", "0"))).isEqualTo(5); + assertThat(redis.opsForHash().size(GRACE).block(WAIT)).isEqualTo(35); + } + + @Test + @DisplayName("커서를_이어_넘기면_전부_정리된다") + void 커서를_이어_넘기면_전부_정리된다() { + // 한 번에 다 안 지우는 대신 다음 틱이 이어받는다. 커서가 돌지 + // 않으면 같은 앞부분만 계속 보고 뒤는 영영 안 지워진다. + for (int i = 0; i < 40; i++) { + redis.opsForHash().put(GRACE, "old" + i, String.valueOf(NOW - 1000)).block(WAIT); + } + + String cursor = "0"; + for (int round = 0; round < 20; round++) { + cursor = nextCursor(sweep("10", "5", cursor)); + if (Boolean.TRUE.equals(redis.opsForHash().size(GRACE).block(WAIT) == 0L)) { + break; + } + } + + assertThat(redis.opsForHash().size(GRACE).block(WAIT)).isZero(); + } + + @Test + @DisplayName("잘못된_커서는_아무것도_바꾸지_않는다") + void 잘못된_커서는_아무것도_바꾸지_않는다() { + // 커서 검증이 쓰기 뒤에 있으면 앞의 쓰기가 남는다 — Lua 는 + // 롤백하지 않는다. + enqueue("m0"); + double before = redis.opsForZSet().score(QUEUE, "m0").block(WAIT); + redis.opsForZSet().remove(ALIVE, "m0").block(WAIT); + redis.opsForHash().put(GRACE, "keep", String.valueOf(NOW)).block(WAIT); + + assertThatThrownBy(() -> sweep("10", "5", "abc")).rootCause() + .hasMessageContaining("커서"); + + assertThat(redis.opsForZSet().score(QUEUE, "m0").block(WAIT)).isEqualTo(before); + assertThat(redis.opsForHash().hasKey(GRACE, "keep").block(WAIT)).isTrue(); + } + + @Test + @DisplayName("만료된_생존_신호도_예산_안에서_걷는다") + void 만료된_생존_신호도_예산_안에서_걷는다() { + // 한 번에 다 지우려 하면 그 자체가 오래 걸린다. + for (int i = 0; i < 20; i++) { + redis.opsForZSet().add(ALIVE, "gone" + i, NOW - 100).block(WAIT); + } + + List result = sweep("1", "5", "0"); + + assertThat(Long.parseLong(String.valueOf(result.get(1)))).isEqualTo(5); + assertThat(redis.opsForZSet().size(ALIVE).block(WAIT)).isEqualTo(15); + } +} diff --git a/src/test/java/com/kafkick/waiting/architecture/DomainPurityTest.java b/src/test/java/com/kafkick/waiting/architecture/DomainPurityTest.java new file mode 100644 index 00000000..daf2665a --- /dev/null +++ b/src/test/java/com/kafkick/waiting/architecture/DomainPurityTest.java @@ -0,0 +1,118 @@ +package com.kafkick.waiting.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static org.assertj.core.api.Assertions.assertThat; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 도메인 순수성 (DS-1). + * + *

순수해야 브랜치 100% 와 뮤테이션 테스트가 가능하다. 한 번 깨지면 조용히 + * 번지고, 그때는 되돌리는 비용이 만들 때보다 훨씬 크다. + */ +class DomainPurityTest { + + private static final String DOMAIN = "com.kafkick.waiting.domain.."; + + private static JavaClasses classes; + + @BeforeAll + static void 클래스를_읽는다() { + classes = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("com.kafkick.waiting"); + } + + @Test + @DisplayName("도메인은_Spring을_참조하지_않는다") + void 도메인은_Spring을_참조하지_않는다() { + noClasses() + .that().resideInAPackage(DOMAIN) + .should().dependOnClassesThat().resideInAnyPackage("org.springframework..") + .because("도메인이 프레임워크를 알면 순수 단위 테스트가 불가능해진다") + .check(classes); + } + + @Test + @DisplayName("도메인은_Redis를_참조하지_않는다") + void 도메인은_Redis를_참조하지_않는다() { + noClasses() + .that().resideInAPackage(DOMAIN) + .should().dependOnClassesThat() + .resideInAnyPackage("io.lettuce..", "redis..", "org.redisson..") + .because("포트는 도메인이 정의하고 어댑터가 구현한다 (DS-6)") + .check(classes); + } + + @Test + @DisplayName("도메인은_시계를_직접_읽지_않는다") + void 도메인은_시계를_직접_읽지_않는다() { + noClasses() + .that().resideInAPackage(DOMAIN) + .should().callMethod(System.class, "currentTimeMillis") + .orShould().callMethod(System.class, "nanoTime") + .orShould().callMethod(Instant.class, "now") + .orShould().callMethod(LocalDate.class, "now") + .orShould().callMethod(LocalDateTime.class, "now") + .orShould().callMethod(ZonedDateTime.class, "now") + .orShould().dependOnClassesThat().haveFullyQualifiedName("java.time.Clock") + .because("시각을 직접 읽으면 초 경계 동작을 시험할 수 없다 (TS-4)") + .check(classes); + } + + @Test + @DisplayName("도메인은_난수를_직접_만들지_않는다") + void 도메인은_난수를_직접_만들지_않는다() { + noClasses() + .that().resideInAPackage(DOMAIN) + .should().callMethod(Math.class, "random") + .because("난수원을 주입받아야 재현 가능한 실패를 만들 수 있다 (DS-1)") + .check(classes); + } + + @Test + @DisplayName("도메인은_리액터를_참조하지_않는다") + void 도메인은_리액터를_참조하지_않는다() { + noClasses() + .that().resideInAPackage(DOMAIN) + .should().dependOnClassesThat().resideInAnyPackage("reactor..") + .because("판정은 동기 계산이다. 리액티브 타입이 섞이면 시험이 어려워진다") + .check(classes); + } + + @Test + @DisplayName("도메인은_바깥_계층을_참조하지_않는다") + void 도메인은_바깥_계층을_참조하지_않는다() { + // 의존은 안에서 밖으로 흐르지 않는다. 어댑터가 도메인을 알지, + // 도메인이 어댑터를 알면 판정을 시험하려고 웹 서버를 띄워야 한다. + noClasses() + .that().resideInAPackage(DOMAIN) + .should().dependOnClassesThat() + .resideInAnyPackage( + "com.kafkick.waiting.adapter..", + "com.kafkick.waiting.api..", + "com.kafkick.waiting.config..") + .because("의존은 바깥에서 안으로만 흐른다 (DS-6)") + .check(classes); + } + + @Test + @DisplayName("모든_클래스가_루트_패키지_아래에_있다") + void 모든_클래스가_루트_패키지_아래에_있다() { + // 루트 밖으로 새면 ArchUnit 도 JaCoCo 도 그 클래스를 안 본다. + // 검사에서 빠진 코드는 검사가 없는 것과 같다. + assertThat(classes) + .allSatisfy(c -> assertThat(c.getPackageName()) + .startsWith("com.kafkick.waiting")); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/admission/AdmissionDeciderTest.java b/src/test/java/com/kafkick/waiting/domain/admission/AdmissionDeciderTest.java new file mode 100644 index 00000000..c1979a45 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/admission/AdmissionDeciderTest.java @@ -0,0 +1,295 @@ +package com.kafkick.waiting.domain.admission; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.kafkick.waiting.domain.coupon.CouponState; +import com.kafkick.waiting.domain.coupon.CouponStates; +import com.kafkick.waiting.domain.coupon.SnapshotMeta; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 판정 사다리 — 순서가 곧 정책이다. + * + *

각 줄에는 앞줄보다 먼저 와야 하는 이유가 있다. 이전 구현이 무너진 곳도 + * 정확히 여기였다. + */ +class AdmissionDeciderTest { + + private static final SnapshotMeta META = new SnapshotMeta(1000, 10); + private static final double IDLE_RATIO = 0.7; + + private AdmissionDecider decider() { + return AdmissionDecider.of(SecondWindowLimiter.withMaxKeys(1000), IDLE_RATIO); + } + + private AdmissionRequest request(CouponState state) { + return new AdmissionRequest("c1", state, META, false, false, false, 0, 100); + } + + @Test + @DisplayName("재고가_없으면_스냅샷이_낡아도_매진으로_종결한다") + void 재고가_없으면_스냅샷이_낡아도_매진으로_종결한다() { + // 1번이 맨 앞이어야 한다. dataStale 뒤에 두면 매진 쿠폰이 + // fail-open 상한을 갉아먹는다. + AdmissionRequest req = request(CouponStates.closed(100)).withDataStale(true); + + assertThat(decider().decide(req)).isEqualTo(AdmissionDecision.REJECT_SOLD_OUT); + } + + @Test + @DisplayName("토큰을_든_사람은_상태와_무관하게_통과한다") + void 토큰을_든_사람은_상태와_무관하게_통과한다() { + AdmissionRequest req = request(CouponStates.queueing(100, 500, 3000)).withValidToken(true); + + assertThat(decider().decide(req)).isEqualTo(AdmissionDecision.PASS_TOKEN); + } + + @Test + @DisplayName("대기열이_꺼져_있으면_붐벼도_통과한다") + void 대기열이_꺼져_있으면_붐벼도_통과한다() { + assertThat(decider().decide(request(CouponStates.off(500)))) + .isEqualTo(AdmissionDecision.PASS_BYPASS); + } + + @Test + @DisplayName("스냅샷이_낡고_줄이_비어_있으면_상한_안에서_통과시킨다") + void 스냅샷이_낡고_줄이_비어_있으면_상한_안에서_통과시킨다() { + AdmissionRequest req = request(CouponStates.idle(500)).withDataStale(true); + + assertThat(decider().decide(req)).isEqualTo(AdmissionDecision.PASS_FAIL_OPEN); + } + + @Test + @DisplayName("스냅샷이_낡아도_줄_선_사람이_있으면_추월시키지_않는다") + void 스냅샷이_낡아도_줄_선_사람이_있으면_추월시키지_않는다() { + // F1 — 이전 구현이 무너진 지점. 상태를 모른다는 것이 추월의 사유가 아니다. + AdmissionRequest req = request(CouponStates.queueing(100, 500, 5000)).withDataStale(true); + + assertThat(decider().decide(req)).isEqualTo(AdmissionDecision.ENQUEUE_STALE); + } + + @Test + @DisplayName("큐가_꽉_차면_큐로_가는_경로보다_먼저_거절한다") + void 큐가_꽉_차면_큐로_가는_경로보다_먼저_거절한다() { + // credit 100 · maxEta 100 → 용량 10000. 그보다 많이 서 있다. + AdmissionRequest req = request(CouponStates.queueing(100, 500, 20_000)); + + assertThat(decider().decide(req)).isEqualTo(AdmissionDecision.REJECT_QUEUE_FULL); + } + + @Test + @DisplayName("항상_큐_모드는_한산해도_줄을_세운다") + void 항상_큐_모드는_한산해도_줄을_세운다() { + assertThat(decider().decide(request(CouponStates.always(500)))) + .isEqualTo(AdmissionDecision.ENQUEUE_ALWAYS); + } + + @Test + @DisplayName("이미_붐비면_뒤에_선다") + void 이미_붐비면_뒤에_선다() { + assertThat(decider().decide(request(CouponStates.queueing(100, 500, 3000)))) + .isEqualTo(AdmissionDecision.ENQUEUE_BACKLOG); + } + + @Test + @DisplayName("방금_큐로_보냈으면_스냅샷이_IDLE이어도_막는다") + void 방금_큐로_보냈으면_스냅샷이_IDLE이어도_막는다() { + // 래치. 스냅샷이 따라잡기 전 한 틱 동안 추월이 생긴다. + AdmissionRequest req = request(CouponStates.idle(500)).withJustEnqueued(true); + + assertThat(decider().decide(req)).isEqualTo(AdmissionDecision.ENQUEUE_BACKLOG); + } + + @Test + @DisplayName("한산한_쿠폰은_credit이_0이어도_대기열_없이_통과한다") + void 한산한_쿠폰은_credit이_0이어도_대기열_없이_통과한다() { + // R1 — 이 제품의 존재 이유. G2.1 이 판정하는 자리다. + CouponState idle = CouponStates.idle(500); + + assertThat(idle.credit()).isZero(); + assertThat(decider().decide(request(idle))).isEqualTo(AdmissionDecision.PASS_UNDER_CAP); + } + + @Test + @DisplayName("토큰을_들어도_노드_상한을_넘으면_큐가_아니라_재시도다") + void 토큰을_들어도_노드_상한을_넘으면_큐가_아니라_재시도다() { + // F8 — 축적된 토큰이 회복 직후 한꺼번에 들어온다. 그렇다고 큐 뒤로 + // 보내면 이미 차례가 온 사람의 허가가 "아마도" 가 된다. + AdmissionDecider d = decider(); + AdmissionRequest req = + request(CouponStates.queueing(100, 500, 3000)).withValidToken(true); + + for (int i = 0; i < 100; i++) { + assertThat(d.decide(req)).isEqualTo(AdmissionDecision.PASS_TOKEN); + } + + assertThat(d.decide(req)).isEqualTo(AdmissionDecision.RETRY_TOKEN); + } + + @Test + @DisplayName("낡은_상태의_fail_open도_상한을_넘으면_거절한다") + void 낡은_상태의_fail_open도_상한을_넘으면_거절한다() { + // 무제한 통과가 아니다. 상한이 없으면 fail-open 이 곧 전면 개방이다. + AdmissionDecider d = decider(); + AdmissionRequest req = request(CouponStates.idle(500)).withDataStale(true); + + for (int i = 0; i < 100; i++) { + assertThat(d.decide(req)).isEqualTo(AdmissionDecision.PASS_FAIL_OPEN); + } + + assertThat(d.decide(req)).isEqualTo(AdmissionDecision.REJECT_OVERLOAD); + } + + @Test + @DisplayName("노드_예산이_먼저_마르면_전역_사유로_큐에_간다") + void 노드_예산이_먼저_마르면_전역_사유로_큐에_간다() { + // 쿠폰 상한(70)보다 노드 상한이 작으면 전역이 먼저 마른다. + // 대응이 다르다 — 이때는 노드를 늘려야 한다. + AdmissionDecider d = AdmissionDecider.of(SecondWindowLimiter.withMaxKeys(1000), 5.0); + CouponState idle = CouponStates.idle(500); + + for (int i = 0; i < 100; i++) { + assertThat(d.decide(request(idle))).isEqualTo(AdmissionDecision.PASS_UNDER_CAP); + } + + assertThat(d.decide(request(idle))) + .isEqualTo(AdmissionDecision.ENQUEUE_RATE_GLOBAL); + } + + @Test + @DisplayName("한산한_쿠폰도_상한을_넘으면_초과분만_큐로_간다") + void 한산한_쿠폰도_상한을_넘으면_초과분만_큐로_간다() { + // globalCredit 1000 / 노드 10 × 0.7 = 70 이 상한이다. + AdmissionDecider d = decider(); + CouponState idle = CouponStates.idle(500); + + int passed = 0; + for (int i = 0; i < 200; i++) { + if (d.decide(request(idle)) == AdmissionDecision.PASS_UNDER_CAP) { + passed++; + } + } + + assertThat(passed).isEqualTo(70); + assertThat(d.decide(request(idle))).isEqualTo(AdmissionDecision.ENQUEUE_RATE_COUPON); + } + + @Test + @DisplayName("줄_길이가_큐_상한과_정확히_같으면_거절한다") + void 줄_길이가_큐_상한과_정확히_같으면_거절한다() { + // credit 100 · maxEta 100 → 용량 10000. 딱 그만큼 서 있다. + // 경계를 초과로만 잡으면 상한을 한 명씩 넘긴다. + AdmissionRequest req = request(CouponStates.queueing(100, 500, 10_000)); + + assertThat(decider().decide(req)).isEqualTo(AdmissionDecision.REJECT_QUEUE_FULL); + } + + @Test + @DisplayName("줄_길이가_큐_상한보다_하나_적으면_받는다") + void 줄_길이가_큐_상한보다_하나_적으면_받는다() { + AdmissionRequest req = request(CouponStates.queueing(100, 500, 9_999)); + + assertThat(decider().decide(req)).isEqualTo(AdmissionDecision.ENQUEUE_BACKLOG); + } + + @Test + @DisplayName("잘못된_설정은_만들_때_막는다") + void 잘못된_설정은_만들_때_막는다() { + // 비율은 10번 줄에서만 쓰인다. 여기서 안 막으면 잘못된 설정으로도 + // 토큰·bypass·fail-open 이 정상으로 돌아가다가, 한산한 쿠폰 요청 + // 하나에서 원인과 먼 곳에서 터진다. + SecondWindowLimiter limiter = SecondWindowLimiter.withMaxKeys(10); + + assertThatThrownBy(() -> AdmissionDecider.of(null, 0.7)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> AdmissionDecider.of(limiter, -0.1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> AdmissionDecider.of(limiter, Double.NaN)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("쿠폰_키가_전역_키와_같아도_예산이_합쳐지지_않는다") + void 쿠폰_키가_전역_키와_같아도_예산이_합쳐지지_않는다() { + // 접두사가 없으면 쿠폰 ID 하나가 전역 키와 같아지는 순간 두 예산이 + // 한 카운터로 합쳐져, 다른 쿠폰의 전역 트래픽이 이 쿠폰 몫을 먹는다. + AdmissionDecider d = decider(); + CouponState idle = CouponStates.idle(500); + AdmissionRequest req = + new AdmissionRequest("node:", idle, META, false, false, false, 0, 100); + + int passed = 0; + for (int i = 0; i < 200; i++) { + if (d.decide(req) == AdmissionDecision.PASS_UNDER_CAP) { + passed++; + } + } + + // 합쳐졌다면 min(70, 100) = 70 이 아니라 절반인 35 만 통과한다 + assertThat(passed).isEqualTo(70); + } + + @Test + @DisplayName("한산_비율_0은_유효한_설정이다") + void 한산_비율_0은_유효한_설정이다() { + // 운영자가 한산 통과를 완전히 잠그는 값이다. 거부하면 그 조작이 막힌다. + AdmissionDecider d = AdmissionDecider.of(SecondWindowLimiter.withMaxKeys(10), 0.0); + + assertThat(d.decide(request(CouponStates.idle(500)))) + .isEqualTo(AdmissionDecision.ENQUEUE_RATE_COUPON); + } + + @Test + @DisplayName("쿠폰이_다르면_유휴_예산도_따로다") + void 쿠폰이_다르면_유휴_예산도_따로다() { + // 키를 뭉개면 먼저 온 쿠폰이 전체 유휴 몫을 먹고 나머지가 굶는다. + AdmissionDecider d = decider(); + CouponState idle = CouponStates.idle(500); + AdmissionRequest first = + new AdmissionRequest("c1", idle, META, false, false, false, 0, 100); + AdmissionRequest second = + new AdmissionRequest("c2", idle, META, false, false, false, 0, 100); + + for (int i = 0; i < 70; i++) { + assertThat(d.decide(first)).isEqualTo(AdmissionDecision.PASS_UNDER_CAP); + } + + // c1 이 자기 몫을 다 썼어도 c2 는 아직 자기 몫이 남아 있다 + assertThat(d.decide(first)).isEqualTo(AdmissionDecision.ENQUEUE_RATE_COUPON); + assertThat(d.decide(second)).isEqualTo(AdmissionDecision.PASS_UNDER_CAP); + } + + @Test + @DisplayName("래치는_풀리면_무대기_통과를_되돌려준다") + void 래치는_풀리면_무대기_통과를_되돌려준다() { + // G2.17 — 래치가 죽은 분기를 만들면 그 노드에서 R1 이 영영 죽는다. + // 같은 상태에서 래치만 내리면 통과가 복귀해야 한다. + AdmissionDecider d = decider(); + AdmissionRequest latched = request(CouponStates.idle(500)).withJustEnqueued(true); + + assertThat(d.decide(latched)).isEqualTo(AdmissionDecision.ENQUEUE_BACKLOG); + assertThat(d.decide(latched.withJustEnqueued(false))) + .isEqualTo(AdmissionDecision.PASS_UNDER_CAP); + } + + @Test + @DisplayName("토큰_보유자는_쿠폰_상한이_말라도_통과한다") + void 토큰_보유자는_쿠폰_상한이_말라도_통과한다() { + // G2.14 — 배분 시점에 이미 크레딧을 썼다. 여기서 쿠폰 상한을 또 걸면 + // 차례가 온 사람이 자기 몫을 못 쓰고 되돌려진다. + AdmissionDecider d = decider(); + CouponState idle = CouponStates.idle(500); + + // 이 쿠폰의 유휴 몫(70)을 먼저 말린다 + for (int i = 0; i < 70; i++) { + assertThat(d.decide(request(idle))).isEqualTo(AdmissionDecision.PASS_UNDER_CAP); + } + assertThat(d.decide(request(idle))).isEqualTo(AdmissionDecision.ENQUEUE_RATE_COUPON); + + // 토큰을 든 사람은 그것과 무관하게 통과한다 + assertThat(d.decide(request(idle).withValidToken(true))) + .isEqualTo(AdmissionDecision.PASS_TOKEN); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/admission/AdmissionDecisionTest.java b/src/test/java/com/kafkick/waiting/domain/admission/AdmissionDecisionTest.java new file mode 100644 index 00000000..86bd122c --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/admission/AdmissionDecisionTest.java @@ -0,0 +1,62 @@ +package com.kafkick.waiting.domain.admission; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 판정값은 통과·큐·거절 중 하나로 분류된다. + * + *

값을 추가하면서 분류를 빠뜨리면 응답 작성이 조용히 기본값으로 떨어진다. + * 그래서 분류 누락 자체를 테스트가 잡는다 (PK-A3). + */ +class AdmissionDecisionTest { + + @Test + @DisplayName("모든_판정값은_통과_큐_거절_중_하나로_분류된다") + void 모든_판정값은_통과_큐_거절_중_하나로_분류된다() { + for (AdmissionDecision d : AdmissionDecision.values()) { + int classes = (d.isPass() ? 1 : 0) + (d.isEnqueue() ? 1 : 0) + (d.isReject() ? 1 : 0); + assertThat(classes) + .withFailMessage("%s 가 정확히 한 분류에 속하지 않는다 (분류 수 %d)", d, classes) + .isEqualTo(1); + } + } + + @Test + @DisplayName("통과_판정은_넷이다") + void 통과_판정은_넷이다() { + assertThat(Arrays.stream(AdmissionDecision.values()).filter(AdmissionDecision::isPass)) + .containsExactlyInAnyOrder( + AdmissionDecision.PASS_TOKEN, + AdmissionDecision.PASS_BYPASS, + AdmissionDecision.PASS_FAIL_OPEN, + AdmissionDecision.PASS_UNDER_CAP); + } + + @Test + @DisplayName("큐_판정은_여섯이다") + void 큐_판정은_여섯이다() { + assertThat(Arrays.stream(AdmissionDecision.values()).filter(AdmissionDecision::isEnqueue)) + .containsExactlyInAnyOrder( + AdmissionDecision.ENQUEUE_STALE, + AdmissionDecision.ENQUEUE_ALWAYS, + AdmissionDecision.ENQUEUE_BACKLOG, + AdmissionDecision.ENQUEUE_RATE_COUPON, + AdmissionDecision.ENQUEUE_RATE_GLOBAL, + AdmissionDecision.ENQUEUE_KEY_SATURATED); + } + + @Test + @DisplayName("거절_판정은_넷이다") + void 거절_판정은_넷이다() { + assertThat(Arrays.stream(AdmissionDecision.values()).filter(AdmissionDecision::isReject)) + .containsExactlyInAnyOrder( + AdmissionDecision.REJECT_SOLD_OUT, + AdmissionDecision.REJECT_QUEUE_FULL, + AdmissionDecision.REJECT_OVERLOAD, + AdmissionDecision.RETRY_TOKEN); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/admission/AtomicAcquireTest.java b/src/test/java/com/kafkick/waiting/domain/admission/AtomicAcquireTest.java new file mode 100644 index 00000000..347b4d36 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/admission/AtomicAcquireTest.java @@ -0,0 +1,128 @@ +package com.kafkick.waiting.domain.admission; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.kafkick.waiting.domain.admission.SecondWindowLimiter.AcquireResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 두 예산을 전부-아니면-전무로 획득한다 (G2.12). + * + *

순서대로 치면 앞엣것을 소비한 뒤 뒤엣것이 거부할 때 통과하지 않은 요청이 + * 예산을 깎는다. 그 유실은 조용해서 부하 시험 전까지 안 보인다. + */ +class AtomicAcquireTest { + + private static final String COUPON = "coupon:1"; + private static final String GLOBAL = "node"; + + @Test + @DisplayName("둘_다_여유가_있으면_함께_차감한다") + void 둘_다_여유가_있으면_함께_차감한다() { + SecondWindowLimiter limiter = new SecondWindowLimiter(1000); + + assertThat(limiter.tryAcquireAll(COUPON, 10, GLOBAL, 100, 10)) + .isEqualTo(AcquireResult.ACQUIRED); + assertThat(limiter.size()).isEqualTo(2); + } + + @Test + @DisplayName("한쪽이_부족하면_다른_쪽도_차감하지_않는다") + void 한쪽이_부족하면_다른_쪽도_차감하지_않는다() { + SecondWindowLimiter limiter = new SecondWindowLimiter(1000); + + // 쿠폰 예산을 다 쓴다. 전역은 여유가 많다. + assertThat(limiter.tryAcquireAll(COUPON, 1, GLOBAL, 100, 10)) + .isEqualTo(AcquireResult.ACQUIRED); + + // 쿠폰이 부족하므로 거부. 이때 전역 카운터가 늘면 안 된다. + assertThat(limiter.tryAcquireAll(COUPON, 1, GLOBAL, 100, 10)) + .isEqualTo(AcquireResult.COUPON_EXHAUSTED); + + // 전역 예산이 1 만 쓰인 상태여야 한다 — 거부된 요청이 깎지 않았다. + for (int i = 0; i < 99; i++) { + assertThat(limiter.tryAcquireAll("other:" + i, 10, GLOBAL, 100, 10)) + .isEqualTo(AcquireResult.ACQUIRED); + } + assertThat(limiter.tryAcquireAll("last", 10, GLOBAL, 100, 10)) + .isEqualTo(AcquireResult.GLOBAL_EXHAUSTED); + } + + @Test + @DisplayName("부족한_쪽에_따라_판정값이_갈린다") + void 부족한_쪽에_따라_판정값이_갈린다() { + // 대응이 다르다. 쿠폰이면 그 쿠폰만 조이면 되고, 전역이면 노드를 늘려야 한다. + SecondWindowLimiter a = new SecondWindowLimiter(1000); + assertThat(a.tryAcquireAll(COUPON, 0, GLOBAL, 100, 10)) + .isEqualTo(AcquireResult.COUPON_EXHAUSTED); + + SecondWindowLimiter b = new SecondWindowLimiter(1000); + assertThat(b.tryAcquireAll(COUPON, 10, GLOBAL, 0, 10)) + .isEqualTo(AcquireResult.GLOBAL_EXHAUSTED); + } + + @Test + @DisplayName("초가_바뀌면_두_예산이_함께_리셋된다") + void 초가_바뀌면_두_예산이_함께_리셋된다() { + SecondWindowLimiter limiter = new SecondWindowLimiter(1000); + + assertThat(limiter.tryAcquireAll(COUPON, 1, GLOBAL, 1, 10)) + .isEqualTo(AcquireResult.ACQUIRED); + assertThat(limiter.tryAcquireAll(COUPON, 1, GLOBAL, 1, 10)) + .isEqualTo(AcquireResult.COUPON_EXHAUSTED); + assertThat(limiter.tryAcquireAll(COUPON, 1, GLOBAL, 1, 11)) + .isEqualTo(AcquireResult.ACQUIRED); + } + + @Test + @DisplayName("쿠폰키와_전역키가_같아도_슬롯을_이중으로_세지_않는다") + void 쿠폰키와_전역키가_같아도_슬롯을_이중으로_세지_않는다() { + // 같은 키면 신규 슬롯은 하나다. 둘로 세면 자리가 있는데도 거부한다. + SecondWindowLimiter limiter = SecondWindowLimiter.withMaxKeys(1); + + assertThat(limiter.tryAcquireAll("same", 10, "same", 10, 10)) + .isEqualTo(AcquireResult.ACQUIRED); + } + + @Test + @DisplayName("같은_키를_두_번_차감하지_않는다") + void 같은_키를_두_번_차감하지_않는다() { + // 요청 하나가 2 를 소비하면 상한 2 에서 한 건만 통과한다. + // 반환값만 보면 안 드러난다 — 몇 건이 통과하는지로 잰다. + SecondWindowLimiter limiter = SecondWindowLimiter.withMaxKeys(10); + + int passed = 0; + for (int i = 0; i < 5; i++) { + if (limiter.tryAcquireAll("same", 2, "same", 2, 10) == AcquireResult.ACQUIRED) { + passed++; + } + } + + assertThat(passed).isEqualTo(2); + } + + @Test + @DisplayName("같은_키면_두_상한_중_작은_쪽을_쓴다") + void 같은_키면_두_상한_중_작은_쪽을_쓴다() { + SecondWindowLimiter limiter = SecondWindowLimiter.withMaxKeys(10); + + assertThat(limiter.tryAcquireAll("same", 5, "same", 1, 10)) + .isEqualTo(AcquireResult.ACQUIRED); + assertThat(limiter.tryAcquireAll("same", 5, "same", 1, 10)) + .isEqualTo(AcquireResult.GLOBAL_EXHAUSTED); + } + + @Test + @DisplayName("맵이_가득_차면_새_키를_받지_않는다") + void 맵이_가득_차면_새_키를_받지_않는다() { + SecondWindowLimiter limiter = new SecondWindowLimiter(2); + + assertThat(limiter.tryAcquireAll(COUPON, 10, GLOBAL, 100, 10)) + .isEqualTo(AcquireResult.ACQUIRED); + // 자리가 없다. 통과시키면 상한이 무의미해진다. 예산 고갈과 구분해서 + // 알려야 운영자가 쿠폰이 아니라 maxKeys 를 본다. + assertThat(limiter.tryAcquireAll("coupon:2", 10, GLOBAL, 100, 10)) + .isEqualTo(AcquireResult.KEY_SATURATED); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/admission/DecisionReachabilityTest.java b/src/test/java/com/kafkick/waiting/domain/admission/DecisionReachabilityTest.java new file mode 100644 index 00000000..60ee1697 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/admission/DecisionReachabilityTest.java @@ -0,0 +1,82 @@ +package com.kafkick.waiting.domain.admission; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.kafkick.waiting.domain.coupon.CouponState; +import com.kafkick.waiting.domain.coupon.CouponStates; +import com.kafkick.waiting.domain.coupon.SnapshotMeta; +import java.util.EnumSet; +import java.util.Set; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 판정값은 전부 도달 가능해야 한다. + * + *

사다리는 위에서부터 처음 걸리는 줄이 답이라, 줄 하나를 잘못 끼우면 그 아래 + * 판정이 영영 안 나오는데 아무 테스트도 안 깨진다. 판정값을 늘리고 + * 사다리를 안 고쳤을 때도 마찬가지다. 여기서 붙잡는다. + */ +class DecisionReachabilityTest { + + private static final SnapshotMeta META = new SnapshotMeta(1000, 10); + + @Test + @DisplayName("사다리는_모든_판정값을_실제로_만들어낸다") + void 사다리는_모든_판정값을_실제로_만들어낸다() { + Set seen = EnumSet.noneOf(AdmissionDecision.class); + + seen.add(decide(CouponStates.closed(100), r -> r)); + seen.add(decide(CouponStates.queueing(100, 500, 3000), r -> r.withValidToken(true))); + seen.add(decide(CouponStates.off(500), r -> r)); + seen.add(decide(CouponStates.idle(500), r -> r.withDataStale(true))); + seen.add(decide(CouponStates.queueing(100, 500, 5000), r -> r.withDataStale(true))); + seen.add(decide(CouponStates.queueing(100, 500, 20_000), r -> r)); + seen.add(decide(CouponStates.always(500), r -> r)); + seen.add(decide(CouponStates.queueing(100, 500, 3000), r -> r)); + seen.add(decide(CouponStates.idle(500), r -> r)); + + // 상한을 말려야 나오는 넷은 같은 리미터를 반복해서 두드린다 + seen.add(drain(CouponStates.queueing(100, 500, 3000), r -> r.withValidToken(true), 0.7)); + seen.add(drain(CouponStates.idle(500), r -> r.withDataStale(true), 0.7)); + seen.add(drain(CouponStates.idle(500), r -> r, 0.7)); + seen.add(drain(CouponStates.idle(500), r -> r, 5.0)); + + // 자리가 하나뿐이면 쿠폰·전역 두 키를 함께 못 넣는다 + AdmissionDecider tight = AdmissionDecider.of(SecondWindowLimiter.withMaxKeys(1), 0.7); + seen.add(tight.decide(request(CouponStates.idle(500)))); + + assertThat(seen) + .withFailMessage( + "도달 못 하는 판정: %s", + EnumSet.complementOf(EnumSet.copyOf(seen))) + .containsExactlyInAnyOrder(AdmissionDecision.values()); + } + + + private AdmissionDecision decide( + CouponState state, UnaryOperator tweak) { + AdmissionDecider decider = AdmissionDecider.of(SecondWindowLimiter.withMaxKeys(1000), 0.7); + return decider.decide(tweak.apply(request(state))); + } + + /** 상한이 마를 때까지 두드리고 마지막 판정을 돌려준다. */ + private AdmissionDecision drain( + CouponState state, + UnaryOperator tweak, + double idleRatio) { + AdmissionDecider decider = + AdmissionDecider.of(SecondWindowLimiter.withMaxKeys(1000), idleRatio); + AdmissionRequest req = tweak.apply(request(state)); + AdmissionDecision last = null; + for (int i = 0; i < 200; i++) { + last = decider.decide(req); + } + return last; + } + + private AdmissionRequest request(CouponState state) { + return new AdmissionRequest("c1", state, META, false, false, false, 0, 100); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/admission/LimiterBoundaryTest.java b/src/test/java/com/kafkick/waiting/domain/admission/LimiterBoundaryTest.java new file mode 100644 index 00000000..b545c738 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/admission/LimiterBoundaryTest.java @@ -0,0 +1,142 @@ +package com.kafkick.waiting.domain.admission; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.kafkick.waiting.domain.admission.SecondWindowLimiter.AcquireResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 리미터의 경계값. + * + *

여기 있는 것들은 전부 뮤테이션 테스트가 짚어준 구멍이다. 상한 그 자체, + * 두 상한이 같은 값일 때, 키 자리가 정확히 찼을 때 — 예시 테스트가 늘 비껴가는 + * 지점이고, 이 리미터에서 이미 한 번 버그가 났던 자리이기도 하다. + */ +class LimiterBoundaryTest { + + private static final String COUPON = "c1"; + private static final String GLOBAL = "__node__"; + + @Test + @DisplayName("상한이_정확히_0이면_한_건도_통과시키지_않는다") + void 상한이_정확히_0이면_한_건도_통과시키지_않는다() { + // cap < 0 로 써도 음수 상한은 안 오므로 아무 테스트도 안 깨진다. + // 0 은 실제로 온다 — credit 0 인 쿠폰이 그렇다. + assertThat(SecondWindowLimiter.withMaxKeys(10).tryAcquire(COUPON, 0, 0)).isFalse(); + } + + @Test + @DisplayName("두_예산_획득에서도_상한_0은_거절한다") + void 두_예산_획득에서도_상한_0은_거절한다() { + SecondWindowLimiter limiter = SecondWindowLimiter.withMaxKeys(10); + + assertThat(limiter.tryAcquireAll(COUPON, 0, GLOBAL, 100, 0)) + .isEqualTo(AcquireResult.COUPON_EXHAUSTED); + assertThat(limiter.tryAcquireAll(COUPON, 100, GLOBAL, 0, 0)) + .isEqualTo(AcquireResult.GLOBAL_EXHAUSTED); + } + + @Test + @DisplayName("키_자리가_다_차면_새_키는_상한이_남아도_거절한다") + void 키_자리가_다_차면_새_키는_상한이_남아도_거절한다() { + // 통과시키면 상한이 무의미해진다. 메모리 상한이 곧 정확성 상한이다. + SecondWindowLimiter limiter = SecondWindowLimiter.withMaxKeys(2); + + assertThat(limiter.tryAcquire("a", 100, 0)).isTrue(); + assertThat(limiter.tryAcquire("b", 100, 0)).isTrue(); + assertThat(limiter.tryAcquire("c", 100, 0)).isFalse(); + + // 이미 자리를 잡은 키는 계속 쓴다 — 자리가 없다고 기존 줄까지 막지 않는다. + assertThat(limiter.tryAcquire("a", 100, 0)).isTrue(); + assertThat(limiter.size()).isEqualTo(2); + } + + @Test + @DisplayName("두_예산_획득은_남은_자리가_하나면_새_키_둘을_넣지_않는다") + void 두_예산_획득은_남은_자리가_하나면_새_키_둘을_넣지_않는다() { + // 하나씩 검사하면 마지막 슬롯을 두 키가 함께 차지해 상한을 넘긴다. + SecondWindowLimiter limiter = SecondWindowLimiter.withMaxKeys(3); + assertThat(limiter.tryAcquire("other1", 100, 0)).isTrue(); + assertThat(limiter.tryAcquire("other2", 100, 0)).isTrue(); + + assertThat(limiter.tryAcquireAll(COUPON, 100, GLOBAL, 100, 0)) + .isEqualTo(AcquireResult.KEY_SATURATED); + assertThat(limiter.size()).isEqualTo(2); + } + + @Test + @DisplayName("두_예산_획득은_남은_자리가_둘이면_새_키_둘을_함께_넣는다") + void 두_예산_획득은_남은_자리가_둘이면_새_키_둘을_함께_넣는다() { + SecondWindowLimiter limiter = SecondWindowLimiter.withMaxKeys(3); + assertThat(limiter.tryAcquire("other1", 100, 0)).isTrue(); + + assertThat(limiter.tryAcquireAll(COUPON, 100, GLOBAL, 100, 0)) + .isEqualTo(AcquireResult.ACQUIRED); + assertThat(limiter.size()).isEqualTo(3); + } + + @Test + @DisplayName("한쪽_키만_새것이면_자리를_하나만_센다") + void 한쪽_키만_새것이면_자리를_하나만_센다() { + SecondWindowLimiter limiter = SecondWindowLimiter.withMaxKeys(2); + assertThat(limiter.tryAcquire(GLOBAL, 100, 0)).isTrue(); + + // 새 키는 쿠폰 하나뿐이다. 둘로 세면 여기서 잘못 거절한다. + assertThat(limiter.tryAcquireAll(COUPON, 100, GLOBAL, 100, 0)) + .isEqualTo(AcquireResult.ACQUIRED); + } + + @Test + @DisplayName("두_키가_같으면_요청_하나가_예산을_하나만_쓴다") + void 두_키가_같으면_요청_하나가_예산을_하나만_쓴다() { + // 따로 차감하면 상한의 절반만 통과한다. 반환값만 보면 안 드러나서 + // 통과 건수를 센다. + SecondWindowLimiter limiter = SecondWindowLimiter.withMaxKeys(10); + int passed = 0; + for (int i = 0; i < 20; i++) { + if (limiter.tryAcquireAll(GLOBAL, 10, GLOBAL, 10, 0) == AcquireResult.ACQUIRED) { + passed++; + } + } + + assertThat(passed).isEqualTo(10); + } + + @Test + @DisplayName("두_키가_같고_상한이_같으면_쿠폰_사유로_고갈된다") + void 두_키가_같고_상한이_같으면_쿠폰_사유로_고갈된다() { + // 동점의 귀속처를 정해 둔다. 대응이 다르다 — 전역이면 노드를 늘리고, + // 쿠폰이면 그 쿠폰의 배분을 본다. 헷갈리면 엉뚱한 데를 고친다. + SecondWindowLimiter limiter = SecondWindowLimiter.withMaxKeys(10); + assertThat(limiter.tryAcquireAll(GLOBAL, 1, GLOBAL, 1, 0)) + .isEqualTo(AcquireResult.ACQUIRED); + + assertThat(limiter.tryAcquireAll(GLOBAL, 1, GLOBAL, 1, 0)) + .isEqualTo(AcquireResult.COUPON_EXHAUSTED); + } + + @Test + @DisplayName("두_키가_같고_전역_상한이_더_작으면_전역_사유로_고갈된다") + void 두_키가_같고_전역_상한이_더_작으면_전역_사유로_고갈된다() { + SecondWindowLimiter limiter = SecondWindowLimiter.withMaxKeys(10); + assertThat(limiter.tryAcquireAll(GLOBAL, 5, GLOBAL, 1, 0)) + .isEqualTo(AcquireResult.ACQUIRED); + + assertThat(limiter.tryAcquireAll(GLOBAL, 5, GLOBAL, 1, 0)) + .isEqualTo(AcquireResult.GLOBAL_EXHAUSTED); + } + + @Test + @DisplayName("두_키가_같으면_새_키_자리를_하나만_센다") + void 두_키가_같으면_새_키_자리를_하나만_센다() { + // 자리가 이미 다 찼으면 같은 키여도 새로 못 넣는다. 여기서 통과시키면 + // maxKeys 가 상한이 아니게 된다. + SecondWindowLimiter limiter = SecondWindowLimiter.withMaxKeys(1); + assertThat(limiter.tryAcquire("other", 100, 0)).isTrue(); + + assertThat(limiter.tryAcquireAll(GLOBAL, 100, GLOBAL, 100, 0)) + .isEqualTo(AcquireResult.KEY_SATURATED); + assertThat(limiter.size()).isEqualTo(1); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/admission/SecondWindowLimiterTest.java b/src/test/java/com/kafkick/waiting/domain/admission/SecondWindowLimiterTest.java new file mode 100644 index 00000000..1924d563 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/admission/SecondWindowLimiterTest.java @@ -0,0 +1,91 @@ +package com.kafkick.waiting.domain.admission; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 초 단위 고정 윈도우 리미터. + * + *

시계를 주입받는다(TS-4). 도메인은 {@code Instant.now()} 를 부르지 않는다 — + * 부르는 순간 초 경계 동작을 시험할 수 없다. + */ +class SecondWindowLimiterTest { + + @Test + @DisplayName("상한_안이면_허용하고_넘으면_거부한다") + void 상한_안이면_허용하고_넘으면_거부한다() { + SecondWindowLimiter limiter = new SecondWindowLimiter(1000); + + assertThat(limiter.tryAcquire("c1", 100, 10)).isTrue(); + assertThat(limiter.tryAcquire("c1", 1, 10)).isFalse(); + } + + @Test + @DisplayName("초가_넘어가면_카운터가_리셋된다") + void 초가_넘어가면_카운터가_리셋된다() { + SecondWindowLimiter limiter = new SecondWindowLimiter(1000); + + assertThat(limiter.tryAcquire("c1", 100, 10)).isTrue(); + assertThat(limiter.tryAcquire("c1", 1, 10)).isFalse(); + assertThat(limiter.tryAcquire("c1", 100, 11)).isTrue(); + } + + @Test + @DisplayName("상한이_0이하면_아무것도_통과시키지_않는다") + void 상한이_0이하면_아무것도_통과시키지_않는다() { + SecondWindowLimiter limiter = new SecondWindowLimiter(1000); + + assertThat(limiter.tryAcquire("c1", 0, 10)).isFalse(); + assertThat(limiter.tryAcquire("c1", -1, 10)).isFalse(); + } + + @Test + @DisplayName("키가_다르면_예산도_따로다") + void 키가_다르면_예산도_따로다() { + SecondWindowLimiter limiter = new SecondWindowLimiter(1000); + + assertThat(limiter.tryAcquire("c1", 1, 10)).isTrue(); + assertThat(limiter.tryAcquire("c2", 1, 10)).isTrue(); + } + + @Test + @DisplayName("같은_초에_경로가_바뀌어도_합산_상한을_넘지_않는다") + void 같은_초에_경로가_바뀌어도_합산_상한을_넘지_않는다() { + // F4 — 회복 전이 순간 정상 경로와 fail-open 경로가 각자 카운터를 들면 + // 같은 초에 두 상한이 동시에 열려 1.5× 버스트가 나간다. + // 리미터를 경로별로 나누지 않고 상한만 인자로 받아 막는다. + SecondWindowLimiter limiter = new SecondWindowLimiter(1000); + + for (int i = 0; i < 60; i++) { + assertThat(limiter.tryAcquire("c1", 100, 10)).isTrue(); + } + // 같은 키·같은 초에 더 낮은 상한으로 전환 — 이미 60 을 썼으므로 0 이어야 한다 + assertThat(limiter.tryAcquire("c1", 50, 10)).isFalse(); + } + + @Test + @DisplayName("윈도우_맵은_상한을_넘지_않는다") + void 윈도우_맵은_상한을_넘지_않는다() { + // 쿠폰 ID 는 URL 경로변수라 공격자가 무한히 넣을 수 있다. + SecondWindowLimiter limiter = new SecondWindowLimiter(1000); + + for (int i = 0; i < 100_000; i++) { + limiter.tryAcquire("k" + i, 10, 10); + } + + assertThat(limiter.size()).isLessThanOrEqualTo(1000); + } + + @Test + @DisplayName("지난_초의_윈도우는_정리된다") + void 지난_초의_윈도우는_정리된다() { + SecondWindowLimiter limiter = new SecondWindowLimiter(1000); + + limiter.tryAcquire("c1", 10, 10); + limiter.tryAcquire("c2", 10, 11); + + assertThat(limiter.size()).isEqualTo(1); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/allocation/AllocationPropertyTest.java b/src/test/java/com/kafkick/waiting/domain/allocation/AllocationPropertyTest.java new file mode 100644 index 00000000..2afd2512 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/allocation/AllocationPropertyTest.java @@ -0,0 +1,191 @@ +package com.kafkick.waiting.domain.allocation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.List; +import java.util.Random; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 배분 총합은 전역 크레딧을 넘지 않는다 — 타협 불가 기준이다. + * + *

예시 몇 개로는 못 잡는다. 두 패스에 걸친 정수 나눗셈은 특정 + * {@code (credit, 쿠폰 수, 요구량 분포)} 조합에서만 어긋난다. + */ +class AllocationPropertyTest { + + private final FairShareAllocator allocator = FairShareAllocator.create(); + + private static final long SEED = 20260819L; + private static final int TRIALS = 100_000; + + @Test + @DisplayName("무작위_10만회에서_배분_총합이_전역_크레딧을_넘지_않는다") + void 무작위_10만회에서_배분_총합이_전역_크레딧을_넘지_않는다() { + Random rnd = new Random(SEED); + int violations = 0; + + for (int t = 0; t < TRIALS; t++) { + long credit = rnd.nextLong(0, 200_000); + List demands = randomDemands(rnd); + + long total = allocator.allocate(credit, demands).stream() + .mapToLong(Grant::credit).sum(); + + if (total > credit) { + violations++; + } + } + + assertThat(violations) + .withFailMessage("초과 배분 %d 건 (시드 %d)", violations, SEED) + .isZero(); + } + + @Test + @DisplayName("무작위_10만회에서_아무도_요구량보다_많이_받지_않는다") + void 무작위_10만회에서_아무도_요구량보다_많이_받지_않는다() { + // 넘지 않는 것만 보면 부족하다. 한 쿠폰에 몰아줘도 총합은 안 넘는다. + Random rnd = new Random(SEED); + int violations = 0; + + for (int t = 0; t < TRIALS; t++) { + long credit = rnd.nextLong(0, 200_000); + List demands = randomDemands(rnd); + + List grants = allocator.allocate(credit, demands); + for (Grant g : grants) { + long want = demands.stream() + .filter(d -> d.couponId().equals(g.couponId())) + .mapToLong(CouponDemand::want).findFirst().orElse(0); + if (g.credit() > want) { + violations++; + } + } + } + + assertThat(violations) + .withFailMessage("요구량 초과 %d 건 (시드 %d)", violations, SEED) + .isZero(); + } + + @Test + @DisplayName("무작위_10만회에서_줄_수_있는_몫을_남기지_않는다") + void 무작위_10만회에서_줄_수_있는_몫을_남기지_않는다() { + // 유휴 낭비 0. 남은 크레딧이 굶주린 쿠폰 수보다 많으면 더 줄 수 있었다는 + // 뜻이다 — 그만큼은 대기자가 이유 없이 기다린 시간이다. + Random rnd = new Random(SEED); + int wasted = 0; + + for (int t = 0; t < TRIALS; t++) { + long credit = rnd.nextLong(0, 200_000); + List demands = randomDemands(rnd); + + List grants = allocator.allocate(credit, demands); + long total = grants.stream().mapToLong(Grant::credit).sum(); + long stillHungry = grants.stream() + .filter(g -> g.credit() < wantOf(demands, g.couponId())) + .count(); + + // 굶주린 쿠폰 수만큼 남았다면 각자 1 씩 더 줄 수 있었다는 뜻이다. + if (stillHungry > 0 && credit - total >= stillHungry) { + wasted++; + } + } + + assertThat(wasted) + .withFailMessage("줄 수 있었는데 남긴 경우 %d 건 (시드 %d)", wasted, SEED) + .isZero(); + } + + private long wantOf(List demands, String couponId) { + return demands.stream() + .filter(d -> d.couponId().equals(couponId)) + .mapToLong(CouponDemand::want).findFirst().orElse(0); + } + + private List randomDemands(Random rnd) { + int n = rnd.nextInt(1, 40); + List demands = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + demands.add(new CouponDemand( + "c" + i, rnd.nextLong(0, 200_000), rnd.nextLong(0, 200_000))); + } + return demands; + } + + @Test + @DisplayName("끝까지_굶주린_쿠폰들은_서로_같은_몫을_받는다") + void 끝까지_굶주린_쿠폰들은_서로_같은_몫을_받는다() { + // 다른 속성 테스트들은 구현의 종료 조건을 다른 말로 되뇐 것에 가깝다. + // 이건 다르다 — 배분이 **등록 순서에 좌우되지 않는가**를 본다. + // 몫을 다 못 채운 쿠폰끼리 받은 양이 다르면 앞쪽이 유리했다는 뜻이고, + // 그러면 노드마다 순서가 달라질 때 총합이 흔들린다. + Random rnd = new Random(SEED); + int unfair = 0; + + for (int t = 0; t < TRIALS; t++) { + long credit = rnd.nextLong(0, 200_000); + List demands = randomDemands(rnd); + + List grants = allocator.allocate(credit, demands); + long min = Long.MAX_VALUE; + long max = Long.MIN_VALUE; + for (Grant g : grants) { + if (g.credit() >= wantOf(demands, g.couponId())) { + continue; // 요구량을 다 채운 쿠폰은 비교 대상이 아니다 + } + min = Math.min(min, g.credit()); + max = Math.max(max, g.credit()); + } + + if (min != Long.MAX_VALUE && max != min) { + unfair++; + } + } + + assertThat(unfair) + .withFailMessage("굶주린 쿠폰끼리 몫이 다른 경우 %d 건 (시드 %d)", unfair, SEED) + .isZero(); + } + + @Test + @DisplayName("배분_결과가_입력_순서에_좌우되지_않는다") + void 배분_결과가_입력_순서에_좌우되지_않는다() { + // 노드마다 수요를 다른 순서로 모을 수 있다. 순서가 결과를 바꾸면 + // 같은 틱에 노드들이 서로 다른 답을 내고 총합이 전역 크레딧을 넘는다. + Random rnd = new Random(SEED); + int mismatches = 0; + + for (int t = 0; t < 20_000; t++) { + long credit = rnd.nextLong(0, 200_000); + List demands = randomDemands(rnd); + List shuffled = new ArrayList<>(demands); + Collections.shuffle(shuffled, rnd); + + Map a = byCoupon(allocator.allocate(credit, demands)); + Map b = byCoupon(allocator.allocate(credit, shuffled)); + + if (!a.equals(b)) { + mismatches++; + } + } + + assertThat(mismatches) + .withFailMessage("순서에 따라 결과가 달라진 경우 %d 건 (시드 %d)", mismatches, SEED) + .isZero(); + } + + private Map byCoupon(List grants) { + Map m = new HashMap<>(); + for (Grant g : grants) { + m.put(g.couponId(), g.credit()); + } + return m; + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/allocation/CouponDemandTest.java b/src/test/java/com/kafkick/waiting/domain/allocation/CouponDemandTest.java new file mode 100644 index 00000000..0e393659 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/allocation/CouponDemandTest.java @@ -0,0 +1,66 @@ +package com.kafkick.waiting.domain.allocation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 배분 요구량. + * + *

여기가 {@code IDLE ⟹ credit == 0}(I1)의 출처다. 대기자가 없으면 요구량이 + * 0 이고, 요구량이 0 이면 배분을 못 받는다. + */ +class CouponDemandTest { + + @Test + @DisplayName("재고가_천장으로_작동한다") + void 재고가_천장으로_작동한다() { + // 재고 3 개에 100 명을 통과시키면 97 명이 헛걸음한다 (C-2). + assertThat(new CouponDemand("c1", 100, 3).want()).isEqualTo(3); + } + + @Test + @DisplayName("재고가_넉넉하면_대기자_수가_그대로_요구량이다") + void 재고가_넉넉하면_대기자_수가_그대로_요구량이다() { + assertThat(new CouponDemand("c1", 40, 1000).want()).isEqualTo(40); + } + + @Test + @DisplayName("대기자가_없으면_배분_대상이_아니다") + void 대기자가_없으면_배분_대상이_아니다() { + assertThat(new CouponDemand("c1", 0, 1000).isActive()).isFalse(); + } + + @Test + @DisplayName("재고가_없으면_대기자가_있어도_배분_대상이_아니다") + void 재고가_없으면_대기자가_있어도_배분_대상이_아니다() { + // 배분해 봐야 뒷단이 전부 거절한다. 크레딧만 버린다. + assertThat(new CouponDemand("c1", 500, 0).isActive()).isFalse(); + } + + @Test + @DisplayName("대기자와_재고가_모두_있으면_배분_대상이다") + void 대기자와_재고가_모두_있으면_배분_대상이다() { + assertThat(new CouponDemand("c1", 500, 10).isActive()).isTrue(); + } + + @Test + @DisplayName("음수_대기자나_음수_재고는_거부한다") + void 음수_대기자나_음수_재고는_거부한다() { + assertThatThrownBy(() -> new CouponDemand("c1", -1, 10)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new CouponDemand("c1", 10, -1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("쿠폰_식별자가_없으면_거부한다") + void 쿠폰_식별자가_없으면_거부한다() { + assertThatThrownBy(() -> new CouponDemand(null, 10, 10)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new CouponDemand(" ", 10, 10)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/allocation/CreditSmootherTest.java b/src/test/java/com/kafkick/waiting/domain/allocation/CreditSmootherTest.java new file mode 100644 index 00000000..e7b49ff0 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/allocation/CreditSmootherTest.java @@ -0,0 +1,124 @@ +package com.kafkick.waiting.domain.allocation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 여유 값을 다듬는다. + * + *

순간값을 그대로 쓰면 GC 스파이크 한 번이 표시 ETA 를 두 배로 만든다. + * ETA 오차의 지배항이 여기다. + */ +class CreditSmootherTest { + + @Test + @DisplayName("첫_관측치가_초기값이_된다") + void 첫_관측치가_초기값이_된다() { + // 0 에서 시작하면 첫 몇 틱 동안 실제보다 한참 낮은 값이 나가고, + // 그 사이 표시 ETA 가 몇 배로 뛴다. + CreditSmoother s = CreditSmoother.of(0.2); + + assertThat(s.observe(1000)).isEqualTo(1000.0); + } + + @Test + @DisplayName("EWMA는_설정된_시상수로_수렴한다") + void EWMA는_설정된_시상수로_수렴한다() { + // α=0.2 로 1000 에서 시작해 0 을 5틱 관측하면 1000×0.8^5 = 327.68 + CreditSmoother s = CreditSmoother.of(0.2); + s.observe(1000); + + double value = 0; + for (int i = 0; i < 5; i++) { + value = s.observe(0); + } + + assertThat(value).isCloseTo(327.68, within(0.01)); + } + + @Test + @DisplayName("순간_스파이크는_그대로_반영되지_않는다") + void 순간_스파이크는_그대로_반영되지_않는다() { + CreditSmoother s = CreditSmoother.of(0.2); + s.observe(1000); + + // 한 틱 튀어도 20% 만 먹는다 + assertThat(s.observe(2000)).isCloseTo(1200, within(0.01)); + } + + @Test + @DisplayName("알파가_범위를_벗어나면_거부한다") + void 알파가_범위를_벗어나면_거부한다() { + // α=0 이면 영원히 안 움직이고, α>1 이면 값이 진동하며 발산한다. + assertThatThrownBy(() -> CreditSmoother.of(0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> CreditSmoother.of(1.1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> CreditSmoother.of(Double.NaN)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("알파가_1이면_평활화하지_않는다") + void 알파가_1이면_평활화하지_않는다() { + // 끄고 싶을 때가 있다. 부하 시험에서 원본을 봐야 할 때다. + CreditSmoother s = CreditSmoother.of(1.0); + s.observe(1000); + + assertThat(s.observe(2000)).isEqualTo(2000.0); + } + + @Test + @DisplayName("음수_관측치는_거부한다") + void 음수_관측치는_거부한다() { + assertThatThrownBy(() -> CreditSmoother.of(0.2).observe(-1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("평활화_상태를_내보내고_되살릴_수_있다") + void 평활화_상태를_내보내고_되살릴_수_있다() { + // F9 — 리더가 바뀌면 평활화가 0 에서 다시 시작해 ETA 가 튄다. + CreditSmoother original = CreditSmoother.of(0.2); + original.observe(1000); + original.observe(500); + + CreditSmoother restored = CreditSmoother.restore(0.2, original.snapshot()); + + assertThat(restored.observe(500)).isEqualTo(original.observe(500)); + } + + @Test + @DisplayName("관측_전_스냅샷을_되살리면_다음_값이_초기값이_된다") + void 관측_전_스냅샷을_되살리면_다음_값이_초기값이_된다() { + CreditSmoother restored = + CreditSmoother.restore(0.2, CreditSmoother.of(0.2).snapshot()); + + assertThat(restored.observe(700)).isEqualTo(700.0); + } + + @Test + @DisplayName("비유한_관측치는_거부한다") + void 비유한_관측치는_거부한다() { + // 한 번 들어오면 EWMA 가 영영 NaN 이고, 표시 ETA 도 함께 죽는다. + assertThatThrownBy(() -> CreditSmoother.of(0.2).observe(Double.NaN)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> CreditSmoother.of(0.2).observe(Double.POSITIVE_INFINITY)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("깨진_스냅샷은_되살릴_수_없다") + void 깨진_스냅샷은_되살릴_수_없다() { + // 이월받은 값이 NaN 이면 그 순간부터 EWMA 가 영영 NaN 이고, 리더가 + // 바뀐 뒤에야 표시 ETA 가 죽은 것으로 드러난다. + assertThatThrownBy(() -> new CreditSmoother.Snapshot(Double.NaN, true)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new CreditSmoother.Snapshot(-1, true)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new CreditSmoother.Snapshot(500, false)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/allocation/FairShareAllocatorTest.java b/src/test/java/com/kafkick/waiting/domain/allocation/FairShareAllocatorTest.java new file mode 100644 index 00000000..5d4eff8b --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/allocation/FairShareAllocatorTest.java @@ -0,0 +1,165 @@ +package com.kafkick.waiting.domain.allocation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 공정 배분 — 기아 불가와 유휴 낭비 0 을 동시에 만족시켜야 한다. + * + *

균등하게만 나누면 한산한 쿠폰이 못 쓰고 남긴 몫이 버려지고, 요구량 비례로만 + * 나누면 몰리는 쿠폰 하나가 전부 가져가 나머지가 굶는다. + */ +class FairShareAllocatorTest { + + private final FairShareAllocator allocator = FairShareAllocator.create(); + + private Map allocate(long globalCredit, CouponDemand... demands) { + return allocator.allocate(globalCredit, List.of(demands)).stream() + .collect(Collectors.toMap(Grant::couponId, Grant::credit)); + } + + @Test + @DisplayName("균등_배분_후_남은_몫을_재배분한다") + void 균등_배분_후_남은_몫을_재배분한다() { + // 1패스: 1000/3 = 333 씩. 콜드는 40·3 만 쓰고 624 를 남긴다. + // 2패스: 남은 624 가 아직 굶주린 핫으로 간다 → 333 + 624 = 957. + // 낭비 0 이다 — 957 + 40 + 3 = 1000. + Map granted = allocate(1000, + new CouponDemand("hot", 200_000, 200_000), + new CouponDemand("cold1", 40, 1000), + new CouponDemand("cold2", 3, 1000)); + + assertThat(granted).containsExactlyInAnyOrderEntriesOf( + Map.of("hot", 957L, "cold1", 40L, "cold2", 3L)); + } + + @Test + @DisplayName("핫에_20만이_밀려도_콜드는_첫_틱에_전부_빠진다") + void 핫에_20만이_밀려도_콜드는_첫_틱에_전부_빠진다() { + // 기아 불가. 1패스가 균등이라 콜드는 자기 요구량을 첫 틱에 다 받는다. + Map granted = allocate(1000, + new CouponDemand("hot", 200_000, 200_000), + new CouponDemand("cold1", 40, 1000), + new CouponDemand("cold2", 3, 1000)); + + assertThat(granted.get("cold1")).isEqualTo(40); + assertThat(granted.get("cold2")).isEqualTo(3); + } + + @Test + @DisplayName("정수_나눗셈_나머지는_다음_틱으로_넘긴다") + void 정수_나눗셈_나머지는_다음_틱으로_넘긴다() { + // 10 을 셋이 나누면 3 씩이고 1 이 남는다. 남는 1 을 누구에게 주면 + // 그 쿠폰만 이득이고, 배분이 노드마다 갈리면 총합이 새어 나간다. + Map granted = allocate(10, + new CouponDemand("a", 100, 100), + new CouponDemand("b", 100, 100), + new CouponDemand("c", 100, 100)); + + assertThat(granted.values().stream().mapToLong(Long::longValue).sum()).isEqualTo(9); + assertThat(granted).containsExactlyInAnyOrderEntriesOf( + Map.of("a", 3L, "b", 3L, "c", 3L)); + } + + @Test + @DisplayName("배분_대상이_없으면_아무것도_주지_않는다") + void 배분_대상이_없으면_아무것도_주지_않는다() { + // 여기가 I1 의 출처다 — 줄이 없으면 credit 이 0 이다. + assertThat(allocate(1000, new CouponDemand("idle", 0, 1000))).isEmpty(); + assertThat(allocate(1000)).isEmpty(); + } + + @Test + @DisplayName("요구량_총합이_크레딧보다_적으면_요구량만큼만_준다") + void 요구량_총합이_크레딧보다_적으면_요구량만큼만_준다() { + // 남는 크레딧을 억지로 밀어 넣지 않는다. 못 쓰는 몫이다. + Map granted = allocate(1000, + new CouponDemand("a", 5, 1000), + new CouponDemand("b", 7, 1000)); + + assertThat(granted).containsExactlyInAnyOrderEntriesOf(Map.of("a", 5L, "b", 7L)); + } + + @Test + @DisplayName("배분_총합은_전역_크레딧을_넘지_않는다") + void 배분_총합은_전역_크레딧을_넘지_않는다() { + long total = allocate(7, + new CouponDemand("a", 100, 100), + new CouponDemand("b", 100, 100)).values().stream() + .mapToLong(Long::longValue).sum(); + + assertThat(total).isLessThanOrEqualTo(7); + } + + @Test + @DisplayName("크레딧이_0이면_아무도_못_받는다") + void 크레딧이_0이면_아무도_못_받는다() { + assertThat(allocate(0, new CouponDemand("a", 100, 100))) + .containsExactlyInAnyOrderEntriesOf(Map.of("a", 0L)); + } + + @Test + @DisplayName("요구량이_0인_쿠폰은_결과에서_빠진다") + void 요구량이_0인_쿠폰은_결과에서_빠진다() { + // 섞여 들어와도 배분 분모를 늘리지 않는다. 늘리면 산 쿠폰이 손해다. + Map granted = allocate(9, + new CouponDemand("a", 100, 100), + new CouponDemand("dead", 100, 0), + new CouponDemand("b", 100, 100)); + + assertThat(granted).containsOnlyKeys("a", "b"); + assertThat(granted.values()).allMatch(v -> v == 4L); + } + + @Test + @DisplayName("재배분_단계에서도_나머지는_남긴다") + void 재배분_단계에서도_나머지는_남긴다() { + // 1패스 100/3 = 33 씩. a 는 1 만 쓰고 32 를 남긴다. + // 2패스 대상은 b·c 둘, 남은 몫 32/2 = 16 씩 → 49 씩. + Map granted = allocate(100, + new CouponDemand("a", 1, 100), + new CouponDemand("b", 500, 500), + new CouponDemand("c", 500, 500)); + + assertThat(granted).containsExactlyInAnyOrderEntriesOf( + Map.of("a", 1L, "b", 49L, "c", 49L)); + } + + @Test + @DisplayName("음수_크레딧은_0으로_본다") + void 음수_크레딧은_0으로_본다() { + // 배분은 틱마다 도는 배경 작업이다. 여기서 예외를 던지면 스케줄러가 + // 멎고 전 쿠폰의 크레딧이 낡는다 — 잘못된 입력 하나가 전체를 세운다. + assertThat(allocate(-100, new CouponDemand("a", 1, 1))).containsEntry("a", 0L); + } + + @Test + @DisplayName("요구량이_같으면_모두_같은_몫을_받는다") + void 요구량이_같으면_모두_같은_몫을_받는다() { + // 앞쪽 쿠폰이 유리해지면 배분이 등록 순서에 좌우된다. + List grants = allocator.allocate(1000, List.of( + new CouponDemand("a", 10_000, 10_000), + new CouponDemand("b", 10_000, 10_000))); + + assertThat(grants).extracting(Grant::credit).containsExactly(500L, 500L); + assertThat(grants).extracting(Grant::couponId).containsExactly("a", "b"); + } + + @Test + @DisplayName("같은_입력은_항상_같은_결과를_낸다") + void 같은_입력은_항상_같은_결과를_낸다() { + // 노드마다 다른 답을 내면 총합이 전역 크레딧을 넘는다. + Function> run = i -> allocator.allocate(997, List.of( + new CouponDemand("a", 300, 300), + new CouponDemand("b", 700, 700), + new CouponDemand("c", 5, 5))); + + assertThat(run.apply(1)).isEqualTo(run.apply(2)); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/allocation/QueueingHysteresisTest.java b/src/test/java/com/kafkick/waiting/domain/allocation/QueueingHysteresisTest.java new file mode 100644 index 00000000..90d1829d --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/allocation/QueueingHysteresisTest.java @@ -0,0 +1,161 @@ +package com.kafkick.waiting.domain.allocation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 대기열 진입과 해제의 임계를 비대칭으로 둔다. + * + *

같은 임계를 쓰면 유입이 임계선 근처에서 흔들릴 때 사용자에게 + * "대기 없음 → 500명 → 대기 없음" 이 반복해서 보인다. + */ +class QueueingHysteresisTest { + + /** 진입 100% · 해제 70% · 최소 유지 3틱 */ + private QueueingHysteresis policy() { + return QueueingHysteresis.of(1.0, 0.7, 3); + } + + @Test + @DisplayName("진입과_해제_임계가_비대칭이다") + void 진입과_해제_임계가_비대칭이다() { + QueueingHysteresis h = policy(); + + // 100% 에 닿아야 켠다 + assertThat(h.shouldQueue(99, 100)).isFalse(); + assertThat(h.shouldQueue(100, 100)).isTrue(); + + // 켜진 뒤에는 70% 아래로 내려가야 끈다 + assertThat(h.shouldQueue(80, 100)).isTrue(); + assertThat(h.shouldQueue(69, 100)).isTrue(); + } + + @Test + @DisplayName("해제_후_최소_유지_시간_동안_재진입하지_않는다") + void 해제_후_최소_유지_시간_동안_재진입하지_않는다() { + QueueingHysteresis h = policy(); + h.shouldQueue(100, 100); + + // 3틱을 버텨야 실제로 꺼진다 + assertThat(h.shouldQueue(0, 100)).isTrue(); + assertThat(h.shouldQueue(0, 100)).isTrue(); + assertThat(h.shouldQueue(0, 100)).isFalse(); + } + + @Test + @DisplayName("유지_시간_중_다시_올라가면_카운트가_초기화된다") + void 유지_시간_중_다시_올라가면_카운트가_초기화된다() { + QueueingHysteresis h = policy(); + h.shouldQueue(100, 100); + h.shouldQueue(0, 100); + h.shouldQueue(90, 100); + + // 다시 내려가도 3틱을 새로 세야 한다 + assertThat(h.shouldQueue(0, 100)).isTrue(); + assertThat(h.shouldQueue(0, 100)).isTrue(); + assertThat(h.shouldQueue(0, 100)).isFalse(); + } + + @Test + @DisplayName("임계선_근처_유입에서_전이가_두_번_이하다") + void 임계선_근처_유입에서_전이가_두_번_이하다() { + // 히스테리시스가 없으면 매 틱 뒤집혀 20회가 된다. + QueueingHysteresis h = policy(); + boolean previous = false; + int transitions = 0; + + for (int tick = 0; tick < 20; tick++) { + boolean queueing = h.shouldQueue(tick % 2 == 0 ? 101 : 95, 100); + if (queueing != previous) { + transitions++; + } + previous = queueing; + } + + assertThat(transitions).isLessThanOrEqualTo(2); + } + + @Test + @DisplayName("용량이_0이면_수요가_있는_한_줄을_세운다") + void 용량이_0이면_수요가_있는_한_줄을_세운다() { + // 0 으로 나누면 무한이 된다. 배수할 수 없으면 줄이 맞다. + QueueingHysteresis h = policy(); + + assertThat(h.shouldQueue(1, 0)).isTrue(); + assertThat(QueueingHysteresis.of(1.0, 0.7, 3).shouldQueue(0, 0)).isFalse(); + } + + @Test + @DisplayName("해제_임계가_진입_임계보다_크면_거부한다") + void 해제_임계가_진입_임계보다_크면_거부한다() { + // 뒤집히면 히스테리시스가 아니라 진동 증폭기가 된다. + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> QueueingHysteresis.of(0.7, 1.0, 3)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("임계가_비유한값이면_거부한다") + void 임계가_비유한값이면_거부한다() { + // NaN 비교는 전부 false 라 대기열이 영영 안 켜진다 — 조용히. + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> QueueingHysteresis.of(Double.NaN, 0.7, 3)) + .isInstanceOf(IllegalArgumentException.class); + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> QueueingHysteresis.of(1.0, Double.NaN, 3)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("해제_임계와_같으면_아직_켜진_상태다") + void 해제_임계와_같으면_아직_켜진_상태다() { + // 경계에서 어느 쪽인지 정해 두지 않으면 부하가 딱 70% 인 동안 + // 노드마다 다른 답을 내고, 사용자는 새로고침마다 다른 화면을 본다. + QueueingHysteresis h = policy(); + h.shouldQueue(100, 100); + + assertThat(h.shouldQueue(70, 100)).isTrue(); + assertThat(h.shouldQueue(70, 100)).isTrue(); + assertThat(h.shouldQueue(70, 100)).isTrue(); + } + + @Test + @DisplayName("진입과_해제_임계가_같아도_만들_수_있다") + void 진입과_해제_임계가_같아도_만들_수_있다() { + // 히스테리시스를 끄는 설정이다. 부하 시험에서 원본 거동을 볼 때 쓴다. + QueueingHysteresis h = QueueingHysteresis.of(1.0, 1.0, 1); + + assertThat(h.shouldQueue(100, 100)).isTrue(); + assertThat(h.shouldQueue(99, 100)).isFalse(); + } + + @Test + @DisplayName("음수_임계는_거부한다") + void 음수_임계는_거부한다() { + // 음수를 허용하면 수요가 0 이어도 load(0) >= enterRatio 가 참이라 + // 아무도 안 왔는데 대기열이 켜진다. + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> QueueingHysteresis.of(-1, -1, 3)) + .isInstanceOf(IllegalArgumentException.class); + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> QueueingHysteresis.of(1.0, -0.1, 3)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("수요가_0이면_대기열을_켜지_않는다") + void 수요가_0이면_대기열을_켜지_않는다() { + assertThat(QueueingHysteresis.of(1.0, 0.7, 3).shouldQueue(0, 100)).isFalse(); + } + + @Test + @DisplayName("임계_0은_무조건_줄을_세우는_유효한_설정이다") + void 임계_0은_무조건_줄을_세우는_유효한_설정이다() { + // 운영자가 이 쿠폰만 항상 큐로 돌리는 값이다. 거부하면 그 조작이 막힌다. + QueueingHysteresis h = QueueingHysteresis.of(0.0, 0.0, 1); + + assertThat(h.shouldQueue(0, 100)).isTrue(); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/allocation/ShardSplitterTest.java b/src/test/java/com/kafkick/waiting/domain/allocation/ShardSplitterTest.java new file mode 100644 index 00000000..20c5c54a --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/allocation/ShardSplitterTest.java @@ -0,0 +1,68 @@ +package com.kafkick.waiting.domain.allocation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 큐 분할의 자리만 미리 잡아 둔다. + * + *

지금은 샤드가 하나뿐이라 나눌 것이 없다. 그래도 배분과 분할을 지금 + * 갈라 두면 Phase 10 에서 비례 분할을 끼워 넣을 때 배분을 안 건드린다. + */ +class ShardSplitterTest { + + @Test + @DisplayName("샤드가_하나면_전량을_그_샤드에_배정한다") + void 샤드가_하나면_전량을_그_샤드에_배정한다() { + ShardSplitter splitter = SingleShardSplitter.create(); + + assertThat(splitter.split(new Grant("c1", 500))) + .containsExactly(new ShardGrant("c1", 0, 500)); + } + + @Test + @DisplayName("몫이_0이어도_샤드_하나를_돌려준다") + void 몫이_0이어도_샤드_하나를_돌려준다() { + // 빈 목록을 주면 호출부가 "이 쿠폰은 없다" 와 구분하지 못한다. + assertThat(SingleShardSplitter.create().split(new Grant("c1", 0))) + .containsExactly(new ShardGrant("c1", 0, 0)); + } + + @Test + @DisplayName("여러_배분도_각각_한_샤드로_간다") + void 여러_배분도_각각_한_샤드로_간다() { + List split = SingleShardSplitter.create() + .splitAll(List.of(new Grant("a", 10), new Grant("b", 20))); + + assertThat(split).containsExactly( + new ShardGrant("a", 0, 10), new ShardGrant("b", 0, 20)); + } + + @Test + @DisplayName("잘못된_샤드_배정은_만들_수_없다") + void 잘못된_샤드_배정은_만들_수_없다() { + assertThatThrownBy(() -> new ShardGrant(null, 0, 10)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ShardGrant(" ", 0, 10)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ShardGrant("c1", -1, 10)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ShardGrant("c1", 0, -1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("잘못된_배분_결과는_만들_수_없다") + void 잘못된_배분_결과는_만들_수_없다() { + assertThatThrownBy(() -> new Grant(null, 10)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new Grant(" ", 10)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new Grant("c1", -1)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/coupon/AdmissionCapTest.java b/src/test/java/com/kafkick/waiting/domain/coupon/AdmissionCapTest.java new file mode 100644 index 00000000..6fa6b1f3 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/coupon/AdmissionCapTest.java @@ -0,0 +1,100 @@ +package com.kafkick.waiting.domain.coupon; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.stream.IntStream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 통과 상한 — 이 제품의 존재 이유(R1). + * + *

한산한 쿠폰은 {@code credit} 이 0 이다(I1). 상한을 {@code credit} 으로 재면 + * 한산한 쿠폰일수록 반드시 큐로 가는 역전이 생긴다. + */ +class AdmissionCapTest { + + @Test + @DisplayName("경합_쿠폰의_몫은_credit을_노드수로_나눈_값이다") + void 경합_쿠폰의_몫은_credit을_노드수로_나눈_값이다() { + CouponState s = CouponState.queueing(1000, 500, 3000); + + assertThat(s.contendedCap(10)).isEqualTo(100); + } + + @Test + @DisplayName("노드수가_0이어도_나눗셈이_터지지_않는다") + void 노드수가_0이어도_나눗셈이_터지지_않는다() { + CouponState s = CouponState.queueing(1000, 500, 3000); + + assertThat(s.contendedCap(0)).isEqualTo(1000); + } + + @Test + @DisplayName("한산한_쿠폰은_전역_여유를_상한으로_쓴다") + void 한산한_쿠폰은_전역_여유를_상한으로_쓴다() { + // credit 은 0 인데 상한은 0 이 아니어야 한다. 이 한 줄이 R1 이다. + CouponState s = CouponState.idle(500); + + assertThat(s.credit()).isZero(); + assertThat(s.idleCap(new SnapshotMeta(1000, 10), 0.7)).isPositive(); + } + + @Test + @DisplayName("한산한_쿠폰의_상한은_노드몫에_유휴비율을_곱한_값이다") + void 한산한_쿠폰의_상한은_노드몫에_유휴비율을_곱한_값이다() { + CouponState s = CouponState.idle(500); + + // globalCredit 1000 / 노드 10 = 100, × 0.7 = 70 + assertThat(s.idleCap(new SnapshotMeta(1000, 10), 0.7)).isEqualTo(70); + } + + @Test + @DisplayName("credit이_노드수보다_작으면_총합이_credit을_넘지_않는다") + void credit이_노드수보다_작으면_총합이_credit을_넘지_않는다() { + // credit 10 을 노드 20 이 나눠 가지면 정수 나눗셈으로 전부 0 이 된다. + // max(1, ...) 로 올리면 20 이 나가 credit 의 두 배가 된다 — 초과 배분이다. + CouponState s = CouponState.queueing(10, 500, 3000); + + long total = IntStream.range(0, 20).mapToLong(node -> s.contendedCap(20, node)).sum(); + + assertThat(total).isEqualTo(10); + // 앞쪽 10개 노드가 1 씩, 나머지는 0 + assertThat(s.contendedCap(20, 0)).isEqualTo(1); + assertThat(s.contendedCap(20, 9)).isEqualTo(1); + assertThat(s.contendedCap(20, 10)).isZero(); + assertThat(s.contendedCap(20, 19)).isZero(); + } + + @Test + @DisplayName("유휴비율이_음수나_비유한값이면_거부한다") + void 유휴비율이_음수나_비유한값이면_거부한다() { + CouponState s = CouponState.idle(500); + SnapshotMeta meta = new SnapshotMeta(1000, 10); + + assertThatThrownBy(() -> s.idleCap(meta, -0.1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> s.idleCap(meta, Double.NaN)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("나머지가_있어도_총합이_credit을_넘지_않는다") + void 나머지가_있어도_총합이_credit을_넘지_않는다() { + // 임의 조합에서도 성립해야 한다. 초과 배분은 타협 불가다. + for (long credit : new long[] {0, 1, 7, 10, 99, 1000, 100_000}) { + for (int nodes : new int[] {1, 3, 7, 20, 100}) { + CouponState s = credit == 0 + ? CouponState.idle(500) + : CouponState.queueing(credit, 500, 3000); + long total = IntStream.range(0, nodes) + .mapToLong(node -> s.contendedCap(nodes, node)) + .sum(); + assertThat(total) + .withFailMessage("credit=%d nodes=%d total=%d", credit, nodes, total) + .isLessThanOrEqualTo(credit); + } + } + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/coupon/CouponStateBoundaryTest.java b/src/test/java/com/kafkick/waiting/domain/coupon/CouponStateBoundaryTest.java new file mode 100644 index 00000000..5a8a2f4c --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/coupon/CouponStateBoundaryTest.java @@ -0,0 +1,44 @@ +package com.kafkick.waiting.domain.coupon; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 파생값의 경계. + * + *

0 은 예외가 아니라 실제로 오는 값이다. 한산한 쿠폰의 credit 이 0 이고(I1), + * 운영자가 비율을 0 으로 내리면 그 쿠폰만 통과가 멎는다 — 둘 다 정상 동작이라 + * "0 이하는 거부" 로 뭉뚱그리면 안 된다. + */ +class CouponStateBoundaryTest { + + private static final SnapshotMeta META = new SnapshotMeta(1000, 10); + + @Test + @DisplayName("한산_비율_0은_유효하고_상한도_0이_된다") + void 한산_비율_0은_유효하고_상한도_0이_된다() { + assertThat(CouponStates.idle(500).idleCap(META, 0.0)).isZero(); + } + + @Test + @DisplayName("한산_비율이_음수면_거부한다") + void 한산_비율이_음수면_거부한다() { + assertThatThrownBy(() -> CouponStates.idle(500).idleCap(META, -0.1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("최대_대기_시간이_0이면_큐_상한도_0이다") + void 최대_대기_시간이_0이면_큐_상한도_0이다() { + assertThat(CouponStates.queueing(100, 500, 10).queueCapacity(0)).isZero(); + } + + @Test + @DisplayName("최대_대기_시간이_1이면_credit_만큼_받는다") + void 최대_대기_시간이_1이면_credit_만큼_받는다() { + assertThat(CouponStates.queueing(100, 500, 10).queueCapacity(1)).isEqualTo(100); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/coupon/CouponStateFactoryTest.java b/src/test/java/com/kafkick/waiting/domain/coupon/CouponStateFactoryTest.java new file mode 100644 index 00000000..7b36b5d5 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/coupon/CouponStateFactoryTest.java @@ -0,0 +1,69 @@ +package com.kafkick.waiting.domain.coupon; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** 팩토리는 도달 가능한 상태 하나씩만 만든다. */ +class CouponStateFactoryTest { + + @Test + @DisplayName("idle_팩토리는_credit_0과_waiting_0을_만든다") + void idle_팩토리는_credit_0과_waiting_0을_만든다() { + CouponState s = CouponState.idle(500); + + assertThat(s.runtime()).isEqualTo(RuntimeState.IDLE); + assertThat(s.credit()).isZero(); + assertThat(s.waiting()).isZero(); + assertThat(s.remainingStock()).isEqualTo(500); + } + + @Test + @DisplayName("queueing_팩토리는_대기자가_있는_상태를_만든다") + void queueing_팩토리는_대기자가_있는_상태를_만든다() { + CouponState s = CouponState.queueing(100, 500, 3000); + + assertThat(s.runtime()).isEqualTo(RuntimeState.QUEUEING); + assertThat(s.waiting()).isEqualTo(3000); + } + + @Test + @DisplayName("draining_팩토리는_몫이_대기자_이상인_상태를_만든다") + void draining_팩토리는_몫이_대기자_이상인_상태를_만든다() { + CouponState s = CouponState.draining(3000, 500, 3000); + + assertThat(s.runtime()).isEqualTo(RuntimeState.DRAINING); + assertThat(s.credit()).isGreaterThanOrEqualTo(s.waiting()); + } + + @Test + @DisplayName("closed_팩토리는_재고가_0인_상태를_만든다") + void closed_팩토리는_재고가_0인_상태를_만든다() { + CouponState s = CouponState.closed(3000); + + assertThat(s.runtime()).isEqualTo(RuntimeState.CLOSED); + assertThat(s.remainingStock()).isZero(); + } + + @Test + @DisplayName("off_팩토리는_대기열이_꺼진_상태를_만든다") + void off_팩토리는_대기열이_꺼진_상태를_만든다() { + CouponState s = CouponState.off(500); + + assertThat(s.mode()).isEqualTo(QueueMode.OFF); + assertThat(s.runtime()).isEqualTo(RuntimeState.IDLE); + } + + @Test + @DisplayName("unknown_팩토리는_스냅샷에_없는_쿠폰을_나타낸다") + void unknown_팩토리는_스냅샷에_없는_쿠폰을_나타낸다() { + // 미지 쿠폰은 404 로 끊는다. 상태를 만들어 두는 것은 판정이 + // null 을 다루지 않게 하려는 것이지 통과시키려는 게 아니다. + CouponState s = CouponState.unknown(); + + assertThat(s.runtime()).isEqualTo(RuntimeState.CLOSED); + assertThat(s.remainingStock()).isZero(); + assertThat(s.credit()).isZero(); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/coupon/CouponStateTest.java b/src/test/java/com/kafkick/waiting/domain/coupon/CouponStateTest.java new file mode 100644 index 00000000..573308bd --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/coupon/CouponStateTest.java @@ -0,0 +1,194 @@ +package com.kafkick.waiting.domain.coupon; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * 불변식은 문서가 아니라 생성자가 지킨다. + * + *

이전 구현이 무너진 이유가 픽스처가 존재할 수 없는 상태를 만들 수 있었던 + * 것이다. {@code (IDLE, credit=1000)} 같은 조합을 찍어낼 수 있었고, 그 상태에서는 + * 버그가 드러나지 않았다. + */ +class CouponStateTest { + + // 규칙의 근거는 바깥 인스턴스 누수인데, 테스트 인스턴스는 실행 후 버려져 해당 없다. + @Nested + @DisplayName("I1 — 한산한 쿠폰은 배분받은 몫이 없다") + class I1 { + + @Test + @DisplayName("IDLE_상태에서_credit이_0이_아니면_생성에_실패한다") + void IDLE_상태에서_credit이_0이_아니면_생성에_실패한다() { + assertThatThrownBy(() -> new CouponState(QueueMode.ADAPTIVE, RuntimeState.IDLE, 1000, 500, 0, 1.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("IDLE"); + } + + @Test + @DisplayName("IDLE_이고_credit이_0이면_생성된다") + void IDLE_이고_credit이_0이면_생성된다() { + CouponState s = new CouponState(QueueMode.ADAPTIVE, RuntimeState.IDLE, 0, 500, 0, 1.0); + + assertThat(s.runtime()).isEqualTo(RuntimeState.IDLE); + assertThat(s.credit()).isZero(); + } + } + + // 규칙의 근거는 바깥 인스턴스 누수인데, 테스트 인스턴스는 실행 후 버려져 해당 없다. + @Nested + @DisplayName("I2 — 종결된 쿠폰에는 재고가 없다") + class I2 { + + @Test + @DisplayName("CLOSED_인데_재고가_남아_있으면_생성에_실패한다") + void CLOSED_인데_재고가_남아_있으면_생성에_실패한다() { + assertThatThrownBy(() -> new CouponState(QueueMode.ADAPTIVE, RuntimeState.CLOSED, 0, 10, 5, 1.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CLOSED"); + } + } + + // 규칙의 근거는 바깥 인스턴스 누수인데, 테스트 인스턴스는 실행 후 버려져 해당 없다. + @Nested + @DisplayName("I3 — 배수 중이면 남은 대기자를 다 뺄 수 있다") + class I3 { + + @Test + @DisplayName("DRAINING_인데_credit이_대기자보다_적으면_생성에_실패한다") + void DRAINING_인데_credit이_대기자보다_적으면_생성에_실패한다() { + assertThatThrownBy(() -> new CouponState(QueueMode.ADAPTIVE, RuntimeState.DRAINING, 10, 500, 100, 1.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("DRAINING"); + } + } + + // 규칙의 근거는 바깥 인스턴스 누수인데, 테스트 인스턴스는 실행 후 버려져 해당 없다. + @Nested + @DisplayName("I4 — 줄이 비었으면 큐 상태일 수 없다") + class I4 { + + @Test + @DisplayName("대기자가_0인데_QUEUEING이면_생성에_실패한다") + void 대기자가_0인데_QUEUEING이면_생성에_실패한다() { + assertThatThrownBy(() -> new CouponState(QueueMode.ADAPTIVE, RuntimeState.QUEUEING, 100, 500, 0, 1.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("waiting"); + } + + @Test + @DisplayName("대기자가_0이고_CLOSED면_생성된다") + void 대기자가_0이고_CLOSED면_생성된다() { + CouponState s = new CouponState(QueueMode.ADAPTIVE, RuntimeState.CLOSED, 0, 0, 0, 1.0); + + assertThat(s.runtime()).isEqualTo(RuntimeState.CLOSED); + assertThat(s.waiting()).isZero(); + } + } + + // 규칙의 근거는 바깥 인스턴스 누수인데, 테스트 인스턴스는 실행 후 버려져 해당 없다. + @Nested + @DisplayName("I6 — 폴링 배수는 1 미만으로 내려가지 않는다") + class I6 { + + @Test + @DisplayName("pollScale이_1미만이면_1로_정규화된다") + void pollScale이_1미만이면_1로_정규화된다() { + // 거부가 아니라 정규화다. 1 미만은 폴링을 더 자주 하라는 뜻이 되는데 + // 그건 예산을 늘리는 방향이라 의미가 없다. + assertThat(new CouponState(QueueMode.ADAPTIVE, RuntimeState.IDLE, 0, 500, 0, 0.3).pollScale()) + .isEqualTo(1.0); + } + + @Test + @DisplayName("pollScale이_1이상이면_그대로_둔다") + void pollScale이_1이상이면_그대로_둔다() { + assertThat(new CouponState(QueueMode.ADAPTIVE, RuntimeState.IDLE, 0, 500, 0, 2.5).pollScale()) + .isEqualTo(2.5); + } + } + + // 규칙의 근거는 바깥 인스턴스 누수인데, 테스트 인스턴스는 실행 후 버려져 해당 없다. + @Nested + @DisplayName("IDLE 은 줄이 없다") + class IDLE에줄이없다 { + + @Test + @DisplayName("IDLE인데_대기자가_있으면_생성에_실패한다") + void IDLE인데_대기자가_있으면_생성에_실패한다() { + // I4 의 대우로는 이 조합이 안 막힌다. 그대로 두면 판정 8번이 + // 통과시켜 줄 선 사람을 추월한다. + assertThatThrownBy( + () -> new CouponState(QueueMode.ADAPTIVE, RuntimeState.IDLE, 0, 500, 5000, 1.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("waiting"); + } + } + + // 규칙의 근거는 바깥 인스턴스 누수인데, 테스트 인스턴스는 실행 후 버려져 해당 없다. + @Nested + @DisplayName("pollScale 유한값") + class pollScale유한값 { + + @Test + @DisplayName("pollScale이_NaN이면_생성에_실패한다") + void pollScale이_NaN이면_생성에_실패한다() { + // NaN 은 비교가 전부 false 라 Math.max 를 그냥 통과한다. + assertThatThrownBy( + () -> new CouponState( + QueueMode.ADAPTIVE, RuntimeState.IDLE, 0, 500, 0, Double.NaN)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + // 규칙의 근거는 바깥 인스턴스 누수인데, 테스트 인스턴스는 실행 후 버려져 해당 없다. + @Nested + @DisplayName("필수 값") + class 필수값 { + + @Test + @DisplayName("mode가_null이면_생성에_실패한다") + void mode가_null이면_생성에_실패한다() { + assertThatThrownBy(() -> new CouponState(null, RuntimeState.IDLE, 0, 500, 0, 1.0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("runtime이_null이면_생성에_실패한다") + void runtime이_null이면_생성에_실패한다() { + assertThatThrownBy(() -> new CouponState(QueueMode.ADAPTIVE, null, 0, 500, 0, 1.0)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + // 규칙의 근거는 바깥 인스턴스 누수인데, 테스트 인스턴스는 실행 후 버려져 해당 없다. + @Nested + @DisplayName("음수 방어") + class 음수 { + + @Test + @DisplayName("재고가_음수면_생성에_실패한다") + void 재고가_음수면_생성에_실패한다() { + assertThatThrownBy(() -> new CouponState(QueueMode.ADAPTIVE, RuntimeState.IDLE, 0, -1, 0, 1.0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("대기자가_음수면_생성에_실패한다") + void 대기자가_음수면_생성에_실패한다() { + assertThatThrownBy(() -> new CouponState(QueueMode.ADAPTIVE, RuntimeState.IDLE, 0, 500, -1, 1.0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("credit이_음수면_생성에_실패한다") + void credit이_음수면_생성에_실패한다() { + assertThatThrownBy(() -> new CouponState(QueueMode.ADAPTIVE, RuntimeState.QUEUEING, -1, 500, 10, 1.0)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/coupon/CreditAllocationPropertyTest.java b/src/test/java/com/kafkick/waiting/domain/coupon/CreditAllocationPropertyTest.java new file mode 100644 index 00000000..9a2fd326 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/coupon/CreditAllocationPropertyTest.java @@ -0,0 +1,80 @@ +package com.kafkick.waiting.domain.coupon; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Random; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 크레딧 초과 배분 속성 테스트 — 타협 불가 기준이다. + * + *

예시 몇 개로는 못 잡는다. 정수 나눗셈의 나머지 처리는 특정 + * {@code (credit, N)} 조합에서만 어긋난다. + */ +class CreditAllocationPropertyTest { + + /** 무작위지만 재현 가능해야 한다. 실패를 다시 못 만들면 고칠 수 없다. */ + private static final long SEED = 20260819L; + + private static final int TRIALS = 100_000; + + @Test + @DisplayName("무작위_10만회에서_배분_총합이_credit을_넘지_않는다") + void 무작위_10만회에서_배분_총합이_credit을_넘지_않는다() { + Random rnd = new Random(SEED); + int violations = 0; + + for (int t = 0; t < TRIALS; t++) { + long credit = rnd.nextLong(0, 200_000); + int nodes = rnd.nextInt(1, 200); + + CouponState s = credit == 0 + ? CouponState.idle(500) + : CouponState.queueing(credit, 500, 1); + + long total = 0; + for (int node = 0; node < nodes; node++) { + total += s.contendedCap(nodes, node); + } + + if (total > credit) { + violations++; + } + } + + assertThat(violations) + .withFailMessage("초과 배분 %d 건 (시드 %d)", violations, SEED) + .isZero(); + } + + @Test + @DisplayName("무작위_10만회에서_배분이_credit에_최대한_가깝다") + void 무작위_10만회에서_배분이_credit에_최대한_가깝다() { + // 넘지 않는 것만으로는 부족하다. 전부 0 을 주면 그것도 "안 넘는다". + // 나머지를 앞쪽 노드에 나눠 주므로 총합은 credit 과 같아야 한다. + Random rnd = new Random(SEED); + int shortfalls = 0; + + for (int t = 0; t < TRIALS; t++) { + long credit = rnd.nextLong(1, 200_000); + int nodes = rnd.nextInt(1, 200); + + CouponState s = CouponState.queueing(credit, 500, 1); + + long total = 0; + for (int node = 0; node < nodes; node++) { + total += s.contendedCap(nodes, node); + } + + // 노드가 credit 보다 많으면 뒤쪽은 0 이지만 총합은 여전히 credit 이다 + if (total != credit) { + shortfalls++; + } + } + + assertThat(shortfalls) + .withFailMessage("총합이 credit 과 다른 경우 %d 건", shortfalls) + .isZero(); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/coupon/QueueDerivedTest.java b/src/test/java/com/kafkick/waiting/domain/coupon/QueueDerivedTest.java new file mode 100644 index 00000000..067c5879 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/coupon/QueueDerivedTest.java @@ -0,0 +1,66 @@ +package com.kafkick.waiting.domain.coupon; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** credit 이 0 이면 나눗셈이 터진다. 한산한 쿠폰이 정확히 그 상태다. */ +class QueueDerivedTest { + + @Test + @DisplayName("credit이_0인데_대기자가_있으면_큐_깊이는_무한이다") + void credit이_0인데_대기자가_있으면_큐_깊이는_무한이다() { + // 배수 속도가 0 이면 영원히 안 빠진다. 예외가 아니라 무한이 맞다. + // CLOSED 가 이 조합의 유일한 도달 경로다 — 매진됐는데 갇힌 사람이 있다. + assertThat(CouponState.closed(3000).queueDepthSec()).isInfinite(); + } + + @Test + @DisplayName("한산한_쿠폰은_credit이_0이어도_깊이가_0이다") + void 한산한_쿠폰은_credit이_0이어도_깊이가_0이다() { + // I1 과 I4 가 겹쳐 IDLE 은 credit 0 · waiting 0 이다. 나눗셈에 닿지 않는다. + assertThat(CouponState.idle(500).queueDepthSec()).isZero(); + } + + @Test + @DisplayName("큐가_비어_있으면_깊이는_0이다") + void 큐가_비어_있으면_깊이는_0이다() { + assertThat(CouponState.closed(0).queueDepthSec()).isZero(); + } + + @Test + @DisplayName("큐_깊이는_대기자를_초당_배수량으로_나눈_값이다") + void 큐_깊이는_대기자를_초당_배수량으로_나눈_값이다() { + assertThat(CouponState.queueing(100, 500, 3000).queueDepthSec()).isEqualTo(30.0); + } + + @Test + @DisplayName("큐_용량은_허용_최대_ETA와_credit의_곱이다") + void 큐_용량은_허용_최대_ETA와_credit의_곱이다() { + assertThat(CouponState.queueing(100, 500, 3000).queueCapacity(60)).isEqualTo(6000); + } + + @Test + @DisplayName("곱셈이_넘치면_최대값으로_막는다") + void 곱셈이_넘치면_최대값으로_막는다() { + // 넘치면 음수가 되어 큐 상한이 사실상 0 — 전원 거절이 된다. + CouponState s = CouponState.queueing(Long.MAX_VALUE / 2, 500, 3000); + + assertThat(s.queueCapacity(1000)).isEqualTo(Long.MAX_VALUE); + } + + @Test + @DisplayName("허용_ETA가_0이하면_용량은_0이다") + void 허용_ETA가_0이하면_용량은_0이다() { + assertThat(CouponState.queueing(100, 500, 3000).queueCapacity(0)).isZero(); + assertThat(CouponState.queueing(100, 500, 3000).queueCapacity(-1)).isZero(); + } + + @Test + @DisplayName("credit이_0이면_큐_용량도_0이다") + void credit이_0이면_큐_용량도_0이다() { + // 배수할 수 없는데 줄을 받으면 갇힌 사람만 늘어난다. + assertThat(CouponState.idle(500).queueCapacity(60)).isZero(); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/coupon/RuntimeStateTest.java b/src/test/java/com/kafkick/waiting/domain/coupon/RuntimeStateTest.java new file mode 100644 index 00000000..ba9a2be8 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/coupon/RuntimeStateTest.java @@ -0,0 +1,27 @@ +package com.kafkick.waiting.domain.coupon; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RuntimeStateTest { + + @Test + @DisplayName("런타임_상태는_네_가지다") + void 런타임_상태는_네_가지다() { + assertThat(RuntimeState.values()) + .containsExactly( + RuntimeState.IDLE, + RuntimeState.QUEUEING, + RuntimeState.DRAINING, + RuntimeState.CLOSED); + } + + @Test + @DisplayName("대기열_모드는_세_가지다") + void 대기열_모드는_세_가지다() { + assertThat(QueueMode.values()) + .containsExactly(QueueMode.OFF, QueueMode.ADAPTIVE, QueueMode.ALWAYS); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/coupon/SnapshotMetaTest.java b/src/test/java/com/kafkick/waiting/domain/coupon/SnapshotMetaTest.java new file mode 100644 index 00000000..9dc037ce --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/coupon/SnapshotMetaTest.java @@ -0,0 +1,36 @@ +package com.kafkick.waiting.domain.coupon; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** 노드 수는 분모로 쓰인다. 0 이 들어오면 판정 전체가 터진다. */ +class SnapshotMetaTest { + + @Test + @DisplayName("게이트웨이_수가_0이면_1로_취급한다") + void 게이트웨이_수가_0이면_1로_취급한다() { + assertThat(new SnapshotMeta(1000, 0).effectiveGatewayCount()).isEqualTo(1); + } + + @Test + @DisplayName("게이트웨이_수가_음수여도_1로_취급한다") + void 게이트웨이_수가_음수여도_1로_취급한다() { + assertThat(new SnapshotMeta(1000, -5).effectiveGatewayCount()).isEqualTo(1); + } + + @Test + @DisplayName("게이트웨이_수가_양수면_그대로_쓴다") + void 게이트웨이_수가_양수면_그대로_쓴다() { + assertThat(new SnapshotMeta(1000, 10).effectiveGatewayCount()).isEqualTo(10); + } + + @Test + @DisplayName("전역_여유가_음수면_생성에_실패한다") + void 전역_여유가_음수면_생성에_실패한다() { + assertThatThrownBy(() -> new SnapshotMeta(-1, 10)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/queue/EtaPolicyTest.java b/src/test/java/com/kafkick/waiting/domain/queue/EtaPolicyTest.java new file mode 100644 index 00000000..7942ea37 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/queue/EtaPolicyTest.java @@ -0,0 +1,66 @@ +package com.kafkick.waiting.domain.queue; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 예상 대기 시간. + * + *

순간 배수율로 나누지 않는다. GC 스파이크 한 번에 표시 시간이 두 배가 + * 되면 사용자는 그것을 서비스가 망가진 신호로 읽는다. + */ +class EtaPolicyTest { + + @Test + @DisplayName("ETA는_EWMA_credit으로_나눈다") + void ETA는_EWMA_credit으로_나눈다() { + // 순간 credit 이 100 으로 튀어도 평활화 값 50 으로 나눈다 + assertThat(EtaPolicy.etaSec(1000, 50)).isCloseTo(20, within(0.001)); + } + + @Test + @DisplayName("credit이_0이면_계산_중을_반환한다") + void credit이_0이면_계산_중을_반환한다() { + // 무한을 그대로 내보내면 표시 계층이 터진다. 모른다고 말한다. + assertThat(EtaPolicy.etaSec(1000, 0)).isEqualTo(EtaPolicy.UNKNOWN); + assertThat(EtaPolicy.etaSec(1000, -1)).isEqualTo(EtaPolicy.UNKNOWN); + } + + @Test + @DisplayName("앞에_아무도_없으면_0초다") + void 앞에_아무도_없으면_0초다() { + assertThat(EtaPolicy.etaSec(0, 0)).isZero(); + assertThat(EtaPolicy.etaSec(0, 50)).isZero(); + } + + @Test + @DisplayName("표시_ETA는_거친_버킷이다") + void 표시_ETA는_거친_버킷이다() { + // ±1.5초 오차가 눈에 보이면 안 된다. 초 단위로 보여 주면 + // 1초씩 줄다 멈추는 게 보이고, 그때마다 신뢰를 잃는다. + assertThat(EtaPolicy.bucket(0)).isEqualTo(EtaDisplay.ALMOST_THERE); + assertThat(EtaPolicy.bucket(29)).isEqualTo(EtaDisplay.ALMOST_THERE); + assertThat(EtaPolicy.bucket(30)).isEqualTo(EtaDisplay.ABOUT_A_MINUTE); + assertThat(EtaPolicy.bucket(89)).isEqualTo(EtaDisplay.ABOUT_A_MINUTE); + assertThat(EtaPolicy.bucket(90)).isEqualTo(EtaDisplay.ABOUT_FIVE_MINUTES); + assertThat(EtaPolicy.bucket(449)).isEqualTo(EtaDisplay.ABOUT_FIVE_MINUTES); + assertThat(EtaPolicy.bucket(450)).isEqualTo(EtaDisplay.OVER_TEN_MINUTES); + } + + @Test + @DisplayName("계산_중은_별도_버킷이다") + void 계산_중은_별도_버킷이다() { + // "10분 이상" 과 "모른다" 를 뭉치면 사용자가 떠날지 판단할 수 없다. + assertThat(EtaPolicy.bucket(EtaPolicy.UNKNOWN)).isEqualTo(EtaDisplay.CALCULATING); + } + + @Test + @DisplayName("음수_순위는_거부한다") + void 음수_순위는_거부한다() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> EtaPolicy.etaSec(-1, 50)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/queue/PollBudgetPlannerTest.java b/src/test/java/com/kafkick/waiting/domain/queue/PollBudgetPlannerTest.java new file mode 100644 index 00000000..1252c439 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/queue/PollBudgetPlannerTest.java @@ -0,0 +1,113 @@ +package com.kafkick.waiting.domain.queue; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import com.kafkick.waiting.domain.allocation.CouponDemand; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 폴링 부하를 큐를 훑지 않고 닫힌 식으로 구한다. + * + *

20,000명을 세면 그 계산 자체가 부하다. 배수율을 알면 각 밴드에 몇 명이 + * 있는지는 곱셈 몇 번으로 나온다. + */ +class PollBudgetPlannerTest { + + @Test + @DisplayName("큐를_훑지_않고_닫힌_식으로_예상_폴링을_구한다") + void 큐를_훑지_않고_닫힌_식으로_예상_폴링을_구한다() { + // 배수 10/s · 1000명. 앞 50명은 5초 안(1s 간격), 다음 250명은 + // 30초 안(3s), 다음 900명 중 남은 700명은 120초 안(10s). + // 50/1 + 250/3 + 700/10 = 50 + 83.33 + 70 = 203.33 + assertThat(PollBudgetPlanner.expectedPollRps(1000, 10)) + .isCloseTo(203.33, within(0.1)); + } + + @Test + @DisplayName("배수율이_0이면_전원이_가장_먼_밴드다") + void 배수율이_0이면_전원이_가장_먼_밴드다() { + // 안 빠지는 줄은 전원의 ETA 가 무한이다. + assertThat(PollBudgetPlanner.expectedPollRps(100_000, 0)) + .isCloseTo(100_000 / 30.0, within(0.01)); + } + + @Test + @DisplayName("대기자가_없으면_폴링도_없다") + void 대기자가_없으면_폴링도_없다() { + assertThat(PollBudgetPlanner.expectedPollRps(0, 10)).isZero(); + } + + @Test + @DisplayName("배수가_빨라_전원이_첫_밴드면_인원만큼_폴링한다") + void 배수가_빨라_전원이_첫_밴드면_인원만큼_폴링한다() { + // 100명 · 배수 1000/s → 전원 0.1초 안. 1초 간격이 하한이다. + assertThat(PollBudgetPlanner.expectedPollRps(100, 1000)) + .isCloseTo(100, within(0.01)); + } + + @Test + @DisplayName("예산이_남아도_배수는_1_미만으로_내려가지_않는다") + void 예산이_남아도_배수는_1_미만으로_내려가지_않는다() { + // 한산할 때 오히려 부하를 만들지 않는다. + assertThat(PollBudgetPlanner.pollScale(10, 4000)).isEqualTo(1.0); + } + + @Test + @DisplayName("예산을_넘으면_넘은_비율만큼_간격을_늘린다") + void 예산을_넘으면_넘은_비율만큼_간격을_늘린다() { + assertThat(PollBudgetPlanner.pollScale(8000, 4000)).isEqualTo(2.0); + } + + @Test + @DisplayName("예산이_0이하면_배수를_1로_둔다") + void 예산이_0이하면_배수를_1로_둔다() { + // 0 으로 나누면 무한이 되어 아무도 폴링을 못 한다. + assertThat(PollBudgetPlanner.pollScale(8000, 0)).isEqualTo(1.0); + } + + @Test + @DisplayName("매진_쿠폰의_대기자는_전역_폴링_예산에_들어가지_않는다") + void 매진_쿠폰의_대기자는_전역_폴링_예산에_들어가지_않는다() { + // 죽은 큐가 살아 있는 쿠폰의 폴링 간격을 늘리면, 배분에서 막아 둔 + // 기아가 폴링 경로로 되살아난다. + List demands = List.of( + new CouponDemand("soldout", 100_000, 0), + new CouponDemand("live", 1000, 10_000)); + + double withDead = PollBudgetPlanner.expectedPollRps(demands, id -> 10); + + assertThat(withDead).isCloseTo(PollBudgetPlanner.expectedPollRps(1000, 10), within(0.1)); + } + + @Test + @DisplayName("살아_있는_쿠폰이_여럿이면_합산한다") + void 살아_있는_쿠폰이_여럿이면_합산한다() { + List demands = List.of( + new CouponDemand("a", 1000, 10_000), + new CouponDemand("b", 1000, 10_000)); + + assertThat(PollBudgetPlanner.expectedPollRps(demands, id -> 10)) + .isCloseTo(2 * PollBudgetPlanner.expectedPollRps(1000, 10), within(0.1)); + } + + @Test + @DisplayName("줄이_길고_배수가_느리면_네_밴드가_모두_찬다") + void 줄이_길고_배수가_느리면_네_밴드가_모두_찬다() { + // 배수 1/s · 1000명. 앞 5명 1초 밴드, 25명 3초, 90명 10초, + // 남은 880명이 30초 밴드다. 5 + 8.33 + 9 + 29.33 = 51.67 + // 이 경우가 없으면 마지막 밴드로 넘어가는 경로가 한 번도 안 돈다. + assertThat(PollBudgetPlanner.expectedPollRps(1000, 1)) + .isCloseTo(51.67, within(0.1)); + } + + @Test + @DisplayName("배수가_아주_느려도_맨_앞사람은_첫_밴드다") + void 배수가_아주_느려도_맨_앞사람은_첫_밴드다() { + // 반올림하면 이 사람이 먼 밴드로 밀려 예산을 과소 추정하고, + // pollScale 이 안 올라 실제 부하가 예산을 넘는다. + assertThat(PollBudgetPlanner.expectedPollRps(1, 0.02)).isCloseTo(1.0, within(0.001)); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/queue/PollIntervalPolicyTest.java b/src/test/java/com/kafkick/waiting/domain/queue/PollIntervalPolicyTest.java new file mode 100644 index 00000000..a591c9f6 --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/queue/PollIntervalPolicyTest.java @@ -0,0 +1,124 @@ +package com.kafkick.waiting.domain.queue; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 폴링 간격은 서버가 정한다 (D-2). + * + *

부하를 정하는 것은 대기 인원이 아니라 큐의 시간 깊이이고, 개인은 + * 자기가 얼마나 기다릴지를 모른다. + */ +class PollIntervalPolicyTest { + + /** 지터를 끈 정책. 밴드 경계만 볼 때 쓴다. */ + private PollIntervalPolicy noJitter() { + return PollIntervalPolicy.of(0.0); + } + + @Test + @DisplayName("ETA_밴드마다_기본_간격이_다르다") + void ETA_밴드마다_기본_간격이_다르다() { + PollIntervalPolicy p = noJitter(); + + assertThat(p.intervalSec(1, () -> 0.5)).isEqualTo(1); + assertThat(p.intervalSec(20, () -> 0.5)).isEqualTo(3); + assertThat(p.intervalSec(100, () -> 0.5)).isEqualTo(10); + assertThat(p.intervalSec(1000, () -> 0.5)).isEqualTo(30); + } + + @Test + @DisplayName("밴드_경계는_아래쪽_밴드에_속한다") + void 밴드_경계는_아래쪽_밴드에_속한다() { + // 경계에서 어느 쪽인지 정해 두지 않으면 5초 남은 사람이 노드마다 + // 다른 간격을 받는다. + PollIntervalPolicy p = noJitter(); + + assertThat(p.intervalSec(5, () -> 0.5)).isEqualTo(3); + assertThat(p.intervalSec(30, () -> 0.5)).isEqualTo(10); + assertThat(p.intervalSec(120, () -> 0.5)).isEqualTo(30); + } + + @Test + @DisplayName("ETA를_모르면_가장_먼_밴드로_본다") + void ETA를_모르면_가장_먼_밴드로_본다() { + // 모를 때 자주 물어보게 하면 모르는 상황일수록 부하가 커진다. + PollIntervalPolicy p = noJitter(); + + assertThat(p.intervalSec(Double.POSITIVE_INFINITY, () -> 0.5)).isEqualTo(30); + assertThat(p.intervalSec(Double.NaN, () -> 0.5)).isEqualTo(30); + } + + @Test + @DisplayName("지터가_적용된다") + void 지터가_적용된다() { + // 같은 밴드가 동기화되면 30초마다 전원이 동시에 두드린다. + PollIntervalPolicy p = PollIntervalPolicy.of(0.2); + + // 난수원을 주입해 결정적으로 본다 (DS-1 — 직접 호출 금지) + assertThat(p.intervalSec(1000, () -> 0.0)).isEqualTo(24); + assertThat(p.intervalSec(1000, () -> 0.5)).isEqualTo(30); + assertThat(p.intervalSec(1000, () -> 1.0)).isEqualTo(36); + } + + @Test + @DisplayName("min과_max로_클램프된다") + void min과_max로_클램프된다() { + // 지터가 커도 1초 아래로 내려가면 부하 계산이 무너지고, + // 상한이 없으면 이탈 판정 TTL 을 넘겨 멀쩡한 사람이 지워진다. + PollIntervalPolicy p = PollIntervalPolicy.of(5.0); + + assertThat(p.intervalSec(1, () -> 0.0)).isEqualTo(1); + assertThat(p.intervalSec(1000, () -> 1.0)).isEqualTo(60); + } + + @Test + @DisplayName("배수가_적용되면_간격이_늘어난다") + void 배수가_적용되면_간격이_늘어난다() { + // 전역 폴링 예산을 넘으면 모두의 간격을 함께 늘린다. + PollIntervalPolicy p = noJitter(); + + assertThat(p.intervalSec(1000, () -> 0.5, 2.0)).isEqualTo(60); + } + + @Test + @DisplayName("배수는_1_미만으로_내려가지_않는다") + void 배수는_1_미만으로_내려가지_않는다() { + // 한산하다고 더 자주 두드리게 만들면 한산할 때 부하를 만든다. + PollIntervalPolicy p = noJitter(); + + assertThat(p.intervalSec(1000, () -> 0.5, 0.1)).isEqualTo(30); + } + + @Test + @DisplayName("생존_TTL은_하한_아래로_내려가지_않는다") + void 생존_TTL은_하한_아래로_내려가지_않는다() { + // 백그라운드 탭은 분당 한 번으로 스로틀된다. 간격만 보고 TTL 을 + // 잡으면 탭을 내려둔 사람이 이탈자로 지워진다. + PollIntervalPolicy p = noJitter(); + + assertThat(p.aliveTtlSec(1)).isEqualTo(30); + assertThat(p.aliveTtlSec(30)).isEqualTo(90); + } + + @Test + @DisplayName("지터_비율이_음수면_거부한다") + void 지터_비율이_음수면_거부한다() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> PollIntervalPolicy.of(-0.1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("지터_비율이_비유한값이면_거부한다") + void 지터_비율이_비유한값이면_거부한다() { + // NaN 이 들어오면 간격 계산이 통째로 NaN 이 되고, 클램프도 못 잡는다. + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> PollIntervalPolicy.of(Double.NaN)) + .isInstanceOf(IllegalArgumentException.class); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> PollIntervalPolicy.of(Double.POSITIVE_INFINITY)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/kafkick/waiting/domain/queue/RankMonotonicityTest.java b/src/test/java/com/kafkick/waiting/domain/queue/RankMonotonicityTest.java new file mode 100644 index 00000000..2512e0dd --- /dev/null +++ b/src/test/java/com/kafkick/waiting/domain/queue/RankMonotonicityTest.java @@ -0,0 +1,84 @@ +package com.kafkick.waiting.domain.queue; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Random; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 표시 순위는 뒤로 가지 않는다 — 타협 불가 기준이다. + * + *

검증 범위는 "입력이 단조면 출력도 단조" 까지다. {@code localRank} 는 + * 어댑터의 {@code ZCOUNT} 결과라 입력이 단조라는 보장은 Phase 3 의 G3.11 이 진다. + */ +class RankMonotonicityTest { + + private static final long SEED = 20260819L; + private static final int SEQUENCES = 100_000; + + @Test + @DisplayName("무작위_10만_시퀀스에서_표시_순위가_증가하지_않는다") + void 무작위_10만_시퀀스에서_표시_순위가_증가하지_않는다() { + Random rnd = new Random(SEED); + int violations = 0; + + for (int seq = 0; seq < SEQUENCES; seq++) { + int shards = rnd.nextInt(1, 17); + long localRank = rnd.nextLong(0, 20_000); + long previous = Long.MAX_VALUE; + + // 단조 감소하는 localRank 를 주입한다 — 줄이 빠지는 모습이다 + for (int step = 0; step < 5 && localRank >= 0; step++) { + long shown = RankEstimator.globalRank(localRank, shards); + if (shown > previous) { + violations++; + } + previous = shown; + localRank -= rnd.nextLong(0, 500); + } + } + + assertThat(violations) + .withFailMessage("순위 역행 %d 건 (시드 %d)", violations, SEED) + .isZero(); + } + + @Test + @DisplayName("샤드가_하나면_로컬_순위가_곧_전역_순위다") + void 샤드가_하나면_로컬_순위가_곧_전역_순위다() { + assertThat(RankEstimator.globalRank(3000, 1)).isEqualTo(3000); + } + + @Test + @DisplayName("전역_순위는_로컬_순위에_샤드_수를_곱한_값이다") + void 전역_순위는_로컬_순위에_샤드_수를_곱한_값이다() { + assertThat(RankEstimator.globalRank(100, 16)).isEqualTo(1600); + } + + @Test + @DisplayName("샤드_수가_0이하면_1로_취급한다") + void 샤드_수가_0이하면_1로_취급한다() { + assertThat(RankEstimator.globalRank(100, 0)).isEqualTo(100); + } + + @Test + @DisplayName("곱셈이_넘치면_음수가_아니라_포화한다") + void 곱셈이_넘치면_음수가_아니라_포화한다() { + // 넘치면 음수가 되어 순위가 뒤로 간다 — 역행 0 을 스스로 깬다. + assertThat(RankEstimator.globalRank(Long.MAX_VALUE, 2)).isEqualTo(Long.MAX_VALUE); + assertThat(RankEstimator.globalRank(Long.MAX_VALUE / 2 + 1, 2)).isEqualTo(Long.MAX_VALUE); + } + + @Test + @DisplayName("음수_로컬_순위는_거부한다") + void 음수_로컬_순위는_거부한다() { + // 조용히 통과시키면 음수 순위나 포화한 MAX_VALUE 로 둔갑해, + // 어댑터의 결함이 표시 계층까지 내려간 뒤에야 드러난다. + assertThatThrownBy(() -> RankEstimator.globalRank(-1, 2)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> RankEstimator.globalRank(Long.MIN_VALUE, 2)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/testFixtures/java/com/kafkick/waiting/Fixtures.java b/src/testFixtures/java/com/kafkick/waiting/Fixtures.java new file mode 100644 index 00000000..caec9d67 --- /dev/null +++ b/src/testFixtures/java/com/kafkick/waiting/Fixtures.java @@ -0,0 +1,19 @@ +package com.kafkick.waiting; + +/** + * 픽스처 소스셋의 존재 증명. + * + *

여기 있는 것은 프로덕션 클래스패스에 들어가지 않는다. + * 도달 불가 상태를 만드는 생성자가 운영 코드에 노출되지 않게 하려는 것이고 + * (TS-3), 동시에 여러 테스트 소스셋에서 재사용할 수 있다. + */ +public final class Fixtures { + + /** 소스셋 배선 확인용. 실제 픽스처는 Phase 2 에서 들어온다. */ + public static String 소스셋이_연결되었다() { + return "testFixtures"; + } + + private Fixtures() { + } +} diff --git a/src/testFixtures/java/com/kafkick/waiting/domain/coupon/CouponStates.java b/src/testFixtures/java/com/kafkick/waiting/domain/coupon/CouponStates.java new file mode 100644 index 00000000..f553be35 --- /dev/null +++ b/src/testFixtures/java/com/kafkick/waiting/domain/coupon/CouponStates.java @@ -0,0 +1,49 @@ +package com.kafkick.waiting.domain.coupon; + +/** + * 테스트용 쿠폰 상태 픽스처. + * + *

자유형 생성 메서드를 두지 않는다. 이전 구현이 무너진 이유가 픽스처로 + * {@code (IDLE, credit=1000)} 을 찍어낼 수 있었던 것이고, 그 상태에서는 버그가 + * 드러나지 않았다. 여기 있는 것은 전부 도달 가능한 상태다. + */ +public final class CouponStates { + + /** 한산한 쿠폰. R1 의 주인공 — 줄 없이 통과해야 한다. */ + public static CouponState idle(long remainingStock) { + return CouponState.idle(remainingStock); + } + + /** 줄이 선 쿠폰. */ + public static CouponState queueing(long credit, long remainingStock, long waiting) { + return CouponState.queueing(credit, remainingStock, waiting); + } + + /** 배수 중인 쿠폰. 배분이 대기자를 따라잡아 이번 틱에 다 뺄 수 있는 상태다. */ + public static CouponState draining(long credit, long remainingStock, long waiting) { + return CouponState.draining(credit, remainingStock, waiting); + } + + /** 매진된 쿠폰. */ + public static CouponState closed(long waiting) { + return CouponState.closed(waiting); + } + + /** 운영자가 무조건 줄을 세우기로 했다. 한산해도 대기열을 태운다. */ + public static CouponState always(long remainingStock) { + return CouponState.always(remainingStock); + } + + /** 대기열이 꺼진 쿠폰. */ + public static CouponState off(long remainingStock) { + return CouponState.off(remainingStock); + } + + /** 스냅샷에 없는 쿠폰. */ + public static CouponState unknown() { + return CouponState.unknown(); + } + + private CouponStates() { + } +}