From 3ba953751551bd37da87df3656c68aa12ece9f54 Mon Sep 17 00:00:00 2001 From: hyungi Date: Tue, 16 Jun 2026 13:59:35 +0900 Subject: [PATCH] =?UTF-8?q?fix(study):=20submit=5Fattempt=20FOR=20UPDATE?= =?UTF-8?q?=20=ED=96=89=20=EC=9E=A0=EA=B8=88=20=E2=80=94=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EC=A4=91=EC=A0=9C=EC=B6=9C=20race=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20(R9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quiz_session 을 session.get(잠금 없음)으로 읽어 모바일 더블탭/재시도 시 동시 제출 둘 다 cursor=N 을 보고 cursor+1·correct/wrong/unsure count 를 이중 가산하던 race. select + with_for_update() 로 행 잠금 → 직렬화. 두 번째 제출은 첫 commit 후 cursor=N+1 을 읽고 cursor 위치 불일치 409 로 거부된다. belt-and-suspenders 인 attempt UNIQUE 제약은 기존 중복 dup-backfill 마이그가 선행조건이라 별도(R9 후속). 검증: py_compile 통과. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/study_questions.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/api/study_questions.py b/app/api/study_questions.py index 86ed340..fcef421 100644 --- a/app/api/study_questions.py +++ b/app/api/study_questions.py @@ -1009,7 +1009,16 @@ async def submit_attempt( # PR-10: 세션 연동. 기본은 None. quiz_session: StudyQuizSession | None = None if body.quiz_session_id is not None: - quiz_session = await session.get(StudyQuizSession, body.quiz_session_id) + # FOR UPDATE 로 행 잠금 (R9) — 모바일 더블탭/재시도로 같은 세션에 동시 제출이 들어오면 + # 둘 다 cursor=N 을 읽고 둘 다 cursor+1·count 가산하는 race(이중 가산). 잠금으로 직렬화 → + # 두 번째 제출은 첫 commit 후 cursor=N+1 을 보고 cursor 불일치 409 로 거부된다. + quiz_session = ( + await session.execute( + select(StudyQuizSession) + .where(StudyQuizSession.id == body.quiz_session_id) + .with_for_update() + ) + ).scalar_one_or_none() if quiz_session is None or quiz_session.user_id != user.id: raise HTTPException(status_code=404, detail="quiz_session 을 찾을 수 없습니다") if quiz_session.study_topic_id != q.study_topic_id: