- 분야: 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] | [3] |
| 답안 저장 요청 순서 | [1] → [3] | [1] → [3] |
| 다음 문항 요청의 답안 | [1] | [3] |
| 다음 요청의 answerRevision | 2 | 2 |
| 다음 요청의 progressRevision | 13 | 13 |
| 화면과 요청의 답안 불일치 | 1건 | 0건 |
저장 실패 뒤의 재시도는 원래 작업을 먼저 재전송하고 이후 선택 [4]를 저장했다. 늦은 서버 스냅샷을 적용해도 현재 위치 6·progressRevision 14가 이전 위치 5·revision 12로 되돌아가지 않았다. 느린 draft 쓰기·마지막 선택 쓰기·읽기·삭제도 순서대로 완료돼 삭제 후 남은 draft는 0건이었다.