- 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)
4.3 KiB
4.3 KiB
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:
동일 reviewId 에 대한 concurrent 요청이 1-2 단계를 통과한 뒤 각자 snapshot.upsert 를 호출 → sampleCount 가 2번 증가하고 s0 도 두 번 덮어씀. trait 의 atomicity 가 깨진다.
// 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 - Impact: 데이터 일관성 손상. QA-4 C1 (review double-submit race) 가 동시에 존재하면 프로덕션에서 실제 발생 가능.
- Suggested fix: snapshot upsert 와 review 업데이트를 같은
prisma.$transaction(async (tx) => { ... })안에 포함.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.prismaSkillSnapshot 의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 데이터 손실) 이 프로덕션 배포 차단 요소.