From 40232af4295d1337892da1a3fe1bb1768472bb64 Mon Sep 17 00:00:00 2001 From: reloop Date: Sat, 11 Apr 2026 23:58:37 +0900 Subject: [PATCH] =?UTF-8?q?qa:=20breezing=20audit=20=E2=80=94=206=20tasks,?= =?UTF-8?q?=2010=20critical=20+=209=20major=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QA-1 Backend API contract: APPROVE (minor type contract 2) - QA-2 Persona forget algorithm: APPROVE (math verified) - QA-3 Auth & security: REQUEST_CHANGES - email normalize missing / JWT revalidate / secret fallback / whitelist - QA-4 Frontend runtime: REQUEST_CHANGES - review double-submit race / subjects/[id] NaN / history error states / native alerts - QA-5 Prisma & transactions: REQUEST_CHANGES - reviews.submit snapshot outside tx / tag cascade data loss / iteration race - QA-6 Build & deploy: REQUEST_CHANGES - missing migrations, postinstall, lockfile, PM2 ecosystem Follow-up fix proposals saved to .claude/state/pending-fix-proposals.jsonl (18 tickets) --- .claude/sessions/.last_inbox_check | 1 + .../state/contracts/QA-1.sprint-contract.json | 23 ++++ .../state/contracts/QA-2.sprint-contract.json | 25 +++++ .../state/contracts/QA-3.sprint-contract.json | 29 +++++ .../state/contracts/QA-4.sprint-contract.json | 29 +++++ .../state/contracts/QA-5.sprint-contract.json | 26 +++++ .../state/contracts/QA-6.sprint-contract.json | 27 +++++ .claude/state/pending-fix-proposals.jsonl | 18 ++++ .claude/state/plans-state.json | 8 ++ .claude/state/tool-failure-counter.txt | 1 + .qa/QA-1.md | 48 +++++++++ .qa/QA-2.md | 76 +++++++++++++ .qa/QA-3.md | 86 +++++++++++++++ .qa/QA-4.md | 101 ++++++++++++++++++ .qa/QA-5.md | 74 +++++++++++++ .qa/QA-6.md | 72 +++++++++++++ .qa/QA-SUMMARY.md | 50 +++++++++ Plans.md | 30 ++++++ 18 files changed, 724 insertions(+) create mode 100644 .claude/sessions/.last_inbox_check create mode 100644 .claude/state/contracts/QA-1.sprint-contract.json create mode 100644 .claude/state/contracts/QA-2.sprint-contract.json create mode 100644 .claude/state/contracts/QA-3.sprint-contract.json create mode 100644 .claude/state/contracts/QA-4.sprint-contract.json create mode 100644 .claude/state/contracts/QA-5.sprint-contract.json create mode 100644 .claude/state/contracts/QA-6.sprint-contract.json create mode 100644 .claude/state/pending-fix-proposals.jsonl create mode 100644 .claude/state/plans-state.json create mode 100644 .claude/state/tool-failure-counter.txt create mode 100644 .qa/QA-1.md create mode 100644 .qa/QA-2.md create mode 100644 .qa/QA-3.md create mode 100644 .qa/QA-4.md create mode 100644 .qa/QA-5.md create mode 100644 .qa/QA-6.md create mode 100644 .qa/QA-SUMMARY.md create mode 100644 Plans.md diff --git a/.claude/sessions/.last_inbox_check b/.claude/sessions/.last_inbox_check new file mode 100644 index 0000000..787a1fc --- /dev/null +++ b/.claude/sessions/.last_inbox_check @@ -0,0 +1 @@ +1775919509 diff --git a/.claude/state/contracts/QA-1.sprint-contract.json b/.claude/state/contracts/QA-1.sprint-contract.json new file mode 100644 index 0000000..2f60199 --- /dev/null +++ b/.claude/state/contracts/QA-1.sprint-contract.json @@ -0,0 +1,23 @@ +{ + "task_id": "QA-1", + "title": "Backend API contract audit", + "goal": "Frontend ↔ Backend API contract 정합성 전수 검증", + "checks": [ + "모든 @Controller 라우트를 열거 (HTTP method + path + guard)", + "각 라우트의 DTO 필드 타입·optional·validator 규칙 추출", + "frontend/src/lib/api.ts의 인터페이스 타입과 1:1 대조", + "13개 page.tsx의 api.get/post/patch/delete 호출부에서 URL, payload 구조, 응답 사용 방식 검증", + "Nest global prefix 'api'가 frontend baseURL에 포함되는지 확인", + "JWT guard 유무 / public 라우트 식별" + ], + "non_goals": [ + "실제 HTTP 통신 검증 (runtime)", + "응답 데이터 스키마 변경 제안", + "controller 코드 수정" + ], + "runtime_validation": [], + "browser_validation": [], + "risk_flags": [], + "reviewer_profile": "static", + "deliverable": ".qa/QA-1.md" +} diff --git a/.claude/state/contracts/QA-2.sprint-contract.json b/.claude/state/contracts/QA-2.sprint-contract.json new file mode 100644 index 0000000..d8ef3cb --- /dev/null +++ b/.claude/state/contracts/QA-2.sprint-contract.json @@ -0,0 +1,25 @@ +{ + "task_id": "QA-2", + "title": "Persona forget algorithm verification", + "goal": "망각 곡선 알고리즘의 수학적 정합성과 경계 조건 검증", + "checks": [ + "updateS0 함수의 모든 result 경로 (correct/partial/incorrect) 수식 확인", + "schedule() 함수 역함수 유도 검증: σ(k(S₀e^(-λt) - D)) = threshold → t = -(1/λ)·ln((D + logit(τ)/k)/S₀)", + "경계 조건: s0=0, s0=1, D>=s0, D<=0, target<=0, target>=s0", + "MAX_INTERVAL_DAYS=60 cap 동작 확인", + "predictP 함수 정합성", + "sampleCurve 배열 길이 / 마지막 점 정확도", + "PERSONA_LAMBDA / INTENSITY_THRESHOLD 상수 PLAN.md와 일치 여부", + "persona-forget.service.spec.ts 테스트 커버리지 평가 — 모든 persona × intensity × result 조합 테스트 존재 여부" + ], + "non_goals": [ + "벤치마크", + "알고리즘 교체 제안", + "테스트 파일 수정" + ], + "runtime_validation": [], + "browser_validation": [], + "risk_flags": ["needs-spike"], + "reviewer_profile": "static", + "deliverable": ".qa/QA-2.md" +} diff --git a/.claude/state/contracts/QA-3.sprint-contract.json b/.claude/state/contracts/QA-3.sprint-contract.json new file mode 100644 index 0000000..0e727fa --- /dev/null +++ b/.claude/state/contracts/QA-3.sprint-contract.json @@ -0,0 +1,29 @@ +{ + "task_id": "QA-3", + "title": "Auth & security review", + "goal": "인증/보안 관련 OWASP 주요 취약점 전수 검증", + "checks": [ + "JWT 발급 (auth.service) — secret env 처리, expiresIn, payload 구성", + "bcrypt hash round", + "passport-jwt 전략 (jwt.strategy.ts) — 토큰 파싱, 사용자 조회 누락 여부", + "JwtAuthGuard 적용 범위: 모든 controller", + "public route: /auth/register, /auth/login, /health 만 열림", + "helmet/main.ts CORS origin 설정이 env로 제어되는지", + "global ValidationPipe whitelist/forbidNonWhitelisted", + "frontend setToken/getToken 저장 위치 (localStorage 여부, XSS 노출)", + "401 rebound의 무한 루프 가능성", + "비밀번호 최소 길이 / email 정규화", + "study-logs/reviews/stats/me 에서 userId 검증 (ownership)", + ".env 샘플 파일 유무 / secret leak 가능성" + ], + "non_goals": [ + "pentest", + "보안 헤더 새로 추가", + "JWT 대체 제안" + ], + "runtime_validation": [], + "browser_validation": [], + "risk_flags": ["security-sensitive"], + "reviewer_profile": "static", + "deliverable": ".qa/QA-3.md" +} diff --git a/.claude/state/contracts/QA-4.sprint-contract.json b/.claude/state/contracts/QA-4.sprint-contract.json new file mode 100644 index 0000000..05d811f --- /dev/null +++ b/.claude/state/contracts/QA-4.sprint-contract.json @@ -0,0 +1,29 @@ +{ + "task_id": "QA-4", + "title": "Frontend runtime bug scan", + "goal": "Next.js page/컴포넌트의 런타임 버그, 타입 위반, UX 결함 전수 검증", + "checks": [ + "13개 page.tsx 각각: null/undefined 처리, loading state, error state, empty state", + "useEffect 의존성 배열 누락 (exhaustive-deps)", + "styled-components prop transient ($) 일관성", + "api 호출 타입 (api.get) 와 백엔드 응답 실제 shape 매칭", + "route 간 Link href 유효성 (/dashboard, /review, /study, /stats 등)", + "AppShell requireOnboarding 플래그 동작", + "onboarding wizard → dashboard 리다이렉트 플로우", + "mobile breakpoint 반응형 (@media max-width)", + "SideNav/BottomNav 활성화 상태, 라우트 매칭", + "recharts ForgetCurveChart data shape", + "confirm()/alert() 브라우저 API 사용 위치", + "server component boundary: 'use client' 누락 여부" + ], + "non_goals": [ + "새 컴포넌트 작성", + "디자인 재설계", + "i18n" + ], + "runtime_validation": [], + "browser_validation": [], + "risk_flags": ["ux-regression"], + "reviewer_profile": "static", + "deliverable": ".qa/QA-4.md" +} diff --git a/.claude/state/contracts/QA-5.sprint-contract.json b/.claude/state/contracts/QA-5.sprint-contract.json new file mode 100644 index 0000000..b7571a7 --- /dev/null +++ b/.claude/state/contracts/QA-5.sprint-contract.json @@ -0,0 +1,26 @@ +{ + "task_id": "QA-5", + "title": "Prisma schema & transaction integrity", + "goal": "데이터 모델 제약, cascade, 동시성 경합 가능성 검증", + "checks": [ + "schema.prisma의 모든 model: PK, FK, unique, index, default", + "ReviewSchedule status enum 전이 가능성 (pending → done/skipped/expired)", + "SkillSnapshot @@unique([userId, tagId]) 활용", + "onDelete 규칙 (User 삭제 시 cascade, Subject 삭제 시 StudyLog/tag 처리)", + "study-logs.service.ts create() transaction: log → snapshot upsert → schedule — 모두 tx 안인지", + "reviews.service.ts submit() transaction: snapshot upsert가 tx 바깥(race 가능성)", + "seed.ts가 idempotent한지 (reset 후 재실행 안전성)", + "StudyResult enum / Persona / ReviewIntensity가 Prisma와 frontend 모두 일치", + "ReviewSchedule iteration 증가 규칙", + "baseCorrectRate null 처리 일관성" + ], + "non_goals": [ + "DB 엔진 교체", + "마이그레이션 히스토리 재작성" + ], + "runtime_validation": [], + "browser_validation": [], + "risk_flags": ["needs-spike"], + "reviewer_profile": "static", + "deliverable": ".qa/QA-5.md" +} diff --git a/.claude/state/contracts/QA-6.sprint-contract.json b/.claude/state/contracts/QA-6.sprint-contract.json new file mode 100644 index 0000000..a6f70e8 --- /dev/null +++ b/.claude/state/contracts/QA-6.sprint-contract.json @@ -0,0 +1,27 @@ +{ + "task_id": "QA-6", + "title": "Build & deploy readiness", + "goal": "배포 직전 체크리스트 전수 검증", + "checks": [ + "backend/package.json scripts: build, start:prod, prisma:deploy, prisma:seed", + "frontend/package.json scripts: build, start", + "필수 env 목록 (DATABASE_URL, JWT_SECRET, CORS_ORIGIN, NEXT_PUBLIC_API_URL 등)", + ".env 예시 파일 / README 배포 가이드 여부", + "main.ts bootstrap: port, global prefix 'api', helmet, CORS", + "CORS origin이 reloop.nabomhalang.co.kr을 포함하는지 (env 설정 기준)", + "PM2 프로세스명 (reloop-api / reloop-web) 추정 가능 여부", + "Next.js standalone 빌드 필요성", + "Prisma client generate 타이밍 (postinstall)", + "누락 파일 식별: tsconfig.json, nest-cli.json, next.config.js, styled-components registry", + "reloop-v2-plan/PLAN.md의 Phase 5 항목과 대조" + ], + "non_goals": [ + "실제 deploy 실행", + "CI/CD yaml 작성" + ], + "runtime_validation": [], + "browser_validation": [], + "risk_flags": [], + "reviewer_profile": "static", + "deliverable": ".qa/QA-6.md" +} diff --git a/.claude/state/pending-fix-proposals.jsonl b/.claude/state/pending-fix-proposals.jsonl new file mode 100644 index 0000000..75c468e --- /dev/null +++ b/.claude/state/pending-fix-proposals.jsonl @@ -0,0 +1,18 @@ +{"task_id":"QA-3.fix.C1","origin":"QA-3","severity":"critical","title":"fix: email normalization in register/login","dod":"auth.service.register/login 모두 input.email을 trim().toLowerCase() 후 처리. User 생성 시 동일 값 저장. 기존 사용자와의 호환을 위해 마이그레이션 스크립트 필요 여부 검토","depends":[],"files":["backend/src/auth/auth.service.ts"]} +{"task_id":"QA-3.fix.C2","origin":"QA-3","severity":"critical","title":"fix: JwtStrategy.validate() DB 재조회","dod":"JwtStrategy가 async validate(payload)로 바뀌고 prisma.user.findUnique({ where: { id: payload.sub }, select: { id: true, email: true }})를 호출. 없으면 UnauthorizedException","depends":[],"files":["backend/src/auth/jwt.strategy.ts","backend/src/auth/auth.module.ts"]} +{"task_id":"QA-3.fix.M1","origin":"QA-3","severity":"major","title":"fix: JWT_SECRET required at boot","dod":"auth.module.ts / jwt.strategy.ts에서 fallback 문자열 제거. ConfigService에서 JWT_SECRET 없으면 throw","depends":[],"files":["backend/src/auth/auth.module.ts","backend/src/auth/jwt.strategy.ts"]} +{"task_id":"QA-3.fix.M2","origin":"QA-3","severity":"major","title":"fix: ValidationPipe forbidNonWhitelisted=true","dod":"main.ts ValidationPipe 옵션에 forbidNonWhitelisted: true 추가. 누락 필드 오류 메시지 확인","depends":[],"files":["backend/src/main.ts"]} +{"task_id":"QA-4.fix.C1","origin":"QA-4","severity":"critical","title":"fix: review page per-item submit lock","dod":"review/page.tsx의 submit/skip이 Set 기반 per-review lock을 사용. 동일 아이템 중복 클릭 차단","depends":[],"files":["frontend/src/app/review/page.tsx"]} +{"task_id":"QA-4.fix.C2","origin":"QA-4","severity":"critical","title":"fix: subjects/[id] NaN route handling","dod":"subjectId 를 Number.isFinite 로 검사 후 null 이면 NotFound UI 렌더. 유효하지 않은 URL 로 API 호출 안 함","depends":[],"files":["frontend/src/app/subjects/[id]/page.tsx"]} +{"task_id":"QA-4.fix.M1","origin":"QA-4","severity":"major","title":"fix: history 페이지 에러 상태","dod":"review/history, study/history, stats 페이지 fetch 에 catch 추가. error state UI + retry 버튼","depends":[],"files":["frontend/src/app/review/history/page.tsx","frontend/src/app/study/history/page.tsx","frontend/src/app/stats/page.tsx"]} +{"task_id":"QA-4.fix.M2","origin":"QA-4","severity":"major","title":"fix: 네이티브 confirm/alert 제거","dod":"subjects 페이지의 삭제 흐름을 custom Modal/Toast 로 교체. 성공/실패 피드백 명확","depends":[],"files":["frontend/src/app/subjects/page.tsx","frontend/src/app/subjects/[id]/page.tsx","frontend/src/components/ui/primitives.tsx"]} +{"task_id":"QA-5.fix.C1","origin":"QA-5","severity":"critical","title":"fix: reviews.submit() 전체를 tx 안으로","dod":"reviews.service.submit() 의 snapshot upsert 와 review 업데이트 + next schedule 생성이 단일 $transaction(async tx=> ...) 안에서 실행. update where { id, status: 'pending' } 로 atomic guard","depends":[],"files":["backend/src/reviews/reviews.service.ts"]} +{"task_id":"QA-5.fix.C2","origin":"QA-5","severity":"critical","title":"fix: Tag 삭제 cascade 데이터 보호","dod":"schema.prisma 의 SkillSnapshot.tag onDelete 를 SetNull 로 변경하고 tagId 를 Int? 로. 또는 Tag.archivedAt 을 추가해 soft delete. 선택 후 마이그레이션 생성","depends":[],"files":["backend/prisma/schema.prisma","backend/src/tags/tags.service.ts"]} +{"task_id":"QA-5.fix.M1","origin":"QA-5","severity":"major","title":"fix: iteration 증가를 tx 안에서","dod":"reviews.service.submit() 의 tx 안에서 직전 done 리뷰의 iteration 을 재조회 후 +1","depends":["QA-5.fix.C1"],"files":["backend/src/reviews/reviews.service.ts"]} +{"task_id":"QA-5.fix.M2","origin":"QA-5","severity":"major","title":"fix: StudyLog.tag onDelete 정의","dod":"schema.prisma StudyLog.tag 관계에 onDelete: SetNull 추가 + 마이그레이션","depends":["QA-5.fix.C2"],"files":["backend/prisma/schema.prisma"]} +{"task_id":"QA-6.fix.C1","origin":"QA-6","severity":"critical","title":"fix: Prisma migrations 생성","dod":"로컬에서 prisma migrate dev --name init 실행 후 migrations/ 디렉토리 커밋","depends":["QA-5.fix.C2","QA-5.fix.M2"],"files":["backend/prisma/migrations/**"]} +{"task_id":"QA-6.fix.C2","origin":"QA-6","severity":"critical","title":"fix: backend postinstall prisma generate","dod":"backend/package.json scripts 에 \"postinstall\": \"prisma generate\" 추가","depends":[],"files":["backend/package.json"]} +{"task_id":"QA-6.fix.C3","origin":"QA-6","severity":"critical","title":"fix: pnpm lockfile 생성","dod":"repo 루트 또는 각 workspace 에서 pnpm install 실행 후 pnpm-lock.yaml 커밋","depends":["QA-6.fix.C2"],"files":["**/pnpm-lock.yaml"]} +{"task_id":"QA-6.fix.C4","origin":"QA-6","severity":"critical","title":"fix: PM2 ecosystem.config.js","dod":"root 에 ecosystem.config.js 생성. reloop-api / reloop-web 프로세스 정의, cwd/env/script 명시","depends":[],"files":["ecosystem.config.js"]} +{"task_id":"QA-6.fix.M1","origin":"QA-6","severity":"major","title":"fix: next.config.js output standalone","dod":"frontend/next.config.js 에 output: 'standalone' 추가","depends":[],"files":["frontend/next.config.js"]} +{"task_id":"QA-6.fix.M2","origin":"QA-6","severity":"major","title":"fix: frontend .env.example + root README","dod":"frontend/.env.example 생성. root README.md 에 env 목록과 배포 순서 정리","depends":[],"files":["frontend/.env.example","README.md"]} diff --git a/.claude/state/plans-state.json b/.claude/state/plans-state.json new file mode 100644 index 0000000..af14145 --- /dev/null +++ b/.claude/state/plans-state.json @@ -0,0 +1,8 @@ +{ + "timestamp": "2026-04-11T14:57:19Z", + "pm_pending": 0, + "cc_todo": 0, + "cc_wip": 0, + "cc_done": 0, + "pm_confirmed": 0 +} diff --git a/.claude/state/tool-failure-counter.txt b/.claude/state/tool-failure-counter.txt new file mode 100644 index 0000000..26d4f8e --- /dev/null +++ b/.claude/state/tool-failure-counter.txt @@ -0,0 +1 @@ +1 1775919057 diff --git a/.qa/QA-1.md b/.qa/QA-1.md new file mode 100644 index 0000000..eed0a1c --- /dev/null +++ b/.qa/QA-1.md @@ -0,0 +1,48 @@ +# QA-1 Report — Backend API Contract Audit + +> Worker: Explore agent · Lead review: 본 세션 (overflag 조정 적용) + +## Summary +- Routes found: 24 (모두 `/api` prefix 적용) +- Frontend calls: 24 unique +- critical: 0, major: 1, minor: 3, recommendation: 1 +- **verdict**: APPROVE (minor/recommendation만 남음) + +## Route × Frontend call 매칭 결과 + +모든 24개 라우트가 frontend에서 실제 호출되며, 반대로 frontend의 모든 호출은 유효한 backend 라우트로 매핑됨. HTTP 메서드, URL, payload 키는 전부 일치. Global prefix `/api`는 `NEXT_PUBLIC_API_URL` 기본값(`https://reloop-api.nabomhalang.co.kr/api`)에 포함되어 있으므로 frontend에서는 `/auth/login` 같은 경로로만 호출해도 정상 매핑. + +## Lead 리뷰 조정 사항 + +Worker가 올린 findings 중 일부 재분류: + +- **기존 [critical] `/reviews/history` Date serialization**: axios는 JSON 파싱 후 Date를 그대로 두지 않고 string으로 받는다. frontend `HistoryItem.reviewedAt: string | null`은 정확한 타입이다. `new Date(it.reviewedAt).toLocaleString()` 호출도 문제없음. → **오플래그. 제거.** +- **기존 [major] ForgetCurveResponse.tag.subject 타입**: frontend 타입은 `Subject`(tags optional)지만 backend는 3필드만 select. frontend 코드가 `curve.tag.subject.tags`를 참조하지 않으므로 런타임 버그는 없다. 타입 계약이 느슨한 문제는 남지만 severity는 minor. → **major → minor로 강등.** + +## Findings (정제판) + +### [minor] ForgetCurveResponse 타입이 실제 응답보다 넓다 +- **Location**: `frontend/src/lib/api.ts:121` +- **Evidence**: `tag: { id; name; subject: Subject }` — 여기서 `Subject`는 `tags?: Tag[]`를 포함한다. 하지만 backend `stats.service.ts:27`은 `{id, name, color}`만 select한다. +- **Impact**: 런타임 버그는 없지만, 향후 누군가 `curve.tag.subject.tags`를 추가하면 undefined 접근이 생긴다. +- **Suggested fix**: `api.ts`의 `ForgetCurveResponse.tag.subject`를 `{ id: number; name: string; color: string }`로 좁힌다. + +### [minor] `/reviews/history` 호출이 params 객체 대신 query string 리터럴 +- **Location**: `frontend/src/app/review/history/page.tsx:28` +- **Evidence**: `api.get('/reviews/history?limit=100')` — 다른 페이지(`study/history/page.tsx:39`)는 `{params: {limit, subjectId}}`를 쓴다. +- **Suggested fix**: `api.get('/reviews/history', { params: { limit: 100 } })`. + +### (철회됨) password hash 노출 추측 +`me.service.ts`의 `toView()`와 `auth.service.ts`의 `safeUser()`가 whitelist로 필드를 고르므로 password는 클라이언트에 전달되지 않는다. `/auth/me`, `/auth/login`, `/auth/register`, `/me/onboarding`, `/me/profile` 응답 전부 안전하게 계산된 `onboarded: boolean` 파생값과 함께 `MeUser` shape로 정확히 돌아온다. Lead 초기 의심은 오인이었다. + +### [recommendation] POST/PATCH 호출에 response type 누락 +- **Location**: `study/page.tsx:72`, `subjects/page.tsx:60`, `subjects/[id]/page.tsx:57` 등 +- **Evidence**: `api.post('/study-logs', ...)` — 타입 파라미터 없음. +- **Impact**: 응답 shape 변경 시 런타임까지 감지 안 됨. +- **Suggested fix**: 타입 파라미터 명시. + +## Verdict + +**APPROVE** (critical/major 없음) + +Worker가 올린 findings를 Lead가 재검토한 결과, axios Date serialization 관련 "critical"은 오플래그였고, ForgetCurveResponse 타입 불일치는 런타임 버그로 이어지지 않는 minor. `password` hash 노출은 QA-3에서 critical로 재분류한다. diff --git a/.qa/QA-2.md b/.qa/QA-2.md new file mode 100644 index 0000000..50adb8e --- /dev/null +++ b/.qa/QA-2.md @@ -0,0 +1,76 @@ +# QA-2 Report — Persona Forget Algorithm Verification + +> Worker: Explore agent (ultrathink) · Lead review: 승인 + +## Summary +- **verdict**: APPROVE +- critical: 0, major: 0, minor: 2 + +## 상수 검증 +| Constant | Expected | Actual | OK? | +|---|---|---|---| +| PERSONA_LAMBDA.senior | 0.1 | 0.1 | ✓ | +| PERSONA_LAMBDA.mid | 0.2 | 0.2 | ✓ | +| PERSONA_LAMBDA.junior | 0.4 | 0.4 | ✓ | +| PERSONA_LAMBDA.crammer | 0.6 | 0.6 | ✓ | +| INTENSITY_THRESHOLD.strict | 0.7 | 0.7 | ✓ | +| INTENSITY_THRESHOLD.moderate | 0.5 | 0.5 | ✓ | +| INTENSITY_THRESHOLD.relaxed | 0.35 | 0.35 | ✓ | +| DEFAULT_K | 4.0 | 4.0 | ✓ | +| DEFAULT_INITIAL_S0 | 0.3 | 0.3 | ✓ | +| MAX_INTERVAL_DAYS | 60 | 60 | ✓ | + +## 수식 유도 검증 + +**목표**: σ(k·(S₀·e^(-λt) - D)) = P_threshold 를 t에 대해 풀기. + +1. σ⁻¹ 적용: `k·(S₀·e^(-λt) - D) = logit(P_threshold)` +2. `S₀·e^(-λt) = D + logit(P_threshold)/k` (== target) +3. `e^(-λt) = target / S₀` +4. `t = -(1/λ)·ln(target / S₀)` + +코드(`persona-forget.service.ts:131`): +```ts +const tDays = -(1 / lambda) * Math.log(target / s0); +``` +**✓ 수식과 코드가 정확히 일치.** + +## 경계 조건 검증 +| Case | 예상 동작 | 코드 경로 | OK? | +|---|---|---|---| +| s0 ≤ 0 | 즉시 (1h) 스케줄 | 103-109 | ✓ | +| target ≤ 0 | 60일 cap | 114-120 | ✓ | +| target ≥ s0 | ln ≤ 0 → t ≤ 0 → 즉시 | 122-128 | ✓ | +| 0 < target < s0 | 정상 공식 | 131 | ✓ | +| t > 60 | `Math.min(tDays, MAX_INTERVAL_DAYS)` | 132 | ✓ | +| logit(0) / logit(1) | ε clamp | 192 | ✓ | +| predictP at t=0 | σ(k(s0 - D)) | 150-153 | ✓ | + +모든 경계 조건에서 수학적 domain error 없음. + +## updateS0 검증 +| prev | result | formula | expected | OK? | +|---|---|---|---|---| +| 0.2 | correct | 0.2·0.5+0.6 | 0.70 | ✓ | +| 1.0 | correct | clamp(1.1) | 1.0 | ✓ | +| 0.8 | incorrect | 0.8·0.5 | 0.40 | ✓ | +| 0.4 | partial | 0.4·0.7+0.3 | 0.58 | ✓ | +| null | correct | 0.3·0.5+0.6 | 0.75 | ✓ (기본값 0.3에서 시작) | + +## Findings + +### [minor] 스펙 커버리지 — persona × intensity matrix 불완전 +- **Location**: `backend/src/forget/persona-forget.service.spec.ts:40-130` +- **Evidence**: senior vs crammer 비교, 3개 intensity 개별 테스트는 있지만 4 persona × 3 intensity = 12 조합을 grid로 돌리는 테스트가 없음. +- **Impact**: 공식상 persona/intensity는 단순 lookup이라 회귀 가능성 낮음. 다만 CI 신뢰도 측면에서 보완 권장. +- **Suggested fix**: `describe.each([...])` 로 matrix 테스트 추가. + +### [minor] study-logs / reviews 에서 `lastUpdatedAt: new Date()` 중복 생성 +- **Location**: `backend/src/study-logs/study-logs.service.ts:96, 115`, `backend/src/reviews/reviews.service.ts:87, 106` +- **Evidence**: snapshot upsert 와 schedule() 호출이 각각 `new Date()`를 독립 생성. 수 ms 차이 발생 가능. +- **Impact**: 스케줄 시각이 수 ms 어긋남. 무시 가능 수준이지만 의미가 모호해짐. +- **Suggested fix**: `const now = new Date()` 를 함수 진입부에서 한 번만 생성. + +## Verdict + +**APPROVE**. 알고리즘의 수식·경계·consumer 호출 모두 정확. minor 2건만 follow-up 개선 권장. diff --git a/.qa/QA-3.md b/.qa/QA-3.md new file mode 100644 index 0000000..881e9a9 --- /dev/null +++ b/.qa/QA-3.md @@ -0,0 +1,86 @@ +# QA-3 Report — Auth & Security Review + +> Worker: Explore agent (ultrathink) · Lead review: critical #1 IDOR은 minor로 강등 + +## Summary +- **verdict**: REQUEST_CHANGES +- critical: 2, major: 2, minor: 3, recommendation: 2 + +## OWASP mapping +| ID | Category | Status | Notes | +|---|---|---|---| +| A01 | Broken Access Control | PASS (minor 하나만 남음) | snapshot unique key 로 IDOR 차단됨. 단 Tag ownership 명시 체크는 없음 | +| A02 | Cryptographic Failures | PASS | bcrypt rounds=10, JWT HS256 | +| A03 | Injection | PASS | Prisma parameterized 쿼리만 사용, raw SQL 없음 | +| A05 | Security Misconfiguration | FAIL | forbidNonWhitelisted=false, JWT secret fallback, helmet CSP 최소화 | +| A07 | AuthN Failures | FAIL | email 정규화 없음, JwtStrategy.validate() 사용자 재조회 없음 | + +## Critical Findings + +### [critical] C1 Email normalization 누락 → 중복 계정/사용자 enumeration +- **Location**: `backend/src/auth/auth.service.ts:22-26, 40-43` +- **Evidence**: + ```ts + const existing = await this.prisma.user.findUnique({ + where: { email: input.email }, // lowercase/trim 없음 + }); + ``` + DB collation 에 따라 `User@Example.com`과 `user@example.com`이 서로 다른 계정으로 저장될 수 있다. 또한 register 는 `ConflictException('already registered')` 를 던지므로 해당 email의 존재 여부가 leak 된다. +- **Impact**: 사용자 enumeration + 중복 가입 + 로그인 실패 유발. +- **Suggested fix**: register/login 진입부에서 `const email = input.email.trim().toLowerCase()` 적용하고 모든 쿼리/저장에 이를 사용. register 의 충돌 메시지를 `UnauthorizedException('invalid credentials')` 수준 일반 메시지로 바꾸거나 register 결과도 동일하게 응답. + +### [critical] C2 JwtStrategy.validate() 가 DB 재조회를 하지 않음 +- **Location**: `backend/src/auth/jwt.strategy.ts:23-25` +- **Evidence**: + ```ts + validate(payload: JwtPayload): AuthUser { + return { id: payload.sub, email: payload.email }; + } + ``` + JWT의 sub가 이미 삭제된 사용자라도 30일 만료 전까지 모든 guarded 엔드포인트가 통과한다. 계정 삭제/정지 기능이 생기면 즉시 취약점이 된다. +- **Impact**: 삭제/정지된 사용자 access 유지. GDPR 즉시 파기 불가. +- **Suggested fix**: `async validate(payload)` 로 바꾸고 `prisma.user.findUnique({ where: { id: payload.sub }, select: { id, email } })` 후 null 이면 `UnauthorizedException` 던지기. + +## Major Findings + +### [major] M1 JWT_SECRET fallback 하드코딩 +- **Location**: `backend/src/auth/auth.module.ts:16`, `backend/src/auth/jwt.strategy.ts:19` +- **Evidence**: + ```ts + secret: cfg.get('JWT_SECRET') ?? 'dev-reloop-secret-change-me', + ``` + prod에서 env 누락 시 예측 가능한 secret으로 서명. 토큰 위조 가능. +- **Impact**: 환경 변수 누락 = 전체 인증 붕괴. +- **Suggested fix**: bootstrap 시점에 `throw new Error('JWT_SECRET is required')` 로 강제. + +### [major] M2 ValidationPipe `forbidNonWhitelisted: false` +- **Location**: `backend/src/main.ts` (ValidationPipe 설정부) +- **Evidence**: `whitelist: true, forbidNonWhitelisted: false`. 미지정 필드는 silently 제거되지만 spread 패턴이 생기는 순간 mass assignment 위험. +- **Suggested fix**: `forbidNonWhitelisted: true` 로 바꿔 명시 오류로 반환. + +## Minor Findings + +### [minor] M3 IDOR on `/stats/forget-curve` — explicit tag ownership check 없음 +- **Location**: `backend/src/stats/stats.service.ts:20` +- **Analysis**: SkillSnapshot 은 `@@unique([userId, tagId])` 이고 service 는 `findUnique({ where: { userId_tagId: { userId, tagId } } })` 로 조회한다. 다른 사용자의 snapshot 에는 **도달 불가**하다. recentLog 쿼리도 `where: { userId, tagId }` 로 보호. → **실질적 IDOR 없음**. Worker 가 올린 critical 은 Lead 재심에서 minor 로 강등. +- **남아있는 리스크**: 존재하는 tagId (타 사용자 소유) 에 대해 `no snapshot for this tag` vs 아예 존재하지 않는 tagId 에 대해 같은 에러가 반환된다 → tagId 공간 enumeration 은 사실상 불가. +- **Suggested fix** (defense-in-depth): Tag 의 `subject.userId === currentUser.id` 를 명시적으로 확인. + +### [minor] M4 localStorage 토큰 저장 (XSS 노출) +- **Location**: `frontend/src/lib/auth.ts` +- **Impact**: XSS 시 토큰 탈취 가능. 현재 XSS sink 없음. +- **Suggested fix** (defense-in-depth): httpOnly cookie + `withCredentials: true` 로 전환. CORS 설정 조정 필요. + +### [minor] M5 Helmet CSP 미설정 / ThrottlerModule 미적용 +- **Location**: `backend/src/main.ts` helmet call, `backend/src/app.module.ts` ThrottlerModule +- **Evidence**: helmet 은 `crossOriginResourcePolicy: false` 만 off. CSP/HSTS default 의존. `@nestjs/throttler` 는 import 돼있지만 auth 엔드포인트에 `@Throttle()` 미적용. +- **Impact**: login brute force 대비 취약. +- **Suggested fix**: `auth.controller` 의 register/login 에 `@Throttle({ default: { limit: 5, ttl: 60_000 } })`. + +## Recommendations +- Logout 엔드포인트 + JTI blacklist +- Audit log (register/login 성공/실패) + +## Verdict + +**REQUEST_CHANGES** — critical 2건(C1 email normalize, C2 JwtStrategy revalidate) + major 2건(M1 JWT secret fallback, M2 forbidNonWhitelisted) 가 프로덕션 배포 이전 반드시 고쳐야 함. diff --git a/.qa/QA-4.md b/.qa/QA-4.md new file mode 100644 index 0000000..7183ed7 --- /dev/null +++ b/.qa/QA-4.md @@ -0,0 +1,101 @@ +# QA-4 Report — Frontend Runtime Bug Scan + +> Worker: Explore agent · Lead review: critical/major 재조정 + +## Summary +- Pages audited: 13 + 3 layout + primitives + chart +- **verdict**: REQUEST_CHANGES +- critical: 2, major: 2, minor: 5, recommendation: 2 + +## Critical Findings + +### [critical] C1 Review page double-submit race +- **Location**: `frontend/src/app/review/page.tsx:34-52` +- **Evidence**: + ```tsx + const submit = async (id: number, result: StudyResult) => { + setSubmitting(true); + try { await api.post(`/reviews/${id}/submit`, { result }); ... } + finally { setSubmitting(false); } + }; + ``` + `submitting` 이 전역 boolean 이라 동일 아이템에 대해 "맞음"→"스킵" 으로 빠르게 더블클릭하면 두 요청이 모두 나감. 두 번째는 backend 에서 "already processed" 로 반려되지만, 모바일 터치 반응이 느려서 중복 POST 발생 가능성이 높다. +- **Impact**: 중복 POST → 백엔드 race (QA-5 C2 와 합성되면 실제 데이터 오염). +- **Suggested fix**: `submitting` 을 `Set` 로 바꿔 per-review lock. + ```tsx + const [pending, setPending] = useState>(new Set()); + const isPending = (id: number) => pending.has(id); + // 버튼 disabled={isPending(q.id)} + ``` + +### [critical] C2 `/subjects/[id]` 에 숫자가 아닌 값이 오면 무한 로딩 +- **Location**: `frontend/src/app/subjects/[id]/page.tsx:31, 45-49` +- **Evidence**: + ```tsx + const subjectId = Number(params.id); // → NaN 가능 + const load = useCallback(() => { + api.get(`/subjects/${subjectId}`) // `/subjects/NaN` 요청 + ... + }, [subjectId]); + useEffect(() => { + if (!Number.isFinite(subjectId)) return; // 이미 load 는 만들어진 상태 + load(); + }, [subjectId, load]); + ``` + `useParams` 는 string 만 주고, `Number('abc')` 는 `NaN`. useEffect guard 는 호출을 막아주지만, **렌더 직후 `return `** 로 넘어가서 사용자는 로딩 스피너만 본다. 유효하지 않은 URL 처리 없음. +- **Impact**: `/subjects/abc` 같은 URL 이 로딩 화면에서 멈춤. 404 리다이렉트 없음. +- **Suggested fix**: + ```tsx + const raw = Number(params.id); + const subjectId = Number.isFinite(raw) ? raw : null; + // 컴포넌트 초입: if (subjectId === null) return ; + ``` + +## Major Findings + +### [major] M1 히스토리 페이지들이 에러 상태 처리 없음 +- **Location**: `frontend/src/app/review/history/page.tsx:26-30`, `frontend/src/app/study/history/page.tsx:31-41`, `frontend/src/app/stats/page.tsx:50-57` +- **Evidence**: `.then(...)` 만 있고 `.catch` 가 없다. API 500 이 오면 `items === null` 로 남아 `로딩 중...` 이 영원히 뜬다. +- **Impact**: 런타임 에러 묵살. +- **Suggested fix**: `try/catch` 또는 `.catch(err => setError(err))` 와 재시도 버튼 UI 제공. + +### [major] M2 네이티브 `confirm()` / `alert()` 사용 +- **Location**: `frontend/src/app/subjects/page.tsx:72, 77`, `frontend/src/app/subjects/[id]/page.tsx:70` 등 +- **Impact**: 일부 모바일 WebView, PWA 컨텍스트에서 차단될 수 있음. 삭제 취소 후 사용자 피드백 없음. +- **Suggested fix**: styled-components 기반 custom modal 또는 toast 로 교체. + +## Minor Findings + +### [minor] m1 `baseCorrectRate` state 타입이 string이라 number DTO 계약과 비대칭 +- **Location**: `frontend/src/app/study/page.tsx:38, 77` +- **Evidence**: `useState('')` + `type="number"` 입력. 전송 시 `Number(...) / 100`. 현재는 런타임 정상 작동하지만 타입 계약이 흐릿. +- **Suggested fix**: state 를 `useState('')` 로 명시. + +### [minor] m2 dashboard `s.tag.subject.color` optional chaining 부재 +- **Location**: `frontend/src/app/dashboard/page.tsx:113` +- **Evidence**: backend `DashboardService.summary()` 가 `skillSnapshot` → `tag` → `subject` 를 항상 include 하므로 **런타임 crash 없음**. Worker 는 major 로 올렸지만 재확인 결과 minor. +- **Suggested fix**: 일관성 유지 목적이면 `s.tag.subject?.color ?? theme.color.accent`. + +### [minor] m3 `profile/page.tsx` setTimeout cleanup 없음 +- **Location**: `profile/page.tsx:77` +- **Impact**: unmount 후 setState 경고 (dev only). +- **Suggested fix**: `useEffect(() => { if (saved) { const t = setTimeout(...); return () => clearTimeout(t); } }, [saved])`. + +### [minor] m4 `AppShell` 에 서버 500 에러 처리 없음 +- **Location**: `frontend/src/components/layout/AppShell.tsx:30-42` +- **Evidence**: 401 은 로그인 페이지로 rebound, 기타 에러는 `clearToken + /login` 으로 같이 처리됨 → **과잉 로그아웃**. 500 일 때도 토큰 지움. +- **Suggested fix**: `catch` 에서 status 분기. 401 만 로그아웃. + +### [minor] m5 form 에러 메시지 `role="alert"` 부재 +- **Location**: login/register/study/profile 페이지 +- **Impact**: screen reader 미공지. +- **Suggested fix**: ``. + +## Recommendations + +- axios 전역 `timeout: 10_000` 추가 (`frontend/src/lib/api.ts`). +- `router.back()` fallback — `window.history.length` 체크 후 `/study/history` 로. + +## Verdict + +**REQUEST_CHANGES** — critical 2건, major 2건. 대부분 UX·안정성 버그이며 배포 전 반드시 수정. diff --git a/.qa/QA-5.md b/.qa/QA-5.md new file mode 100644 index 0000000..f006544 --- /dev/null +++ b/.qa/QA-5.md @@ -0,0 +1,74 @@ +# QA-5 Report — Prisma Schema & Transaction Integrity + +> Worker: Explore agent · Lead review: 승인 + +## Summary +- **verdict**: REQUEST_CHANGES +- critical: 2, major: 2, minor: 2 + +## Schema 요약 +- 모든 enum (Persona / ReviewIntensity / StudyResult / ReviewStatus) 정의 완료. +- User 는 email unique, createdAt default, onboardedAt nullable. +- Subject/Tag 는 userId cascade, (subjectId, name) composite unique, 필요한 index 포함. +- StudyLog 는 (userId, studiedAt) index, User/Subject cascade, Tag FK 는 onDelete 미지정. +- ReviewSchedule 은 (userId, status, scheduledAt) index, StudyLog cascade. +- SkillSnapshot 은 `@@unique([userId, tagId])` 와 Tag cascade. + +## Critical Findings + +### [critical] C1 `reviews.service.submit()` — snapshot upsert 가 트랜잭션 바깥 +- **Location**: `backend/src/reviews/reviews.service.ts:45-132` +- **Evidence**: + ```ts + // 1) outside tx: find review + status check + // 2) outside tx: user fetch + // 3) outside tx: snapshot upsert ← ❌ + // 4) inside tx: update review + create next schedule + ``` + 동일 reviewId 에 대한 concurrent 요청이 1-2 단계를 통과한 뒤 각자 snapshot.upsert 를 호출 → sampleCount 가 2번 증가하고 s0 도 두 번 덮어씀. trait 의 atomicity 가 깨진다. +- **Impact**: 데이터 일관성 손상. QA-4 C1 (review double-submit race) 가 동시에 존재하면 프로덕션에서 실제 발생 가능. +- **Suggested fix**: snapshot upsert 와 review 업데이트를 같은 `prisma.$transaction(async (tx) => { ... })` 안에 포함. + ```ts + return this.prisma.$transaction(async (tx) => { + const fresh = await tx.reviewSchedule.update({ + where: { id: reviewId, status: 'pending' }, // atomic guard + data: { status: 'done', reviewedAt: new Date(), result }, + }); + // snapshot upsert with tx + // create next schedule with iteration = fresh.iteration + 1 + }); + ``` + `update where status = 'pending'` 이 조건 불만족시 P2025 를 던지므로 그걸로 "already processed" 판정. + +### [critical] C2 Tag 삭제 cascade → SkillSnapshot 영구 손실 +- **Location**: `backend/prisma/schema.prisma` SkillSnapshot 의 `tag` 관계 `onDelete: Cascade` +- **Evidence**: `tags.service.remove()` → `prisma.tag.delete(...)` → SkillSnapshot cascade 삭제. +- **Impact**: 사용자가 태그를 "정리" 하려다가 수개월치 복습 데이터(s0, sampleCount)를 복구 불가능하게 잃는다. +- **Suggested fix**: 옵션 ① Tag 에 `archivedAt DateTime?` 필드 추가해서 soft-delete. ② SkillSnapshot → Tag onDelete 를 `SetNull` 로 바꾸고 `tagId` 를 nullable 로 변경. ③ Tag 삭제 전 snapshot 의 s0/sampleCount 를 archive 테이블로 옮김. + +## Major Findings + +### [major] M1 iteration 증가 race +- **Location**: `backend/src/reviews/reviews.service.ts:125` +- **Evidence**: `iteration: review.iteration + 1` — `review` 는 tx 바깥에서 조회된 snapshot. 두 요청이 iteration=1 을 동시에 계산하면 pending 행이 둘 생긴다. +- **Suggested fix**: tx 안에서 `tx.reviewSchedule.findFirst({ where: { studyLogId, status: 'done' }, orderBy: { iteration: 'desc' }})` 로 최신 iteration 을 가져오고 +1. + +### [major] M2 StudyLog.tag FK 에 onDelete 미지정 +- **Location**: `backend/prisma/schema.prisma` +- **Impact**: Tag 삭제 시 StudyLog.tagId 가 orphan 으로 남아 FK 제약 위반. +- **Suggested fix**: `tag @relation(fields: [tagId], references: [id], onDelete: SetNull)` 로 전환. (tagId 는 이미 Int? 이므로 SetNull 가능) + +## Minor Findings + +### [minor] m1 snapshot upsert 의 findUnique + upsert 패턴 +- **Location**: `study-logs.service.ts:82-104`, `reviews.service.ts:65-90` +- **Evidence**: 이미 upsert 한 단계로 처리 가능. `findUnique` 후 `upsert` 는 race 에서 sampleCount 부조화. +- **Suggested fix**: 단일 `upsert({ create, update: { sampleCount: { increment: 1 }, s0: computed }})` 사용. 기존 s0 접근이 필요하면 `findUnique` 를 같은 tx 안에서 먼저 호출. + +### [minor] m2 demo 사용자 seed bcrypt round 확인 +- **Location**: `backend/prisma/seed.ts` +- **Evidence**: `bcrypt.hash(DEMO_PASSWORD, 10)` — auth.service 와 동일 rounds. OK. upsert 방식이라 idempotent 하므로 seed 재실행 안전. + +## Verdict + +**REQUEST_CHANGES** — critical 2건 (concurrent review race, tag cascade 데이터 손실) 이 프로덕션 배포 차단 요소. diff --git a/.qa/QA-6.md b/.qa/QA-6.md new file mode 100644 index 0000000..cab925d --- /dev/null +++ b/.qa/QA-6.md @@ -0,0 +1,72 @@ +# QA-6 Report — Build & Deploy Readiness + +> Worker: Explore agent · Lead review: 승인 + +## Summary +- **verdict**: REQUEST_CHANGES +- critical: 4, major: 3, minor: 4 + +## Critical (배포 차단) + +### [critical] C1 Prisma migrations 디렉토리 비어있음 +- **Location**: `backend/prisma/migrations/` +- **Impact**: `prisma migrate deploy` 가 적용할 SQL 이 없다. Dev VM 새 DB 에 스키마 생성 불가. +- **Suggested fix**: 로컬에서 `pnpm prisma migrate dev --name init` 한 번 돌려서 `migrations/` 생성 + commit. 프로덕션에서는 `prisma migrate deploy` 만. + +### [critical] C2 `postinstall` hook 부재 → Prisma Client 미생성 +- **Location**: `backend/package.json` +- **Evidence**: scripts 에 `postinstall` 없음. Fresh `pnpm install` 후 `@prisma/client` import 가 비어있는 기본 client 로 연결. +- **Suggested fix**: `"postinstall": "prisma generate"` 추가. 또는 `prisma:deploy` 스크립트에서 generate 를 묶어 실행. + +### [critical] C3 Lockfile 부재 +- **Location**: repo 루트 +- **Impact**: `pnpm install --frozen-lockfile` 불가. 재현 빌드 불가, 버전 drift. +- **Suggested fix**: 로컬에서 `pnpm install` 한 번 실행해서 `pnpm-lock.yaml` 생성 + commit. 또는 각 workspace 별로. + +### [critical] C4 PM2 ecosystem 설정 파일 부재 +- **Location**: repo 루트 +- **Impact**: 이전 세션에서 언급한 `reloop-api` / `reloop-web` 프로세스 정의가 없다. 수동 기동 가능하지만 restart 정책 / env 경로 / working directory 미기록. +- **Suggested fix**: `ecosystem.config.js` 생성: + ```js + module.exports = { + apps: [ + { name: 'reloop-api', cwd: './backend', script: 'dist/main.js', + env: { NODE_ENV: 'production' }, max_memory_restart: '512M' }, + { name: 'reloop-web', cwd: './frontend', script: 'node_modules/next/dist/bin/next', + args: 'start -p 3000', env: { NODE_ENV: 'production' } }, + ], + }; + ``` + +## Major + +### [major] M1 `frontend/next.config.js` 에 `output: 'standalone'` 없음 +- **Impact**: `.next/standalone/server.js` 경로 사용 불가 → PM2 에서 `next start` 호출해야 하므로 node_modules 전체 동반 필요. +- **Suggested fix**: `nextConfig.output = 'standalone'` 추가. + +### [major] M2 `frontend/.env.example` 부재 +- **Impact**: `NEXT_PUBLIC_API_URL` 이 build-time 변수라는 점이 문서화되지 않음. +- **Suggested fix**: `.env.example` 생성해서 key 와 의미 설명. + +### [major] M3 root `README.md` + `packageManager` 필드 없음 +- **Impact**: 신규 환경 셋업 방법이 기록 안 됨. pnpm 버전 drift. +- **Suggested fix**: README 에 bootstrap 순서 작성, `"packageManager": "pnpm@9.0.0"` pin. + +## Minor +- backend/frontend `.eslintrc.json` 부재. +- `.env.example` 에 `NODE_ENV` 키가 있지만 런타임 미사용. +- `package.json` 에 `engines.node` 필드 부재. +- `frontend/next.config.js` 에 `compiler.styledComponents: true` 만 있고 `experimental` 플래그 정비 안 됨. + +## PLAN.md Phase 5 교차 체크 + +| Phase 5 item | 상태 | +|---|---| +| 차트 (recharts ForgetCurveChart) | DONE | +| PWA manifest | PENDING | +| PM2 스왑 | PENDING (C4 로 차단됨) | +| QA | 진행 중 (본 실행) | + +## Verdict + +**REQUEST_CHANGES** — critical 4건 중 어느 하나라도 해결하지 않으면 Dev VM 배포 불가. diff --git a/.qa/QA-SUMMARY.md b/.qa/QA-SUMMARY.md new file mode 100644 index 0000000..56878b3 --- /dev/null +++ b/.qa/QA-SUMMARY.md @@ -0,0 +1,50 @@ +# Breezing QA — 종합 리포트 + +> harness-work --breezing · Lead + 6 Worker · 2026-04-11 + +## 6 태스크 결과 + +| Task | 영역 | verdict | 주요 finding | +|------|------|---------|--------------| +| QA-1 | API contract | APPROVE | 24/24 라우트 매칭, 타입 계약 minor 2 | +| QA-2 | Persona 망각 알고리즘 | APPROVE | 수식 정확, 경계 조건 전부 방어, spec matrix 보완 권장 | +| QA-3 | Auth & 보안 | REQUEST_CHANGES | critical 2 (email norm, JWT revalidate), major 2 (secret fallback, whitelist) | +| QA-4 | Frontend 런타임 | REQUEST_CHANGES | critical 2 (review double-submit, NaN route), major 2 (history 에러 처리, alert/confirm) | +| QA-5 | Prisma/트랜잭션 | REQUEST_CHANGES | critical 2 (submit tx 바깥, tag cascade 데이터 손실), major 2 | +| QA-6 | 빌드/배포 | REQUEST_CHANGES | critical 4 (migrations, postinstall, lockfile, PM2 config) | + +## Aggregate verdict +**REQUEST_CHANGES** — critical 10, major 9, minor 16 + +## Critical 순위 (fix 우선순위) + +1. **QA-5 C1 + QA-4 C1 (동시성 데이터 오염 연쇄)** — reviews.submit() 을 tx 로 감싸고 review page 에 per-item lock. 두 개가 맞물려 있어 같이 고쳐야 하는 pair. +2. **QA-3 C1/C2 (인증 취약점)** — email 정규화 + JwtStrategy 재조회. 프로덕션 이전 필수. +3. **QA-5 C2 (Tag cascade 데이터 손실)** — schema 변경이라 migration 포함. +4. **QA-6 C1-C4 (배포 인프라 부재)** — migrations, postinstall, lockfile, PM2 ecosystem. 이거 없으면 Dev VM 에서 빌드 자체가 안 돌아감. +5. **QA-4 C2 (NaN route)** — 단일 파일 수정, 가볍지만 사용자 직면. + +## Fix 태스크 제안 (`.claude/state/pending-fix-proposals.jsonl`) + +총 18개의 후속 fix 태스크를 등록했다. critical 10개, major 8개. + +승인 명령: +``` +approve fix QA-3.fix.C1 # 개별 승인 +approve fix all # 전체 승인 +``` + +거부: +``` +reject fix QA-3.fix.M2 +``` + +## AI Residuals +깨끗함. `TODO|FIXME|Claude|ChatGPT|GPT-|Copilot` 어느 것도 소스에 없음. + +## QA 로 인한 코드 변경 +**없음**. 이번 Breezing 은 read-only 감사였다. fix 는 재티켓화된 후속 태스크로 분리. + +## 남은 작업 (Plans.md `cc:TODO` 목록) + +Plans.md 에는 QA 태스크 6개가 이제 모두 `cc:완료` 로 전환됨. 후속 fix 태스크는 pending-fix-proposals.jsonl 에 있고, 사용자가 승인하면 Plans.md 에 `cc:TODO` 로 편입. diff --git a/Plans.md b/Plans.md new file mode 100644 index 0000000..78fc48c --- /dev/null +++ b/Plans.md @@ -0,0 +1,30 @@ +# ReLoop v2 — QA Plan + +Harness v3 Breezing 모드로 전체 QA를 실행한다. 구현 자체는 이전 세션에서 완료됐고, 이 Plans.md는 **검증 태스크**만 다룬다. 각 QA 태스크는 정적 분석/리포트 작성이며 파일 수정은 원칙적으로 하지 않는다 (findings 수집 단계). Critical/major finding은 Phase C에서 별도 fix 태스크로 재티켓화한다. + +## Context + +- Repo: `/home/erang/reloop-v2` (backend: NestJS+Prisma, frontend: Next.js 14 + styled-components) +- Plan 원본: `/home/erang/reloop-v2-plan/PLAN.md` +- Deploy target: `reloop.nabomhalang.co.kr` / `reloop-api.nabomhalang.co.kr` (Dev VM PM2) +- Persona forgetting curve: `S(t)=S₀·exp(-λt)`, `P=σ(k(S(t)−D))`, λ∈{0.1,0.2,0.4,0.6} +- Node modules 미설치 — runtime 검증은 Phase C에서 Dev VM에서만 가능 + +## Tasks + +| Task | 내용 | DoD | Depends | Status | +|------|------|-----|---------|--------| +| QA-1 | Backend API contract audit — 모든 Nest controller의 라우트/DTO를 frontend `src/lib/api.ts` 및 각 page.tsx 호출부와 대조. URL/메서드/payload/응답 타입 불일치를 전부 목록화 | findings JSON 반환 (critical/major/minor/recommendation 분류). verdict 결론 | - | cc:완료 | +| QA-2 | Persona forget algorithm verification — `persona-forget.service.ts`의 수식/경계조건(s0=0, D≥s0, D≤0, 극한 λ), `schedule()` 역함수 유도, spec 파일 커버리지 검증 | 수학적 정합성 검증 리포트, 커버리지 gap 목록, verdict | - | cc:완료 | +| QA-3 | Auth & security review — JWT 발급/검증, bcrypt 라운드, CORS origin env, helmet, global ValidationPipe, public vs. guarded routes, 토큰 localStorage 취급, 401 rebound | OWASP 상위 10 중 관련 항목 체크 + verdict | - | cc:완료 | +| QA-4 | Frontend runtime bug scan — 13개 page.tsx + AppShell/SideNav/BottomNav의 null 처리, useEffect 의존성, 타입 위반, 깨진 링크/라우트, 모바일 레이아웃 | page별 findings + verdict | - | cc:완료 | +| QA-5 | Prisma schema & transaction integrity — schema.prisma 제약/인덱스/cascade, study-logs·reviews service의 트랜잭션 경계, 동시성 경합 가능성, seed 정합성 | 스키마 + 트랜잭션 findings + verdict | - | cc:완료 | +| QA-6 | Build & deploy readiness — backend/frontend package.json scripts, 환경변수 목록, CORS origin, PM2 process 명, main.ts bootstrap, 누락 파일, reloop-v2-plan의 Phase 5 항목 교차 검증 | 배포 체크리스트 + verdict | - | cc:완료 | + +## Execution + +Mode: **Breezing (명시 플래그 --breezing)** +Lead: 본 세션 (claude-opus-4-6[1m]) +Workers: `claude-code-harness:task-worker` (read-only 모드, 리포트만 반환) — isolation=worktree 비적용 (QA는 정적 분석) +Reviewer: `claude-code-harness:code-reviewer` — 각 findings 리포트를 재검증 +수정 루프: QA 태스크는 finding 수집이 DoD이므로 "REQUEST_CHANGES = 리포트 누락/근거 부족". 실제 코드 수정은 Phase C에서 fix 태스크로 별도 생성.