Skip to content

[fix/MAT-524+542] 필기 자동저장 race 봉쇄 + dataJson 전환 + save queue#313

Open
b0nsu wants to merge 2 commits into
fix/mat-349-handwriting-api-cache-strategyfrom
fix/mat-542-handwriting-data-json
Open

[fix/MAT-524+542] 필기 자동저장 race 봉쇄 + dataJson 전환 + save queue#313
b0nsu wants to merge 2 commits into
fix/mat-349-handwriting-api-cache-strategyfrom
fix/mat-542-handwriting-data-json

Conversation

@b0nsu
Copy link
Copy Markdown
Collaborator

@b0nsu b0nsu commented May 5, 2026

Summary

학생 스크랩 상세 필기 자동저장 race 봉쇄 / 인지 경로 / 사용자 행동 통제 / 매니저 책임 분리 정리. PR #312 (MAT-524 race) 와 본 PR (MAT-542 dataJson) 통합. PR #312 의 변경은 본 PR 에 흡수, #312 close. 단일 squash commit.

  • dataJson 전환: 5초 autosave 직렬화 4단계 (stringify → encodeURIComponent → unescape → btoa) → 1단계 JSON.stringify. dataJson: string 필드 사용, data (base64) decoder fallback 유지.
  • 큐 race 봉쇄: version 단조 + 단일 flushLock + version stale guard. enqueueAutosave (autosave/background, backoff retry) / flushExplicit (1회 시도 + 5s timeout) 분리. explicit waiter 살아있는 동안 autosave skip.
  • 인지 경로: autosave 실패 → wiring 1초 debounce toast (일시 5xx 깜박임 차단). explicit 실패 → 매니저 Alert "다시 시도/확인". 4xx 도 retry 분류 (drop 제거 — 일시 4xx 자동 복구).
  • UX 통제: navigation.addListener('beforeRemove') 으로 iOS swipe / Android hardware back 캡처. Header ← / 다른 탭 / 탭 × / swipe / hardware back / AllPointings 모든 이탈 경로 → flushPending.
  • 매니저 책임 축소: mutateAsync 직접 호출 / silentFlushPausedRef / evaluateFlush enum / ref-mirror 제거. flushPending while 루프 → pure helper 추출. unmount cleanup 에서 큐 dequeue (화면 떠난 후 retry 안 함).

Linear

Changes

apps/native/src/features/student/scrap/services/handwritingSaveQueue.ts (rewrite)

  • module-level 단일 큐. QueueEntry.version: number (scrapId 별 단조 증가).
  • enqueueAutosave(scrapId, data, 'autosave' | 'background'): backoff retry. [1,2,4,8,16,30]s cap, 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 봉쇄.
  • success outcome 분기에서 stale guard 우회 — cleanup dequeue 후 inflight 응답 도착해도 onSaved fire (cache 갱신 보장).
  • enqueueAutosaveentries.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).
  • hook 외부 pure 함수라 jest 직접 테스트 가능 (mock alert + queue).

apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts (rewrite)

  • useUpdateHandwriting import / mutateAsync 직접 호출 제거. explicit 도 큐 단일 경로.
  • silentFlushPausedRef / evaluateFlush / canMark / FlushDecision enum / FlushContext / MarkContext / decodeErrorRef / updateMutationRef / flushFireAndForgetRef / flushOnBackgroundRef 제거.
  • flushFireAndForget + flushOnBackgroundenqueueAutosave('autosave' | 'background') 단일 호출.
  • flushPending(): Promise<void>runExplicitFlushLoop 호출로 위임. 결과는 매니저 안에서 needsSaveRef.current = false 처리.
  • useEffect cleanup 에서 handwritingSaveQueue.dequeue(scrapId) — 화면 떠난 후 retry 안 함.
  • discard 분기에서 queryClient.removeQueries(handwritingQueryKey(scrapId)) — 다음 진입 시 GET 강제 (서버 진짜 상태 확인).
  • canvas mount: callback ref + canvasMounted boolean state (drawing.tsx useImperativeHandle deps 없는 문제 우회).
  • decode 실패 시 decodeError state → 전화면 에러 + PUT 차단.
  • hasUnsavedChanges() getter 노출 (beforeRemove listener 에서 사용).

apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx

  • navigation.addListener('beforeRemove') 추가 — swipe back / hardware back / programmatic pop 캡처. 큐에 entry 있거나 hasUnsavedChanges()e.preventDefault()flushPending await → navigation.dispatch(e.data.action).
  • handleViewAllPointings async 화 + 선 flushPending (같은 stack push 도 통제).
  • <HandwritingSaveQueueWiring /> 마운트.
  • 기존 onBack / onTabPress / onTabClose 핸들러는 유지 (이중 보호, idempotent).

apps/native/src/features/student/scrap/services/HandwritingSaveQueueWiring.tsx (rewrite)

  • setCallbacks({ onSaved, onAutosaveFailed }) 사용.
  • onSavedqueryClient.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

  • 분류: 2xx → success / 401·403 → hold (10s 대기 후 retry) / 그 외 (4xx·5xx·fetch throw) → retry. 옛 4xx → drop 제거.
  • 일시 4xx (서버 schema 일시 drift, 422 일시 validation, 429 등) 자동 복구.
  • __DEV__ + applyHwTestMode() 호출 추가.

apps/native/src/apis/controller/student/scrap/handwriting/putUpdateHandwriting.ts

  • response.ok 체크 후 throw — client.PUT 의 4xx/5xx silent resolve 차단 (mutate 가 success 로 처리되던 케이스).
  • onSuccess setQueryData 제거 — wiring 단일 cache 경로.

apps/native/src/features/student/scrap/utils/handwritingDecoder.ts (rename from handwritingEncoder.ts)

  • encode 함수 인라인 후 파일명/역할 정합. decodeHandwritingData(source: { dataJson?, data? }) 만 export. legacy base64 fallback 유지.

apps/native/src/features/student/scrap/hooks/useDrawingState.ts

  • 사용 안 하던 hasUnsavedChanges state / SET_UNSAVED_CHANGES / MARK_AS_SAVED action / markAsSaved callback 제거.

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_6sflushExplicit 5s 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.ts

  • pnpm openapi regen — dataJson 필드 추가, data optional 완화 외 도메인 누적 변경 동기화.

Testing

  • pnpm --filter native typecheck — exit 0
  • pnpm --filter native exec eslint (변경 파일) — 0 error
  • pnpm --filter native exec jest — 25/25 passed
  • QA: 정상 환경 그림 + 5초 autosave + onBack → success silent → navigate
  • QA: setHwTestMode('retry') → autosave 1초 debounce toast, explicit Alert (재시도/확인). "다시 시도" 누르는 동안 화면 전환 안 됨
  • QA: setHwTestMode('hold') → 401/403 동작 (10s 후 retry)
  • QA: setHwTestMode('slow_2s') → autosave V1 inflight 중 onBack → flushExplicit 가 V1 응답 await 후 V2 발사 (큐 단일 lane 직렬화)
  • QA: setHwTestMode('slow_6s')flushExplicit 5s timeout → Alert (재시도/확인)
  • QA: iOS swipe back / Android hardware back → beforeRemoveflushPending → success/discard 후 navigate
  • QA: 다른 탭 / 탭 × / "전체 포인팅 보기" → 모두 flushPending 거침
  • QA: 명시 discard ("확인") → removeQueries → 다음 진입 시 GET fresh
  • QA: WiFi 정상 환경 일반 저장 회귀 0

Risk / Impact

  • 영향 범위: 학생 스크랩 상세 화면 필기 (autosave + 명시 저장 + Alert + cache).
  • 호환성: 옛 base64 row 는 decoder fallback 으로 한 번 디코드 → 다음 PUT 시 dataJson 으로 자연 전환.
  • 성능: autosave 메인 스레드 비용 4단계 → 1단계.
  • 큐 영속성: AsyncStorage 안 함 (in-memory). 앱 재시작 시 휘발 — 의도적 (영속성은 서버 idempotency / multi-device sequence 와 함께 follow-up).
  • 화면 떠난 후 inflight: 큐 dequeue + version stale guard 로 응답 skip. success 응답은 wiring 으로 cache 갱신 보장.
  • 머지 순서: PR fix: handwriting API 캐시 전략 개선 #288 (MAT-349) → 본 PR.

Follow-ups

  • @testing-library/react-native 셋업 + 매니저 hook 통합 테스트 (RNTL 부재로 본 PR 에선 helper pure 함수 + 큐 단위만)
  • AsyncStorage 영속성 (앱 force-kill 복구) — 서버 idempotency / multi-device sequence 와 함께
  • MAT-543 — autosave debounce + stroke count limit
  • 향후 saveCoordinator / mutationCache 비교 로직 (PR [fix/MAT-524] autosave 데이터 유실 방지 #308 의 C1 해결책) 재도입 시, 본 PR 의 dataJson 직렬화 덕분에 byte-equal 비교가 안전

@linear
Copy link
Copy Markdown

linear Bot commented May 5, 2026

@vercel
Copy link
Copy Markdown

vercel Bot commented May 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pointer-admin Ready Ready Preview, Comment May 10, 2026 0:30am

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 → btoa to a single deterministic JSON.stringify(canonicalize(...)).
  • Simplify handwriting decode to accept the full API response object and prefer dataJson, falling back to legacy base64 data and older “strokes-only array” format.
  • Update API schema typings to add dataJson?: string and make data optional 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.

Comment thread apps/native/src/features/student/scrap/utils/handwritingEncoder.ts Outdated
Comment thread apps/native/src/features/student/scrap/utils/handwritingEncoder.ts Outdated
@b0nsu b0nsu changed the base branch from fix/mat-524-autosave-data-loss to fix/mat-349-handwriting-api-cache-strategy May 8, 2026 13:00
@b0nsu b0nsu changed the title [fix/MAT-542] handwriting dataJson 전환 — base64 wrapping 제거 [fix/MAT-524+542] 필기 자동저장 race 봉쇄 + dataJson 전환 + save queue May 8, 2026
@b0nsu b0nsu requested a review from sterdsterd May 8, 2026 13:16
b0nsu added a commit that referenced this pull request May 10, 2026
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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 15 changed files in this pull request and generated 5 comments.

Comment thread apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts Outdated
Comment on lines +265 to +276
// 화면 떠나는 모든 경로 (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]);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flushPending / hasUnsavedChanges 만 분해해서 deps 에 둠. 매니저 내부 useCallback 으로 stable 한 콜백이라 effect 재구독은 scrapId / decodeError 변경 시에만 발생.

  • screens/ScrapDetailScreen.tsx:269-282

Comment thread apps/native/src/features/student/scrap/services/handwritingSaveQueue.ts Outdated
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants