qa: breezing audit — 6 tasks, 10 critical + 9 major findings

- 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)
This commit is contained in:
reloop
2026-04-11 23:58:37 +09:00
parent 99d5892eb4
commit 40232af429
18 changed files with 724 additions and 0 deletions

View File

@@ -0,0 +1 @@
1775919509

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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<T>) 와 백엔드 응답 실제 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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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<number> 기반 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"]}

View File

@@ -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
}

View File

@@ -0,0 +1 @@
1 1775919057

48
.qa/QA-1.md Normal file
View File

@@ -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<HistoryItem[]>('/reviews/history?limit=100')` — 다른 페이지(`study/history/page.tsx:39`)는 `{params: {limit, subjectId}}`를 쓴다.
- **Suggested fix**: `api.get<HistoryItem[]>('/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로 재분류한다.

76
.qa/QA-2.md Normal file
View File

@@ -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 개선 권장.

86
.qa/QA-3.md Normal file
View File

@@ -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<string>('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) 가 프로덕션 배포 이전 반드시 고쳐야 함.

101
.qa/QA-4.md Normal file
View File

@@ -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<number>` 로 바꿔 per-review lock.
```tsx
const [pending, setPending] = useState<Set<number>>(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<Subject>(`/subjects/${subjectId}`) // `/subjects/NaN` 요청
...
}, [subjectId]);
useEffect(() => {
if (!Number.isFinite(subjectId)) return; // 이미 load 는 만들어진 상태
load();
}, [subjectId, load]);
```
`useParams` 는 string 만 주고, `Number('abc')` 는 `NaN`. useEffect guard 는 호출을 막아주지만, **렌더 직후 `return <Loading/>`** 로 넘어가서 사용자는 로딩 스피너만 본다. 유효하지 않은 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 <NotFound ... />;
```
## 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<string>('')` + `type="number"` 입력. 전송 시 `Number(...) / 100`. 현재는 런타임 정상 작동하지만 타입 계약이 흐릿.
- **Suggested fix**: state 를 `useState<number | ''>('')` 로 명시.
### [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**: `<ErrorText role="alert">`.
## 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·안정성 버그이며 배포 전 반드시 수정.

74
.qa/QA-5.md Normal file
View File

@@ -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 데이터 손실) 이 프로덕션 배포 차단 요소.

72
.qa/QA-6.md Normal file
View File

@@ -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 배포 불가.

50
.qa/QA-SUMMARY.md Normal file
View File

@@ -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` 로 편입.

30
Plans.md Normal file
View File

@@ -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 태스크로 별도 생성.