문제와 원인
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개를 성공으로 반환했다.
같은 실행의 저장 직후 DB 집계는 풀이 2행·완료 마커 1행이었다.
수정 후에는 같은 입력에 HTTP 200·imported: false·deferred: true를 반환해 가져오기를 보류했다.
저장 직후 X-Local-Attempts·X-Local-Markers 집계도 모두 0행이었다.
브라우저 완료 조건
원본 프런트엔드 완료 함수·저장 모듈을 합성 메모리 localStorage에서 완료 응답 6종 각 1회, 제출 뒤 새 풀이가 추가되는 조건 1회로 실행했다.
| 입력·조건 | 결과 |
|---|---|
| 검증된 정상 완료 / 같은 snapshot의 중복 완료 | 2/2종 완료 인정 |
| 보류 / verified 없음 / 풀이 수 부족 / 북마크 수 부족 | 4/4종 완료 거부 |
| 풀이 1개 제출 후 새 풀이 1개 추가 | 원본 2개 유지, 읽은 미이관 기록 1개 |
| 영수증 저장 후 pending 처리 | 제출 snapshot의 pending만 제거 |