[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.
| try { | ||
| const decodedData = decodeURIComponent(escape(atob(base64Data))); | ||
| const data = JSON.parse(decodedData); | ||
| export function encodeHandwritingData(strokes: Stroke[], texts: TextItem[]): string { |
There was a problem hiding this comment.
stringify는 encoding이 아닌데 역할이 바뀌었으면 이름을 바꾸는 편이 낫지 않을까?
| }; | ||
|
|
||
| // 캐노니컬 키 순서로 재구성한다 — 동일 입력에 대한 byte-stable JSON 출력 보장. | ||
| function canonicalize(strokes: Stroke[], texts: TextItem[]): HandwritingData { |
There was a problem hiding this comment.
byte-stable json이 왜 필요하다고 생각했지?
현재 코드에서 byte-stable 하지 않으면 안 돌아가나?
저장 로직에서도 이거 string equality로 구분하는 게 아니고 needsSaveRef 쓰는 거 아냐?
그럼 byte stable string을 쓰는 애가 없는데
그리고 모든 strokes, points, texts map 돌려서 deep copy해서 새 객체로 만드는 거 자체도 연산이 추가되는건데
얘 또 stringify하면 모든 객체를 매 5초마다 두 번 iteration 도는거임
base64 버리라고 한 이유가 매 5초마다 메인스레드에서 인코딩 돌리기 비싸니까 빼라한것도 있는데
이렇게 안 하고 그냥 stringify만 돌려도 key order 안 바뀜 js object property order는 string key에서 stable이라서..
그냥 canonicalize 안 태우고 바로 JSON.strungify({ strokes: strokes ?? [], texts: texts ?? []}) 로 꽂아도 충분할거 같은데
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
| import { showToast } from '../components/Notification/Toast'; | ||
|
|
||
| import { handwritingSaveQueue } from './index'; | ||
|
|
||
| type ScrapHandwritingResp = | ||
| paths['/api/student/scrap/{scrapId}/handwriting']['get']['responses']['200']['content']['*/*']; | ||
|
|
||
| const TOAST_DEBOUNCE_MS = 1_000; | ||
|
|
||
| export function HandwritingSaveQueueWiring(): null { | ||
| const queryClient = useQueryClient(); | ||
| const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| handwritingSaveQueue.setCallbacks({ | ||
| onSaved: ({ scrapId, data }) => { | ||
| if (debounceTimerRef.current) { |
There was a problem hiding this comment.
services/handwritingSaveQueueSingleton.ts 신규 파일로 분리. Wiring 은 singleton 파일을 직접 import 하고 services/index.ts 는 re-export 만 함 → cycle 0.
services/handwritingSaveQueueSingleton.ts:1-11services/HandwritingSaveQueueWiring.tsx:18services/index.ts:12
| // explicit flush — onBack/swipe/탭/onTabClose/AllPointings 등 사용자 명시 의도 | ||
| const flushPending = useCallback(async (): Promise<void> => { | ||
| if (!canFlush()) return; | ||
| const dataJson = buildDataJson(); | ||
| if (dataJson === null) return; | ||
|
|
||
| await runExplicitFlushLoop({ | ||
| scrapId, | ||
| dataJson, | ||
| queue: handwritingSaveQueue, | ||
| showRetryAlert: () => | ||
| new Promise<'retry' | 'discard'>((resolve) => { | ||
| Alert.alert('저장에 실패했어요', '', [ | ||
| { text: '확인', style: 'destructive', onPress: () => resolve('discard') }, | ||
| { text: '다시 시도', onPress: () => resolve('retry') }, | ||
| ]); | ||
| }), | ||
| onDiscard: () => { | ||
| queryClient.removeQueries({ queryKey: handwritingQueryKey(scrapId) }); | ||
| }, | ||
| }); | ||
| needsSaveRef.current = false; | ||
| }, [scrapId, canFlush, buildDataJson, handwritingQueryKey, queryClient]); |
There was a problem hiding this comment.
flushPending 이 canFlush() false 일 때도 handwritingSaveQueue.dequeue(scrapId) 호출하도록 확장. 큐 entry 만 남고 로컬 dirty 없는 경우 = 사용자 명시 swipe back / 탭 의도로 해석, 추가 PUT 시도 없이 큐 비우고 떠남. 두 번째 listener 호출 시 has(scrapId)=false && hasUnsavedChanges()=false → early return → dispatch 통과 → 무한루프 봉쇄.
hooks/useHandwritingManager.ts:153-180
There was a problem hiding this comment.
정정 — silent loss 우려로 옵션 A (flushExplicit 끌고감) 로 변경.
dequeue 만 하면 autosave 실패 (retry/hold) 후 swipe back 시 사용자 그림이 영원히 유실. wiring cleanup 으로 onAutosaveFailed toast 도 무력화 → 사용자가 인지 못 함.
새 동작: 큐 entry 만 있어도 runExplicitFlushLoop 끌고 감. success → dispatch / retry|hold|timeout → Alert "재시도/확인" → 사용자 명시 결정. 무한루프 차단은 동일 (success/discard 모두 큐 dequeue → 두 번째 listener has=false → early return).
hooks/useHandwritingManager.ts:153-186
| // 데이터 로드 — canvas mount + handwritingData 도착 후 | ||
| useEffect(() => { | ||
| // 저장 중이 아니고, scrapId가 일치할 때만 로드 (데이터 유실 방지) | ||
| if ( | ||
| handwritingData?.data && | ||
| canvasRef.current && | ||
| currentScrapIdRef.current === scrapId && | ||
| !isSaving | ||
| ) { | ||
| // clear() 완료를 보장하기 위해 약간의 지연 후 로드 | ||
| const loadTimer = setTimeout(() => { | ||
| // 다시 한번 scrapId 확인 (clear() 실행 중일 수 있음) | ||
| if (currentScrapIdRef.current === scrapId && canvasRef.current && !isSaving) { | ||
| try { | ||
| const decodedData = decodeHandwritingData(handwritingData.data); | ||
| canvasRef.current.setStrokes(decodedData.strokes); | ||
| canvasRef.current.setTexts(decodedData.texts); | ||
| lastSavedDataRef.current = handwritingData.data; | ||
| } catch (error) { | ||
| console.error('필기 데이터 로드 실패:', error); | ||
| } | ||
| } | ||
| }, 50); // 50ms 지연으로 clear() 완료 보장 | ||
|
|
||
| return () => clearTimeout(loadTimer); | ||
| if (handwritingData === undefined) return; | ||
| if (appliedScrapIdRef.current === scrapId) return; | ||
| if (!canvasMounted) return; | ||
| try { | ||
| const decoded = handwritingData | ||
| ? decodeHandwritingData(handwritingData) | ||
| : { strokes: [] as Stroke[], texts: [] as TextItem[] }; | ||
| pendingLoadRef.current = true; | ||
| try { | ||
| canvasRef.current?.setStrokes(decoded.strokes); | ||
| canvasRef.current?.setTexts(decoded.texts); | ||
| } finally { | ||
| pendingLoadRef.current = false; | ||
| } | ||
| appliedScrapIdRef.current = scrapId; | ||
| needsSaveRef.current = false; |
There was a problem hiding this comment.
매니저에 isReady boolean state 추가 + 화면이 캔버스 위 StyleSheet.absoluteFillObject overlay 로 입력 차단. appliedScrapIdRef.current = scrapId 직후 setIsReady(true), scrapId 변경 시 setIsReady(false). load 적용 전 사용자 입력 자체가 발생 못 하므로 race window 자체가 사라짐.
hooks/useHandwritingManager.ts:48-50, 56, 86screens/ScrapDetailScreen.tsx:632-639
There was a problem hiding this comment.
정정 — overlay 대신 매니저 load effect 가드 (옵션 A) 로 단순화. 매니저-only 변경, ScrapDetailScreen 안 건드림.
useEffect(() => {
if (handwritingData === undefined) return;
if (appliedScrapIdRef.current === scrapId) return;
if (!canvasMounted) return;
// load 전 사용자 입력이 있으면 서버 데이터로 덮어쓰지 않음 → 다음 flush 가 사용자 그림을 PUT
if (needsSaveRef.current) {
appliedScrapIdRef.current = scrapId;
return;
}
...
});markNeedsSave 의 appliedScrapIdRef 가드 제거 → load 전 입력도 needsSaveRef=true 기록. pendingLoadRef 가 load 자체 onChange 오탐 차단 책임 유지.
매니저가 ScrapDetailScreen 외 ProblemScreen / PointingScreen 에서도 쓰일 generic hook 이라 화면 specific overlay 보다 매니저 invariant 가 적합.
hooks/useHandwritingManager.ts:64-89, 96-100
There was a problem hiding this comment.
정정 — 옵션 A 도 폐기, #3 fix 보류.
옵션 A (load effect 에 needsSaveRef 가드 + markNeedsSave 의 appliedScrapIdRef 가드 제거) 적용 후 normal 케이스에서 빈 캔버스 발생.
원인: drawing.tsx mount useEffect (useEffect(() => notifyHistoryChange(), [...])) 가 mount 후 1회 notifyHistoryChange 호출. 시점은 매니저 reset effect 의 pendingLoadRef=true 윈도우가 이미 끝난 후. 옵션 A 의 가드 제거로 이 발화가 markNeedsSave 통과 → needsSaveRef=true 잘못 set → load effect 의 needsSaveRef 가드가 server data skip → 빈 캔버스.
→ 매니저는 PR #313 원본 동작과 로직상 동일하게 복원 (markNeedsSave 의 appliedScrapIdRef 가드 유지, load effect needsSaveRef 가드 제거).
#3 의 본래 race (handwritingData=undefined && canvas mounted 윈도우에서 사용자 입력 → load 가 덮어씀) 는 LoadingScreen 가드 (if (isLoading || handwriting.isLoading) return <LoadingScreen />;) 로 사실상 차단되어 실 발생 빈도 매우 낮음.
근본 fix 는 drawing.tsx 에 silent 옵션 추가 (clear(silent?: boolean) / setStrokes(strokes, silent?: boolean)). 매니저가 reset/load 시 silent=true 호출 → mount useEffect 부작용 차단. 단 drawing 패키지 변경이라 별도 follow-up.
| // 화면 떠나는 모든 경로 (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
| export class HandwritingSaveQueue { | ||
| private readonly entries = new Map<number, HandwritingQueueEntry>(); | ||
| private readonly waiters = new Map<number, (result: ExplicitFlushResult) => void>(); | ||
| private readonly versionCounter = new Map<number, number>(); | ||
| private callbacks: QueueCallbacks = {}; | ||
| private flushLock = false; | ||
| private timer: ReturnType<typeof setTimeout> | null = null; | ||
|
|
||
| constructor(private readonly poster: QueuePoster) {} | ||
|
|
||
| setCallbacks(cb: QueueCallbacks): void { | ||
| this.callbacks = cb; | ||
| } | ||
|
|
||
| /** | ||
| * autosave / AppState background 경로. | ||
| * explicit waiter 가 살아있는 동안엔 skip — explicit 의 source/version 보존. | ||
| */ | ||
| enqueueAutosave(scrapId: number, data: string, source: 'autosave' | 'background'): void { | ||
| const existing = this.entries.get(scrapId); | ||
| if (existing?.source === 'explicit' && this.waiters.has(existing.version)) { | ||
| return; | ||
| } | ||
| const version = this.nextVersion(scrapId); |
There was a problem hiding this comment.
versionCounter 를 scrapId 별 Map<number, number> 에서 단일 글로벌 private versionCounter = 0 으로 변경. nextVersion() 인자 제거, ++versionCounter 만. version 자체가 큐 lifetime 동안 unique → cross-scrapId waiter mis-resolve 불가.
services/handwritingSaveQueue.ts:50, 142-144services/__tests__/handwritingSaveQueue.test.ts25/25 passed
- 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 함수 + 큐 단위만)