Files
reloop-v2/.qa/QA-5.md
reloop 40232af429 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)
2026-04-11 23:58:37 +09:00

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:
    // 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) => { ... }) 안에 포함.
    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 + 1review 는 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 한 단계로 처리 가능. findUniqueupsert 는 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 데이터 손실) 이 프로덕션 배포 차단 요소.