그룹 시험의 마지막 선택이 다음 문항 요청에 반영되지 않는 문제

이 글의 목차 7개 항목
  • 분야: DB
  • 기술 스택: TypeScript · React · IndexedDB · Cloudflare D1
  • 관련 기술: 비동기 저장 순서 · CAS revision · stale closure · 답안 draft

문제와 원인

그룹 시험에서 첫 선택의 저장이 끝나기 전에 답을 바꾸고 바로 다음 문항으로 이동하면, 화면과 다음 요청의 답안이 달라질 수 있었다. 선택 인덱스 [1]을 누른 뒤 [3]으로 바꾼 재현에서는 답안 저장 요청이 [1] → [3] 순서로 완료됐지만 다음 문항 요청은 [1]을 담았다.

selection.flush()는 진행 중인 저장을 기다렸지만, advance()의 answers는 호출 당시 React 렌더가 캡처한 값이었다. 기다리는 동안 저장 객체가 최신 상태가 돼도 그 배열은 바뀌지 않는다. 이 때문에 최신 answerRevision과 이전 선택값이 한 요청 안에 함께 들어갔다.

별도로 늦게 완료한 draft 쓰기가 최신 draft를 덮거나, 삭제된 draft를 다시 만드는 순서 문제도 있었다. 서버 응답과 브라우저 복구 기록의 순서를 함께 맞췄다.

수정 전 코드

advance()

저장 완료를 기다린 뒤 revision은 최신 스냅샷에서 가져왔지만 답안은 렌더의 answers를 사용했다.

await selection.flush();
const saved = selection.snapshot;
const body = {
  action, runId, position, answers,
  expectedAnswerRevision: saved?.position === position
    ? saved.answerRevision : Number(display.answer_revision ?? 0),
  expectedProgressRevision: saved?.position === position
    ? saved.progressRevision : Number(current.progress?.revision ?? 0),
  idempotencyKey,
};

수정 후 코드

confirmForAdvance()

화면에서 체크된 선택을 마지막 입력으로 확정하고, 그 값을 저장한 스냅샷을 반환한다. 저장이 끝났어도 문항 위치가 바뀌거나 미저장 선택이 남으면 이동을 막는다.

async confirmForAdvance(position: number, answers: number[]) {
  if (this.state?.position !== position)
    throw new Error("현재 문항이 변경되었습니다. 다시 확인해 주세요.");
  await this.select([...answers]);
  const saved = this.state;
  if (!saved || saved.position !== position || this.unsaved)
    throw new Error("마지막으로 선택한 답안을 확인하지 못했습니다. 다시 시도해 주세요.");
  return { ...saved, answers: [...saved.answers] };
}

advance()의 최종 요청

답안과 두 revision을 같은 반환 스냅샷에서 가져온다.

const saved = await selection.confirmForAdvance(position, latest);
const body = {
  action, runId, position,
  answers: saved.answers,
  expectedAnswerRevision: saved.answerRevision,
  expectedProgressRevision: saved.progressRevision,
  idempotencyKey,
};

저장이 실패하면 진행 요청을 만들지 않는다. 응답이 유실된 저장 작업은 원래 작업 ID·답안·예상 revision으로 먼저 재시도한 뒤 새 선택을 처리한다. 서버 스냅샷도 현재보다 낮은 progressRevision이면 채택하지 않는다.

GroupDraftOrder는 같은 문항의 쓰기·읽기·삭제를 직렬화한다. 먼저 시작한 느린 쓰기가 끝나기 전에 마지막 선택 저장이나 삭제가 앞질러 실행되지 않게 했다.

검증 결과

Chrome에서 수정 전후 GroupAnswerSelection 원본 모듈과 실제 fetch() 전송을 실행했다. 합성 전송의 첫 저장 응답을 보류한 상태에서 [1] → [3]으로 선택을 바꾸고 다음 요청을 구성했다. 두 저장 응답의 revision은 실제 요청의 예상 revision에서 순서대로 증가시켰다.

콘솔의 visibleAnswers는 화면의 마지막 선택, advanceAnswers는 다음 요청에 넣은 값이다. stalePayload는 두 배열이 다른지를 비교한 값이다.

화면 선택 3과 다음 요청의 수정 전 1·수정 후 3, answerRevision 2·progressRevision 13원본 보기 (새 창)

화면 선택 3과 다음 요청의 수정 전 1·수정 후 3, answerRevision 2·progressRevision 13

100%원본 보기 (새 창)

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

항목수정 전수정 후
마지막 화면 선택 인덱스[3][3]
답안 저장 요청 순서[1] → [3][1] → [3]
다음 문항 요청의 답안[1][3]
다음 요청의 answerRevision22
다음 요청의 progressRevision1313
화면과 요청의 답안 불일치1건0건

저장 실패 뒤의 재시도는 원래 작업을 먼저 재전송하고 이후 선택 [4]를 저장했다. 늦은 서버 스냅샷을 적용해도 현재 위치 6·progressRevision 14가 이전 위치 5·revision 12로 되돌아가지 않았다. 느린 draft 쓰기·마지막 선택 쓰기·읽기·삭제도 순서대로 완료돼 삭제 후 남은 draft는 0건이었다.