From 22c07191d576328f2d317a00063ff29fd744169a Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 19 Oct 2026 13:45:19 +0900 Subject: [PATCH 1/5] =?UTF-8?q?docs(agents):=20=EC=97=94=EC=A7=80=EB=8B=88?= =?UTF-8?q?=EC=96=B4=EB=A7=81=20=EC=8A=A4=ED=82=AC=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=EA=B3=BC=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20261019_엔지니어링스킬설정/plan-task.md | 107 ++++++++++++++++++ docs/20261019_엔지니어링스킬설정/prd.md | 43 +++++++ 2 files changed, 150 insertions(+) create mode 100644 docs/20261019_엔지니어링스킬설정/plan-task.md create mode 100644 docs/20261019_엔지니어링스킬설정/prd.md diff --git a/docs/20261019_엔지니어링스킬설정/plan-task.md b/docs/20261019_엔지니어링스킬설정/plan-task.md new file mode 100644 index 00000000..5a28d0c2 --- /dev/null +++ b/docs/20261019_엔지니어링스킬설정/plan-task.md @@ -0,0 +1,107 @@ +# 엔지니어링 스킬 문서 설정 계획 + +| 항목 | 내용 | +|---|---| +| 상태 | 구현 완료 | +| 작성일 | 2026-10-19 | +| 요구사항 기준 | `docs/20261019_엔지니어링스킬설정/prd.md` | +| 현재 Phase | Phase 1: 승인 설정 반영 완료 | +| 현재 활성 Goal | 없음 | + +## 목표 + +승인된 이슈 트래커 및 도메인 문서 설정을 저장소 안내 문서에 반영한다. + +## 시작 조건 + +- 사용자가 설정 초안을 승인했다. +- 변경 대상과 제외 범위가 PRD에 확정되어 있다. +- 이 변경은 문서 전용이며 PRD와 계획 문서를 먼저 작성한다. + +## 범위 + +- 포함: 승인된 `AGENTS.md` 안내 블록과 이슈 트래커·도메인 안내 문서 작성 +- 제외: 런타임 파일, `.gitignore`, 스킬 설치·활성화, triage 설정, 추가 예시 문서 및 원격 작업 + +### Phase 1: 승인 설정 반영 + +**결과:** 승인된 로컬 이슈 트래커와 single-context 도메인 문서 지침을 정확한 위치에 기록한다. + +#### Task 1.1: 선행 PRD 및 계획 문서 작성 + +**Goal ID:** `P1-T1` + +- **목표:** 작업 범위, 승인 결정, 수용 기준과 검증 계획을 기록한다. +- **시작 조건:** 필수 작업 가이드와 샘플 문서를 읽는다. +- **정확한 파일:** `docs/20261019_엔지니어링스킬설정/prd.md`, `docs/20261019_엔지니어링스킬설정/plan-task.md` +- **완료 증거:** 두 문서가 존재하고 승인 범위와 검증 책임이 서로 일치한다. +- **범위 밖:** 설정 블록 및 안내 파일 작성. +- **TDD 예외 사유:** 요구사항·계획 문서 작성은 실행 동작을 변경하지 않는다. +- **대체 검증 방법:** 문서 파일과 경로를 읽어 내용 및 계획 형식을 확인한다. + +- [x] **Task 1.1: 선행 PRD 및 계획 문서 작성** + +#### Task 1.2: 승인된 설정 반영 + +**Goal ID:** `P1-T2` + +- **목표:** 승인된 로컬 이슈 트래커와 도메인 문서 읽기 규칙을 반영한다. +- **시작 조건:** `P1-T1` 완료 및 사용자 초안 승인. +- **정확한 파일:** `AGENTS.md`, `docs/agents/issue-tracker.md`, `docs/agents/domain.md` +- **완료 증거:** 승인 초안과 파일 본문이 일치하고 기존 `AGENTS.md` 내용이 보존된다. +- **범위 밖:** 예시 이슈, 실제 용어집·ADR 생성, triage 설정, 원격 저장소 변경. +- **TDD 예외 사유:** 문서 전용 설정이며 애플리케이션 실행 동작을 변경하지 않는다. +- **대체 검증 방법:** 세 파일을 직접 읽고 승인 초안 및 `git diff -- AGENTS.md`와 대조한다. + +- [x] **Task 1.2: 승인된 설정 반영** + +#### Phase 1 Gate + +**Goal ID:** `P1-GATE` + +- **목표:** 다섯 파일이 승인 초안과 PRD 범위에 맞게 작성됐는지 검증한다. +- **시작 조건:** `P1-T1`, `P1-T2` 완료. +- **정확한 파일:** `AGENTS.md`, `docs/agents/issue-tracker.md`, `docs/agents/domain.md`, `docs/20261019_엔지니어링스킬설정/prd.md`, `docs/20261019_엔지니어링스킬설정/plan-task.md` +- **완료 증거:** 부모 실행자가 diff 공백 검사, 문서·참조 확인, Gradle task 목록 확인을 수행하고 결과를 기록한다. +- **범위 밖:** 전체 회귀 테스트 및 문서 외 파일 변경. +- **TDD 예외 사유:** 문서 전용 설정이라 자동화된 실패 테스트를 추가하지 않는다. +- **대체 검증 방법:** `git diff --check`, 변경 파일·문서 참조 대조, `./gradlew tasks --all` 실행 결과를 확인한다. +- **회귀 범위:** 전체 회귀는 문서 전용 변경에 필요하지 않다. + +- [x] **Phase 1 Gate: 설정 및 문서 검증** + +## 진행 기록 + +- `P1-T1`: 필수 선행 문서 작성 완료. 이 기록은 파일 생성 확인에 한하며 설정 검증은 뜻하지 않는다. +- `P1-GATE`: 부모 실행자 검증 대기. 미실행 검사는 완료로 기록하지 않는다. + +### P1-T1 / P1-T2 검토 및 보정 - 2026-10-19 + +- 무엇을: 다섯 대상 파일을 읽고 승인 초안과 대조했다. 누락된 설정 반영 Task를 명시하고 초안 외 내부 주석을 제거했다. +- 왜: 실제 설정 작업의 추적성을 확보하고 승인된 문서 내용만 유지하기 위해서다. +- 어떻게: `git diff -- AGENTS.md`에서 기존 본문 변경 없이 안내 블록만 추가된 것을 확인했다. +- 결과: PRD·계획 경로와 안내 문서 참조가 일치하며 `P1-GATE` 최종 검증은 별도로 수행한다. + +### P1-GATE 검증 - 2026-10-19 + +- 무엇을: 승인된 다섯 문서의 내용·참조·변경 범위와 Gradle 명령 유효성을 검증했다. +- 왜: 원격 저장소에 종속되지 않는 설정을 유지하고 기존 작업 절차에 영향을 주지 않는지 확인하기 위해서다. +- `git diff --check`: 종료 코드 0, 추적 파일 공백 오류 없음. +- 신규 네 파일에 각각 `git diff --no-index --check /dev/null "$file"`: 종료 코드 0, 공백 오류 없음. +- 신규 파일 검사 첫 시도는 zsh 특수 변수 `path`를 반복 변수로 사용해 `git` 탐색에 실패했다. + 반복 변수를 `file`로 수정한 뒤 같은 검사에 통과했으며, 파일 내용이나 Git 설정은 변경하지 않았다. +- `test` 및 `rg` 검사: 종료 코드 0. 필수 파일 존재, `Agent skills` 블록 1개, + triage 설정·`CLAUDE.md`·빈 `.scratch/` 및 내부 주석 미생성을 확인했다. +- `git status --short --untracked-files=all`: 승인된 다섯 Markdown 파일만 변경·생성됐다. +- `git check-ignore -v --no-index .scratch/skill-setup/issues/01-example.md`: 종료 코드 1, + 일치하는 ignore 규칙 없음. 실제 예시 파일은 만들지 않았다. +- `./gradlew tasks --all`: 종료 코드 0, `BUILD SUCCESSFUL`. 기존 Gradle 9.0 비호환 deprecated 기능 경고는 유지된다. +- Markdown 전용 변경으로 언어 서버 진단 대상 코드는 없으며, 별도 LSP 진단 도구는 제공되지 않았다. +- 전체 테스트·빌드는 실행하지 않았다. 실행 코드·의존성·빌드 설정 변경이 없어 위 문서 검사와 Gradle task 목록 확인으로 검증했다. +- 결과: `P1-T1`, `P1-T2`, `P1-GATE` 완료. 커밋·푸시 및 원격 설정 변경 없음. + +### 별도 문서 검토 - 2026-10-19 + +- 최초 검토는 지원되지 않는 모델 오류로 실행되지 않아 사용 가능한 문서 검토 경로로 재시도했다. +- 재검토 결과: 중대한 지적 사항 없음. 승인 범위, 기존 `AGENTS.md` 보존, PRD·계획 규칙과의 일치를 확인했다. +- 검토자는 파일과 diff만 확인했으며 Gradle·공백 검사는 위 실행 결과를 기준으로 했다. diff --git a/docs/20261019_엔지니어링스킬설정/prd.md b/docs/20261019_엔지니어링스킬설정/prd.md new file mode 100644 index 00000000..efdf4bba --- /dev/null +++ b/docs/20261019_엔지니어링스킬설정/prd.md @@ -0,0 +1,43 @@ +# 엔지니어링 스킬 문서 설정 PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 상태 | 구현 완료 | +| 작성일 | 2026-10-19 | +| 최종 수정일 | 2026-10-19 | +| 대상 | 엔지니어링 스킬용 저장소 문서 설정 | +| 관련 계획 | `docs/20261019_엔지니어링스킬설정/plan-task.md` | + +## 목표와 배경 + +원격 저장소 변경에 의존하지 않는 로컬 Markdown 이슈 관리 규칙과 single-context 도메인 문서 안내를 기록한다. 기존 `AGENTS.md`에 승인된 안내를 추가해 스킬이 저장소의 기준 문서를 찾을 수 있게 한다. + +## 확정 요구사항 + +- 이슈 트래커는 `.scratch//issues/`의 로컬 Markdown으로 지정한다. 원격 저장소가 변경될 예정이므로 원격 이슈 트래커를 기준으로 삼지 않는다. +- 도메인 문서 구성은 single-context로 안내하며 루트 `GLOSSARY.md`와 `docs/adr/`를 참조한다. +- 루트 `AGENTS.md` 끝에 승인된 `Agent skills` 블록을 추가하고, `docs/agents/issue-tracker.md`와 `docs/agents/domain.md`에 규칙을 기록한다. +- 이 설정을 위한 PRD 및 계획 문서를 기존 날짜 디렉터리 규칙에 따라 함께 둔다. + +## 수용 기준 + +- 승인된 설정 내용과 다섯 개의 정확한 대상 파일만 변경된다. +- 기존 `AGENTS.md` 내용은 바이트 단위로 보존되고 승인 블록만 추가된다. +- 요구사항 문서와 계획 문서의 경로, 파일 지침, 검증 계획이 서로 일치한다. +- 검증 미수행 항목을 성공으로 기록하지 않는다. + +## 제외 범위 + +- 애플리케이션·런타임 코드, `.gitignore`, 의존성, 테스트 변경 +- triage 라벨 및 triage 스킬 설정 +- `GLOSSARY.md`, `GLOSSARY-MAP.md`, `docs/adr/`, `.scratch/`나 예시 이슈 생성 +- 스킬 설치·활성화, 원격 서비스 사용, Git 변경 + +## 결정 기록 + +| 날짜 | 결정 | 근거 | +|---|---|---| +| 2026-10-19 | 이슈 관리는 로컬 Markdown으로 설정 | 원격 저장소 변경에 의존하지 않으며 사용자가 전체 초안을 승인함 | +| 2026-10-19 | 도메인 문서는 single-context로 안내 | 단일 루트 프로젝트이며 승인된 구성임 | From 7187485ab1850d2ce7d524036f198ce0b2ffcef2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 19 Oct 2026 13:45:25 +0900 Subject: [PATCH 2/5] =?UTF-8?q?docs(agents):=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=EC=99=80=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EA=B4=80=EB=A6=AC=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/domain.md | 13 +++++++++++++ docs/agents/issue-tracker.md | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 00000000..a14fcb16 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,13 @@ +# 도메인 문서 + +## 구성 및 읽기 순서 +- 기본 구성은 single-context다. +- 코드 탐색 전 루트 `GLOSSARY.md`와 작업 영역에 관련된 `docs/adr/` 문서를 읽는다. +- 루트 `GLOSSARY-MAP.md`가 있으면 관련 문맥의 용어집도 읽는다. +- 해당 문서가 없으면 별도 경고나 선행 생성 제안 없이 작업을 진행한다. +- 용어·결정이 실제로 확정될 때 `domain-modeling` 스킬로 문서를 작성한다. + +## 사용 규칙 +- 이슈·설명·테스트 이름에는 용어집에 정의된 용어를 사용한다. +- 필요한 용어가 없으면 실제 프로젝트 용어인지 확인하고 정리 필요성을 기록한다. +- 기존 ADR과 충돌하는 제안은 대상 ADR과 재검토 이유를 명시한다. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 00000000..5d9cec97 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,26 @@ +# 이슈 트래커: 로컬 Markdown + +원격 저장소 변경에 의존하지 않도록 이슈를 `.scratch/`에서 관리한다. +이슈 생성·조회·갱신에 원격 이슈 트래커를 사용하지 않는다. + +## 파일 규칙 +- 기능별 디렉터리: `.scratch//` +- 구현 이슈: `issues/-.md`. 번호는 기능별로 `01`부터 부여한다. +- 이슈마다 파일 하나를 사용하며 여러 이슈를 한 파일로 합치지 않는다. +- 대화와 후속 기록은 파일 하단 `## Comments`에 누적한다. +- 이슈 식별자는 전체 경로로 전달한다. 번호만 받으면 기능을 확인한다. + +## 기존 요구사항·계획 문서와의 관계 +- PRD·구현 계획은 기존 `docs/[날짜]_구현할내용한글/` 규칙을 따른다. +- 스킬이 `spec.md`를 요구하면 `.scratch//spec.md`에 + 원본 PRD·구현 계획 링크를 기록하고 내용을 중복 관리하지 않는다. +- 이슈는 관련 PRD·Task/Goal을 참조하며 기존 구현 전 절차를 대체하지 않는다. + +## 스킬 요청 처리 +- "이슈 트래커에 게시": 위 규칙에 따라 이슈 파일을 생성한다. +- "관련 티켓 조회": 전달받은 이슈 파일을 읽는다. +- triage 스킬이 없으므로 triage 라벨 설정은 만들지 않는다. + +## 원격 저장소 이전 +이슈 이력을 함께 이전하려면 `.scratch/` 문서도 Git으로 관리한다. +인증 정보와 개인정보는 기록하지 않는다. From bf252795ed9efb9362d273c58dc7f573ba9351a4 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 19 Oct 2026 13:45:31 +0900 Subject: [PATCH 3/5] =?UTF-8?q?docs(agents):=20=EC=97=90=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=ED=8A=B8=20=EC=A7=80=EC=B9=A8=EC=97=90=20=EC=8A=A4=ED=82=AC=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EB=AC=B8=EC=84=9C=EB=A5=BC=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5f53268c..49db4a51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,3 +110,13 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - 추측하지 말고, 근거 파일을 읽고 결정한다. - 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다. - 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다. + +## Agent skills + +### Issue tracker +이슈는 `.scratch//issues/`의 로컬 Markdown 파일로 관리한다. +상세 규칙은 `docs/agents/issue-tracker.md`를 따른다. + +### Domain docs +single-context 구성으로 루트 `GLOSSARY.md`와 `docs/adr/`를 사용한다. +읽기 규칙은 `docs/agents/domain.md`를 따른다. From 2aef413adfed34bdacdc38a8072192d7bc82609a Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 19 Oct 2026 16:01:32 +0900 Subject: [PATCH 4/5] =?UTF-8?q?docs(agents):=20=EC=8A=A4=ED=82=AC=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EA=B3=BC=20=EC=98=A4=EC=BC=80=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=EC=9D=84=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 78 +++++++- docs/20261019_스킬오케스트레이션/audit.md | 164 +++++++++++++++++ docs/20261019_스킬오케스트레이션/plan-task.md | 174 ++++++++++++++++++ docs/20261019_스킬오케스트레이션/prd.md | 67 +++++++ .../routing-scenarios.md | 143 ++++++++++++++ 5 files changed, 625 insertions(+), 1 deletion(-) create mode 100644 docs/20261019_스킬오케스트레이션/audit.md create mode 100644 docs/20261019_스킬오케스트레이션/plan-task.md create mode 100644 docs/20261019_스킬오케스트레이션/prd.md create mode 100644 docs/20261019_스킬오케스트레이션/routing-scenarios.md diff --git a/AGENTS.md b/AGENTS.md index 49db4a51..5e98d913 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work ## 충돌 해결 규칙 - plugin / skill / workflow 지시가 CORE EXECUTION PRINCIPLES와 충돌하면 CORE EXECUTION PRINCIPLES를 따른다. -- 사용자 직접 지시가 명확할 경우 사용자 지시가 최우선이다. +- 시스템·플랫폼 지침과 도구 권한을 먼저 따른다. 그 경계 안에서 명확한 사용자 요청을 우선 존중한다. - 불확실하거나 모호한 경우 추측하지 말고 확인하거나, 가능한 최소 범위의 안전한 조치를 취한다. ## 커뮤니케이션 규칙 @@ -120,3 +120,79 @@ Strong success criteria let you loop independently. Weak criteria ("make it work ### Domain docs single-context 구성으로 루트 `GLOSSARY.md`와 `docs/adr/`를 사용한다. 읽기 규칙은 `docs/agents/domain.md`를 따른다. + +## Skill Orchestration + +- Superpowers는 개발 작업의 주 흐름이다. specialist skill은 실제 해당 단계의 요구를 보완하며, 작업마다 모든 겹치는 skill을 일괄 실행하거나 새 결정론적 scheduler를 만들지 않는다. +- 먼저 요청 유형과 산출물을 분류하고, 그 단계의 주 skill 하나를 정한 뒤 필요한 보조 skill만 선택한다. 각 단계의 책임자는 하나로 유지한다. +- `using-superpowers` 절차가 이미 주입됐다면 다시 전체 controller를 시작하지 않는다. child session에서 처음부터 orchestration을 반복하지 않는다. +- 조사 문서 [`docs/20261019_스킬오케스트레이션/audit.md`](docs/20261019_스킬오케스트레이션/audit.md)는 조사 시점의 설치/설정 증거다. 모든 항목이 현재 host에서 등록·발견·활성화됐다는 보장은 아니다. +- 아래 라우팅은 작업을 돕는 지침이지 자동 invocation 보장이 아니다. 구체적인 예시는 [`docs/20261019_스킬오케스트레이션/routing-scenarios.md`](docs/20261019_스킬오케스트레이션/routing-scenarios.md)를 참고한다. + +## Skill Priority and Selection + +- 모든 현재 요청을 질문/설명, 조사, 신규 기능, 요구사항 정리, 버그 수정, 아키텍처 평가, API 설계, 보안 작업, 리뷰, 테스트/검증, 기존 작업 이어하기 중 해당 유형으로 분류한다. 혼합 작업은 산출물 단계별로 나눈다. +- 일반 질문이나 read-only review를 코드 변경으로 바꾸지 않는다. specialist는 그 skill 고유 책임이 실제 범위에 있을 때만 추가한다. +- 신규 기능 구현은 `brainstorming` → `writing-plans` → `executing-plans` 또는 `subagent-driven-development` 중 정확히 하나 → 구현 중 TDD → 변경 review → 완료 직전 fresh verification 순서를 따른다. +- 이미 승인된 설계/plan의 연속 작업은 기존 미완료 Task부터 재개한다. known decisions, open questions, acceptance criteria를 다시 묻거나 interview/planning을 반복하지 않는다. 새 critical gap이 있을 때만 요구/계획을 다시 연다. +- 독립적이고 공유 상태가 없는 작업의 병렬 조율에는 `dispatching-parallel-agents`를 사용한다. 같은 파일을 수정하거나 선행 결과가 필요한 구현 작업은 동시에 보내지 않는다. +- 실행 방식 선택, `using-git-worktrees`를 통한 필요 시 격리 확인, `finishing-a-development-branch`의 통합 판단은 별개다. 확인만으로 worktree를 자동 생성하지 않으며 commit/push/merge/delete는 명시적 승인 없이 하지 않는다. + +## Requirements Discovery + +- 신규 동작의 사용자 의도와 선택 정리는 `brainstorming`이 주 책임이다. 계획만 필요한 경우 `writing-plans`를 사용하고, 이미 확정된 기존 계획을 중복 생성하지 않는다. +- 요구사항이 중요하게 모호할 때만 `deep-interview`를 조건부로 사용한다. 먼저 코드/기존 문서에서 알려진 사실을 확인하고, 이미 답이 있는 질문은 되묻지 않는다. +- `deep-interview`는 라운드마다 가장 큰 미해결 질문 하나를 묻고 Goal, Scope, Constraints, Success, Context를 평가한다. Context는 기존 코드베이스를 변경하는 경우에만 평가한다. 새 프로젝트 가중치는 `.35/.25/.15/.25`, 기존 코드베이스 작업은 `.30/.20/.15/.20/.15` 순서다. 모호성 `<=.20`이며 중요한 미지수가 해결되면 멈춘다. 중요한 dimension이 `<=.50`이어도 핵심 미해결점이 남으면 계속할 수 있다. 응답, 결정, 알려진 사실을 반영하고 다시 brainstorming으로 돌려보낸다. +- `grilling`은 plan/idea/decision 전반의 stress-test이며 아키텍처 전용이 아니다. 사용자의 의미상 stress-test 의도가 있을 때만 조건부 선택한다. skill 절차상 정착된 전체 decision frontier를 매 라운드 질문하므로 `deep-interview`와 혼합/중복하지 않는다. 정확한 skill 이름 언급을 필수 조건으로 오인하지 않는다. +- 한 시점에는 요구 질문 프로세스 하나만 활성화한다. 기존 결정, 열린 질문, acceptance criteria를 재사용하고 이미 답한 질문을 반복하지 않는다. + +## Skill Routing by Task Type + +| 작업 유형 | 주 흐름 | 조건부 보완 | +|---|---|---| +| 일반 조사/모듈 이해 | 요청된 범위만 조사하고 근거를 보고한다. | 구조 seam 평가가 핵심일 때 `codebase-design`; 설계 loop를 강제하지 않는다. | +| 신규 기능/모호한 요구 | 신규 동작은 `brainstorming`; 모호성이 중요할 때만 `deep-interview` 후 재개. | domain decision이면 `domain-modeling`; 이후 승인된 구현은 계획·단일 실행 경로·TDD를 따른다. | +| 새 모듈/구조 설계 | 새 사용자/동작 선택은 `brainstorming`. | 구조 경계가 실제 주제일 때 `codebase-design`. 기존 dependency를 read-only 검토하는 요청은 `codebase-design` 중심으로 평가하고 구현하지 않는다. | +| Domain 용어/상태 | `domain-modeling`은 실제 domain model 또는 용어를 수정/정의할 때 사용한다. | glossary를 읽기만 하는 요청은 호출 근거가 아니다. ADR은 별도 결정 기록이 필요할 때만 작성한다. | +| API 설계 | `brainstorming`이 system behavior/요구를 맡는다. | `api-and-interface-design`은 contract substage(호환성, 입출력/오류 경계)를 맡는다. 기존 Kotlin/Spring naming과 계약을 바꾸지 않는다. | +| 보안 구현 | 승인된 구현의 단일 실행 경로와 TDD를 적용한다. | 실제 인증/민감정보/외부 trust boundary에서 `security-and-hardening`을 사용한다. | +| Read-only 보안 리뷰 | 해당 범위에 적용 가능한 리뷰 프로세스 하나로 evidence 기반 finding을 보고한다. | `security-and-hardening`은 보안 기준을 보완한다. 리뷰 요청을 자동 수정으로 바꾸지 않는다. | +| 계획 작성 | `writing-plans`; 저장소 PRD/plan 경로와 형식이 기준이다. | 요구 discovery가 미해결일 때만 discovery skill을 먼저 사용한다. Superpowers 전용 중복 계획 폴더를 만들지 않는다. | +| 버그 수정 | 재현 가능한 실패/오류는 `systematic-debugging`으로 원인을 좁힌다. | 구현 시 TDD. 단순 조사/설명만이면 debugging workflow를 강제하지 않는다. | +| 리뷰/피드백 | 요청된 변경은 `requesting-code-review`; 받은 피드백을 실제 반영할 때 `receiving-code-review`. | 중복 full review를 만들지 않고 리뷰 권한만으로 수정하지 않는다. | +| 테스트/완료 검증 | 실패 재현/수정은 TDD; 완료 주장은 `verification-before-completion` 기준의 fresh verification 뒤에 한다. | 프로젝트 focused test 우선순위와 검증 기록 규칙을 유지한다. | +| 기존 작업 이어하기 | 확정된 기존 설계/plan의 첫 미완료 Task를 재개한다. | 새 critical gap만 discovery/planning 재개 사유다. | +| 아키텍처 개선 후보 | 일반 read-only 조사, 구조 평가는 필요 시 `codebase-design`. | `improve-codebase-architecture`는 별도 명시 요청으로 사용자가 선택하기 전 호출하지 않는다. | + +- 실제 범위에 맞는 추가 specialist만 선택한다: 화면 구현은 `frontend-ui-engineering`, CI/deploy 변경은 `ci-cd-and-automation`, production telemetry는 `observability-and-instrumentation`, 근거 있는 성능 목표/병목은 `performance-optimization`이다. 이들은 blanket mandatory가 아니다. +- `diagnosing-superpowers`는 superpowers workflow 자체의 장애 분석, `writing-skills`는 skill 작성/수정 때만 선택한다. 일반 개발 기본 체인의 단계가 아니다. + +## Conflict and Duplication Resolution + +- CORE와 상위 지침, 저장소 규칙, 실제 사용자 범위를 지킨다. stage마다 main owner 하나만 둔다. +- `brainstorming` / `deep-interview`: 새 동작의 선택·설계 대 모호한 요구를 푸는 단일 질문/turn discovery. 설계 main, 큰 unknown이 남을 때만 interview 후 설계로 복귀. +- `brainstorming` / `grilling`: 새 요구를 설계하고 선택하는 흐름 대 확정된 전체 frontier를 압박 시험하는 흐름. 기본 owner는 brainstorming; 의미상 stress-test 필요가 있을 때만 grilling. +- `brainstorming` / `codebase-design`: 제품 동작/요구 선택 대 코드 구조·seam 평가. 새 동작은 brainstorming, 구조 경계만 요청됐으면 codebase-design. +- `codebase-design` / `api-and-interface-design`: 모듈 깊이/seam 대 소비자-facing contract/호환성·오류. 구조 평가는 codebase-design, API skill은 계약 substage만 소유한다. +- `requesting-code-review` / `security-and-hardening`: review process 대 보안 기준. 적용 가능한 단일 review process가 main이고 security skill은 criteria 보완이며 동일 대상을 두 번 full review하지 않는다. +- `systematic-debugging` / general exploration: 재현 가능한 실패의 hypothesis/cause loop 대 read-only code 이해. 실패 재현이면 debugging, 일반 질문이면 조사만 수행한다. +- Domain/구조 어휘 skill은 프로젝트 코드나 공개 contract의 Kotlin/Spring 명명·타입·관례를 rename하거나 덮어쓰지 않는다. + +## Automatic Invocation Constraints + +- 실행 전에 현재 host의 skill catalog, 설치된 대상 `SKILL.md`와 관련 참조, invocation metadata, host tool permission을 확인한다. 조사 시점의 audit 목록만으로 현재 등록/활성을 단정하지 않는다. +- Codex의 `agents/openai.yaml` `policy.allow_implicit_invocation`, OpenCode의 `metadata["opencode/autoinvoke"]` 및 `skill` allow/ask/deny 권한을 각각 해당 host 문서대로 해석한다. 서로 동등하다고 추정하지 않는다. +- `disable-model-invocation`은 명시적 사용자 의도를 위한 정책 신호다. OpenCode v1이 이 키를 지원한다고 가정하지 않으며, 지원 여부와 무관하게 사용자의 explicit-only 의도를 지킨다. +- `improve-codebase-architecture`와 `setup-matt-pocock-skills`는 catalog에 보여도 explicit-only다. 일반적인 “코드베이스 구조 개선 후보를 찾아줘”는 `improve-codebase-architecture` 호출 승인이 아니다. 사용자가 해당 skill을 명시적으로 이름 붙여 선택해야 한다. setup은 초기 설정을 명시적으로 요청할 때만 실행한다. +- Metadata를 바꾸거나 skill 파일을 읽어 호출 제한을 우회하지 않는다. 제한을 조사하는 것과 skill을 실제 실행하는 것은 다르다. +- Host에서 skill을 찾지 못하거나 권한이 거부되면 한계를 알리고 허용된 프로젝트 절차를 따른다. 존재하지 않는 skill/명령을 가장하거나 직접 설치·생성하지 않는다. Codex Superpowers cache 존재는 activation 증거가 아니다. +- AGENTS 파일의 로딩 계층과 skill 단계 우선순위는 별개다. 이 문서는 플랫폼 instruction loader를 변경하지 않는다. `gstack`은 설치하거나 사용하지 않는다. + +## Verification and Completion + +- 필요한 문서/도구/참조가 없으면 어느 검토 부분이 미검증인지 보고한다. 공유 checklist 결손을 조용히 무시하거나 global checklist를 만들거나 설치해 full sign-off로 바꾸지 않는다. +- 기존 PRD/plan 경로와 project guides가 변경 범위·테스트·검증 기준이다. 이미 완료된 분석을 다른 skill workflow로 반복하지 않는다. +- 구현 시 focused test부터 시작하고 프로젝트의 TDD, 보안 및 사람 승인 경계를 지킨다. 실패한 테스트를 삭제/완화하지 않는다. +- 새 session은 갱신된 `AGENTS.md`를 안정적으로 다시 읽을 수 있도록 시작한다. skill 단계는 논리적 routing guidance이며 runtime 자동 invocation 성공을 보장하지 않는다. +- worktree는 필요성을 확인하되 자동 생성하지 않는다. branch 종료/merge는 실제 해당 작업일 때만 다루며 commit/push/merge/delete는 명시적 승인 없이는 하지 않는다. +- 사용자가 이미 명확하게 승인한 범위는 다시 묻지 않는다. 승인 경계 밖의 새로운 결정이나 scope 확장만 확인한다. diff --git a/docs/20261019_스킬오케스트레이션/audit.md b/docs/20261019_스킬오케스트레이션/audit.md new file mode 100644 index 00000000..4d936108 --- /dev/null +++ b/docs/20261019_스킬오케스트레이션/audit.md @@ -0,0 +1,164 @@ +# Skill orchestration 근거 감사 + +## 범위와 판정 + +읽기 전용 조사 기준일은 2026-10-19다. 설치, 설정 등록, host discovery, skill 선택 metadata, 실제 모델 invocation은 서로 다른 사실이다. 조사에서 확인한 설치 상태만 기록하며 자동 invocation 성공을 테스트했다고 주장하지 않는다. + +## 저장소 및 지침 baseline + +| 항목 | 관찰 증거 | +|---|---| +| cwd / git root | `/Users/klaus/Develop/sodalive/Server/sodalive_` (동일) | +| baseline HEAD | `bf252795ed9efb9362d273c58dc7f573ba9351a4` | +| 루트 `AGENTS.md` SHA-256 | `eb99f0fa50609aa6cad7c03a546bfffc6da981348b7ee5cf59b48628e6c40331` | +| 루트 `AGENTS.md` 크기 | 5,608 bytes, 122 lines | +| baseline worktree | clean, branch `test`가 `origin/test`보다 3 commit 앞서 있음 | +| 계층 조사 | `/`, `/Users`, `/Users/klaus`, `/Users/klaus/Develop`, `/Users/klaus/Develop/sodalive`, `/Users/klaus/Develop/sodalive/Server`에 추가 AGENTS/override/CLAUDE 지침 없음. 저장소에는 루트 AGENTS만 있고 child/override/CLAUDE, `opencode.json/jsonc`, `.opencode` 설정이 없었음. | +| 전역 지침 조사 | `~/.codex/AGENTS.md`, `~/.codex/AGENTS.override.md`, `~/.config/opencode/AGENTS.md`, `~/.claude/CLAUDE.md` 없음. 조사된 범위 한정. | + +루트 `AGENTS.md`의 CORE/충돌 규칙은 lines 8-78, 한국어 보고는 80-83, 프로젝트 가이드와 공개 API 경계는 92-102, PRD/plan 선행 규칙은 104-107, issue/domain docs 안내는 114-122다. 요청 문서인 `docs/agent-guides/작업절차.md`, `문서유지보수.md`, `docs/sample/sample-prd.md`, `sample-plan-task.md`도 읽었다. 기존 문서 절차는 구현 전 PRD와 plan, goal/phase gate, TDD 또는 문서 작업의 대체 검증, focused test 우선과 실행 결과 기록을 이미 규정한다. 새 정책은 이를 대체하지 않는다. + +기존 routing baseline은 작업 유형별 stage 선택, 중복 책임, implicit invocation 제약을 루트에서 연결하지 않는 문서 공백이다. 이는 runtime 실패 판정이 아니다. 가이드의 Markdown 참조가 자동으로 instruction에 import된다고 간주하지 않는다. 루트 `AGENTS.md`의 경로 안내 오기 `.../sodalive` 대 실제 `.../sodalive_`는 관찰 사실이며 이번 요청의 변경 범위가 아니므로 수정하지 않는다. + +## Host/설정 계층과 범위 + +설치 조사에서 확인한 host는 Codex `0.163.0`, OpenCode `1.19.3`이다. `CODEX_HOME`, `XDG_CONFIG_HOME`은 조사 환경에서 설정되지 않았다. 실제 실행 시 CLI arguments/profile은 미확인이다. + +| 경로/집합 | 범위 판정 및 확인 내용 | +|---|---| +| `~/.codex/config.toml` | trusted project, model/approval/sandbox 설정은 관찰됐으나 skill routing permission과 혼동하지 않는다. multi_agent/plugins 및 `omo@sisyphuslabs` 사용 설정 확인. 명시적 Superpowers enabled 항목, `skills.config` disable/policy override, project doc budget/fallback/instruction override는 관찰되지 않았다. | +| `~/.codex/agents/*.toml` | 13개 role별 TOML 경로가 확인됐다. 대표 경로 `default.toml`, `explorer.toml`, `plan.toml`, `librarian.toml`, `metis.toml`, `momus.toml`, `lazycodex-code-reviewer.toml`, `lazycodex-gate-reviewer.toml`. 이들은 전역 repo 우선순위가 아니라 해당 subagent 역할 지침이다. 후자의 두 reviewer role은 `remove-ai-slops`/`programming`을 role 내부에서만 언급한다. | +| `~/.config/opencode/opencode.json` | Superpowers, Ponytail, OMO plugin 선언 확인. 명시적 instructions/skills/permission 설정은 관찰되지 않았다. | +| `~/.config/opencode/agents/` 및 `oh-my-openagent` | 전역 agents 폴더는 비어 있음. OMO 디렉터리의 `native-nudge.json`은 state이며 universal instructions가 아니다. | +| repo `.agents/skills`, `.opencode/skills` | `.agents/skills`는 비어 있고 `.opencode/skills`는 없다. `.opencode/` 자체는 존재하며 package 메타데이터와 node_modules만 있고 활성 지침/설정은 없다. | +| `~/.codex/skills` | `.system`만 있음. 이는 plain `system` 이름의 skill과 다르다. | +| `/etc/codex` | 경로 없음. | +| `~/.claude/skills` | 없음. `~/.config/opencode/skills/commit-with-validation`은 별도 user skill이며 이번 routing catalog와 혼동하지 않는다. | + +Codex role별 TOML은 repo/global instruction hierarchy에 속하지 않는 role-specific guidance다. 전역 설정 파일이 있다는 사실도 skill을 자동 실행한다는 보장이 아니다. + +## 공식 문서 및 참조 + +다음 문서/저장소 주소를 조사 근거로 사용했다. host 동작은 설치 버전과 문서 버전을 구분한다. + +- Codex AGENTS: +- Codex skills: +- OpenCode rules: +- OpenCode skills: +- Superpowers: +- Matt Pocock skills: +- Addy Osmani skills: +- Addy upstream skills index: + +Codex AGENTS 문서는 global override/fallback, repo-root→cwd 지침 계층과 32 KiB 기본 예산을 설명한다. OpenCode rules v1은 cwd 상위 탐색과 instruction 결합을 설명하며 v2 문서를 설치된 v1.19.3에 혼합하지 않는다. Codex skills는 `agents/openai.yaml`의 `policy.allow_implicit_invocation: false` 및 skills 설정을 설명한다. OpenCode skills 문서의 key 표기는 `metadata["opencode/autoinvoke"]`다. OpenCode v1 공식 문서는 Codex식 top-level `disable-model-invocation` 호환을 약속하지 않는다. 명시적 요청 의도는 host가 같은 통제를 보장한다고 추정하지 않고 루트 routing에서도 존중한다. + +Codex는 전역에서 비어 있지 않은 `AGENTS.override.md`를 먼저 선택하고 없으면 `AGENTS.md`를 사용한다. +프로젝트는 Git root부터 cwd까지 디렉터리마다 override, AGENTS, 설정된 fallback 순으로 최대 한 파일을 선택한다. +루트에서 하위 방향으로 결합하며 더 가까운 지침이 앞선 지침을 구체화한다. +OpenCode v1은 cwd에서 상위로 로컬 AGENTS/CLAUDE를 찾고 같은 범주에서는 먼저 일치한 파일을 선택한다. +전역 AGENTS가 없으면 Claude 전역 지침으로 fallback하며, 설정의 `instructions`는 발견한 지침과 결합한다. +OpenCode v1 문서는 `AGENTS.override.md` 지원을 명시하지 않는다. 이 파일 발견 규칙은 아래 스킬의 단계별 책임 순서와 별개다. + +## Superpowers 설치 및 역할 + +경로 표기: + +- `SP=/Users/klaus/.cache/opencode/packages/superpowers@git+https:/github.com/obra/superpowers.git/node_modules/superpowers` +- `CSP=/Users/klaus/.codex/plugins/cache/openai-curated-remote/superpowers/6.4.2` +- `U=/Users/klaus/.agents/skills` + +OpenCode의 `SP/skills//SKILL.md`에서 확인된 실제 15개: + +1. `using-superpowers` +2. `brainstorming` +3. `writing-plans` +4. `executing-plans` +5. `subagent-driven-development` +6. `dispatching-parallel-agents` +7. `using-git-worktrees` +8. `test-driven-development` +9. `systematic-debugging` +10. `requesting-code-review` +11. `receiving-code-review` +12. `verification-before-completion` +13. `finishing-a-development-branch` +14. `diagnosing-superpowers` +15. `writing-skills` + +`deep-interview`는 별도 사용자 skill이며 이 15개 Superpowers 목록에 포함되지 않는다. Codex cache `CSP/skills//SKILL.md`에도 15개가 있으나 cache 존재는 plugin 활성/실제 호출을 증명하지 않는다. OpenCode plugin `.opencode/plugins/superpowers.js` lines 219-264는 `skills.paths` 등록, 첫 top-level 사용자 메시지에 `using-superpowers` 절차 주입, child session 제외를 수행한다. Codex에서 Superpowers 활성화/자동 invocation 성공은 미확인이다. OpenCode parent의 available skill catalog는 여러 skill을 노출하지만 CLI `debug skill`은 일부만 관찰됐다. 부분 listing으로 부재를 추정하지 않는다. OpenCode 복사본과 Codex 캐시 복사본은 동일하지 않으며 brainstorming/systematic-debugging/requesting-code-review 차이가 조사됐다. + +선택 원칙: 필요한 작업에서 `using-superpowers`가 이미 주입된 경우 같은 절차를 중복 로드하지 않는다. 새 설계는 brainstorming, 계획은 `writing-plans`, 실행은 `executing-plans` inline 또는 `subagent-driven-development` 중 하나, 구현 중 TDD, 필요 시 debugging, 요청된 리뷰, 완료 전 fresh verification 순으로 연결한다. `finishing-a-development-branch`는 통합 판단이 실제 필요할 때만 쓴다. worktree는 필요한지 확인하는 것과 자동 생성하는 것을 구분하며 자동 commit은 없다. runtime tool/skill permission은 계속 우선한다. + +## 사용자 영역 skill 목록과 메타데이터 + +### Matt Pocock set + +| 정확한 경로 | 책임/선택 | 발견된 invocation metadata | +|---|---|---| +| `U/domain-modeling/SKILL.md` | domain terminology, `GLOSSARY.md`, ADR 기록/수정. | `agents/openai.yaml` display metadata만, policy block 없음. | +| `U/codebase-design/SKILL.md` | module/interface 경계, deepening/seam 설계 어휘. | display metadata만, policy block 없음. | +| `U/grilling/SKILL.md` | plan/idea/decision을 충분히 구체화한 뒤 선택지를 stress-test하는 질문. | SKILL frontmatter에 `name`, `description`만. `agents/openai.yaml`은 display metadata만. disable/policy invocation 제한 없음. | +| `U/improve-codebase-architecture/SKILL.md` | architecture 후보를 시각 보고서로 제시, 후보 선택 뒤 정해진 후속 흐름. | `disable-model-invocation: true`와 `agents/openai.yaml`의 `policy.allow_implicit_invocation: false`. explicit 요청 없이 자동 선택 금지. | +| `U/setup-matt-pocock-skills/SKILL.md` | 해당 engineering skill set 초기 설정. setup 완료 상태. | `disable-model-invocation: true`와 `policy.allow_implicit_invocation: false`. explicit 요청 없이 재실행 금지. | + +정확한 경로는 위 표의 `U=/Users/klaus/.agents/skills` 치환이다. `grilling`의 일반 언어 요청 trigger는 stress-test plan/decision/idea이며 정확한 skill 이름을 요구하지 않는다. 다만 구조화된 인터뷰가 아니라 매 라운드에 정착된 전체 frontier를 질문하며 사용자의 응답을 기다리고, frontier가 비고 사용자가 확인할 때 끝난다. 이는 요구사항 discovery를 위한 단일 불확실성 질문 루프와 목적이 다르다. + +### `deep-interview` 상세 근거 + +- 경로: `U/deep-interview/SKILL.md` (127 lines); 조사된 디렉터리에는 `SKILL.md`만 있다. Frontmatter는 `name`, `description`, `argument-hint`; `disable-model-invocation`, `agents/openai.yaml`, OpenCode autoinvoke metadata, file/tool-specific dependency가 발견되지 않았다. +- Trigger: 모호한 goal/scope/constraints/success/context, 명시적인 deep-interview 또는 생각 정리 요청. 구체적이고 작은 수정/테스트 보강에서는 제외한다. +- 순서: 기존 코드/근거로 답할 수 있으면 먼저 조사하고 사용자에게 반복 질문하지 않는다. 라운드마다 가장 큰 미해결 질문 하나를 묻고 답을 반영해 점수를 갱신한다. +- Dimensions: Goal, Scope, Constraints, Success, Context 각 0..1. `Context`는 기존 코드베이스를 변경하는 경우에만 평가한다. 새 프로젝트 가중치는 `.35/.25/.15/.25`; 기존 코드베이스 작업 가중치는 `.30/.20/.15/.20/.15`다. 모호성은 1에서 가중 명확성 합을 뺀 판단 보조값이지 객관적인 측정값이 아니다. +- 종료: ambiguity `<=.20`이고 중요한 unknown이 해결됐을 때 종료한다. 중요한 dimension이 `<=.50`이면 중요 미결점이 남아 라운드를 계속할 수 있다. +- 출력: 최종 score, dimensions, decisions, open questions를 chat에 출력한다. 고정 산출 파일 작성이나 파일 변경은 요구되지 않는다. +- 적용 한계: 정적 skill 경로상 host의 implicit invocation은 가능할 수 있지만 실제 Codex/OpenCode invocation은 테스트되지 않았다. 이 문서 작성 요청은 이미 명확하므로 이번 작업에서 skill을 실제 호출하지 않았다. + +brainstorming은 새로운 동작/선택의 제품 의도를 정리하는 기본 설계 단계다. deep-interview는 특히 모호한 요구사항에만 필요한 조건부 discovery 도구다. grilling은 충분히 정해진 선택지/결정을 압박 시험하는 user-triggered 다중 질문 절차다. 이 세 가지를 모든 작업마다 순차 실행하거나 동일한 단일 질문 인터뷰로 취급하지 않는다. + +### 추가 specialist 6개 + +| 정확한 경로 | 선택 조건 | upstream 확인 경계 | +|---|---|---| +| `U/api-and-interface-design/SKILL.md` | 공개 API/module contract 변경, 호환성, 오류, boundary input. | Upstream 동일 이름 파일 확인: `https://github.com/addyosmani/agent-skills/blob/main/skills/api-and-interface-design/SKILL.md`. 이름 대응은 검증됐지만 local provenance/byte identity는 보장하지 않는다. | +| `U/security-and-hardening/SKILL.md` | auth, sensitive data, external integrations, CORS, uploads, rate limit, elevated roles. | `https://github.com/addyosmani/agent-skills/blob/main/skills/security-and-hardening/SKILL.md`; 동일한 한계. | +| `U/ci-cd-and-automation/SKILL.md` | CI/CD/build/deploy pipeline 자동화. | `https://github.com/addyosmani/agent-skills/blob/main/skills/ci-cd-and-automation/SKILL.md`; 동일한 한계. | +| `U/observability-and-instrumentation/SKILL.md` | 운영 logging, metrics, tracing, alerting. | `https://github.com/addyosmani/agent-skills/blob/main/skills/observability-and-instrumentation/SKILL.md`; 동일한 한계. | +| `U/performance-optimization/SKILL.md` | 성능 목표/측정 병목/명시 최적화. | `https://github.com/addyosmani/agent-skills/blob/main/skills/performance-optimization/SKILL.md`; 동일한 한계. | +| `U/frontend-ui-engineering/SKILL.md` | 사용자 frontend UI/page/component. | `https://github.com/addyosmani/agent-skills/blob/main/skills/frontend-ui-engineering/SKILL.md`; 동일한 한계. | + +Addy README 및 `using-agent-skills` mapping에서 같은 이름이 확인됐다. 이는 upstream correspondence만 검증하며 로컬 설치 출처/byte identity는 증명하지 않는다. 위 여섯 로컬 skill에서 invocation 제한 metadata는 관찰되지 않았지만, host가 실제로 자동 호출한다는 보장은 아니다. 예시 specialist 선택은 기존 Kotlin/Spring naming, DTO, API contract를 대체하거나 rename하지 않는다. + +`U/deprecation-and-migration/SKILL.md`, `U/debugging-and-error-recovery/SKILL.md`는 존재하지 않는다. 이 참조를 active routing target으로 취급하지 않는다. + +## 중복 라우팅 판정: 여섯 쌍 + +| 조합 | 겹치는 부분 | 고유 책임 | 선택 predicate / owner | +|---|---|---|---| +| `brainstorming` / `deep-interview` | 새 요구의 불확실성 정리. | brainstorming은 새 동작의 선택/설계; deep-interview는 모호한 목표·범위·수용기준의 인터뷰. | 설계 시작이 main; 요구 핵심이 불명확할 때만 deep-interview 먼저, 답 반영 후 brainstorming. | +| `brainstorming` / `grilling` | 아이디어와 tradeoff를 다룰 수 있음. | brainstorming은 설계를 만들고 선택; grilling은 정착된 결정 frontier를 stress-test한다. | 새 설계에서 brainstorming owner; 사용자가 결정 스트레스테스트를 요청할 때만 grilling 추가. 정확 skill명 불필요. | +| `brainstorming` / `codebase-design` | 설계 옵션을 분석할 수 있음. | brainstorming은 제품 동작/요구 선택; codebase-design은 구조적 seam/module boundary 평가. | 새 동작 설계 주체는 brainstorming; 구조 boundary가 요청/실질 이슈일 때 codebase-design 보조. | +| `codebase-design` / `api-and-interface-design` | module/interface 경계와 계약. | codebase-design은 모듈 깊이/seam; API skill은 소비자-facing 계약, 호환성, request/response/error. | 구조 평가 owner는 codebase-design; 공개 contract 하위 작업이 있을 때 API specialist만 해당 하위 범위 owner. | +| `requesting-code-review` / `security-and-hardening` | 위험과 결함 검토. | requesting-code-review는 리뷰 실행/완료 gate 프로세스; security skill은 threat model 및 보안 기준. | review delegation이 적용되면 단일 main review process, security는 criteria/specialist 보조. 같은 변경을 중복 full review하지 않고 자동 수정 금지. | +| `systematic-debugging` / general exploration | 문제 원인 조사. | debugging은 재현된 실패의 hypothesis/causal test loop; general exploration은 요청된 read-only code understanding. | 재현 가능한 오류/실패가 main이면 systematic-debugging; 광범위한 이해 질문만 있으면 일반 조사, 무관한 debugging 절차를 붙이지 않음. | + +## checklist 결손과 sign-off + +- 다음 네 공유 문서는 모두 없다: `/Users/klaus/.agents/references/security-checklist.md`, `/Users/klaus/.agents/references/accessibility-checklist.md`, `/Users/klaus/.agents/references/performance-checklist.md`, `/Users/klaus/.agents/references/observability-checklist.md`. +- `U/security-and-hardening/references/`에는 `hardening-patterns.md`만 있다. +- 전역 파일을 생성/수정하거나 설치하지 않는다. 해당 checklist가 필요한 audit/sign-off는 그 참조 부분에 대해 완전하다고 주장하지 않고 결손을 보고한다. 확인 가능한 자체 코드/skill guidance 검토는 별도로 수행할 수 있지만 누락된 기준을 대체한다고 주장하지 않는다. + +## 후속 루트 정책의 제한 영역 + +루트 요약은 Skill Orchestration, Skill Priority and Selection, Requirements Discovery, Skill Routing by Task Type, Conflict and Duplication Resolution, Automatic Invocation Constraints, Verification and Completion의 7개 주제를 짧게 다룬다. skill body를 복제하지 않는다. 프로젝트 CORE/가이드/기존 naming·contract 우선, 권한, 한국어 설명, PRD/plan, focused test, no automatic commit/install을 유지한다. + +## 미확인 및 비보장 사항 + +이번 범위에서 전역 설정·설치 파일은 변경하지 않았다. 후속으로 Codex Superpowers의 활성 상태를 확인하고 +필요하면 공식 관리 절차로 활성화하거나, 설치 버전에 맞는 전문 스킬의 누락 참조를 복구하는 작업은 별도 승인이 필요하다. +호출 제한을 풀거나 우회하기 위해 frontmatter를 바꾸는 제안은 하지 않는다. + +- Codex에서 Superpowers plugin의 실제 자동 활성/호출 성공, OpenCode 전체 runtime catalog와 각 모델 invocation. +- 조사 순간 이후의 config, 실행 CLI arguments/profile, tool permission 및 skill precedence. +- 캐시와 installed skill 간 byte identity; Addy upstream name correspondence는 확인됐으나 local provenance는 확인되지 않음. +- missing checklist를 사용하는 complete sign-off. 이는 결손으로 확인됨. +- 사용자 routing scenario의 runtime 재현/자동 invocation 성공. diff --git a/docs/20261019_스킬오케스트레이션/plan-task.md b/docs/20261019_스킬오케스트레이션/plan-task.md new file mode 100644 index 00000000..e9c98914 --- /dev/null +++ b/docs/20261019_스킬오케스트레이션/plan-task.md @@ -0,0 +1,174 @@ +# 스킬 오케스트레이션 구현 계획 + +| 항목 | 내용 | +|---|---| +| 상태 | 구현 완료 | +| 작성일 | 2026-10-19 | +| 요구사항 기준 | [`prd.md`](prd.md) | +| 현재 Phase | Phase 1: 문서 및 루트 정책 통합 | +| 현재 활성 Goal | 없음 | + +## 목표 + +사용자 의도에 맞는 skill orchestration 정책을 루트 `AGENTS.md`에 적용하고, 근거 감사와 9개 routing scenario 및 문서 검증 증거를 완성한다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | `2/2` | 없음 | 논리 대조·문서 검사·별도 검토 완료, 런타임 자동 호출은 미검증 | + +동시에 하나의 미완료 Goal만 실행한다. 사용자는 안전한 조사, 문서 및 범위 내 루트 변경을 이미 명시적으로 승인했다. 후속 승인 대기 단계를 만들지 않는다. + +## 범위 + +### 포함 + +- `AGENTS.md`의 기존 원칙 아래 skill orchestration 요약 변경. +- `docs/20261019_스킬오케스트레이션/`의 PRD, 계획, 근거 감사, 지정 원문 기반 9 scenario. +- scenario/PRD/audit/루트 정책 사이 일관성 및 문서 검증. + +### 제외 + +- 앱 코드/테스트/스크립트, 전역 설정/지침, skill/plugin 파일 수정 또는 설치. +- `AGENTS.md`의 요청 범위 바깥 기존 내용 변경. +- gstack/worktree/commit/push 및 실제 skill invocation 실험. +- 자동 invocation이 동작한다고 주장하거나 누락된 checklist를 만들고 sign-off 기준을 완화하는 일. + +## 문서 작업 TDD 예외 + +문서/정책 변경은 생산 동작을 바꾸지 않으므로 fake unit test를 만들지 않는다. 대체 검증은 네 파일 정확성, 9개 원문/필드, source·metadata 주장 및 root diff의 assertion 대조다. 실행 시 다음을 사용하고 결과를 사실대로 기록한다. + +```bash +git diff --check +git diff --no-index --check /dev/null docs/20261019_스킬오케스트레이션/prd.md +git diff --no-index --check /dev/null docs/20261019_스킬오케스트레이션/plan-task.md +git diff --no-index --check /dev/null docs/20261019_스킬오케스트레이션/audit.md +git diff --no-index --check /dev/null docs/20261019_스킬오케스트레이션/routing-scenarios.md +wc -c AGENTS.md +./gradlew tasks --all +``` + +현재 문서 정정 작업에서는 이 명령들을 실행하지 않는다. `wc -c` 출력은 Codex의 32,768 byte 기본 budget과 비교하고, Gradle 명령은 문서 변경에 대한 프로젝트 요구 점검이다. 네 파일 신규 비교가 필요하면 `git diff --no-index -- /dev/null <위의 실제 파일 경로>`를 실행한다. 이는 차이를 출력하고 통상 exit 1이므로 차이 출력과 실제 오류를 구분한다. + +### Phase 1: 문서 및 루트 정책 통합 + +**Phase 결과:** 루트 정책과 네 문서가 같은 책임 분리와 승인 경계를 표현하며, 지정 시나리오와 실제 점검 결과를 추적할 수 있다. + +**선행조건:** 필수 프로젝트 문서와 설치 근거를 읽었고 사용자가 전체 범위를 승인했다. + +- [x] **Task 1.1: 사전 문서 작성 및 정정** + +**Goal ID: `P1-T1`** + +- **Objective:** 정확한 범위, 조사 근거, 9개 지정 요청의 라우팅 기대를 네 문서에 일관되게 남긴다. +- **시작 조건:** 루트 지침/가이드/샘플 및 조사된 host/skill 근거 확인. +- **정확한 파일 경로:** `docs/20261019_스킬오케스트레이션/prd.md`, `plan-task.md`, `audit.md`, `routing-scenarios.md`. +- **완료 증거:** 네 파일 존재, PRD/plan이 루트 구현까지 포괄, 9개 원문과 필수 8필드, 확정된 metadata 사실, 계획된 검증을 실제 수행했다고 오기하지 않음. +- **범위 밖:** `AGENTS.md` 편집, 기존 문서/설정/skill 변경, 실제 skill invocation. +- **TDD 예외 사유:** 문서만 작성한다. +- **대체 검증 방법:** 파일 수, assertion, 시나리오/근거 대조. 검증 명령 미실행 상태를 보존한다. + +- [x] 필수 문서 및 조사 근거 기반 초안 네 파일을 작성했다. +- [x] 정정 요청을 반영해 PRD/plan 범위, invocation 사실, 감사 근거 및 시나리오를 갱신했다. + +- [x] **Task 1.2: 루트 skill orchestration 정책 적용** + +**Goal ID: `P1-T2`** + +- **Objective:** PRD의 확정 요구와 audit의 근거를 적용해 기존 루트 정책 아래에 최소 routing 요약을 반영한다. +- **시작 조건:** `P1-T1`의 정정된 문서가 있고 기존 CORE/프로젝트 규칙을 보존한다. +- **정확한 파일 경로:** 수정 `AGENTS.md`; 근거 `docs/20261019_스킬오케스트레이션/prd.md`, `audit.md`, `routing-scenarios.md`. +- **완료 증거:** 요청된 제한 영역만 바꾸고 9 scenario에 대조하며 실제 실행한 검증만 기록한다. +- **범위 밖:** 다른 기존 파일, 글로벌 설정, `SKILL.md`/plugin, 설치, gstack, 자동 commit, runtime 호출 주장. +- **TDD 예외 사유:** 자연어 정책 편집이며 독립 생산 동작이 없다. +- **대체 검증 방법:** AGENTS diff와 문서 assertion 대조, `git diff --check`, actual-path no-index whitespace check, byte budget 및 `./gradlew tasks --all` 결과. + +- [x] 변경할 루트 영역을 읽고 유지할 기존 우선순위/작업 절차를 대조한다. +- [x] 분류, 조건부 deep-interview, 단일 실행 경로, 중복 방지, 권한/발견 제한, 검증 시점만 최소 문구로 반영한다. +- [x] 일반 architecture 요청에서 explicit-only skill을 선택하지 않으며 `grilling`을 explicit-only로 잘못 분류하지 않았는지 확인한다. +- [x] 9개 exact prompt와 정책을 대조하고 실제 검증 결과를 기록한다. + +- [x] **Phase Gate: 정책·근거·시나리오 정합성** + +**Goal ID: `P1-GATE`** + +- **Objective:** 루트 변경과 네 문서가 사용자 범위, 사실 근거, 프로젝트 문서 검증 요건을 충족하는지 판정한다. +- **시작 조건:** `P1-T1`, `P1-T2` 완료. +- **정확한 파일 경로:** `AGENTS.md`, `docs/20261019_스킬오케스트레이션/{prd.md,plan-task.md,audit.md,routing-scenarios.md}`. +- **완료 증거:** 4파일/9원문 assertion, 9 scenario 8필드, diff 공백 확인, `wc -c AGENTS.md`, Gradle task 명령 결과와 미확인 조건 기록. +- **범위 밖:** 자동 호출 성공 주장, checklist 결손 은폐/완화, 무관한 수정. +- **TDD 예외 사유:** 최종 정책·문서 검토. +- **대체 검증 방법:** 위 명령을 실제 실행하고 출력 및 exit status를 기록한다. `git diff --no-index`의 정상 차이 exit 1은 whitespace 검사 실패로 취급하지 않는다. + +- [x] 모든 자동/보조 라우팅 조건이 audit의 설치/metadata 근거와 일치한다. +- [x] 전역 공유 checklist 부재를 보고하고 그 checklist 기반 full sign-off를 주장하지 않는다. +- [x] 실제 검증 결과, 남은 제한, 요청 범위 외 변경 부재를 기록한다. + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 상태 | +|---:|---|---|---| +| 1 | `P1-T1` | 필수 가이드/근거 확인 | 완료, 이 정정판으로 갱신 | +| 2 | `P1-T2` | 정정된 사전 문서 | 완료 | +| 3 | `P1-GATE` | `P1-T1`, `P1-T2` 및 실제 검증 | 완료 | + +## Progress + +### `P1-T1` 실행 기록 (최초 작성) + +- 무엇을: PRD/plan을 별도 patch로 먼저 만들고 audit/scenarios를 뒤에 추가했다. +- 어떻게: 네 파일/9개 scenario 존재는 읽기/검색 도구로 확인했다. `git diff --check`, no-index, `wc -c`, Gradle 명령은 실행하지 않았다. +- 결과: 초안 작성은 완료했으나 이후 부모 검토에서 범위, grilling metadata, 설치 근거, checklist 결손과 scenario prompt/소유권 오류가 발견되어 아래 정정 기록을 추가했다. + +### `P1-T1` 정정 기록 + +- 상태: 문서 정정 완료, 검증 명령 대기. +- 무엇을: 전체 사용자 승인 범위와 실제 근거, 요청 원문 그대로의 9 scenario, 명시적 요청/자동 선택 구분을 네 파일에 반영했다. +- 검증: `P1-T1` 문서 편집을 완료로 표시한다. 최종 문서 점검 명령은 실행하지 않았다. +- 이전 오류 기록을 삭제하지 않고 현재 문서 내용을 정정했다. + +### `P1-T2` 실행 기록 + +- 상태: 대기. +- 다음 행동: 정정된 PRD/audit/scenarios를 따라 루트 정책 편집. + +### `P1-T1` 부모 검증 및 `P1-T2` 착수 - 2026-10-19 + +- 무엇을: 네 선행 문서를 모두 읽고 설치 파일·사용자 원문과 대조했다. +- 보정: `.opencode/` 자체의 존재와 활성 설정 부재를 구분하고, deep-interview의 `argument-hint` 및 Context 평가 조건을 바로잡았다. +- `test`/`rg` 검사: 종료 코드 0. 필수 문서 존재, 시나리오 9개 및 각 8개 필드(총 72개)를 확인했다. +- 신규 네 파일의 `git diff --no-index --check /dev/null "$file"`: 종료 코드 0, 공백 오류 없음. +- 결과: 선행 문서 검증 완료. `P1-T2`는 기존 내용 보존 및 한 줄 우선순위 명확화와 7개 짧은 정책 섹션 추가를 위임해 진행 중이다. + +### `P1-GATE` 실행 기록 + +- 상태: 대기. +- 다음 행동: 루트 편집 및 실제 검증 결과가 준비된 뒤 판정. + +### `P1-T2` 완료 및 `P1-GATE` 1차 검증 - 2026-10-19 + +- 무엇을: 기존 우선순위 문장 한 줄을 상위 지침/권한 경계로 명확히 하고 7개 routing 섹션을 추가했다. +- 왜: Superpowers 메인 흐름과 단계별 단일 책임, 선택적 specialist, 명시 호출 전용 제한을 실제 루트 지침에 반영하기 위해서다. +- `git diff -- AGENTS.md`: 기존 본문은 우선순위 한 줄 외 보존. `git diff --numstat`는 77줄 추가/1줄 삭제다. +- `git status --short --untracked-files=all`: `AGENTS.md`와 이 폴더의 신규 네 Markdown 문서만 변경됐다. +- `git diff --check` 및 신규 네 문서의 no-index `--check`: 종료 코드 0, 공백 오류 없음. +- `test`/`rg`: 종료 코드 0. 7개 루트 heading이 각각 한 번, 9개 원문 요청과 72개 필드, 참조 파일 존재 및 내부 주석/루트 override 미생성을 확인했다. +- 수동 논리 대조: 9개 scenario 모두 현재 루트 정책에 일치. 도메인 분석·보안 리뷰·버그 수정 등 가상 작업 자체나 모델의 자동 호출은 실행하지 않았다. +- `wc -c AGENTS.md`: 17,988 bytes로 Codex 기본 32,768 byte 예산 미만이다. +- `./gradlew tasks --all`: 종료 코드 0, `BUILD SUCCESSFUL`. 기존 Gradle 9.0 관련 deprecation 경고는 유지된다. +- 전역 설정 두 개 및 deep-interview/명시 호출 제한 관련 다섯 파일의 SHA-256은 편집 전 값과 동일하다. 전역 설정·설치 파일은 수정하지 않았다. +- 다섯 변경 파일에 LSP 진단을 요청했으나 `.md` 서버가 설정되지 않아 수행할 수 없었다. 서버/설정은 추가하지 않고 문서 대조 및 공백 검사로 검증했다. +- 전체 테스트·빌드는 실행하지 않았다. 실행 코드·의존성·빌드 설정 변경이 없어 문서 정합성·diff 검사와 Gradle task 목록으로 검증했다. +- 남은 항목: 별도 최종 검토 결과와 최종 문서 공백/범위 재검사. 런타임 자동 invocation은 이번 Gate 범위 밖이며 미검증이다. + +### `P1-GATE` 별도 검토 및 완료 판정 - 2026-10-19 + +- 별도 검토 결과: 수정 필수/권고 수준의 실질적 finding 없음. 기존 내용 보존, 실제 스킬 원문/metadata와의 일치, + 명시 호출 제한, 문서 크기, checklist 결손 보고 및 변경 범위를 확인했다. +- 검토자는 Gradle·모델 실행을 반복하지 않았다. 사용자 9개 원문 대조와 Gradle/전역 파일 해시 검증은 부모 실행 결과를 근거로 한다. +- 시나리오 문서에 9개 논리 대조 PASS와 9개 런타임 자동 호출 미실행을 별도로 기록했다. +- 결과: `P1-T1`, `P1-T2`, `P1-GATE` 완료. 남은 호스트 활성화/참조 결손은 audit의 비보장 및 별도 승인 영역으로 남긴다. +- 커밋·푸시·전역 설정 변경·스킬 설치·gstack 실행은 수행하지 않았다. +- 최종 재검사: 시나리오 9개/72개 필드/논리 PASS 9행 및 미완료 계획 항목 부재를 확인했고, + 변경·신규 문서 공백 검사는 종료 코드 0, `./gradlew tasks --all` 재실행도 `BUILD SUCCESSFUL`이었다. diff --git a/docs/20261019_스킬오케스트레이션/prd.md b/docs/20261019_스킬오케스트레이션/prd.md new file mode 100644 index 00000000..41c0fe0a --- /dev/null +++ b/docs/20261019_스킬오케스트레이션/prd.md @@ -0,0 +1,67 @@ +# 스킬 오케스트레이션 요구사항 + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 완료 | +| 작성일 | 2026-10-19 | +| 최종 수정일 | 2026-10-19 | +| 대상 | 루트 `AGENTS.md`의 skill orchestration 정책 및 사전 문서 | +| 관련 구현 계획 | [`plan-task.md`](plan-task.md) | +| 조사/시나리오 | [`audit.md`](audit.md), [`routing-scenarios.md`](routing-scenarios.md) | + +## 1. 문제와 목표 + +루트 `AGENTS.md`는 일부 skill을 소개하지만 작업 유형별 선택, 순서, 중복 해소, 명시적 요청 경계와 host별 발견 한계를 한곳에서 안내하지 않는다. 이는 문서 baseline의 routing 공백이며, 자동 호출 실패라는 런타임 판정은 아니다. + +목표는 사용자의 작업 유형에 맞는 최소 skill 선택 및 순서를 루트 정책으로 정리하고, 그 정책을 뒷받침하는 근거와 아홉 개의 실제 사용자 요청 예시를 함께 제공하는 것이다. 전체 승인 범위는 루트 `AGENTS.md`의 제한된 skill orchestration 영역 변경과 이 폴더의 네 문서 완성이다. + +## 2. 범위 + +### 포함 + +- `AGENTS.md`에 skill 선택, 선행 순서, 중복 해소, explicit-only 처리, 자동 호출 조건, 검증/완료 경계를 좁게 추가한다. +- 다음 네 문서를 완성한다: `docs/20261019_스킬오케스트레이션/prd.md`, `plan-task.md`, `audit.md`, `routing-scenarios.md`. +- 현재 루트/전역 지침, 설치 경로, skill metadata와 공식 host 문서를 구분해 근거/불확실성을 기록한다. +- 9개 지정 요청에 각기 분류, 주 skill, 보조 skill, 실행 순서, 제외 대상, 조건부 자동 선택, 승인 경계, 관찰/검증 상태를 기록한다. +- 프로젝트 CORE와 기존 PRD/plan, TDD, focused verification, 한국어 보고 규칙을 유지한다. + +### 제외 + +- 런타임 앱 코드, 테스트, 스크립트 또는 새 skill/plugin 구현. +- 전역/프로젝트 host 설정, global `AGENTS.md`, `SKILL.md` 변경, 설치 또는 gstack 설치. +- 문서에서 제안한 routing의 모델 자동 invocation을 테스트/보장한다고 주장하는 일. +- 요청된 루트 skill orchestration 영역 밖의 `AGENTS.md` 정리, 다른 기존 파일 변경, 자동 commit/push. +- PRD/plan 절차, API 계약, 보안 gate, 공통 tooling을 새로 정의하거나 약화하는 일. + +## 3. 정책 요구사항 + +| ID | 요구사항 | 수용 기준 | +|---|---|---| +| ORCH-001 | 작업 산출물에 맞게 최소 skill 조합을 선택한다. | 대표 작업 유형에서 주 책임과 조건부 specialist가 구분되며 무관한 skill을 체인에 넣지 않는다. | +| ORCH-002 | 흐름은 요청의 명료도와 구현 여부에 맞춘다. | 새 기능은 brainstorming을 사용하고, 모호할 때만 deep-interview를 선택한 뒤 설계로 돌아간다. 구현은 기존 PRD/plan, 한 실행 경로, 구현 시 TDD를 따른다. | +| ORCH-003 | explicit-only 제약과 사용자 의도를 보존한다. | `improve-codebase-architecture`, `setup-matt-pocock-skills`는 implicit invocation하지 않는다. `grilling`을 metadata상 암묵 호출 금지로 오인하지 않고, 의미상 stress-test 요청일 때 조건부 선택한다. | +| ORCH-004 | 겹치는 skill은 책임별로 나눈다. | `audit.md`에 6개 쌍의 역할 차이와 선택 기준이 있고, 9개 시나리오에서 중복 실행/소유권을 판정한다. | +| ORCH-005 | 발견 상태와 실제 invocation을 분리한다. | 조건부 사용은 host에서 발견 가능하고 권한이 허용되는 경우로 한정한다. runtime 자동 호출은 테스트됐다고 하지 않는다. | +| ORCH-006 | 기존 프로젝트 정책과 상위 권한을 보존한다. | 기존 CORE/프로젝트 절차가 skill 일반 지침보다 우선한다. 자동 승인, 설치, commit을 추가하지 않는다. | +| ORCH-007 | 누락된 필수 checklist를 숨기거나 약화하지 않는다. | 전역 checklist 결손은 해당 checklist 기반 sign-off 제한으로 보고하며 파일 생성/설치/기준 완화는 하지 않는다. | + +## 4. 충돌과 의존 + +- 시스템/플랫폼/도구 권한 및 루트 CORE와 프로젝트 규칙을 보존한다. 명확한 사용자 승인은 이 경계를 우회하지 않는다. +- 기존 `docs/agent-guides/작업절차.md`, `문서유지보수.md`의 PRD/plan 경로, goal, TDD 예외, focused test 우선 및 실제 결과 누적 요구를 유지한다. +- 조사된 shared checklist가 없으면 대체 문서를 임의로 작성하거나 full sign-off를 주장하지 않는다. +- Codex/OpenCode의 발견/우선순위는 각 host 문서에 귀속해 설명하며 하나의 공통 탐색 규칙으로 합치지 않는다. + +## 5. 완료 기준 + +- 신규 문서는 지정 폴더 아래 정확히 네 개이며, 이 구현은 기존 `AGENTS.md`의 해당 routing 영역만 수정한다. +- 9개 scenario가 사용자 지정 원문 요청을 그대로 포함하고 각각 8개 필드를 갖는다. 문서상 논리 대조 결과와 런타임 자동 호출 미검증을 구분한다. +- `plan-task.md`의 루트 편집, 9 scenario 대조, 문서 점검 및 Gate가 실제 증거와 함께 완료된다. +- source/evidence path와 runtime 미확인 사항, checklist 결손이 정확히 구분된다. +- `git diff --check`, 신규 파일별 실제 경로 `git diff --no-index /dev/null ` 비교, `wc -c AGENTS.md`의 32,768 byte 기본 budget 비교, `./gradlew tasks --all` 결과는 실제 실행 뒤에만 기록한다. + +## 6. 근거 범위 + +루트 `AGENTS.md`, 프로젝트 가이드와 샘플, 설치/설정 경로, Codex/OpenCode 공식 문서, skill 파일과 metadata를 [`audit.md`](audit.md)에 구체적으로 기록한다. 사용자 요청 원문 및 문서상 라우팅 기대는 [`routing-scenarios.md`](routing-scenarios.md)에 둔다. diff --git a/docs/20261019_스킬오케스트레이션/routing-scenarios.md b/docs/20261019_스킬오케스트레이션/routing-scenarios.md new file mode 100644 index 00000000..5b6d83d7 --- /dev/null +++ b/docs/20261019_스킬오케스트레이션/routing-scenarios.md @@ -0,0 +1,143 @@ +# Skill routing 시나리오 + +## 판정 기준 + +각 요청 문구는 사용자가 지정한 원문 그대로다. 아래는 확인된 skill 책임을 적용한 문서상 routing 기대이며 모델 실행 결과가 아니다. 각 `관찰/검증` 필드의 PENDING은 해당 가상 업무와 모델 자동 호출을 실행하지 않았다는 뜻이다. 수정된 `AGENTS.md`와의 논리 대조는 아래 결과표에 별도로 기록한다. Host에서 해당 skill을 발견하고 도구 권한이 허용되어야 선택할 수 있다. + +## 1. 로그인 기능 + +**요청:** 사용자 로그인 기능을 만들어줘. + +- **분류:** 신규 사용자 기능, 인증/보안 경계 및 API 가능성이 있는 구현. +- **주 skill:** `brainstorming`. +- **보조 skill:** 인증/민감 정보 경계에 `security-and-hardening`; 공개 API가 포함될 때 `api-and-interface-design`; frontend가 범위일 때 `frontend-ui-engineering`. +- **실행 순서:** 요구/승인된 로그인 동작 정리 → 해당 보안/API/frontend 하위 범위 검토 → 기존 PRD/plan → 실행 경로 하나 → 구현 시 TDD → 변경 리뷰 → 완료 전 verification. +- **제외/중복:** 요구가 명료하면 `deep-interview` 제외. explicit stress-test가 아니면 `grilling` 제외. 보안 리뷰만 요청한 것이 아니므로 review skill로 구현을 대체하지 않는다. +- **조건부 자동 선택:** host discovery/permission이 허용하면 필요한 specialist만 선택 가능. 이는 자동 호출이 검증됐다는 뜻이 아니다. +- **승인/명시 요청:** 인증 방식, token lifecycle, scope 등 미정 정책을 임의 결정하지 않는다. 실제 기능 구현은 요청됐지만 보안 설계의 미결값은 해결이 필요하다. +- **관찰/검증:** PENDING. scenario simulation only; 실행 또는 자동 호출 증거 없음. + +## 2. 모호한 신규 서비스 + +**요청:** 요구사항이 아직 모호한 신규 서비스를 설계해줘. + +- **분류:** 모호한 목표/범위/성공 기준의 신규 설계. +- **주 skill:** `brainstorming`. +- **보조 skill:** ambiguity를 codebase/기존 문서로 해결할 수 없을 때만 `deep-interview`. +- **실행 순서:** 확인 가능한 기존 근거 확인 → 중요한 요구 unknown이 남으면 한 번에 가장 불확실한 질문을 하는 deep-interview → 응답 반영 → brainstorming으로 설계 정리. +- **제외/중복:** 모든 작업에서 deep-interview를 기본 실행하지 않는다. 정착된 결정 frontier를 사용자가 stress-test하라고 하지 않으면 `grilling` 제외. 구현 요청이 없으면 plan/실행/TDD 생략. +- **조건부 자동 선택:** deep-interview는 의미상 모호한 요청 조건에 맞을 수 있으나 실제 host/model 호출은 검증되지 않았다. grilling은 의미상 stress-test 요청일 때만 선택하며 명시적 skill 이름은 필수 아님. +- **승인/명시 요청:** 미정 제품 선택은 사용자에게 남기고 확정된 것처럼 기록하지 않는다. +- **관찰/검증:** PENDING. scenario simulation only; 인터뷰를 수행하지 않았다. + +## 3. 결제 도메인 용어와 상태 + +**요청:** 결제 도메인의 용어와 상태를 정리해줘. + +- **분류:** 결제 도메인 어휘와 state model 정리 요청. +- **주 skill:** `domain-modeling`. +- **보조 skill:** 상태 전이가 코드 구조/모듈 seam 평가를 요구할 때만 `codebase-design`. +- **실행 순서:** 현재 결제 코드/문서 용례를 조사 → 용어와 상태 의미를 분리해 정리 → 필요할 경우 현재 domain doc convention에 반영. +- **제외/중복:** glossary 언급만으로 ADR 작성, API 변경, 기능 구현, brainstorming-기반 신규 서비스 설계를 강제하지 않는다. 기술 seam이 없는 한 codebase-design 생략. +- **조건부 자동 선택:** glossary/domain model을 실제로 수정하는 범위면 domain-modeling이 관련된다. 파일 read-only 설명만 요청됐다면 수정 workflow로 넘어가지 않는다. +- **승인/명시 요청:** 용어의 의미/상태 전이가 코드와 다르면 제품 의미를 추측하지 말고 근거 및 미결점을 보고한다. ADR은 별도 결정 기록 필요가 있을 때만. +- **관찰/검증:** PENDING. scenario simulation only; domain 파일을 변경/검증하지 않았다. + +## 4. 모듈 의존성 설계 검토 + +**요청:** 이 모듈의 의존성 설계를 검토해줘. + +- **분류:** 기존 구조의 read-only architecture/interface evaluation. +- **주 skill:** `codebase-design`. +- **보조 skill:** 새 동작/구조 대안을 실제로 결정해야 할 때만 `brainstorming`. +- **실행 순서:** 의존성 및 호출자 근거 조사 → seam/ownership/tradeoff 평가 → 사실과 제안 분리 보고. +- **제외/중복:** 일반 architecture review는 `improve-codebase-architecture` explicit invocation을 뜻하지 않는다. HTML 보고서, `grilling`, 수정/리팩터링을 자동 시작하지 않는다. +- **조건부 자동 선택:** 일반 탐색 및 codebase-design은 질문의 구조 검토 의도에 맞는다. skill 선택은 host discovery/permission에 따라 다르며 runtime 미검증. +- **승인/명시 요청:** 파일 이동/코드 변경은 요청되지 않았으므로 읽기 전용으로 유지한다. 보고서 artifact도 임의 생성하지 않는다. +- **관찰/검증:** PENDING. scenario simulation only; 실제 의존성 분석은 수행하지 않았다. + +## 5. 새 REST API 설계 + +**요청:** 새로운 REST API를 설계해줘. + +- **분류:** 신규 API 동작 및 public contract 설계. +- **주 skill:** `brainstorming`이 system behavior/요구 설계를 소유한다. +- **보조 skill:** `api-and-interface-design`은 contract substage(호환성, request/response/error, 입력 경계)를 소유한다. 실제 trust/security boundary가 있으면 `security-and-hardening` 보조. +- **실행 순서:** 사용자/소비자 의도와 기존 contract 확인 → 필요한 신규 동작 brainstorming → API specialist의 제한된 contract 분석 → 보안 경계가 있으면 보안 검토 → 설계 결과 보고. 구현 요청이 추가되면 PRD/plan 이후 실행. +- **제외/중복:** API specialist가 전체 제품 의도나 전체 구현을 대신하지 않는다. 설계만 요청된 경우 코드/migration/test 구현은 제외. +- **조건부 자동 선택:** API contract substage가 존재할 때 API specialist 선택 가능; 보안은 경계가 있는 경우에 한한다. host discovery/permission 필요, 실제 invocation 미검증. +- **승인/명시 요청:** 기존 public schema compatibility를 깨는 변경과 미확정 error/validation 정책을 추정하지 않는다. +- **관찰/검증:** PENDING. scenario simulation only; API 계약/runtime 검증을 수행하지 않았다. + +## 6. 인증 취약점 검토 + +**요청:** 인증 과정에 보안 취약점이 있는지 검토해줘. + +- **분류:** 인증 security review, read-only. +- **주 skill:** `requesting-code-review`는 review delegation/검토 단계가 적용되는 경우 단일 main review process다. +- **보조 skill:** `security-and-hardening`은 threat model 및 security criteria를 제공한다. main review를 중복 실행하지 않는다. +- **실행 순서:** 허용 범위/인증 경계 조사 → 단일 review process로 evidence 기반 finding → 보안 기준에 따라 영향/완화안 구분 보고 → 요청된 검증만 실행. +- **제외/중복:** 자동 fix, `test-driven-development`, 구현용 brainstorming은 수정 요청이 별도로 없으면 제외한다. 두 번째 전체 security review를 같은 목적에 중복하지 않는다. +- **조건부 자동 선택:** 보안 경계 때문에 security specialist가 관련되지만 host discovery/permission에 의존한다. invocation은 미검증. +- **승인/명시 요청:** review는 수정 승인이 아니다. 체크리스트 참조가 필요한 sign-off는 checklist 결손을 밝히고 full sign-off로 표현하지 않는다. +- **관찰/검증:** PENDING. scenario simulation only; 코드 review/security test는 수행하지 않았다. + +## 7. 테스트 실패 버그 수정 + +**요청:** 테스트에 실패하는 버그를 수정해줘. + +- **분류:** 실패를 재현하고 근본 원인을 고쳐야 하는 bugfix. +- **주 skill:** `systematic-debugging`. +- **보조 skill:** 구현 변경이면 TDD 절차 및 단일 실행 경로; 마무리에 적절한 review와 fresh verification. +- **실행 순서:** 실패 재현/원인 증거 → 최소 회귀 test → 의도한 실패 확인 → 최소 수정/통과 → 직접 영향 회귀 → review → 완료 전 verification. +- **제외/중복:** 실패 원인이 명료하면 brainstorming/deep-interview/grilling을 추가하지 않는다. 일반 탐색을 별도 full process로 중복하지 않는다. 테스트 삭제/완화 금지. +- **조건부 자동 선택:** 실제 재현된 failure가 있을 때 systematic-debugging 관련. host discovery/permission 및 호출은 미검증. +- **승인/명시 요청:** 범위 변경은 기존 plan 갱신 후 진행한다. 수정 요청이므로 재현된 범위의 fix는 승인됐다. +- **관찰/검증:** PENDING. scenario simulation only; 테스트/코드 변경은 수행하지 않았다. + +## 8. 기존 코드베이스 구조 개선 후보 + +**요청:** 기존 코드베이스에서 구조 개선 후보를 찾아줘. + +- **분류:** 기존 시스템의 read-only exploratory review. +- **주 skill:** 일반 조사; 구조/경계 판단이 필요할 때 `codebase-design`. +- **보조 skill:** 일반 요청만으로는 explicit-only인 `improve-codebase-architecture`를 선택하지 않는다. +- **실행 순서:** 관련 코드/의존성 근거를 읽기 → 개선 후보/비용/근거를 설명 → 보고서/후속 작업 형식이 필요하면 사용자에게 opt-in 명시 호출을 안내. +- **제외/중복:** skill 주도의 HTML report workflow, 후보 선택 대기, `grilling`, 구현/commit을 자동 시작하지 않는다. +- **조건부 자동 선택:** `codebase-design`은 구조 평가에 조건부. `improve-codebase-architecture`는 사용자가 해당 스킬을 이름으로 명시하거나 host의 skill 선택기에서 직접 선택한 뒤에만 실행한다. 일반적인 보고서 요청도 명시 호출 제한을 우회하지 않는다. +- **승인/명시 요청:** 일반 후보 탐색은 읽기 전용 요청이다. HTML 산출물과 skill workflow의 explicit invocation은 별도 opt-in이다. +- **관찰/검증:** PENDING. scenario simulation only; 실제 repository review나 skill invocation은 수행하지 않았다. + +## 9. 확정 설계 뒤 구현 재개 + +**요청:** 기존 설계가 확정되었으니 구현을 계속해줘. + +- **분류:** 결정된 요구사항/기존 plan에 따른 연속 구현. +- **주 skill:** 하나의 plan execution path만 선택: inline 수행이면 `executing-plans`, subagent 방식이 실제 선택된 경우 `subagent-driven-development`. +- **보조 skill:** 구현 단계의 TDD, 필요한 debugging, 변경 review, fresh verification. +- **실행 순서:** 기존 PRD/plan·Decision Log 및 저장소 상태 확인 → 기존 미완료 task 실행 → 구현 시 TDD → review → 새 상태/검증 기록. +- **제외/중복:** blocker/새로운 critical gap 없이 brainstorming, deep-interview, 재계획, grilling을 반복하지 않는다. 두 execution path를 같이 실행하지 않는다. +- **조건부 자동 선택:** host에서 discover되고 permission이 허용되는 절차만 사용한다. Codex/OpenCode 자동 invocation 실제 결과는 미검증. +- **승인/명시 요청:** 기존 설계와 task를 따르는 구현은 요청됨. 새로운 제품 결정, scope expansion, 자동 commit은 포함하지 않는다. +- **관찰/검증:** PENDING. scenario simulation only; 구현/test/호출은 수행하지 않았다. + +## 공통 host/승인 경계 + +### 문서상 논리 대조 결과 - 2026-10-19 + +| 시나리오 | AGENTS.md 대조 기준 | 논리 판정 | 런타임 자동 호출 | +|---|---|---|---| +| 1 | 신규 기능 흐름과 보안/API specialist 조건 | PASS | 미실행 | +| 2 | Requirements Discovery의 선택적 인터뷰와 설계 복귀 | PASS | 미실행 | +| 3 | Domain 용어/상태 및 glossary 소비/수정 구분 | PASS | 미실행 | +| 4 | 기존 dependency read-only 평가와 구조 어휘 | PASS | 미실행 | +| 5 | API 설계 main과 contract substage 책임 분리 | PASS | 미실행 | +| 6 | 단일 보안 review process와 criteria/checklist 한계 | PASS | 미실행 | +| 7 | 재현 가능한 실패의 원인 조사/TDD와 테스트 완화 금지 | PASS | 미실행 | +| 8 | 일반 조사와 explicit-only 스킬의 명시 선택 제한 | PASS | 미실행 | +| 9 | 기존 미완료 Task 재개와 중복 설계/인터뷰 금지 | PASS | 미실행 | + +판정 방법: 부모 에이전트가 각 요청의 분류·주/보조 책임·순서·제외·승인 경계를 현재 루트 지침과 직접 대조했다. +`test`/`rg`로 9개 요청 원문과 8개 필드(총 72개)도 확인했다. PASS는 정책 논리의 일치만 뜻하며 실제 업무 실행이나 자동 호출 성공이 아니다. + +모든 `조건부 자동 선택`은 모델이 실제로 자동 호출할 것이라는 보장이 아니다. Host가 skill을 발견하고 도구 권한이 허용될 때만 가능하다. 일반 언어상 grilling 요청은 stress-test 의도를 뜻할 수 있으며, skill 이름을 정확히 입력해야만 한다고 제한하지 않는다. 반대로 `improve-codebase-architecture`와 `setup-matt-pocock-skills`는 확인된 explicit-only metadata 때문에 일반 주제 언급만으로 선택하지 않는다. 9개 모든 관찰/검증은 PENDING이다. From 45b97088ca7c74b5a23f4ef41f8047a237166626 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 19 Oct 2026 18:33:34 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(charge):=20=EC=B2=AB=20=EC=B6=A9?= =?UTF-8?q?=EC=A0=84=20=EB=B3=B4=EB=84=88=EC=8A=A4=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9D=84=20=EC=A4=91=EB=8B=A8=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 219 ++++++++++++++ docs/20261019_첫충전보너스신규지급중단/prd.md | 81 ++++++ .../can/charge/event/ChargeEventJobService.kt | 35 +-- .../charge/AdminChargeEventJobServiceTest.kt | 123 ++++++++ .../charge/event/ChargeEventJobServiceTest.kt | 274 ++++++++++++++++++ .../charge/event/ChargeEventJobWorkerTest.kt | 79 +++++ 6 files changed, 777 insertions(+), 34 deletions(-) create mode 100644 docs/20261019_첫충전보너스신규지급중단/plan-task.md create mode 100644 docs/20261019_첫충전보너스신규지급중단/prd.md diff --git a/docs/20261019_첫충전보너스신규지급중단/plan-task.md b/docs/20261019_첫충전보너스신규지급중단/plan-task.md new file mode 100644 index 00000000..5dfd547d --- /dev/null +++ b/docs/20261019_첫충전보너스신규지급중단/plan-task.md @@ -0,0 +1,219 @@ +# 첫 충전 보너스 신규 지급 중단 구현 계획 + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 구현 완료, 검증·독립 리뷰 통과, 미배포 | +| 작성일 | 2026-10-19 | +| 요구사항 기준 | [prd.md](prd.md) | +| 현재 Phase | Phase 1, 신규 생성 분기 변경 | +| 현재 활성 Goal | 없음 | +| 다음 Goal | 없음, 구현 범위 완료 | +| 작업 위치 | 현재 `test` 브랜치, 별도 worktree·브랜치 생성 없음 | + +## 목표 + +신규 충전에서 첫 충전 보너스 작업만 만들지 않고, 기존 활성 이벤트 지급과 이미 저장된 모든 작업 계약을 보존한다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | 1/1 | 없음 | 운영 배포는 별도 범위 | + +사용자의 구현 승인을 받아 `P1-T1`과 독립 리뷰를 포함한 `P1-GATE`를 완료했다. 아래 문서 작성 단계의 미실행 기록은 당시 이력으로 유지한다. + +## 범위 + +### 포함 + +- `ChargeEventJobService.buildSnapshot(charge, member)`에서 첫 충전 분기를 없애고 활성 이벤트 스냅샷 경로로 통일한다. +- 삭제된 분기 때문에 사용되지 않는 생성자 의존성, 함수, import와 상수를 해당 파일에서만 정리한다. +- 신규 생성 차단과 기존 활성 이벤트/저장 작업의 회귀 테스트를 추가 또는 보강한다. + +### 제외 + +- 구매·결제 완료 경로, 유료 캔, 회원 정보 응답의 `chargeCount`, `isFirstCharged`, API, DB schema, 이벤트 enum과 기존 지급 처리 변경. +- `PENDING`/`FAILED`/`PROCESSING`/`DONE` 상태 정책, worker/admin 서비스, `publishPaidNotification`의 `FIRST_CHARGE` 분기 변경. +- `ChargeEventService.applyChargeEvent` 및 그 공유 메서드의 정리. 현재 live caller가 없지만 별도 레거시 경계다. +- migration, 과거 작업 수정/삭제/환수, feature flag, cutoff 값, dependency 추가, 별도 운영 실행. + +## 구현 제약 + +- 기술 스택은 Kotlin, Spring Boot, JUnit 5이며 기존 서비스 패턴을 따른다. +- gateway 누락 예외 검증은 snapshot 선택보다 먼저 일어나도록 유지한다. +- 인증 여부/charge count를 조회해 첫 충전 자격을 판정하지 않는다. 매 충전에서 기존 활성 이벤트와 회원별 횟수 한도를 평가한다. +- `processJob(jobId)`는 저장 스냅샷으로 작업을 수행하므로 해당 경로와 retry/idempotency를 수정하지 않는다. +- 기존 `FIRST_CHARGE` 데이터는 배포 시점과 관계없이 보존하고 처리한다. + +## Phase 1: 신규 생성 분기 변경 + +**Phase 결과:** 신규 `FIRST_CHARGE` 작업 생성은 중단되고 활성 이벤트 및 기존 저장 작업의 지급은 기존대로 동작한다. + +**선행조건:** 별도의 구현 승인. 이 문서 작성은 구현 승인이 아니다. + +**Phase 완료 조건:** `P1-T1` 완료, `P1-GATE` 통과 및 실제 결과 기록. + +### 구현 항목 + +#### Task 1.1 신규 생성 차단과 회귀 보존 + +**Goal 실행 `P1-T1`** + +**Objective:** 새 결제 완료 충전의 작업 스냅샷을 활성 이벤트로만 제한하면서 기존 이벤트·저장 작업의 계약을 테스트로 고정한다. + +- **시작 조건:** PRD `STOP-001`~`STOP-004` 확정, 별도 구현 승인, 작업 트리에서 대상 파일 재확인. +- **완료 증거:** 모든 실행 체크박스 완료, focused test와 ktlint 결과, task Progress에 실제 명령/결과 기록. +- **범위 밖:** worker/admin 동작 수정, 결제 데이터 조작, 문서 밖 범위 확장. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorkerTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobServiceTest.kt` + +**Interfaces:** + +- Consumes: `ChargeEventJobService.createProcessingJob(sourceChargeId: Long, memberId: Long): ChargeEventJob?`의 기존 gateway 검증, charge/member 조회, 멱등 키 조회 및 저장 계약. +- Produces: 외부 시그니처 변경 없음. `buildSnapshot(charge: Charge, member: Member): ChargeEventJob?`는 활성 이벤트 스냅샷 결과 또는 `null`만 반환한다. + +- [x] **RED:** `ChargeEventJobServiceTest`에 인증 회원 첫 충전에서 신규 `FIRST_CHARGE`가 생기지 않으며, 인증 이력 조회를 하지 않고 활성 이벤트 스냅샷만 생기는 테스트를 추가한다. 15% 계산 대신 활성 이벤트 설정의 `addPercent`/제목/ID/횟수 제한으로 저장 내용을 검증한다. +- [x] **RED 확인:** 아래 focused test 명령을 실행해 새 회귀 assertion이 `FIRST_CHARGE` 분기 때문에 실패하는지 확인한다. 테스트 설정 오류나 fixture 오류는 RED로 인정하지 않는다. +- [x] **기존 동작 고정:** 활성 이벤트 테스트 픽스처로 `PG`, `PAYVERSE`, `APPLE_IAP`, `GOOGLE_IAP` 각각 gateway, container(`pg`/`ios`/`aos`), `additionalCan`, `methodSnapshot`, `ACTIVE_CHARGE_EVENT`와 event ID가 기존 규칙 그대로 저장되는지 확인한다. 프로세스 후 지급 알림의 제목/수신자/추가 캔 내용이 기존 규칙을 따르는지 검증한다. 기존 계약을 고정하는 테스트는 변경 전부터 통과해야 한다. +- [x] **RED:** 활성 이벤트 없음과 회원별 횟수 한도 소진에서 새 작업이 없고, 새로 첫 결제한 인증 회원도 일반 활성 이벤트를 적용받는 테스트를 추가한다. +- [x] **기존 동작 고정:** 과거 `FIRST_CHARGE` snapshot fixture로 활성 이벤트가 없어도 저장된 `PENDING`/`PROCESSING` 작업을 `processJob`가 저장 금액·gateway·container·method대로 한 번 처리하고 인증/자격 조회나 이벤트 조회를 다시 하지 않는지 확인한다. 네 gateway의 보너스 잔액과 첫 충전 알림을 검증하며, 별도 상태 자동 복구 동작은 기대하지 않는다. +- [x] **기존 동작 고정:** `FAILED`는 관리자 수동 retry가 `PENDING`으로 되돌린 뒤 worker에 처리되고, `DONE`은 재호출해도 재지급되지 않는 기존 동작을 검증한다. +- [x] **RED 확인:** `./gradlew test --tests '*ChargeEventJobServiceTest' --tests '*ChargeEventJobWorkerTest' --tests '*AdminChargeEventJobServiceTest'`를 실행한다. 신규 중단 assertion은 미구현 동작으로 실패하고, 저장된 작업/일반 이벤트 보존 테스트는 통과하는지 구분한다. +- [x] **GREEN:** `buildSnapshot`에서 gateway 선검증을 유지하고 `buildActiveEventSnapshot(charge, member, paymentGateway)`을 반환하도록 최소 변경한다. 미사용 `AuthRepository` 생성자 의존성/import, `buildFirstChargeSnapshot`, `ceil` import 및 더 이상 참조되지 않는 `FIRST_CHARGE_METHOD`만 제거한다. +- [x] **GREEN 확인:** 동일 focused test 명령으로 신규 차단 및 활성 이벤트/기존 작업 회귀가 통과하는지 확인한다. +- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리한다. 서비스·worker·admin 테스트와 `./gradlew ktlintCheck`를 다시 실행하고 실제 결과를 Progress에 기록한다. 필요한 경우에만 테스트 fixture 중복을 정리한다. + +**Task 검증 기록 (2026-10-19):** 원본 focused 11개 PASS 후 테스트를 추가했다. RED에서는 35개 중 신규 생성 assertion 9개만 실패하고 보존 테스트 26개는 통과했다. `AuthRepository`를 실제 주입한 fixture에 DI·충전 1건 stub을 설정해 기존 `FIRST_CHARGE`가 생성되는 실패를 확인했다. 최소 변경 후 서비스 25개·worker 6개·관리자 4개, 총 35개가 통과했다. 기존 테스트 삭제·완화는 없다. RED의 의존성 주입·stub은 production 의존성 제거에 맞춰 정리하되 결과 assertion은 유지했다. + +#### 필수 테스트와 판정 + +| 테스트 이름 | 핵심 assertion | +|---|---| +| `shouldNotCreateFirstChargeJobForNewAuthenticatedCharge` | 첫 충전 대상 회원도 신규 `FIRST_CHARGE`를 만들지 않는다. 활성 이벤트가 없으면 저장 없이 `null`이다. | +| `shouldApplyActiveEventToNewChargeAcrossGateways` | 네 gateway에서 일반 이벤트의 ID·제목·비율·container를 사용한다. 예: 100캔, 일반 이벤트 10%라면 15캔이 아닌 10캔이다. | +| `shouldNotCreateJobWhenActiveEventLimitIsExhausted` | 일반 이벤트 한도가 소진되면 첫 충전 보너스로 우회하지 않고 저장 없이 `null`이다. | +| `shouldPayExistingFirstChargeSnapshotWithoutActiveEvent` | 기존 스냅샷 금액이 15이면 현재 이벤트/인증과 무관하게 15를 유지하고, 네 gateway의 해당 보너스 잔액·기록·첫 충전 알림을 검증한다. | +| `shouldProcessPendingFirstChargeJobAndSkipDoneJob` | worker의 기존 `PENDING` 처리와 `DONE` 재호출에서 지급 1회·잔액 불변을 검증한다. | +| `shouldRetryFailedFirstChargeJobWithoutChangingSnapshot` | 관리자 재시도 후 `PENDING`, retryCount 0, 원본 스냅샷 보존과 후속 지급을 검증한다. | + +문서 작성의 **TDD 예외 사유:** 실행 코드 동작이 없는 문서 전용 작업이다. **대체 검증 방법:** PRD와 계획의 요구사항·파일·불변조건 대조, Markdown 참조 확인 및 `./gradlew tasks --all` 실행이다. 구현 Task `P1-T1`은 TDD 예외가 아니다. + +### Phase 1 Gate + +**Goal 실행 `P1-GATE`** + +**Objective:** 첫 충전 신규 생성 차단과 기존 활성 이벤트/저장 작업 계약 보존을 자동 테스트 및 배포 경계 체크리스트로 판정한다. + +- **시작 조건:** `P1-T1`의 모든 체크박스 완료, 해당 Progress 기록 존재. +- **완료 증거:** focused test 및 ktlint 통과, 아래 불변조건 확인, 미실행 배포 체크를 운영 검증 완료로 오인하지 않도록 기록. +- **범위 밖:** 운영 DB/결제/SQL 조작, 배포 실행, 테스트 삭제·완화, 새 복구 정책. + +**Files:** + +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJob.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorker.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobService.kt` + +- [x] `./gradlew test --tests '*ChargeEventJobServiceTest' --tests '*ChargeEventJobWorkerTest' --tests '*AdminChargeEventJobServiceTest'` 통과 확인. +- [x] `./gradlew ktlintCheck` 통과 확인. +- [x] 코드 diff가 PRD 포함 파일과만 일치하며 enum, `processJob`, worker, admin service, 결제 API 변경이 없는지 확인. +- [x] 읽기 전용 배포 체크리스트에 cutoff 이후 신규 `FIRST_CHARGE` 부재 및 기존 snapshot 처리 지속을 남긴다. 이는 이 계획에서 실행하지 않는다. +- [x] 전체 회귀는 공통 경계/여러 domain 영향, targeted 결과로 영향 판단 불가, release/final 전체 증거 요구 또는 사용자 요청일 때 실행한다. 생략 시 근거와 focused 명령을 Progress에 기록한다. + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 1 | `P1-T1` | 별도 구현 승인 | 아니요 | 승인이 없으면 문서 상태 유지 | +| 2 | `P1-GATE` | `P1-T1` 완료 | 아니요 | 실패 원인 소유 테스트/변경 확인 후 별도 회귀 goal 결정 | + +```text +P1-T1 → P1-GATE +``` + +## 변경 금지 항목 + +- `ChargeEventJobType.FIRST_CHARGE`와 기존 저장 snapshot 처리 코드를 삭제하지 않는다. +- `publishPaidNotification`의 `FIRST_CHARGE` 처리, 지급 알림/현지화, retry, idempotency 또는 `DONE` 중복 방지를 바꾸지 않는다. +- 원본 충전, 광고/클라이언트 추적, 공개 API, DB, 구매 완료 경로를 바꾸지 않는다. +- `AuthRepository`/`ChargeRepository` 공유 메서드와 레거시 `ChargeEventService`를 삭제하지 않는다. +- cutoff 설정, migration, 운영용 SQL, 새 dependency와 관계없는 정리를 추가하지 않는다. +- RED 확인 전에 구현하지 않으며 테스트를 삭제/skip/완화하지 않는다. + +## Progress + +### 문서 작성 및 검증, 2026-10-19 + +- 상태: 문서 완료, 구현 미승인·미실행 +- 무엇을: 사용자 확정 정책과 기술 범위로 PRD 및 실행 계획을 작성했다. +- 왜: 구현 전에 신규 생성 차단 범위와 기존 작업 보존 계약을 분명히 하기 위해서다. +- 어떻게: + - 가이드/샘플 및 소스·테스트 읽기 — 완료. + - `./gradlew tasks --all` — 문서 생성 후 실행, exit 0 / `BUILD SUCCESSFUL`. 계획에서 참조한 `test`, `ktlintCheck` task 등록 확인. + - 현재 `test` 브랜치 확인 및 Git 변경 범위 확인 — 신규 문서 디렉터리만 추가됐으며 소스·테스트·설정 변경 없음. + - 테스트/결제/운영 확인 — 이번 문서 작업에서는 미실행. 미래 구현 항목이며 코드 변경이나 지급 중단 효과가 발생하지 않았다. +- 남은 항목: `P1-T1` 별도 승인, 이후 `P1-GATE`. +- 다음 행동: 별도 승인 이후 `P1-T1`만 goal로 활성화한다. + +## 문서 검증 기록 + +- [x] `./gradlew tasks --all` 실행으로 명령 유효성과 task 등록을 확인했다. +- Markdown LSP 진단은 `.md` 서버 미설정으로 불가했다. 문서 직접 읽기와 소스 대조로 보완하며, 서버를 설치하거나 설정을 바꾸지 않는다. +- 문서 전용 변경이므로 focused/전체 테스트와 ktlint를 재실행하지 않았다. 대신 문서 검토와 Gradle task 등록을 확인했다. 위 구현 체크박스는 모두 미실행 상태다. +- 앞선 동작 조사에서 관련 기존 테스트 11건이 통과했으나, 확인된 픽스처는 `ACTIVE_CHARGE_EVENT`다. 따라서 이는 신규 중단이나 `FIRST_CHARGE` 회귀 커버리지 증거가 아니며 별도 테스트가 필요하다. + +### P1-T1 시작, 2026-10-19 + +- 사용자 승인: "승인 문서대로 구현 진행". 현재 `test` 브랜치에서 수행하며 worktree/브랜치를 생성하지 않는다. +- 사전 대조: `P1-T1`의 신규 생성 경로 변경과 `P1-GATE`의 기존 저장 작업 보존 검증은 일치한다. 공용 enum/worker/admin/원 결제 API는 변경하지 않는다. +- 기존 테스트 읽기: 일반 이벤트 지급/중복 방지/재시도를 다루며 첫 충전 신규 중단과 저장된 첫 충전 스냅샷의 회귀는 추가해야 한다. +- 승인된 범위 외 운영 DB/결제 호출/배포는 수행하지 않는다. 실제 서비스 메서드를 실행하는 격리 QA로 변경 동작을 검증한다. + +### P1-T1 완료 및 P1-GATE 검증, 2026-10-19 + +- 운영 코드 변경은 `ChargeEventJobService.kt` 한 파일, 회귀 테스트는 기존 세 파일에 추가했다. +- 기준선: `./gradlew --no-daemon test --rerun --tests '*ChargeEventJobServiceTest' --tests '*ChargeEventJobWorkerTest' --tests '*AdminChargeEventJobServiceTest'` — 기존 11개 PASS. +- RED: 같은 focused 명령 — 35개 중 9개 의도한 실패. `expected ACTIVE_CHARGE_EVENT but was FIRST_CHARGE` 및 `expected null but was ChargeEventJob` 확인. +- GREEN/정리: `./gradlew --no-daemon test --tests '*ChargeEventJobServiceTest' --tests '*ChargeEventJobWorkerTest' --tests '*AdminChargeEventJobServiceTest' ktlintCheck` — 35개 PASS, ktlint PASS. 신규 테스트 줄바꿈 lint를 수정 후 재검증했다. +- 최종 실행: `./gradlew --no-daemon -I "$RUN_TMP/qa.init.gradle" firstBonusManualQa build ktlintCheck` — exit 0, `BUILD SUCCESSFUL in 9m 11s`. `build`가 전체 test를 실제 실행했고 322 suites / 2,291 tests / failures 0 / errors 0 / skipped 0이다. +- 빌드 산출물: `build/libs/sodalive-0.0.1-SNAPSHOT.jar`. 테스트 결과: `build/test-results/test/`, `build/reports/tests/test/index.html`. +- Kotlin LSP는 서버 미설치로 네 파일 모두 진단 불가다. 설치하지 않고 Gradle 컴파일·전체 테스트·ktlint로 보완했다. Markdown LSP도 미설정이다. +- `git diff --check` PASS. 소스 변경은 승인된 한 파일이며 enum/worker/admin/원본 결제와 `isFirstCharged`는 변경하지 않았다. +- 사용자 ULW 요청으로 전체 suite/build를 실행했다. 운영 DB/결제/푸시 호출 및 배포는 하지 않았다. + +#### 독립 실행 QA 증거 + +임시 `BonusQa.java`와 Gradle init task가 JUnit 밖에서 실제 `createAndProcessImmediate`, `processJob`, `AdminChargeEventJobService.retry`, `ChargeEventJobWorker.runPendingJobs`를 호출했다. repository/트랜잭션/알림 전송 경계는 격리 fixture로 대체했다. 이는 실제 서비스 로직 실행 증거이며, DB 커밋·운영 결제·푸시 수신의 검증은 아니다. + +| 시나리오 | 이진 판정 | 관찰 결과 | +|---|---|---| +| S1 신규 생성/일반 이벤트 | 활성 이벤트 없음은 작업·지급 0, 100캔·일반 이벤트 10%는 FIRST가 아닌 ACTIVE 10캔 한 번 | 네 gateway 모두 PASS | +| S2 한도/입력 경계 | 한도 소진은 작업 0, 결제 정보 누락은 기존 예외 | 네 gateway 모두 PASS | +| S3 저장된 첫 충전 | PENDING/PROCESSING의 저장 15캔을 정확한 지갑에 1회 지급, 원본 유료 잔액 유지, 기록·알림 일치 | 네 gateway 모두 PASS | +| S4 재시도/중복 | FAILED 관리자 재시도 후 worker 지급, DONE 재호출은 잔액·기록·알림 증가 없음 | 네 gateway 모두 PASS | + +- 실행 출력: `QA PASS: 28 isolated real-service scenarios; no external payment, DB or push calls.` +- 임시 로그의 핵심 결과를 이 문서에 보존한다. QA 스크립트·init task·task 전용 캐시와 로그는 최종 리뷰 후 삭제하며 저장소에 추가하지 않는다. + +#### 배포 후 별도 운영 체크리스트 (이번 실행 범위 밖) + +- 모든 구버전 생성 인스턴스 drain 이후 새 `FIRST_CHARGE` 작업이 생기지 않는지 확인한다. 문서 작성일/구매 시작일을 cutoff로 사용하지 않는다. +- 이전에 생성된 `FIRST_CHARGE`의 저장 지급액·gateway·수신자·지급 기록을 대조하고 기존 재시도가 유지되는지 확인한다. 롤링 배포 중 구버전이 생성한 작업도 보존한다. +- `PROCESSING`에 새 자동 복구를 추가하지 않았고, `FAILED`는 기존 관리자 재시도가 필요하다. + +### P1-GATE 최종 판정, 2026-10-19 + +- 상태: 완료. 별도 구현자의 결과를 부모 세션에서 직접 읽고 실행 검증한 뒤 독립 리뷰를 수행했다. +- 독립 리뷰: `APPROVED`, spec compliance와 코드 품질 모두 차단 결함 없음. S1~S4, 변경 diff, 저장 작업 enum/지급/알림/worker/admin 계약 및 RED/GREEN·전체 XML·QA 로그를 대조했다. +- 검증 수치: focused 35개 PASS, 전체 2,291개 PASS, 실패·오류·skip 0, build·ktlint PASS, Java 드라이버 28개 시나리오 PASS. +- 원본 결제/API/광고 추적·공용 enum/레거시 서비스 변경 없음. 배포 또는 데이터 migration을 수행하지 않았다. +- 사전 판단: 추가 SDD 작업 디렉터리 대신 이 계획의 Progress에 결과를 누적하고 현재 `test` 브랜치를 유지했다. 제품 정책의 추가 변경은 없다. +- 문서 최종 확인: `./gradlew --no-daemon tasks --all` 성공. Markdown/Kotlin LSP 미설정·미설치 한계는 그대로 명시한다. +- 임시 QA 자원은 task 소유 경로에만 생성했다. 검증·리뷰 결과를 이 문서에 남겼으며 최종 커밋 후 스크립트/init task/로그/캐시/메모를 제거하고 부재를 확인한다. 기존 공유 Gradle 캐시와 다른 작업의 프로세스는 건드리지 않는다. +- 남은 운영 확인: 실제 DB 커밋·다중 인스턴스 부하·PG/스토어 결제·푸시 수신 및 배포 후 신규 생성 중단은 이번 검증에 포함하지 않았다. 위 배포 체크리스트에 따라 별도로 확인한다. diff --git a/docs/20261019_첫충전보너스신규지급중단/prd.md b/docs/20261019_첫충전보너스신규지급중단/prd.md new file mode 100644 index 00000000..0a36410e --- /dev/null +++ b/docs/20261019_첫충전보너스신규지급중단/prd.md @@ -0,0 +1,81 @@ +# 첫 충전 보너스 신규 지급 중단 PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현·검증·독립 리뷰 완료, 미배포 | +| 작성일 | 2026-10-19 | +| 최종 수정일 | 2026-10-19 | +| 대상 제품 | 충전 이벤트 보너스 지급 | +| 작성자·결정권자 | 사용자 요청 | +| 관련 구현 계획 | [plan-task.md](plan-task.md) | +| 관련 review | [plan-task.md 최종 Gate 기록](plan-task.md#p1-gate-최종-판정-2026-10-19) | + +## 1. 개요와 문제 + +변경 전에는 결제 완료 뒤 `ChargeEventJobService`가 인증 회원의 첫 충전을 판정해 원본 충전량의 15%를 올림 계산한 `FIRST_CHARGE` 작업을 만들었다. 이번 변경은 첫 충전 보너스 신규 생성을 중단하되, 이미 저장된 작업과 일반 활성 충전 이벤트의 지급 계약을 유지한다. + +사용자 확인 정책: 기존 지급은 정상 동작 중이며, “이미 생성된 것은 기존대로 지급한다.” 이 문서는 정책과 구현 범위를 기록할 뿐이며, 문서 변경 자체는 실제 지급을 중단하지 않는다. + +## 2. 목표와 제외 범위 + +### 목표 + +- 이후 코드가 만드는 새 보너스 작업은 기존 `ACTIVE_CHARGE_EVENT` 조건을 만족할 때만 생성한다. +- 신규 `FIRST_CHARGE` 작업을 만들지 않는다. +- 저장된 작업은 현재 worker와 관리자의 기존 계약대로 처리한다. +- 현재 `test` 브랜치에서 작업하며 별도 worktree나 브랜치를 생성하지 않는다. 문서 작성 후 사용자 승인에 따라 아래 범위만 구현한다. + +### 제외 + +- 원본 유료 충전, 회원 정보 응답의 `chargeCount`, `isFirstCharged` 광고 추적, 클라이언트 API 변경. +- `FIRST_CHARGE` enum, 기존 작업 스냅샷, 지급·알림·현지화·재시도·멱등성 처리 제거 또는 변경. +- 과거 작업·보너스 데이터 수정/삭제, clawback, DB migration, feature flag, cutoff-date 설정. +- 새 의존성, 광범위한 정리, 사용처가 없는 레거시 서비스 제거. + +## 3. 확정 요구사항과 수용 기준 + +| ID | 상태 | 요구사항 | 수용 기준 | +|---|---|---|---| +| STOP-001 | 확정 | 새 `FIRST_CHARGE` 작업을 생성하지 않는다. | 각 결제 게이트웨이의 검증을 통과한 신규 충전에서 생성되는 작업이 없거나 `ACTIVE_CHARGE_EVENT`뿐이다. 15% 계산 및 첫 충전 사유 스냅샷을 신규 생성 경로에서 사용하지 않는다. | +| STOP-002 | 확정 | 일반 활성 이벤트를 기존 기준으로 유지한다. | 인증 회원의 첫 결제도 같은 `buildActiveEventSnapshot` 경로를 따른다. 활성 이벤트 부재 또는 회원별 횟수 한도 소진이면 작업을 만들지 않는다. 결제 및 일반 충전 처리는 성공한다. | +| STOP-003 | 확정 | 이미 생성된 작업을 기존대로 처리한다. | 저장된 `PENDING`은 worker 대상이다. `FAILED`는 관리자 재시도 후 worker 대상이다. `PROCESSING`에는 자동 복구를 새로 추가하지 않는다. `DONE`은 재지급하지 않는다. 처리 과정은 인증/이벤트 자격을 재계산하지 않고 저장된 gateway, container, amount, method snapshot을 사용한다. | +| STOP-004 | 확정 | 원본 충전 기록과 외부 계약을 보존한다. | 유료 충전, 회원 충전 이력/광고 추적, 공개 API에는 변경이 없다. | + +## 4. 기술 범위와 보존 경계 + +변경 지점은 [`ChargeEventJobService.kt`](../../src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt)의 `buildSnapshot(charge, member): ChargeEventJob?`이다. `buildActiveEventSnapshot`을 직접 반환하도록 하며, 결제 gateway 검증은 그 호출 전에 유지한다. 이후 인증회원 첫 결제 여부와 무관하게 활성 이벤트 조회 및 횟수 제한을 적용한다. + +이 변경으로 더 이상 사용하지 않는 `AuthRepository` 생성자 의존성/import, `buildFirstChargeSnapshot`, `ceil` import를 제거한다. `FIRST_CHARGE_METHOD`는 이 파일에서만 참조되며 제거 대상이다. 단, `FIRST_CHARGE` enum과 `methodSnapshot`을 이용하는 기존 작업 처리 및 `publishPaidNotification` 분기는 보존한다. + +[`ChargeEventJob.kt`](../../src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJob.kt), [`ChargeEventJobRepository.kt`](../../src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobRepository.kt), [`ChargeEventJobWorker.kt`](../../src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorker.kt), [`AdminChargeEventJobService.kt`](../../src/main/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobService.kt)와 결제 완료 경로는 수정 대상이 아니다. `ChargeEventService.applyChargeEvent`에는 현재 호출자가 없지만 이를 포함한 레거시 서비스는 이 작업에서 제거하지 않는다. 레거시가 참조하는 `AuthRepository` 및 `ChargeRepository` 공용 메서드도 유지한다. + +## 5. 배포와 운영 경계 + +- 적용 기준은 구매 시작일이나 별도 cutoff 설정이 아니라, 새 작업을 만들지 않도록 갱신된 코드가 실제 실행되는 시점이다. +- 구버전 인스턴스가 남아 있는 rolling 배포 중에는 `FIRST_CHARGE` 작업이 더 생길 수 있다. 모든 구버전 생성 인스턴스가 drain된 뒤에야 신규 생성 중단이 완결된다. +- 배포 시점/일정은 이 문서에서 정하지 않는다. +- 배포 후 운영 확인 항목은 cutoff 이후 신규 `FIRST_CHARGE` 작업 부재와 기존 스냅샷 작업의 정상 지속 처리다. 이는 향후 확인 체크리스트이며, 실행된 운영 검증이 아니다. + +## 6. 추적과 의사결정 + +| 요구사항 | 계획 Goal | 검증 | +|---|---|---| +| STOP-001, STOP-002 | `P1-T1` | `ChargeEventJobServiceTest` 신규 생성/활성 이벤트 회귀 | +| STOP-003, STOP-004 | `P1-T1`, `P1-GATE` | worker/admin 회귀, 충전 불변조건 테스트 | + +| 날짜 | ID | 상태 | 결정 | +|---|---|---|---| +| 2026-10-19 | DEC-001 | 확정, 사용자 정책 | 첫 충전 보너스 신규 지급 중단, 생성된 작업은 기존대로 지급한다. | +| 2026-10-19 | DEC-002 | 기술 접근 | 신규 스냅샷 선택을 `buildActiveEventSnapshot`으로 단순화하고 기존 저장 작업 처리 경로는 유지한다. | +| 2026-10-19 | DEC-003 | 확정, 사용자 작업 조건 | worktree를 사용하지 않고 현재 `test` 브랜치에서 문서만 작성한다. | +| 2026-10-19 | DEC-004 | 정정, 사용자 구현 승인 | 문서대로 구현 진행 승인. `test` 브랜치/worktree 미사용 조건은 유지하며 신규 생성 분기와 관련 테스트만 변경한다. | + +## 7. 성공 기준 + +- [x] 신규 작업 생성 테스트에서 `FIRST_CHARGE`가 생성되지 않는다. +- [x] 활성 이벤트가 있으면 신규 일반 이벤트 스냅샷만 생성되고, 이벤트 부재/한도 소진이면 작업 없이 종료한다. +- [x] PG, PAYVERSE, APPLE_IAP, GOOGLE_IAP의 기존 활성 이벤트 snapshot과 잔액/알림 계약이 유지된다. +- [x] 저장된 첫 충전 `PENDING`/`PROCESSING` 작업은 현재 방식대로 한 번 처리되고, `FAILED` 수동 재시도와 `DONE` 중복 방지가 유지된다. +- [x] 사용자 승인 이후 구현하고 focused 35개·전체 2,291개 테스트, build·ktlint와 격리 실행 QA 28개 시나리오를 통과했다. 운영 배포·실지급 확인은 별도다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt index 60da8940..77b357cb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobService.kt @@ -13,7 +13,6 @@ import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository -import kr.co.vividnext.sodalive.member.auth.AuthRepository import org.springframework.context.ApplicationEventPublisher import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service @@ -24,7 +23,6 @@ import org.springframework.transaction.support.TransactionSynchronization import org.springframework.transaction.support.TransactionSynchronizationManager import org.springframework.transaction.support.TransactionTemplate import java.time.LocalDateTime -import kotlin.math.ceil import kotlin.math.round @Service @@ -34,7 +32,6 @@ class ChargeEventJobService( private val chargeRepository: ChargeRepository, private val memberRepository: MemberRepository, private val chargeEventRepository: ChargeEventRepository? = null, - private val authRepository: AuthRepository? = null, private val applicationEventPublisher: ApplicationEventPublisher? = null, transactionManager: PlatformTransactionManager? = null ) { @@ -121,36 +118,7 @@ class ChargeEventJobService( private fun buildSnapshot(charge: Charge, member: Member): ChargeEventJob? { val paymentGateway = charge.payment?.paymentGateway ?: throw SodaException(messageKey = "can.charge.event.not_applied_contact") - return if (member.auth != null) { - val authRepository = authRepository ?: return null - val authDate = authRepository.getOldestCreatedAtByDi(member.auth!!.di) - val chargeCount = authRepository.getMemberIdsByDi(member.auth!!.di) - .sumOf { id -> chargeRepository.getChargeCountAfterDate(memberId = id, authDate) } - if (chargeCount > 1) { - buildActiveEventSnapshot(charge, member, paymentGateway) - } else { - buildFirstChargeSnapshot(charge, member, paymentGateway) - } - } else { - buildActiveEventSnapshot(charge, member, paymentGateway) - } - } - - private fun buildFirstChargeSnapshot(charge: Charge, member: Member, paymentGateway: PaymentGateway): ChargeEventJob { - val additionalCan = ceil(charge.chargeCan * 0.15).toInt() - return ChargeEventJob( - sourceChargeId = charge.id!!, - memberId = member.id!!, - chargeEventId = null, - jobType = ChargeEventJobType.FIRST_CHARGE, - idempotencyKey = idempotencyKey(charge.id!!, ChargeEventJobType.FIRST_CHARGE, null), - additionalCan = additionalCan, - paymentGateway = paymentGateway, - container = container(paymentGateway), - methodSnapshot = FIRST_CHARGE_METHOD, - status = ChargeEventJobStatus.PROCESSING, - processingStartedAt = LocalDateTime.now() - ) + return buildActiveEventSnapshot(charge, member, paymentGateway) } private fun buildActiveEventSnapshot(charge: Charge, member: Member, paymentGateway: PaymentGateway): ChargeEventJob? { @@ -222,7 +190,6 @@ class ChargeEventJobService( } companion object { - private const val FIRST_CHARGE_METHOD = "첫 충전 이벤트" private const val MAX_ERROR_LENGTH = 1000 } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobServiceTest.kt index fa0f3fd9..a2eb801d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/admin/event/charge/AdminChargeEventJobServiceTest.kt @@ -1,14 +1,32 @@ package kr.co.vividnext.sodalive.v2.admin.event.charge +import kr.co.vividnext.sodalive.can.charge.Charge +import kr.co.vividnext.sodalive.can.charge.ChargeRepository +import kr.co.vividnext.sodalive.can.charge.ChargeStatus import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJob import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobRepository +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobService import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobStatus import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobType +import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobWorker import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito +import org.springframework.context.ApplicationEventPublisher +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.AbstractPlatformTransactionManager +import org.springframework.transaction.support.DefaultTransactionStatus import java.time.LocalDateTime +import java.util.Optional class AdminChargeEventJobServiceTest { @Test @@ -54,6 +72,111 @@ class AdminChargeEventJobServiceTest { assertEquals(2, pending.retryCount) } + @Test + @DisplayName("실패한 첫 충전 작업은 관리자 재시도 후 저장 스냅샷 그대로 워커에서 한 번 지급한다") + fun shouldRetryFailedFirstChargeJobWithoutChangingSnapshot() { + val repository = Mockito.mock(ChargeEventJobRepository::class.java) + val chargeRepository = Mockito.mock(ChargeRepository::class.java) + val memberRepository = Mockito.mock(MemberRepository::class.java) + val service = AdminChargeEventJobService(repository) + val notifications = mutableListOf() + val payoutService = ChargeEventJobService( + repository, + chargeRepository, + memberRepository, + applicationEventPublisher = ApplicationEventPublisher { notifications.add(it as FcmEvent) } + ) + val transactionManager = object : AbstractPlatformTransactionManager() { + override fun doGetTransaction(): Any = Any() + override fun doBegin(transaction: Any, definition: TransactionDefinition) {} + override fun doCommit(status: DefaultTransactionStatus) {} + override fun doRollback(status: DefaultTransactionStatus) {} + } + val worker = ChargeEventJobWorker(repository, payoutService, transactionManager) + val member = Member(password = "pw", nickname = "tester").also { + it.id = 10L + it.appleChargeCan = 100 + } + val failed = ChargeEventJob( + sourceChargeId = 100L, + memberId = 10L, + jobType = ChargeEventJobType.FIRST_CHARGE, + idempotencyKey = "charge-event:100:FIRST_CHARGE:none", + additionalCan = 15, + paymentGateway = PaymentGateway.APPLE_IAP, + container = "ios", + methodSnapshot = "첫 충전 이벤트", + status = ChargeEventJobStatus.FAILED, + retryCount = 3, + nextRetryAt = LocalDateTime.now().minusDays(1), + lastError = "bonus down" + ).also { it.id = 1L } + val savedCharges = mutableListOf() + Mockito.`when`(repository.findByIdForUpdate(1L)).thenReturn(failed) + Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(failed)) + Mockito.`when`( + repository.findNextPendingJobIdsForUpdate( + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now(), + Mockito.eq(30) + ) + ).thenAnswer { + if (failed.status == ChargeEventJobStatus.PENDING) listOf(1L) else emptyList() + } + Mockito.`when`(memberRepository.findByIdForUpdate(10L)).thenReturn(member) + Mockito.`when`(chargeRepository.save(Mockito.any(Charge::class.java))).thenAnswer { + assertEquals(ChargeEventJobStatus.PROCESSING, failed.status) + (it.arguments[0] as Charge).also { charge -> + charge.id = 200L + savedCharges.add(charge) + } + } + val beforeRetry = LocalDateTime.now() + + worker.runPendingJobs() + assertEquals(ChargeEventJobStatus.FAILED, failed.status) + assertTrue(savedCharges.isEmpty()) + service.retry(1L) + + assertEquals(ChargeEventJobStatus.PENDING, failed.status) + assertEquals(0, failed.retryCount) + assertNull(failed.lastError) + assertTrue(requireNotNull(failed.nextRetryAt) >= beforeRetry) + assertEquals(100L, failed.sourceChargeId) + assertEquals(10L, failed.memberId) + assertNull(failed.chargeEventId) + assertEquals(ChargeEventJobType.FIRST_CHARGE, failed.jobType) + assertEquals("charge-event:100:FIRST_CHARGE:none", failed.idempotencyKey) + assertEquals(15, failed.additionalCan) + assertEquals(PaymentGateway.APPLE_IAP, failed.paymentGateway) + assertEquals("ios", failed.container) + assertEquals("첫 충전 이벤트", failed.methodSnapshot) + + worker.runPendingJobs() + worker.runPendingJobs() + payoutService.processJob(1L) + + assertEquals(ChargeEventJobStatus.DONE, failed.status) + assertEquals(200L, failed.resultChargeId) + assertNotNull(failed.processingStartedAt) + assertNotNull(failed.processedAt) + assertNull(failed.nextRetryAt) + assertEquals(listOf(0, 15, 0), listOf(member.pgRewardCan, member.appleRewardCan, member.googleRewardCan)) + assertEquals(listOf(0, 100, 0), listOf(member.pgChargeCan, member.appleChargeCan, member.googleChargeCan)) + val paidCharge = savedCharges.single() + assertEquals(0, paidCharge.chargeCan) + assertEquals(15, paidCharge.rewardCan) + assertEquals(ChargeStatus.EVENT, paidCharge.status) + assertEquals(member, paidCharge.member) + assertEquals(PaymentGateway.APPLE_IAP, paidCharge.payment?.paymentGateway) + assertEquals(PaymentStatus.COMPLETE, paidCharge.payment?.status) + assertEquals("첫 충전 이벤트", paidCharge.payment?.method) + val notification = notifications.single() + assertEquals("can.charge.event.first_title", notification.titleKey) + assertEquals("can.charge.event.additional_can_paid", notification.messageKey) + assertEquals(listOf(15), notification.args) + assertEquals(listOf(10L), notification.recipients) + } + private fun job(id: Long, status: ChargeEventJobStatus): ChargeEventJob { return ChargeEventJob( sourceChargeId = 100L, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt index 8f581f6a..374fafa3 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobServiceTest.kt @@ -1,15 +1,31 @@ package kr.co.vividnext.sodalive.v2.can.charge.event +import kr.co.vividnext.sodalive.admin.event.charge.ChargeEvent import kr.co.vividnext.sodalive.can.charge.Charge import kr.co.vividnext.sodalive.can.charge.ChargeRepository import kr.co.vividnext.sodalive.can.charge.ChargeStatus +import kr.co.vividnext.sodalive.can.charge.event.ChargeEventRepository +import kr.co.vividnext.sodalive.can.payment.Payment import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.auth.Auth import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.EnumSource import org.mockito.Mockito +import org.springframework.context.ApplicationEventPublisher import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.support.AbstractPlatformTransactionManager import org.springframework.transaction.support.DefaultTransactionStatus @@ -78,6 +94,264 @@ class ChargeEventJobServiceTest { assertEquals(TransactionDefinition.PROPAGATION_REQUIRES_NEW, transactionTemplate.propagationBehavior) } + @ParameterizedTest + @EnumSource(PaymentGateway::class, names = ["PG", "PAYVERSE", "APPLE_IAP", "GOOGLE_IAP"]) + @DisplayName("인증 회원의 첫 충전도 활성 이벤트가 없으면 보너스 작업을 만들지 않는다") + fun shouldNotCreateFirstChargeJobForNewAuthenticatedCharge(gateway: PaymentGateway) { + val fixture = SnapshotFixture(gateway) + + val result = fixture.service.createAndProcessImmediate(100L, 10L) + + assertNull(result) + assertEquals(emptyList(), fixture.savedJobs) + assertEquals(emptyList(), fixture.savedCharges) + assertEquals(emptyList(), fixture.notifications) + fixture.assertWallet(1, 2, 3) + fixture.assertNoEligibilityLookup() + } + + @ParameterizedTest + @CsvSource( + "PG, pg, false, 11, 2, 3", + "PAYVERSE, pg, false, 11, 2, 3", + "APPLE_IAP, ios, false, 1, 12, 3", + "GOOGLE_IAP, aos, false, 1, 2, 13", + "PG, pg, true, 11, 2, 3", + "PAYVERSE, pg, true, 11, 2, 3", + "APPLE_IAP, ios, true, 1, 12, 3", + "GOOGLE_IAP, aos, true, 1, 2, 13" + ) + @DisplayName("모든 결제 수단과 인증 여부에서 첫 충전 대신 일반 이벤트의 10퍼센트를 지급한다") + fun shouldApplyActiveEventToNewChargeAcrossGateways( + gateway: PaymentGateway, + container: String, + authenticated: Boolean, + pgReward: Int, + appleReward: Int, + googleReward: Int + ) { + val fixture = SnapshotFixture(gateway, authenticated) + fixture.activeEvent(paymentCount = 1) + + val result = requireNotNull(fixture.service.createAndProcessImmediate(100L, 10L)) + + assertEquals(ChargeEventJobType.ACTIVE_CHARGE_EVENT, result.jobType) + assertEquals(20L, result.chargeEventId) + assertEquals(100L, result.sourceChargeId) + assertEquals(10L, result.memberId) + assertEquals("charge-event:100:ACTIVE_CHARGE_EVENT:20", result.idempotencyKey) + assertEquals(10, result.additionalCan) + assertEquals(gateway, result.paymentGateway) + assertEquals(container, result.container) + assertEquals("봄 이벤트", result.methodSnapshot) + assertEquals(listOf(result), fixture.savedJobs) + assertEquals(ChargeEventJobStatus.DONE, result.status) + fixture.assertPayout(10, gateway, "봄 이벤트") + fixture.assertWallet(pgReward, appleReward, googleReward) + fixture.assertNotification(10, title = "봄 이벤트") + fixture.assertNoEligibilityLookup() + Mockito.verify(fixture.eventRepository).getPaymentCount( + fixture.member, + "봄 이벤트", + fixture.event.startDate, + fixture.event.endDate + ) + } + + @Test + @DisplayName("일반 이벤트 한도를 소진한 인증 회원은 첫 충전 보너스로 우회하지 않는다") + fun shouldNotCreateJobWhenActiveEventLimitIsExhausted() { + val fixture = SnapshotFixture(PaymentGateway.PG) + fixture.activeEvent(paymentCount = 2) + + val result = fixture.service.createAndProcessImmediate(100L, 10L) + + assertNull(result) + assertEquals(emptyList(), fixture.savedJobs) + assertEquals(emptyList(), fixture.savedCharges) + assertEquals(emptyList(), fixture.notifications) + fixture.assertWallet(1, 2, 3) + fixture.assertNoEligibilityLookup() + } + + @Test + @DisplayName("결제 정보가 없으면 활성 이벤트 조회보다 먼저 기존 예외를 유지한다") + fun shouldRejectMissingGatewayBeforeLookingUpActiveEvent() { + val fixture = SnapshotFixture(PaymentGateway.PG) + fixture.sourceCharge.payment = null + + assertThrows(SodaException::class.java) { fixture.service.createProcessingJob(100L, 10L) } + + Mockito.verifyNoInteractions(fixture.eventRepository, fixture.jobRepository) + } + + @ParameterizedTest + @CsvSource( + "PG, pg, PENDING, 16, 2, 3", + "PAYVERSE, pg, PENDING, 16, 2, 3", + "APPLE_IAP, ios, PENDING, 1, 17, 3", + "GOOGLE_IAP, aos, PENDING, 1, 2, 18", + "PG, pg, PROCESSING, 16, 2, 3", + "PAYVERSE, pg, PROCESSING, 16, 2, 3", + "APPLE_IAP, ios, PROCESSING, 1, 17, 3", + "GOOGLE_IAP, aos, PROCESSING, 1, 2, 18" + ) + @DisplayName("기존 첫 충전 작업은 현재 이벤트나 인증 재계산 없이 저장된 15캔을 한 번 지급한다") + fun shouldPayExistingFirstChargeSnapshotWithoutActiveEvent( + gateway: PaymentGateway, + container: String, + status: ChargeEventJobStatus, + pgReward: Int, + appleReward: Int, + googleReward: Int + ) { + val fixture = SnapshotFixture(gateway) + val storedJob = ChargeEventJob( + sourceChargeId = 100L, + memberId = 10L, + jobType = ChargeEventJobType.FIRST_CHARGE, + idempotencyKey = "charge-event:100:FIRST_CHARGE:none", + additionalCan = 15, + paymentGateway = gateway, + container = container, + methodSnapshot = "첫 충전 이벤트", + status = status, + nextRetryAt = LocalDateTime.now(), + lastError = "previous failure" + ).also { it.id = 1L } + Mockito.`when`(fixture.jobRepository.findByIdForUpdate(1L)).thenReturn(storedJob) + + fixture.service.processJob(1L) + fixture.service.processJob(1L) + + assertEquals(ChargeEventJobStatus.DONE, storedJob.status) + assertEquals(15, storedJob.additionalCan) + assertEquals(gateway, storedJob.paymentGateway) + assertEquals(container, storedJob.container) + assertEquals("첫 충전 이벤트", storedJob.methodSnapshot) + assertEquals(200L, storedJob.resultChargeId) + assertNotNull(storedJob.processedAt) + assertNull(storedJob.nextRetryAt) + assertNull(storedJob.lastError) + assertEquals(emptyList(), fixture.savedJobs) + fixture.assertPayout(15, gateway, "첫 충전 이벤트") + fixture.assertWallet(pgReward, appleReward, googleReward) + fixture.assertNotification(15, titleKey = "can.charge.event.first_title") + fixture.assertNoEligibilityLookup() + Mockito.verifyNoInteractions(fixture.eventRepository) + Mockito.verify(fixture.chargeRepository, Mockito.never()).findByIdForUpdate(Mockito.anyLong()) + } + + private class SnapshotFixture(gateway: PaymentGateway, authenticated: Boolean = true) { + val jobRepository = Mockito.mock(ChargeEventJobRepository::class.java) + val chargeRepository = Mockito.mock(ChargeRepository::class.java) + private val memberRepository = Mockito.mock(MemberRepository::class.java) + val eventRepository = Mockito.mock(ChargeEventRepository::class.java) + val savedJobs = mutableListOf() + val savedCharges = mutableListOf() + val notifications = mutableListOf() + val member = Member(password = "pw", nickname = "tester").also { + it.id = 10L + it.pgChargeCan = 100 + it.appleChargeCan = 200 + it.googleChargeCan = 300 + it.pgRewardCan = 1 + it.appleRewardCan = 2 + it.googleRewardCan = 3 + if (authenticated) { + Auth("tester", "19900101", "test-ci", "test-di", 1).member = it + } + } + val sourceCharge = Charge(100, 5).also { + it.id = 100L + it.member = member + it.payment = Payment(PaymentStatus.COMPLETE, gateway).also { payment -> payment.method = "카드" } + } + val event = ChargeEvent( + "봄 이벤트", + LocalDateTime.of(2026, 1, 1, 0, 0), + LocalDateTime.of(2026, 12, 31, 0, 0), + 2, + 0.10f + ).also { it.id = 20L } + val service = ChargeEventJobService( + jobRepository, + chargeRepository, + memberRepository, + chargeEventRepository = eventRepository, + applicationEventPublisher = ApplicationEventPublisher { notifications.add(it as FcmEvent) } + ) + + init { + Mockito.`when`(chargeRepository.findByIdForUpdate(100L)).thenReturn(sourceCharge) + Mockito.`when`(memberRepository.findByIdForUpdate(10L)).thenReturn(member) + Mockito.`when`(jobRepository.saveAndFlush(Mockito.any(ChargeEventJob::class.java))).thenAnswer { + (it.arguments[0] as ChargeEventJob).also { job -> + job.id = 1L + savedJobs.add(job) + } + } + Mockito.`when`(jobRepository.findByIdForUpdate(1L)).thenAnswer { savedJobs.singleOrNull() } + Mockito.`when`(chargeRepository.save(Mockito.any(Charge::class.java))).thenAnswer { + (it.arguments[0] as Charge).also { charge -> + charge.id = 200L + savedCharges.add(charge) + } + } + } + + fun activeEvent(paymentCount: Int) { + Mockito.`when`(eventRepository.getChargeEvent()).thenReturn(event) + Mockito.`when`( + eventRepository.getPaymentCount(member, "봄 이벤트", event.startDate, event.endDate) + ).thenReturn(paymentCount) + } + + fun assertNoEligibilityLookup() { + Mockito.verify(chargeRepository, Mockito.never()).getChargeCountAfterDate( + Mockito.anyLong(), + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + ) + } + + fun assertWallet(pgReward: Int, appleReward: Int, googleReward: Int) { + assertEquals( + listOf(pgReward, appleReward, googleReward), + listOf(member.pgRewardCan, member.appleRewardCan, member.googleRewardCan) + ) + assertEquals(listOf(100, 200, 300), listOf(member.pgChargeCan, member.appleChargeCan, member.googleChargeCan)) + assertEquals(100, sourceCharge.chargeCan) + assertEquals(5, sourceCharge.rewardCan) + assertEquals(ChargeStatus.CHARGE, sourceCharge.status) + assertEquals(PaymentStatus.COMPLETE, sourceCharge.payment?.status) + assertEquals("카드", sourceCharge.payment?.method) + } + + fun assertPayout(amount: Int, gateway: PaymentGateway, method: String) { + val paidCharge = savedCharges.single() + assertEquals(0, paidCharge.chargeCan) + assertEquals(amount, paidCharge.rewardCan) + assertEquals(ChargeStatus.EVENT, paidCharge.status) + assertEquals("$amount 캔", paidCharge.title) + assertEquals(member, paidCharge.member) + assertEquals(gateway, paidCharge.payment?.paymentGateway) + assertEquals(PaymentStatus.COMPLETE, paidCharge.payment?.status) + assertEquals(method, paidCharge.payment?.method) + } + + fun assertNotification(amount: Int, title: String = "", titleKey: String? = null) { + val notification = notifications.single() + assertEquals(FcmEventType.INDIVIDUAL, notification.type) + assertEquals(PushNotificationCategory.SYSTEM, notification.category) + assertEquals(title, notification.title) + assertEquals(titleKey, notification.titleKey) + assertEquals("can.charge.event.additional_can_paid", notification.messageKey) + assertEquals(listOf(amount), notification.args) + assertEquals(listOf(10L), notification.recipients) + assertNull(notification.isAuth) + } + } + private fun job( id: Long, memberId: Long = 10L, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorkerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorkerTest.kt index 3fa5cef9..5ef0eef8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorkerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/can/charge/event/ChargeEventJobWorkerTest.kt @@ -1,10 +1,20 @@ package kr.co.vividnext.sodalive.v2.can.charge.event +import kr.co.vividnext.sodalive.can.charge.Charge +import kr.co.vividnext.sodalive.can.charge.ChargeRepository +import kr.co.vividnext.sodalive.can.charge.ChargeStatus import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito +import org.springframework.context.ApplicationEventPublisher import org.springframework.scheduling.annotation.Scheduled import org.springframework.transaction.support.AbstractPlatformTransactionManager import org.springframework.transaction.support.DefaultTransactionStatus @@ -92,6 +102,75 @@ class ChargeEventJobWorkerTest { assertTrue(retryJob.nextRetryAt!!.isBefore(LocalDateTime.now().plusMinutes(6))) } + @Test + @DisplayName("워커는 저장된 첫 충전 대기 작업을 지급하고 완료 작업 재호출에는 중복 지급하지 않는다") + fun shouldProcessPendingFirstChargeJobAndSkipDoneJob() { + val repository = Mockito.mock(ChargeEventJobRepository::class.java) + val chargeRepository = Mockito.mock(ChargeRepository::class.java) + val memberRepository = Mockito.mock(MemberRepository::class.java) + val notifications = mutableListOf() + val service = ChargeEventJobService( + repository, + chargeRepository, + memberRepository, + applicationEventPublisher = ApplicationEventPublisher { notifications.add(it as FcmEvent) } + ) + val worker = ChargeEventJobWorker(repository, service, ChargeEventJobTestTransactionManager()) + val member = Member(password = "pw", nickname = "tester").also { + it.id = 10L + it.pgChargeCan = 100 + } + val pending = ChargeEventJob( + sourceChargeId = 100L, + memberId = 10L, + jobType = ChargeEventJobType.FIRST_CHARGE, + idempotencyKey = "charge-event:100:FIRST_CHARGE:none", + additionalCan = 15, + paymentGateway = PaymentGateway.PAYVERSE, + container = "pg", + methodSnapshot = "첫 충전 이벤트", + nextRetryAt = LocalDateTime.now().minusMinutes(1) + ).also { it.id = 1L } + val savedCharges = mutableListOf() + Mockito.`when`(repository.findNextPendingJobIdsForUpdate(anyLocalDateTime(), Mockito.eq(30))).thenAnswer { + if (pending.status == ChargeEventJobStatus.PENDING) listOf(1L) else emptyList() + } + Mockito.`when`(repository.findById(1L)).thenReturn(Optional.of(pending)) + Mockito.`when`(repository.findByIdForUpdate(1L)).thenReturn(pending) + Mockito.`when`(memberRepository.findByIdForUpdate(10L)).thenReturn(member) + Mockito.`when`(chargeRepository.save(Mockito.any(Charge::class.java))).thenAnswer { + assertEquals(ChargeEventJobStatus.PROCESSING, pending.status) + (it.arguments[0] as Charge).also { charge -> + charge.id = 200L + savedCharges.add(charge) + } + } + + worker.runPendingJobs() + worker.runPendingJobs() + service.processJob(1L) + + assertEquals(ChargeEventJobStatus.DONE, pending.status) + assertNotNull(pending.processingStartedAt) + assertNotNull(pending.processedAt) + assertEquals(200L, pending.resultChargeId) + assertEquals(listOf(15, 0, 0), listOf(member.pgRewardCan, member.appleRewardCan, member.googleRewardCan)) + assertEquals(listOf(100, 0, 0), listOf(member.pgChargeCan, member.appleChargeCan, member.googleChargeCan)) + val paidCharge = savedCharges.single() + assertEquals(0, paidCharge.chargeCan) + assertEquals(15, paidCharge.rewardCan) + assertEquals(ChargeStatus.EVENT, paidCharge.status) + assertEquals(member, paidCharge.member) + assertEquals(PaymentGateway.PAYVERSE, paidCharge.payment?.paymentGateway) + assertEquals(PaymentStatus.COMPLETE, paidCharge.payment?.status) + assertEquals("첫 충전 이벤트", paidCharge.payment?.method) + val notification = notifications.single() + assertEquals("can.charge.event.first_title", notification.titleKey) + assertEquals("can.charge.event.additional_can_paid", notification.messageKey) + assertEquals(listOf(15), notification.args) + assertEquals(listOf(10L), notification.recipients) + } + private fun job(id: Long, retryCount: Int = 0): ChargeEventJob { return ChargeEventJob( sourceChargeId = 100L,