[fix/MAT-524+542] 필기 자동저장 race 봉쇄 + dataJson 전환 + save queue#313
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR updates the native scrap handwriting autosave/load pipeline to stop base64-wrapping handwriting payloads and instead persist/load plain JSON via the newly introduced dataJson field (with legacy data base64 fallback for reads). It also updates the generated OpenAPI TypeScript schema to reflect dataJson support and other upstream schema drift.
Changes:
- Replace handwriting save encoding from
stringify → encodeURIComponent → unescape → btoato a single deterministicJSON.stringify(canonicalize(...)). - Simplify handwriting decode to accept the full API response object and prefer
dataJson, falling back to legacy base64dataand older “strokes-only array” format. - Update API schema typings to add
dataJson?: stringand makedataoptional for handwriting request/response types (plus additional OpenAPI sync changes).
Reviewed changes
Copilot reviewed 2 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| apps/native/src/types/api/schema.d.ts | Sync OpenAPI typings; adds dataJson/optional data for handwriting types and introduces additional schema drift updates. |
| apps/native/src/features/student/scrap/utils/handwritingEncoder.ts | Implement deterministic JSON encoding + unified decoder (dataJson preferred, data base64 fallback, strokes-only compatibility). |
| apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts | Switch PUT body to { dataJson } and simplify load path to decodeHandwritingData(handwritingData). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
PR #313 sterdsterd review + codex 검토 결과 반영. race / silent / lock-in 12건 봉쇄 + 사용자 행동 5종 통제 + 매니저 책임 축소. ## 큐 (handwritingSaveQueue.ts) - QueueEntry.version 추가 (scrapId 별 단조 증가) - enqueueAutosave / flushExplicit (1회 시도, 5s timeout) / setCallbacks 분리 - waiters version 별 Promise map — 동시 호출 race 봉쇄 - explicit waiter 살아있는 동안 autosave skip (source priority) - success 분기 stale guard 우회 — cleanup dequeue 후도 cache 갱신 - 4xx 도 retry 분류 (drop 제거) — 일시 4xx 자동 복구 ## 매니저 (useHandwritingManager.ts) + flushPending pure helper - mutateAsync 직접 호출 제거 → 큐 단일 경로 - silentFlushPausedRef / evaluateFlush / FlushDecision / ref-mirror 제거 - flushPending while 루프를 pure helper (handwritingFlushPending.ts) 추출 - unmount cleanup 에서 큐 dequeue (화면 떠난 후 retry 안 함) - discard 시 removeQueries — 다음 진입 시 GET fresh ## 화면 (ScrapDetailScreen.tsx) - navigation.addListener('beforeRemove') — swipe/hardware back 통제 - handleViewAllPointings 선 flush ## Wiring (HandwritingSaveQueueWiring.tsx) - setCallbacks({ onSaved, onAutosaveFailed }) 단일 등록점 - onAutosaveFailed 1초 debounce → showToast('error', '자동 저장에 실패했어요') - onSaved 들어오면 debounce cancel — 일시 5xx 깜박임 차단 ## Mutate (putUpdateHandwriting.ts) - onSuccess setQueryData 제거 — wiring 단일 cache 경로 ## Encoder rename - handwritingEncoder.ts → handwritingDecoder.ts (encode 함수 사라짐, 역할 정합) ## Dev test mode (services/__dev__/handwritingTestMode.ts) - globalThis.setHwTestMode('hold'|'retry'|'network_error'|'slow_2s'|'slow_6s'|'normal') - production 영향 0 (__DEV__ 가드) ## 테스트 - handwritingSaveQueue.test.ts: 18 cases (race / version stale / cleanup race / explicit 1회 spec / dedup / explicit waiter dedup skip) - handwritingFlushPending.test.ts: 7 cases (success / retry-success / retry-discard / hold / timeout / 연속 retry / 순서 보장) ## 5조건 매핑 - Acknowledgement: autosave 1초 debounce toast / explicit Alert - Durability: 큐 retry (화면 안) + 명시 discard - Ordering: version 단조 + 단일 flushLock + version stale guard - Convergence: wiring 단일 setQueryData + discard 시 removeQueries - Visibility: success cache sync 완료 + discard 후 다음 진입 GET fresh ## 사용자 행동 5종 통제 Header ← / 다른 탭 / 탭 × / iOS swipe / Android hardware back / AllPointings 모두 flushPending 거침. 재시도 누르면 navigate 안 됨 (while 루프). ## Follow-up (별도 PR) - @testing-library/react-native 셋업 + 매니저 hook 통합 테스트 - AsyncStorage 영속성 (앱 force-kill 복구) - 서버 idempotency / multi-device sequence Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #312 sterdsterd review + codex 검토 통합. race / silent / lock-in 12건 봉쇄 + 사용자 행동 5종 통제 + dataJson 전환 + 매니저 책임 축소. ## MAT-542 dataJson 전환 - 5초 autosave 4단계 직렬화(stringify → encodeURIComponent → unescape → btoa) → 1단계 JSON.stringify - dataJson: string 필드 사용. data (base64) decoder fallback 유지 - OpenAPI schema sync. 옛 strokes-only 배열 호환 유지 ## 큐 (handwritingSaveQueue.ts) — version 기반 race 봉쇄 - module-level 단일 큐 (hook unmount 무관) - QueueEntry.version (scrapId 별 단조 증가) - enqueueAutosave / flushExplicit (1회 시도, 5s timeout) / setCallbacks 분리 - waiters version 별 Promise map — 동시 호출 race 봉쇄 (R1) - explicit waiter 살아있는 동안 autosave skip — source priority (R2) - success 분기 stale guard 우회 — cleanup dequeue 후도 cache 갱신 (R4) - exponential backoff [1, 2, 4, 8, 16, 30]s, 401/403 hold 10s - 4xx 도 retry 분류 (drop 제거) — 일시 4xx 자동 복구 (R3) ## 매니저 (useHandwritingManager.ts) + flushPending pure helper - race 가드 ref 3개 (appliedScrapIdRef, pendingLoadRef, needsSaveRef) - mutateAsync 직접 호출 제거 → 큐 단일 경로 (D1) - flushPending while 루프 → pure helper (handwritingFlushPending.ts) 추출 (D5) - unmount cleanup dequeue (D8) — 화면 떠난 후 retry 안 함 - discard 시 removeQueries — 다음 진입 시 GET fresh - decode 실패 시 전화면 에러 + PUT 차단 ## 화면 (ScrapDetailScreen.tsx) - navigation.addListener('beforeRemove') — swipe/hardware back 통제 (D7) - handleViewAllPointings 선 flush - HandwritingSaveQueueWiring 마운트 ## Wiring (HandwritingSaveQueueWiring.tsx) - setCallbacks({ onSaved, onAutosaveFailed }) 단일 등록점 (D4) - onSaved → setQueryData (single-device 가정 — invalidate 안 함) - onAutosaveFailed 1초 debounce → showToast('error', '자동 저장에 실패했어요') ## Mutate (putUpdateHandwriting.ts) - response.ok 체크 후 throw — 4xx/5xx silent resolve 차단 - onSuccess setQueryData 제거 — wiring 단일 cache 경로 ## Encoder rename - handwritingEncoder.ts → handwritingDecoder.ts (D6) ## useDrawingState - 사용 안 하던 hasUnsavedChanges / markAsSaved 액션/상태 제거 ## Dev test mode (services/__dev__/handwritingTestMode.ts) - globalThis.setHwTestMode('hold' | 'retry' | 'network_error' | 'slow_2s' | 'slow_6s' | 'normal') - __DEV__ 가드 → production 영향 0 ## 5조건 매핑 (sterdsterd 시선) - Acknowledgement: autosave debounce toast / explicit Alert / success silent - Durability: 큐 retry (화면 안) + 명시 discard. force-kill follow-up - Ordering: version 단조 + 단일 flushLock + version stale guard - Convergence: wiring 단일 setQueryData + discard 시 removeQueries - Visibility: success cache sync 완료 + discard 후 다음 진입 GET fresh ## 사용자 행동 5종 통제 Header ← / 다른 탭 / 탭 × / iOS swipe / Android hardware back / AllPointings 모두 flushPending 거침. 재시도 누르는 동안 navigate 안 됨 (while 루프). ## 테스트 - handwritingSaveQueue.test.ts: 18 cases - handwritingFlushPending.test.ts: 7 cases - jest 25/25, typecheck/lint 통과 - 실기기 spike — globalThis.setHwTestMode(...) 시나리오별 검증 ## Follow-up (별도 PR) - @testing-library/react-native 셋업 + 매니저 hook 통합 테스트 - AsyncStorage 영속성 (force-kill 복구) — 서버 idempotency / multi-device sequence - MAT-543 — autosave debounce + stroke count limit Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ee82fd2 to
e0c74fa
Compare
| // 화면 떠나는 모든 경로 (swipe back / hardware back / programmatic pop) 통제 — flushPending 강제 | ||
| useEffect(() => { | ||
| const unsub = navigation.addListener('beforeRemove', (e) => { | ||
| if (!handwritingSaveQueue.has(scrapId) && !handwriting.hasUnsavedChanges()) return; | ||
| e.preventDefault(); | ||
| void (async () => { | ||
| await handwriting.flushPending(); | ||
| navigation.dispatch(e.data.action); | ||
| })(); | ||
| }); | ||
| return unsub; | ||
| }, [navigation, scrapId, handwriting]); |
There was a problem hiding this comment.
flushPending / hasUnsavedChanges 만 분해해서 deps 에 둠. 매니저 내부 useCallback 으로 stable 한 콜백이라 effect 재구독은 scrapId / decodeError 변경 시에만 발생.
screens/ScrapDetailScreen.tsx:269-282
- services/handwritingSaveQueueSingleton.ts (신규): 큐 인스턴스 분리. Wiring → ./index → Wiring 순환 의존성 제거 (#1) - services/handwritingSaveQueue.ts: versionCounter scrapId 별 Map → global single counter. waiter cross-scrapId mis-resolve 봉쇄 (#5) - hooks/useHandwritingManager.ts: flushPending 이 로컬 dirty 없어도 큐 entry 잔존 시 explicit PUT 끌고감. autosave 실패 후 swipe back 시 silent loss 차단 + 실패 시 Alert (#2) - screens/ScrapDetailScreen.tsx: beforeRemove deps 분해. 매 렌더 재구독 차단 (#4) #3 (load 전 사용자 입력 유실 가드) 는 drawing.tsx mount useEffect 부작용으로 옵션 A/B 모두 폐기. drawing.tsx silent 옵션 follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
학생 스크랩 상세 필기 자동저장 race 봉쇄 / 인지 경로 / 사용자 행동 통제 / 매니저 책임 분리 정리. PR #312 (MAT-524 race) 와 본 PR (MAT-542 dataJson) 통합. PR #312 의 변경은 본 PR 에 흡수, #312 close. 단일 squash commit.
stringify → encodeURIComponent → unescape → btoa) → 1단계JSON.stringify.dataJson: string필드 사용,data(base64) decoder fallback 유지.version단조 + 단일flushLock+ version stale guard.enqueueAutosave(autosave/background, backoff retry) /flushExplicit(1회 시도 + 5s timeout) 분리. explicit waiter 살아있는 동안 autosave skip.navigation.addListener('beforeRemove')으로 iOS swipe / Android hardware back 캡처. Header ← / 다른 탭 / 탭 × / swipe / hardware back / AllPointings 모든 이탈 경로 →flushPending.mutateAsync직접 호출 /silentFlushPausedRef/evaluateFlushenum / ref-mirror 제거.flushPendingwhile 루프 → pure helper 추출. unmount cleanup 에서 큐dequeue(화면 떠난 후 retry 안 함).Linear
Changes
apps/native/src/features/student/scrap/services/handwritingSaveQueue.ts(rewrite)QueueEntry.version: number(scrapId 별 단조 증가).enqueueAutosave(scrapId, data, 'autosave' | 'background'): backoff retry.[1,2,4,8,16,30]scap, 401/403 hold 10s.flushExplicit(scrapId, data): Promise<ExplicitFlushResult>: 1회 시도 + 5s timeout. retry/hold/drop 모두 즉시dequeue+ waiter 통보 (autosave 와 달리 backoff 안 함).setCallbacks({ onSaved, onAutosaveFailed })단일 등록점. 옛setOnSuccess단일 슬롯 제거.waiters: Map<version, resolver>—flushPending동시 호출 시 후행이 선행 콜백 덮는 race 봉쇄.successoutcome 분기에서 stale guard 우회 — cleanupdequeue후 inflight 응답 도착해도onSavedfire (cache 갱신 보장).enqueueAutosave가entries.get(scrapId).source === 'explicit' && waiters.has(version)일 때 skip — explicit waiter 살아있는 동안 autosave 가 source/version 못 덮음.apps/native/src/features/student/scrap/hooks/handwritingFlushPending.ts(신규)runExplicitFlushLoop({ scrapId, dataJson, queue, showRetryAlert, onDiscard }): Promise<'success' | 'discard'>pure helper.flushExplicit호출 → outcome 'success' 면 즉시 return / 그 외showRetryAlert호출 → 'retry' 루프 또는 'discard' (queue.dequeue+onDiscard).apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts(rewrite)useUpdateHandwritingimport /mutateAsync직접 호출 제거. explicit 도 큐 단일 경로.silentFlushPausedRef/evaluateFlush/canMark/FlushDecisionenum /FlushContext/MarkContext/decodeErrorRef/updateMutationRef/flushFireAndForgetRef/flushOnBackgroundRef제거.flushFireAndForget+flushOnBackground→enqueueAutosave('autosave' | 'background')단일 호출.flushPending(): Promise<void>가runExplicitFlushLoop호출로 위임. 결과는 매니저 안에서needsSaveRef.current = false처리.useEffectcleanup 에서handwritingSaveQueue.dequeue(scrapId)— 화면 떠난 후 retry 안 함.queryClient.removeQueries(handwritingQueryKey(scrapId))— 다음 진입 시 GET 강제 (서버 진짜 상태 확인).canvasMountedboolean state (drawing.tsxuseImperativeHandledeps 없는 문제 우회).decodeErrorstate → 전화면 에러 + PUT 차단.hasUnsavedChanges()getter 노출 (beforeRemovelistener 에서 사용).apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsxnavigation.addListener('beforeRemove')추가 — swipe back / hardware back / programmatic pop 캡처. 큐에 entry 있거나hasUnsavedChanges()면e.preventDefault()후flushPendingawait →navigation.dispatch(e.data.action).handleViewAllPointingsasync 화 + 선flushPending(같은 stack push 도 통제).<HandwritingSaveQueueWiring />마운트.onBack/onTabPress/onTabClose핸들러는 유지 (이중 보호, idempotent).apps/native/src/features/student/scrap/services/HandwritingSaveQueueWiring.tsx(rewrite)setCallbacks({ onSaved, onAutosaveFailed })사용.onSaved→queryClient.setQueryData(handwritingQueryKey(scrapId), prev => ({ ...prev, dataJson: data, data: undefined })). single-device 가정 — invalidate 안 함.onAutosaveFailed→ 1초 debounce 후showToast('error', '자동 저장에 실패했어요').onSaved가 1초 안에 들어오면 timer cancel (일시 5xx 깜박임 차단).apps/native/src/features/student/scrap/services/handwritingSavePoster.ts__DEV__+applyHwTestMode()호출 추가.apps/native/src/apis/controller/student/scrap/handwriting/putUpdateHandwriting.tsresponse.ok체크 후 throw —client.PUT의 4xx/5xx silent resolve 차단 (mutate 가 success 로 처리되던 케이스).onSuccess setQueryData제거 — wiring 단일 cache 경로.apps/native/src/features/student/scrap/utils/handwritingDecoder.ts(rename fromhandwritingEncoder.ts)decodeHandwritingData(source: { dataJson?, data? })만 export. legacy base64 fallback 유지.apps/native/src/features/student/scrap/hooks/useDrawingState.tshasUnsavedChangesstate /SET_UNSAVED_CHANGES/MARK_AS_SAVEDaction /markAsSavedcallback 제거.apps/native/src/features/student/scrap/services/__dev__/handwritingTestMode.ts(신규, dev only)globalThis.setHwTestMode('hold' | 'retry' | 'network_error' | 'slow_2s' | 'slow_6s' | 'normal').__DEV__가드.applyHwTestMode()가 null 아닌 outcome 반환 시 실제 PUT 안 보내고 시뮬레이션.slow_2s는 race 검증 /slow_6s는flushExplicit5s timeout 검증용.테스트
services/__tests__/handwritingSaveQueue.test.ts(rewrite, 18 cases) — backoff / autosave success·hold·retry·dedup / multi-scrapId / explicit 1회 spec (success·drop·retry·hold·timeout) / explicit waiter dedup skip / version stale guard / cleanup 후 inflight success → onSaved fire / utility (has·dequeue·_reset).hooks/__tests__/handwritingFlushPending.test.ts(신규, 7 cases) — success / retry → success / retry → discard / hold / timeout / 연속 retry 3회 후 discard / dequeue → onDiscard 순서 보장.apps/native/src/types/api/schema.d.tspnpm openapiregen —dataJson필드 추가,dataoptional 완화 외 도메인 누적 변경 동기화.Testing
pnpm --filter native typecheck— exit 0pnpm --filter native exec eslint(변경 파일) — 0 errorpnpm --filter native exec jest— 25/25 passedsetHwTestMode('retry')→ autosave 1초 debounce toast, explicit Alert (재시도/확인). "다시 시도" 누르는 동안 화면 전환 안 됨setHwTestMode('hold')→ 401/403 동작 (10s 후 retry)setHwTestMode('slow_2s')→ autosave V1 inflight 중 onBack →flushExplicit가 V1 응답 await 후 V2 발사 (큐 단일 lane 직렬화)setHwTestMode('slow_6s')→flushExplicit5s timeout → Alert (재시도/확인)beforeRemove→flushPending→ success/discard 후 navigateflushPending거침removeQueries→ 다음 진입 시 GET freshRisk / Impact
dequeue+ version stale guard 로 응답 skip. success 응답은 wiring 으로 cache 갱신 보장.Follow-ups
@testing-library/react-native셋업 + 매니저 hook 통합 테스트 (RNTL 부재로 본 PR 에선 helper pure 함수 + 큐 단위만)