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