채점 성공 뒤 브라우저 풀이 기록이 사라지는 문제 해결

이 글의 목차 7개 항목
  • 분야: DB
  • 기술 스택: TypeScript · IndexedDB · localStorage · 비동기 답안 큐
  • 관련 기술: 트랜잭션 커밋 · 응답 체크포인트 · 멱등성 · 작업 ID

문제와 원인

일반 문제 풀이에서 서버 채점 응답을 받은 직후 브라우저 저장이 실패하면, 결과는 화면에 보였지만 다시 열었을 때 풀이 기록이 사라질 수 있었다. 채점 성공과 브라우저 기록의 영구 저장은 서로 다른 단계였다.

기존 답안 큐는 서버 응답을 받은 다음 localStorage의 대기 답안을 먼저 지웠다. 이어서 호출하는 saved()의 타입은 void였고 비동기 저장 Promise를 기다리지 않았다. 따라서 IndexedDB 트랜잭션이 뒤늦게 중단돼도 다시 처리할 대기 답안이 남지 않았다. 동기 try/catch로 감싼 콜백도 나중의 Promise 실패를 처리하지 못했다.

IndexedDB의 put 성공 이벤트 역시 트랜잭션 완료와 다르다. 쓰기 요청이 성공한 뒤 abort()되면 실제 저장 행은 0개로 남는다.

수정 전 코드

PracticeAnswerOutbox.drain()

응답 수신만으로 큐를 비운 뒤 로컬 저장 콜백을 호출했다.

const result = await this.options.send(entry, this.controller.signal);
this.options.storage().removeItem(this.prefix + entry.clientOperationId);
this.entries.delete(entry.clientOperationId);
const callback = this.callbacks.get(entry.clientOperationId);
this.callbacks.delete(entry.clientOperationId);
try {
  this.options.saved(entry, result);
  callback?.(result);
} catch { }

큐 제거와 영구 저장의 순서가 반대였고, 비동기 저장 완료를 성공 조건에 포함하지 않았다.

수정 후 코드

응답 체크포인트와 로컬 커밋 순서

응답을 clientOperationId에 묶어 메모리와 localStorage에 보관한 뒤 로컬 저장을 기다린다. 응답 체크포인트가 저장 공간 부족으로 실패해도 원래 대기 답안은 유지한다.

const result = this.responses.has(entry.clientOperationId)
  ? this.responses.get(entry.clientOperationId)!
  : await this.options.send(entry, this.controller.signal);
this.responses.set(entry.clientOperationId, result);
try {
  this.options.storage().setItem(
    this.prefix + entry.clientOperationId,
    JSON.stringify({ ...entry, acknowledgedResponse: result }),
  );
} catch { }
await this.options.saved(entry, result);
this.options.storage().removeItem(this.prefix + entry.clientOperationId);
this.entries.delete(entry.clientOperationId);
this.responses.delete(entry.clientOperationId);

historyOperation()은 transaction.oncomplete에서만 저장 Promise를 완료하고 onabort·onerror에서는 거절한다. 재시작할 때 acknowledgedResponse가 있으면 서버에 채점을 다시 요청하지 않고 로컬 저장부터 이어 간다.

saveBrowserPracticeAttempt()

서버의 임시 응답 ID가 바뀌거나 두 탭이 같은 응답을 저장해도, 브라우저 기록은 사용자·엔진·작업 ID로 한 행을 식별한다.

const key = `${engine}:practice:${owner
  ? `${encodeURIComponent(owner)}:` : ""}${clientOperationId}`;

검증 결과

Chrome의 네이티브 IndexedDB에서 수정 전후 답안 큐 모듈을 실행했다. 합성 문항 1개를 채점하고 completed 저장소의 첫 put 성공 이벤트 직후 트랜잭션을 중단했다. 이어 답안 큐를 다시 만들어 복구 결과를 조회했다.

아래 committedRows는 실제 IndexedDB에서 다시 읽은 풀이 행 수, durableQueue는 해당 작업의 localStorage 대기 행 수다. putSuccess는 성공 이벤트가 발생한 쓰기 요청 수이고, gradingRequests는 실제 전송한 채점 요청 수다.

put 성공 뒤 중단 시 수정 전 큐 0·수정 후 큐 1, 재시작 뒤 추가 채점 없이 풀이 1건 복구원본 보기 (새 창)

put 성공 뒤 중단 시 수정 전 큐 0·수정 후 큐 1, 재시작 뒤 추가 채점 없이 풀이 1건 복구

100%원본 보기 (새 창)

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

단계·항목수정 전수정 후
중단 직후 채점 요청1회1회
중단 직전 put 성공 이벤트1회1회
중단 뒤 커밋된 풀이 기록0건0건
중단 뒤 남은 대기 답안0건1건
응답 체크포인트없음보존
재시작 뒤 풀이 기록0건1건
재시작까지 총 채점 요청1회1회
복구 후 대기 답안0건0건

응답 체크포인트 저장까지 실패한 경우에도 원래 답안 1건을 유지해 같은 작업 ID와 선택값으로 재전송했다. 두 탭이 같은 작업을 저장하면 풀이 행은 1개였고, 같은 임시 응답 ID를 받은 별도 두 작업을 추가하면 서로 다른 기록 3개가 유지됐다. 사용자 전환 중 완료된 응답은 다른 사용자의 화면 콜백을 실행하지 않고 원래 사용자의 복구 대상으로 남았다.