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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 85 additions & 3 deletions .claude/hooks/review-branch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ ROOT_SCRIPT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOU
cd "$(git rev-parse --show-toplevel)" || exit 1

HOOKS=".claude/hooks"
# 자기검증이 갈아 끼운다 — 저장소의 진짜 색인 상태에 결과가 흔들리면 안 된다.
JOURNAL_INDEX="ai/journal/index.md"
findings=0

say() { printf '%s\n' "$*"; }
Expand Down Expand Up @@ -97,14 +99,55 @@ check_rx2() {
| 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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=()
local java=() lua=() journal=()
for f in "${files[@]}"; do
[[ -f "$f" ]] || continue
case "$f" in
*.java) java+=("$f") ;;
*.lua) lua+=("$f") ;;
*.java) java+=("$f") ;;
*.lua) lua+=("$f") ;;
*/ai/journal/*/*/AIJ-*.md|ai/journal/*/*/AIJ-*.md) journal+=("$f") ;;
esac
done

Expand Down Expand Up @@ -138,6 +181,20 @@ review_files() {
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fi
}

# ── 자기검증 — 통과만 하는 검사는 검사가 아니다 ──────────────────────────────
Expand Down Expand Up @@ -171,6 +228,31 @@ self_test() {
# 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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# 본문의 '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 은 선행 슬래시 없는 경로를 준다. 절대경로로
# 안 바꾸면 테스트 전용 검사가 아예 안 돌고(미탐), 동시에 테스트가
# 프로덕션 규칙으로 검사된다(오탐). 둘 다 실제로 났다.
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -48,6 +48,9 @@ jobs:
context) ./gradlew --no-daemon contextTest ;;
chaos) ./gradlew --no-daemon chaosTest ;;
mutation) ./gradlew --no-daemon pitest ;;
# 어댑터는 단위·통합 양쪽에서 덮인다. 계층이 잡으로 갈려 exec 가
# 한자리에 안 모이므로 여기서 둘을 함께 돌려 합쳐 잰다.
coverage) ./gradlew --no-daemon test integrationTest adapterCoverageVerification ;;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
*) echo "::error::알 수 없는 계층 '${{ inputs.layer }}'"; exit 1 ;;
esac

Expand Down
11 changes: 9 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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 }}
Expand All @@ -175,6 +181,7 @@ jobs:
<tr><td>통합 테스트</td><td>${{ needs.test-integration.result }}</td></tr>
<tr><td>컨텍스트 테스트</td><td>${{ needs.test-context.result }}</td></tr>
<tr><td>카오스 테스트</td><td>${{ needs.test-chaos.result }}</td></tr>
<tr><td>어댑터 커버리지</td><td>${{ needs.test-coverage.result }}</td></tr>
<tr><td>뮤테이션 테스트</td><td>${{ needs.test-mutation.result }}</td></tr>
<tr><td>보안 스캔</td><td>${{ needs.security.result }}</td></tr>
<tr><td>부하 (R1 검증)</td><td>${{ needs.load-idle.result }}</td></tr>
Expand Down
16 changes: 14 additions & 2 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 스캔만 코드가 바뀌었을 때로 좁힌다.
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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 }}
Expand Down
84 changes: 84 additions & 0 deletions ai/journal/2026/08/AIJ-0024-adapter-coverage.md
Original file line number Diff line number Diff line change
@@ -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% 를
보고 테스트를 더 쓰는 것은 틀린 대응이었다. 재는 대상이 어긋나 있었다.
1 change: 1 addition & 0 deletions ai/journal/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
| ID | 날짜 | 종류 | 제목 | 확신 | 승격 |
|---|---|---|---|---|---|
| [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 | — |
Expand Down
48 changes: 48 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,54 @@ tasks.named('jacocoTestCoverageVerification') {
}
}

// 어댑터는 단위·통합 양쪽에서 덮인다. 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
}
Expand Down
Loading