게스트 기록 가져오기의 저장 완료 오판정

이 글의 목차 14개 항목

문제와 원인

SQL 학습의 guest-import는 저장한 풀이의 내용보다 완료 마커의 생성 여부로 성공을 판정했다. 유효하지 않은 항목을 건너뛰거나 INSERT OR IGNORE가 기존 작업 ID를 무시해도 마커를 남겼고, 브라우저는 요청 함수가 반환되면 로컬 기록을 삭제했다.

수정 전 코드

GUEST_IMPORT_MARKER_SQL

INSERT OR IGNORE INTO guest_import_batches (user_key, import_id, imported_at)
VALUES (?, ?, CURRENT_TIMESTAMP)

commitGuestImport

statements.push(d1.prepare(GUEST_IMPORT_MARKER_SQL).bind(input.userKey, input.importId));
const results = await d1.batch(statements);
return { imported: Number(results.at(-1)?.meta?.changes ?? 0) > 0 };

importGuestLearning

await requestStudyMutation(
  "guest-import",
  /* 가져올 기록 */
  signal,
);
window.localStorage.removeItem(GUEST_LEARNING_STORAGE_KEY);

수정 후 코드

GUEST_IMPORT_ATTEMPT_SQL

활성 시험 검사를 사전 조회와 SQL 쓰기 조건에 함께 배치했다. 가져올 문항 중 하나라도 미제출 시험에 속하면 batch 전체를 보류하고, 사전 조회 뒤 시험이 활성화돼도 쓰기 시점에서 차단한다.

WHERE NOT EXISTS (
  SELECT 1 FROM exam_session_items item
  JOIN exam_sessions session ON session.id = item.session_id
  WHERE item.question_id IN (SELECT value FROM json_each(?)) AND session.user_key = ?
    AND session.status != 'submitted'
)
  AND NOT EXISTS (
    SELECT 1 FROM guest_import_batches WHERE user_key = ? AND import_id = ?
  )

GUEST_IMPORT_MARKER_SQL

완료 마커는 기대한 풀이의 내용과 요청 digest가 모두 일치할 때 작성한다. 문항·답안·채점 결과·모드·시험 유형·점수·본문·복습 상태를 비교하고, 같은 batch에 예상하지 않은 작업이 있는지도 검사한다. 같은 ID의 다른 요청과 digest 없는 과거 마커는 HTTP 409, 유효하지 않은 항목은 HTTP 422로 보류한다.

INSERT OR IGNORE INTO guest_import_batches (user_key, import_id, imported_at)
SELECT ?, ?, CURRENT_TIMESTAMP WHERE ${GUEST_IMPORT_COMPLETENESS_SQL}
  AND EXISTS (
    SELECT 1 FROM guest_import_receipts
    WHERE user_key = ? AND import_id = ? AND payload_digest = ?
  )

commitGuestImport

const results = await d1.batch(statements);
const imported = Number(results.at(-1)?.meta?.changes ?? 0) > 0;
const receipt = !imported ? await readGuestImportReceipt(d1, input.userKey, input.importId) : null;
const duplicate = Boolean(receipt?.payloadDigest && receipt.payloadDigest === input.payloadDigest);
return {
  imported,
  duplicate,
  conflict: !imported && (Boolean(receipt && !duplicate) || await hasConflictingExistingAttempt()),
  attempts: imported || duplicate ? input.attempts.length : 0,
};

guestImportCompletedForSnapshot

브라우저는 완료 상태와 제출 수량을 확인하고, 계정에 묶인 제출 snapshot의 영수증을 저장한다. 원본 초안은 유지한 채 읽을 때 승인된 snapshot만 차감해 요청 중 추가된 기록을 남긴다.

return result.ok === true && result.imported === true && result.verified === true
  && result.bookmarks === submitted.bookmarks.length
  && result.attempts === submitted.attempts.length
  && result.theoryProgress === 0;

importGuestLearning

if (guestImportCompletedForSnapshot(submitted, response)) {
  // Manual retry runs this saved operation after the original caller
  // has returned, so receipt reconciliation belongs in the operation.
  if (!reconcileGuestImportSnapshot(submitted, userKeyHash)) {
    return { ...response, verified: false };
  }
  if (retry) await onImported.current();
}
return response;

검증 결과

서버 저장

수정 전후 원본 study.service·저장소를 Node.js·SQLite의 로컬 메모리 DB에서 같은 현재 스키마와 합성 계정·요청으로 조건별·버전별 1회 실행했다. D1.batch() 대역은 SQLite 트랜잭션으로 순차 실행했으며, 경쟁 조건은 batch 진입 직전에 활성 시험 행을 주입했다.

입력 조건수정 전수정 후
활성 시험 문항 1개 + 일반 문항 1개HTTP 200, imported: true, 풀이 2행·마커 1행HTTP 200, imported: false, deferred: true, 풀이 0행·마커 0행
사전 검사와 batch 사이에 시험 활성화HTTP 200, 풀이 2행·마커 1행HTTP 200, 풀이 0행·마커 0행
존재하지 않는 문항 1개HTTP 200, 풀이 0행·마커 1행HTTP 422, 풀이 0행·마커 0행
기존 작업의 답안·문항·모드·시험 유형 변경, 각 1회4/4건 HTTP 200, 풀이 2행·마커 1행4/4건 HTTP 409, 기존 풀이 1행·마커 0행 유지
첫 조건의 시험 제출 후 같은 요청 재시도풀이 2행·마커 1행풀이 2행·마커 1행, verified: true

행 수는 합성 사용자의 풀이와 완료 마커를 집계했다. 변경 요청 4개는 기존 작업 1개가 있는 동일한 초기 상태에서 각각 시작했다.

로컬 Swagger UI의 1280×720 CSS px·DPR 2 화면에서도 합성 문항 3개·계정과 새 메모리 DB를 사용해 활성 시험 문항 1개·일반 문항 1개의 요청을 버전별 1회 실행했다.

수정 전 응답은 HTTP 200·imported: true였고, 활성 시험 문항까지 포함한 풀이 2개를 성공으로 반환했다.

수정 전 응답: HTTP 200·imported true·attempts 2원본 보기 (새 창)

수정 전 응답: HTTP 200·imported true·attempts 2

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

같은 실행의 저장 직후 DB 집계는 풀이 2행·완료 마커 1행이었다.

수정 전 DB 집계: 풀이 2행·마커 1행원본 보기 (새 창)

수정 전 DB 집계: 풀이 2행·마커 1행

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

수정 후에는 같은 입력에 HTTP 200·imported: false·deferred: true를 반환해 가져오기를 보류했다.

수정 후 응답: imported false·deferred true원본 보기 (새 창)

수정 후 응답: imported false·deferred true

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

저장 직후 X-Local-Attempts·X-Local-Markers 집계도 모두 0행이었다.

수정 후 DB 집계: 풀이 0행·마커 0행원본 보기 (새 창)

수정 후 DB 집계: 풀이 0행·마커 0행

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

브라우저 완료 조건

원본 프런트엔드 완료 함수·저장 모듈을 합성 메모리 localStorage에서 완료 응답 6종 각 1회, 제출 뒤 새 풀이가 추가되는 조건 1회로 실행했다.

입력·조건결과
검증된 정상 완료 / 같은 snapshot의 중복 완료2/2종 완료 인정
보류 / verified 없음 / 풀이 수 부족 / 북마크 수 부족4/4종 완료 거부
풀이 1개 제출 후 새 풀이 1개 추가원본 2개 유지, 읽은 미이관 기록 1개
영수증 저장 후 pending 처리제출 snapshot의 pending만 제거